mirror of
https://github.com/abusoww/tuxmate.git
synced 2026-04-17 19:53:11 +02:00
committed by
GitHub
parent
0dc0ef830c
commit
39dbc468cc
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
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