diff --git a/src/app/page.tsx b/src/app/page.tsx index f887c5e..feef525 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,7 @@ import gsap from 'gsap'; import { useLinuxInit } from '@/hooks/useLinuxInit'; import { useTooltip } from '@/hooks/useTooltip'; import { useKeyboardNavigation, type NavItem } from '@/hooks/useKeyboardNavigation'; +import { useVerification } from '@/hooks/useVerification'; // Data import { categories, getAppsByCategory } from '@/lib/data'; @@ -47,6 +48,9 @@ export default function Home() { const [searchQuery, setSearchQuery] = useState(''); const searchInputRef = useRef(null); + // Verification status for Flatpak/Snap apps + const { isVerified, getVerificationSource } = useVerification(); + // Handle "/" key to focus search useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -287,6 +291,8 @@ export default function Home() { categoryIndex={catIdx} onCategoryFocus={() => setFocusByItem('category', category)} onAppFocus={(appId) => setFocusByItem('app', appId)} + isVerified={isVerified} + getVerificationSource={getVerificationSource} /> ))} @@ -326,6 +332,8 @@ export default function Home() { categoryIndex={globalIdx + catIdx} onCategoryFocus={() => setFocusByItem('category', category)} onAppFocus={(appId) => setFocusByItem('app', appId)} + isVerified={isVerified} + getVerificationSource={getVerificationSource} /> ))} diff --git a/src/components/app/AppItem.tsx b/src/components/app/AppItem.tsx index e9ebc9d..071464e 100644 --- a/src/components/app/AppItem.tsx +++ b/src/components/app/AppItem.tsx @@ -43,6 +43,9 @@ interface AppItemProps { onTooltipLeave: () => void; onFocus?: () => void; color?: string; + // Flatpak/Snap verification status + isVerified?: boolean; + verificationSource?: 'flathub' | 'snap' | null; } export const AppItem = memo(function AppItem({ @@ -56,6 +59,8 @@ export const AppItem = memo(function AppItem({ onTooltipLeave, onFocus, color = 'gray', + isVerified = false, + verificationSource = null, }: AppItemProps) { // Why isn't this app available? Tell the user. const getUnavailableText = () => { @@ -135,13 +140,26 @@ export const AppItem = memo(function AppItem({ {app.name} {isAur && ( - // eslint-disable-next-line @next/next/no-img-element - AUR + viewBox="0 0 24 24" + fill="#1793d1" + aria-label="AUR package" + > + This is an AUR package + + + )} + {isVerified && verificationSource && ( + + {verificationSource === 'flathub' ? 'Verified on Flathub' : 'Verified publisher on Snap Store'} + + )} {/* Exclamation mark icon for unavailable apps */} diff --git a/src/components/app/CategorySection.tsx b/src/components/app/CategorySection.tsx index a6155ea..88e2e77 100644 --- a/src/components/app/CategorySection.tsx +++ b/src/components/app/CategorySection.tsx @@ -28,6 +28,9 @@ interface CategorySectionProps { categoryIndex: number; onCategoryFocus?: () => 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, onCategoryFocus, onAppFocus, + isVerified, + getVerificationSource, }: CategorySectionProps) { const selectedInCategory = categoryApps.filter(a => selectedApps.has(a.id)).length; const isCategoryFocused = focusedType === 'category' && focusedId === category; @@ -162,6 +167,15 @@ function CategorySectionComponent({ onTooltipLeave={onTooltipLeave} onFocus={() => onAppFocus?.(app.id)} 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 + } /> ))} @@ -169,10 +183,7 @@ function CategorySectionComponent({ ); } -/** - * Custom memo comparison because React's shallow compare was killing perf. - * This is the kind of thing that makes you question your career choices. - */ +// Custom memo comparison - React's shallow compare was killing perf export const CategorySection = memo(CategorySectionComponent, (prevProps, nextProps) => { // Always re-render if app count changes 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.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 for (const app of nextProps.categoryApps) { if (prevProps.selectedApps.has(app.id) !== nextProps.selectedApps.has(app.id)) { diff --git a/src/hooks/useVerification.ts b/src/hooks/useVerification.ts new file mode 100644 index 0000000..29373b3 --- /dev/null +++ b/src/hooks/useVerification.ts @@ -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, + }; +} diff --git a/src/lib/verification.ts b/src/lib/verification.ts new file mode 100644 index 0000000..cee2166 --- /dev/null +++ b/src/lib/verification.ts @@ -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 | 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 | 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): 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 { + 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> { + // 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( + 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); +}