mirror of
https://github.com/abusoww/tuxmate.git
synced 2026-04-17 22:53:12 +02:00
committed by
GitHub
parent
0dc0ef830c
commit
39dbc468cc
@@ -7,6 +7,7 @@ import gsap from 'gsap';
|
|||||||
import { useLinuxInit } from '@/hooks/useLinuxInit';
|
import { useLinuxInit } from '@/hooks/useLinuxInit';
|
||||||
import { useTooltip } from '@/hooks/useTooltip';
|
import { useTooltip } from '@/hooks/useTooltip';
|
||||||
import { useKeyboardNavigation, type NavItem } from '@/hooks/useKeyboardNavigation';
|
import { useKeyboardNavigation, type NavItem } from '@/hooks/useKeyboardNavigation';
|
||||||
|
import { useVerification } from '@/hooks/useVerification';
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
import { categories, getAppsByCategory } from '@/lib/data';
|
import { categories, getAppsByCategory } from '@/lib/data';
|
||||||
@@ -47,6 +48,9 @@ export default function Home() {
|
|||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Verification status for Flatpak/Snap apps
|
||||||
|
const { isVerified, getVerificationSource } = useVerification();
|
||||||
|
|
||||||
// Handle "/" key to focus search
|
// Handle "/" key to focus search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -287,6 +291,8 @@ export default function Home() {
|
|||||||
categoryIndex={catIdx}
|
categoryIndex={catIdx}
|
||||||
onCategoryFocus={() => setFocusByItem('category', category)}
|
onCategoryFocus={() => setFocusByItem('category', category)}
|
||||||
onAppFocus={(appId) => setFocusByItem('app', appId)}
|
onAppFocus={(appId) => setFocusByItem('app', appId)}
|
||||||
|
isVerified={isVerified}
|
||||||
|
getVerificationSource={getVerificationSource}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -326,6 +332,8 @@ export default function Home() {
|
|||||||
categoryIndex={globalIdx + catIdx}
|
categoryIndex={globalIdx + catIdx}
|
||||||
onCategoryFocus={() => setFocusByItem('category', category)}
|
onCategoryFocus={() => setFocusByItem('category', category)}
|
||||||
onAppFocus={(appId) => setFocusByItem('app', appId)}
|
onAppFocus={(appId) => setFocusByItem('app', appId)}
|
||||||
|
isVerified={isVerified}
|
||||||
|
getVerificationSource={getVerificationSource}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ interface AppItemProps {
|
|||||||
onTooltipLeave: () => void;
|
onTooltipLeave: () => void;
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
color?: string;
|
color?: string;
|
||||||
|
// Flatpak/Snap verification status
|
||||||
|
isVerified?: boolean;
|
||||||
|
verificationSource?: 'flathub' | 'snap' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppItem = memo(function AppItem({
|
export const AppItem = memo(function AppItem({
|
||||||
@@ -56,6 +59,8 @@ export const AppItem = memo(function AppItem({
|
|||||||
onTooltipLeave,
|
onTooltipLeave,
|
||||||
onFocus,
|
onFocus,
|
||||||
color = 'gray',
|
color = 'gray',
|
||||||
|
isVerified = false,
|
||||||
|
verificationSource = null,
|
||||||
}: AppItemProps) {
|
}: AppItemProps) {
|
||||||
// Why isn't this app available? Tell the user.
|
// Why isn't this app available? Tell the user.
|
||||||
const getUnavailableText = () => {
|
const getUnavailableText = () => {
|
||||||
@@ -135,13 +140,26 @@ export const AppItem = memo(function AppItem({
|
|||||||
{app.name}
|
{app.name}
|
||||||
</span>
|
</span>
|
||||||
{isAur && (
|
{isAur && (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<svg
|
||||||
<img
|
|
||||||
src="https://api.iconify.design/simple-icons/archlinux.svg?color=%231793d1"
|
|
||||||
className="ml-1.5 w-3 h-3 flex-shrink-0 opacity-80"
|
className="ml-1.5 w-3 h-3 flex-shrink-0 opacity-80"
|
||||||
alt="AUR"
|
viewBox="0 0 24 24"
|
||||||
title="This is an AUR package"
|
fill="#1793d1"
|
||||||
/>
|
aria-label="AUR package"
|
||||||
|
>
|
||||||
|
<title>This is an AUR package</title>
|
||||||
|
<path d="M12 0c-.39 0-.77.126-1.11.365a2.22 2.22 0 0 0-.82 1.056L0 24h4.15l2.067-5.58h11.666L19.95 24h4.05L13.91 1.42A2.24 2.24 0 0 0 12 0zm0 4.542l5.77 15.548H6.23l5.77-15.548z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{isVerified && verificationSource && (
|
||||||
|
<svg
|
||||||
|
className="ml-1 w-3.5 h-3.5 flex-shrink-0 opacity-90"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill={verificationSource === 'flathub' ? '#4A90D9' : '#82BEA0'}
|
||||||
|
aria-label={verificationSource === 'flathub' ? 'Verified on Flathub' : 'Verified publisher on Snap Store'}
|
||||||
|
>
|
||||||
|
<title>{verificationSource === 'flathub' ? 'Verified on Flathub' : 'Verified publisher on Snap Store'}</title>
|
||||||
|
<path d="M23 12l-2.44-2.79.34-3.69-3.61-.82-1.89-3.2L12 2.96 8.6 1.5 6.71 4.69 3.1 5.5l.34 3.7L1 12l2.44 2.79-.34 3.7 3.61.82 1.89 3.2 3.4-1.47 3.4 1.46 1.89-3.19 3.61-.82-.34-3.69L23 12m-12.91 4.72l-3.8-3.81 1.48-1.48 2.32 2.33 5.85-5.87 1.48 1.48-7.33 7.35z" />
|
||||||
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Exclamation mark icon for unavailable apps */}
|
{/* Exclamation mark icon for unavailable apps */}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ interface CategorySectionProps {
|
|||||||
categoryIndex: number;
|
categoryIndex: number;
|
||||||
onCategoryFocus?: () => void;
|
onCategoryFocus?: () => void;
|
||||||
onAppFocus?: (appId: string) => void;
|
onAppFocus?: (appId: string) => void;
|
||||||
|
// Flatpak/Snap verification status
|
||||||
|
isVerified?: (distro: DistroId, packageName: string) => boolean;
|
||||||
|
getVerificationSource?: (distro: DistroId, packageName: string) => 'flathub' | 'snap' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +71,8 @@ function CategorySectionComponent({
|
|||||||
categoryIndex,
|
categoryIndex,
|
||||||
onCategoryFocus,
|
onCategoryFocus,
|
||||||
onAppFocus,
|
onAppFocus,
|
||||||
|
isVerified,
|
||||||
|
getVerificationSource,
|
||||||
}: CategorySectionProps) {
|
}: CategorySectionProps) {
|
||||||
const selectedInCategory = categoryApps.filter(a => selectedApps.has(a.id)).length;
|
const selectedInCategory = categoryApps.filter(a => selectedApps.has(a.id)).length;
|
||||||
const isCategoryFocused = focusedType === 'category' && focusedId === category;
|
const isCategoryFocused = focusedType === 'category' && focusedId === category;
|
||||||
@@ -162,6 +167,15 @@ function CategorySectionComponent({
|
|||||||
onTooltipLeave={onTooltipLeave}
|
onTooltipLeave={onTooltipLeave}
|
||||||
onFocus={() => onAppFocus?.(app.id)}
|
onFocus={() => onAppFocus?.(app.id)}
|
||||||
color={color}
|
color={color}
|
||||||
|
isVerified={
|
||||||
|
(selectedDistro === 'flatpak' || selectedDistro === 'snap') &&
|
||||||
|
isVerified?.(selectedDistro, app.targets?.[selectedDistro] || '') || false
|
||||||
|
}
|
||||||
|
verificationSource={
|
||||||
|
(selectedDistro === 'flatpak' || selectedDistro === 'snap')
|
||||||
|
? getVerificationSource?.(selectedDistro, app.targets?.[selectedDistro] || '') || null
|
||||||
|
: null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -169,10 +183,7 @@ function CategorySectionComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Custom memo comparison - React's shallow compare was killing perf
|
||||||
* Custom memo comparison because React's shallow compare was killing perf.
|
|
||||||
* This is the kind of thing that makes you question your career choices.
|
|
||||||
*/
|
|
||||||
export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => {
|
export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => {
|
||||||
// Always re-render if app count changes
|
// Always re-render if app count changes
|
||||||
if (prevProps.categoryApps.length !== nextProps.categoryApps.length) return false;
|
if (prevProps.categoryApps.length !== nextProps.categoryApps.length) return false;
|
||||||
@@ -190,6 +201,10 @@ export const CategorySection = memo(CategorySectionComponent, (prevProps, nextPr
|
|||||||
if (prevProps.focusedType !== nextProps.focusedType) return false;
|
if (prevProps.focusedType !== nextProps.focusedType) return false;
|
||||||
if (prevProps.categoryIndex !== nextProps.categoryIndex) return false;
|
if (prevProps.categoryIndex !== nextProps.categoryIndex) return false;
|
||||||
|
|
||||||
|
// Re-render when verification functions change (Flathub data loads)
|
||||||
|
if (prevProps.isVerified !== nextProps.isVerified) return false;
|
||||||
|
if (prevProps.getVerificationSource !== nextProps.getVerificationSource) return false;
|
||||||
|
|
||||||
// Check if selection state changed for any app in this category
|
// Check if selection state changed for any app in this category
|
||||||
for (const app of nextProps.categoryApps) {
|
for (const app of nextProps.categoryApps) {
|
||||||
if (prevProps.selectedApps.has(app.id) !== nextProps.selectedApps.has(app.id)) {
|
if (prevProps.selectedApps.has(app.id) !== nextProps.selectedApps.has(app.id)) {
|
||||||
|
|||||||
78
src/hooks/useVerification.ts
Normal file
78
src/hooks/useVerification.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import type { DistroId } from '@/lib/data';
|
||||||
|
import {
|
||||||
|
fetchFlathubVerifiedApps,
|
||||||
|
isFlathubVerified,
|
||||||
|
isSnapVerified,
|
||||||
|
} from '@/lib/verification';
|
||||||
|
|
||||||
|
export interface UseVerificationResult {
|
||||||
|
isLoading: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
isVerified: (distro: DistroId, packageName: string) => boolean;
|
||||||
|
getVerificationSource: (distro: DistroId, packageName: string) => 'flathub' | 'snap' | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches Flathub data on mount, Snap uses static list (instant)
|
||||||
|
export function useVerification(): UseVerificationResult {
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const [flathubReady, setFlathubReady] = useState(false);
|
||||||
|
const fetchedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetchedRef.current) return;
|
||||||
|
fetchedRef.current = true;
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
fetchFlathubVerifiedApps()
|
||||||
|
.then(() => {
|
||||||
|
if (isMounted) setFlathubReady(true);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (isMounted) {
|
||||||
|
console.error('Failed to fetch Flathub verification:', error);
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isMounted) setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Check if package is verified for the distro
|
||||||
|
const isVerified = useCallback((distro: DistroId, packageName: string): boolean => {
|
||||||
|
if (distro === 'flatpak' && flathubReady) {
|
||||||
|
return isFlathubVerified(packageName);
|
||||||
|
}
|
||||||
|
if (distro === 'snap') {
|
||||||
|
return isSnapVerified(packageName);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [flathubReady]);
|
||||||
|
|
||||||
|
// Get verification source for badge styling
|
||||||
|
const getVerificationSource = useCallback((distro: DistroId, packageName: string): 'flathub' | 'snap' | null => {
|
||||||
|
if (distro === 'flatpak' && flathubReady && isFlathubVerified(packageName)) {
|
||||||
|
return 'flathub';
|
||||||
|
}
|
||||||
|
if (distro === 'snap' && isSnapVerified(packageName)) {
|
||||||
|
return 'snap';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [flathubReady]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
isVerified,
|
||||||
|
getVerificationSource,
|
||||||
|
};
|
||||||
|
}
|
||||||
188
src/lib/verification.ts
Normal file
188
src/lib/verification.ts
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// Flatpak/Snap verification status - shows badges for verified publishers
|
||||||
|
|
||||||
|
// Flathub API response shape
|
||||||
|
interface FlathubSearchResponse {
|
||||||
|
hits: Array<{
|
||||||
|
app_id: string;
|
||||||
|
verification_verified: boolean;
|
||||||
|
}>;
|
||||||
|
totalPages: number;
|
||||||
|
totalHits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level cache
|
||||||
|
let flathubVerifiedCache: Set<string> | null = null;
|
||||||
|
|
||||||
|
// localStorage cache key and TTL (1 hour)
|
||||||
|
const CACHE_KEY = 'tuxmate_verified_flatpaks';
|
||||||
|
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Known verified Snap publishers (static list - Snapcraft API doesn't support CORS)
|
||||||
|
const KNOWN_VERIFIED_SNAP_PACKAGES = new Set([
|
||||||
|
// Mozilla
|
||||||
|
'firefox', 'thunderbird',
|
||||||
|
// Canonical/Ubuntu
|
||||||
|
'chromium',
|
||||||
|
// Brave
|
||||||
|
'brave',
|
||||||
|
// Spotify
|
||||||
|
'spotify',
|
||||||
|
// Microsoft
|
||||||
|
'code',
|
||||||
|
// JetBrains
|
||||||
|
'intellij-idea-community', 'intellij-idea-ultimate', 'pycharm-community', 'pycharm-professional',
|
||||||
|
// Slack
|
||||||
|
'slack',
|
||||||
|
// Discord
|
||||||
|
'discord',
|
||||||
|
// Signal
|
||||||
|
'signal-desktop',
|
||||||
|
// Telegram
|
||||||
|
'telegram-desktop',
|
||||||
|
// Zoom
|
||||||
|
'zoom-client',
|
||||||
|
// Obsidian
|
||||||
|
'obsidian',
|
||||||
|
// Bitwarden
|
||||||
|
'bitwarden',
|
||||||
|
// Creative
|
||||||
|
'blender', 'gimp', 'inkscape', 'krita',
|
||||||
|
// Media
|
||||||
|
'vlc', 'obs-studio',
|
||||||
|
// Office
|
||||||
|
'libreoffice',
|
||||||
|
// Dev
|
||||||
|
'node', 'go', 'rustup', 'ruby', 'cmake', 'docker', 'kubectl',
|
||||||
|
// Gaming
|
||||||
|
'steam', 'retroarch',
|
||||||
|
// Browser
|
||||||
|
'vivaldi',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Try to load from localStorage cache
|
||||||
|
function loadFromCache(): Set<string> | null {
|
||||||
|
try {
|
||||||
|
const cached = localStorage.getItem(CACHE_KEY);
|
||||||
|
if (!cached) return null;
|
||||||
|
|
||||||
|
const { data, timestamp } = JSON.parse(cached);
|
||||||
|
if (Date.now() - timestamp > CACHE_TTL_MS) {
|
||||||
|
localStorage.removeItem(CACHE_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set(data);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to localStorage cache
|
||||||
|
function saveToCache(apps: Set<string>): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify({
|
||||||
|
data: Array.from(apps),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// localStorage might be full or disabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch a single page
|
||||||
|
async function fetchPage(page: number): Promise<string[]> {
|
||||||
|
const response = await fetch('https://flathub.org/api/v2/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: '',
|
||||||
|
filter: 'verification_verified=true',
|
||||||
|
page,
|
||||||
|
hitsPerPage: 250,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const data: FlathubSearchResponse = await response.json();
|
||||||
|
return data.hits
|
||||||
|
.filter(h => h.verification_verified && h.app_id)
|
||||||
|
.map(h => h.app_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all verified Flatpak app IDs (parallel + cached)
|
||||||
|
export async function fetchFlathubVerifiedApps(): Promise<Set<string>> {
|
||||||
|
// Return memory cache if available
|
||||||
|
if (flathubVerifiedCache !== null) {
|
||||||
|
return flathubVerifiedCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try localStorage cache
|
||||||
|
const cached = loadFromCache();
|
||||||
|
if (cached) {
|
||||||
|
flathubVerifiedCache = cached;
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch page 1 to get totalPages
|
||||||
|
try {
|
||||||
|
const firstResponse = await fetch('https://flathub.org/api/v2/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: '',
|
||||||
|
filter: 'verification_verified=true',
|
||||||
|
page: 1,
|
||||||
|
hitsPerPage: 250,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!firstResponse.ok) {
|
||||||
|
console.warn('Flathub API returned', firstResponse.status);
|
||||||
|
flathubVerifiedCache = new Set();
|
||||||
|
return flathubVerifiedCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstData: FlathubSearchResponse = await firstResponse.json();
|
||||||
|
const verifiedApps = new Set<string>(
|
||||||
|
firstData.hits.filter(h => h.verification_verified && h.app_id).map(h => h.app_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch remaining pages in parallel (limit to 20 pages = 5,000 apps)
|
||||||
|
const totalPages = Math.min(firstData.totalPages, 20);
|
||||||
|
if (totalPages > 1) {
|
||||||
|
const pagePromises = [];
|
||||||
|
for (let p = 2; p <= totalPages; p++) {
|
||||||
|
pagePromises.push(fetchPage(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(pagePromises);
|
||||||
|
for (const appIds of results) {
|
||||||
|
for (const id of appIds) {
|
||||||
|
verifiedApps.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flathubVerifiedCache = verifiedApps;
|
||||||
|
saveToCache(verifiedApps);
|
||||||
|
return verifiedApps;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to fetch Flathub verification data:', error);
|
||||||
|
flathubVerifiedCache = new Set();
|
||||||
|
return flathubVerifiedCache;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a Flatpak app ID is verified
|
||||||
|
export function isFlathubVerified(appId: string): boolean {
|
||||||
|
return flathubVerifiedCache?.has(appId) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a Snap package is from a verified publisher
|
||||||
|
export function isSnapVerified(snapName: string): boolean {
|
||||||
|
const cleanName = snapName.split(' ')[0];
|
||||||
|
return KNOWN_VERIFIED_SNAP_PACKAGES.has(cleanName);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user