Add verified app badges for Flatpak and Snap apps (#49) closes #48

This commit is contained in:
Dhananjay Singh Chauhan
2026-01-25 08:43:45 -05:00
committed by GitHub
parent 0dc0ef830c
commit 39dbc468cc
5 changed files with 317 additions and 10 deletions

View File

@@ -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<HTMLInputElement>(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}
/>
))}
</div>
@@ -326,6 +332,8 @@ export default function Home() {
categoryIndex={globalIdx + catIdx}
onCategoryFocus={() => setFocusByItem('category', category)}
onAppFocus={(appId) => setFocusByItem('app', appId)}
isVerified={isVerified}
getVerificationSource={getVerificationSource}
/>
))}
</div>

View File

@@ -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}
</span>
{isAur && (
// eslint-disable-next-line @next/next/no-img-element
<img
src="https://api.iconify.design/simple-icons/archlinux.svg?color=%231793d1"
<svg
className="ml-1.5 w-3 h-3 flex-shrink-0 opacity-80"
alt="AUR"
title="This is an AUR package"
/>
viewBox="0 0 24 24"
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>
{/* Exclamation mark icon for unavailable apps */}

View File

@@ -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
}
/>
))}
</div>
@@ -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)) {

View 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
View 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);
}