diff --git a/src/services/ecosystem/registry.ts b/src/services/ecosystem/registry.ts index bfce6dcbd..cb35fa91a 100644 --- a/src/services/ecosystem/registry.ts +++ b/src/services/ecosystem/registry.ts @@ -7,341 +7,287 @@ * - Provide merged app catalog + helpers (ranking/search) */ -import { ecosystemStore, ecosystemSelectors, ecosystemActions } from '@/stores/ecosystem' -import type { EcosystemSource, MiniappManifest, SourceRecord } from './types' -import { EcosystemSearchResponseSchema, EcosystemSourceSchema } from './schema' -import { loadSourcePayload, saveSourcePayload } from './storage' -import { computeFeaturedScore } from './scoring' -import { createResolver } from '@/lib/url-resolver' - -const REQUEST_TIMEOUT = 10_000 +import { keyFetch, etag, cache, IndexedDBCacheStorage, ttl } from '@biochain/key-fetch'; +import { ecosystemStore, ecosystemSelectors, ecosystemActions } from '@/stores/ecosystem'; +import type { EcosystemSource, MiniappManifest, SourceRecord } from './types'; +import { EcosystemSearchResponseSchema, EcosystemSourceSchema } from './schema'; +import { computeFeaturedScore } from './scoring'; +import { createResolver } from '@/lib/url-resolver'; + +const SUPPORTED_SEARCH_RESPONSE_VERSIONS = new Set(['1', '1.0.0']); + +const ecosystemSourceStorage = new IndexedDBCacheStorage('ecosystem-sources', 'sources'); + +function createSourceFetcher(url: string) { + return keyFetch.create({ + name: `ecosystem.source.${url}`, + outputSchema: EcosystemSourceSchema, + url, + use: [etag(), cache({ storage: ecosystemSourceStorage }), ttl(5 * 60 * 1000)], + }); +} -const SUPPORTED_SEARCH_RESPONSE_VERSIONS = new Set(['1', '1.0.0']) +function createSearchFetcher(url: string) { + return keyFetch.create({ + name: `ecosystem.search.${url}`, + outputSchema: EcosystemSearchResponseSchema, + url, + use: [ttl(30 * 1000)], + }); +} -/** Cached apps from enabled sources */ -let cachedApps: MiniappManifest[] = [] +let cachedApps: MiniappManifest[] = []; -type AppsSubscriber = (apps: MiniappManifest[]) => void -const appSubscribers: AppsSubscriber[] = [] +type AppsSubscriber = (apps: MiniappManifest[]) => void; +const appSubscribers: AppsSubscriber[] = []; function notifyApps(): void { - const snapshot = [...cachedApps] - appSubscribers.forEach((fn) => fn(snapshot)) + const snapshot = [...cachedApps]; + appSubscribers.forEach((fn) => fn(snapshot)); } export function subscribeApps(listener: AppsSubscriber): () => void { - appSubscribers.push(listener) + appSubscribers.push(listener); return () => { - const index = appSubscribers.indexOf(listener) - if (index >= 0) appSubscribers.splice(index, 1) - } + const index = appSubscribers.indexOf(listener); + if (index >= 0) appSubscribers.splice(index, 1); + }; } function isValidAppId(appId: string): boolean { - // Reverse-domain style: com.domain.my-app (must contain at least one dot) - return /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/i.test(appId) + return /^[a-z0-9]+(?:\.[a-z0-9-]+)+$/i.test(appId); } -function normalizeAppFromSource(app: MiniappManifest, source: SourceRecord, payload: EcosystemSource): MiniappManifest | null { +function normalizeAppFromSource( + app: MiniappManifest, + source: SourceRecord, + payload: EcosystemSource, +): MiniappManifest | null { if (!isValidAppId(app.id)) { - - return null + return null; } - // 创建基于 source URL 的路径解析器 - const resolve = createResolver(source.url) + const resolve = createResolver(source.url); return { ...app, - // 解析相对路径 icon: resolve(app.icon), url: resolve(app.url), screenshots: app.screenshots?.map(resolve), - // splashScreen 现在是 true | { timeout?: number },不再需要解析 icon splashScreen: app.splashScreen, - // 来源元数据 sourceUrl: source.url, sourceName: source.name, sourceIcon: source.icon ?? (payload.icon ? resolve(payload.icon) : undefined), - } + }; } -function requestWithTimeout(input: RequestInfo | URL, init?: RequestInit, timeoutMs = REQUEST_TIMEOUT): Promise { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeoutMs) - - return fetch(input, { ...init, signal: controller.signal }) - .finally(() => clearTimeout(timeoutId)) +async function fetchSourceWithCache(url: string): Promise { + try { + const fetcher = createSourceFetcher(url); + return await fetcher.fetch({}); + } catch { + return null; + } } -async function fetchSourceWithCache(url: string, force: boolean): Promise { - const cached = await loadSourcePayload(url) - const etag = !force ? cached?.etag : undefined +async function rebuildCachedAppsFromSources( + sources: Array<{ source: SourceRecord; payload: EcosystemSource | null }>, +): Promise { + const next: MiniappManifest[] = []; + const seen = new Set(); - try { - const response = await requestWithTimeout(url, { - method: 'GET', - headers: { - Accept: 'application/json', - ...(etag !== undefined ? { 'If-None-Match': etag } : {}), - }, - }) - - if (response.status === 304) { - return cached?.payload ?? null - } + for (const { source, payload } of sources) { + if (!payload) continue; - if (!response.ok) { - - return cached?.payload ?? null - } + for (const app of payload.apps ?? []) { + const normalized = normalizeAppFromSource(app, source, payload); + if (!normalized) continue; - const json: unknown = await response.json() - const parsed = EcosystemSourceSchema.safeParse(json) - if (!parsed.success) { - - return cached?.payload ?? null + if (seen.has(normalized.id)) continue; + seen.add(normalized.id); + next.push(normalized); } - - const payload = parsed.data as EcosystemSource - const responseEtag = response.headers.get('ETag') ?? undefined - - await saveSourcePayload({ - url, - payload, - ...(responseEtag !== undefined ? { etag: responseEtag } : {}), - lastUpdated: new Date().toISOString(), - }) - - return payload - } catch (error) { - // Error occurred, return cached payload if available - return cached?.payload ?? null } + + cachedApps = next; + notifyApps(); } async function rebuildCachedAppsFromCache(): Promise { - const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state) + const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state); const entries = await Promise.all( enabledSources.map(async (source) => { - const cached = await loadSourcePayload(source.url) - return { source, cached } + const payload = await fetchSourceWithCache(source.url); + return { source, payload }; }), - ) - - const next: MiniappManifest[] = [] - const seen = new Set() - - for (const { source, cached } of entries) { - const payload = cached?.payload - if (!payload) continue - - for (const app of payload.apps ?? []) { - const normalized = normalizeAppFromSource(app, source, payload) - if (!normalized) continue + ); - if (seen.has(normalized.id)) { - - continue - } - seen.add(normalized.id) - next.push(normalized) - } - } - - cachedApps = next - notifyApps() + await rebuildCachedAppsFromSources(entries); } -let lastSourcesSignature = '' +let lastSourcesSignature = ''; ecosystemStore.subscribe(() => { - // Only react to source config changes (not permissions). - const nextSignature = JSON.stringify(ecosystemStore.state.sources) - if (nextSignature === lastSourcesSignature) return - lastSourcesSignature = nextSignature - void rebuildCachedAppsFromCache() -}) - -/** Initialize registry: load cached apps first, then refresh in background */ + const nextSignature = JSON.stringify(ecosystemStore.state.sources); + if (nextSignature === lastSourcesSignature) return; + lastSourcesSignature = nextSignature; + void rebuildCachedAppsFromCache(); +}); + export async function initRegistry(options?: { refresh?: boolean }): Promise { - await rebuildCachedAppsFromCache() + await rebuildCachedAppsFromCache(); if (options?.refresh === true) { - void refreshSources({ force: false }) + void refreshSources({ force: false }); } } -/** Get all sources (from ecosystemStore) */ export function getSources(): SourceRecord[] { - // ecosystemStore is the single source of truth for source configs. - return ecosystemStore.state.sources.map((s) => ({ - url: s.url, - name: s.name, - enabled: s.enabled, - lastUpdated: s.lastUpdated, - })) + return ecosystemStore.state.sources; } -/** Fetch enabled sources and update cache + store timestamp */ export async function refreshSources(options?: { force?: boolean }): Promise { - const force = options?.force === true - const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state) + const force = options?.force === true; + const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state); + + if (force) { + keyFetch.invalidate('ecosystem.source'); + } const results = await Promise.all( enabledSources.map(async (source) => { - const payload = await fetchSourceWithCache(source.url, force) - if (payload) { - ecosystemActions.updateSourceTimestamp(source.url) + ecosystemActions.updateSourceStatus(source.url, 'loading'); + try { + const fetcher = createSourceFetcher(source.url); + const payload = await fetcher.fetch({}); + ecosystemActions.updateSourceStatus(source.url, 'success'); + return { source, payload }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + ecosystemActions.updateSourceStatus(source.url, 'error', message); + return { source, payload: null }; } - return { source, payload } }), - ) - - // Rebuild from payloads fetched (avoid extra IndexedDB reads) - const next: MiniappManifest[] = [] - const seen = new Set() + ); - for (const { source, payload } of results) { - if (!payload) continue - for (const app of payload.apps ?? []) { - const normalized = normalizeAppFromSource(app, source, payload) - if (!normalized) continue + await rebuildCachedAppsFromSources(results); + return [...cachedApps]; +} - if (seen.has(normalized.id)) { - - continue - } - seen.add(normalized.id) - next.push(normalized) +export async function refreshSource(url: string): Promise { + ecosystemActions.updateSourceStatus(url, 'loading'); + try { + keyFetch.invalidate(`ecosystem.source.${url}`); + const fetcher = createSourceFetcher(url); + const payload = await fetcher.fetch({}); + if (payload) { + ecosystemActions.updateSourceStatus(url, 'success'); + await rebuildCachedAppsFromCache(); + } else { + ecosystemActions.updateSourceStatus(url, 'error', 'Failed to fetch source'); } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + ecosystemActions.updateSourceStatus(url, 'error', message); } - - cachedApps = next - notifyApps() - - - return [...cachedApps] } export async function loadSource(url: string): Promise { - const cached = await loadSourcePayload(url) - return cached?.payload ?? null + return fetchSourceWithCache(url); } -/** Get all cached apps */ export function getApps(): MiniappManifest[] { - return [...cachedApps] + return [...cachedApps]; } -/** Get app by ID */ export function getAppById(id: string): MiniappManifest | undefined { - return cachedApps.find((app) => app.id === id) + return cachedApps.find((app) => app.id === id); } export async function getAllApps(): Promise { - return getApps() + return getApps(); } export async function getFeaturedApps(limit: number, date: Date = new Date()): Promise { - const apps = getApps() - const ranked = [...apps].toSorted((a, b) => computeFeaturedScore(b, date) - computeFeaturedScore(a, date)) - return ranked.slice(0, Math.max(0, limit)) + const apps = getApps(); + const ranked = [...apps].toSorted((a, b) => computeFeaturedScore(b, date) - computeFeaturedScore(a, date)); + return ranked.slice(0, Math.max(0, limit)); } export async function searchCachedApps(query: string): Promise { - const q = query.trim().toLowerCase() - if (!q) return [] + const q = query.trim().toLowerCase(); + if (!q) return []; return getApps().filter((app) => { - const haystacks = [ - app.name, - app.description, - app.longDescription ?? '', - ...(app.tags ?? []), - ] - return haystacks.some((v) => v.toLowerCase().includes(q)) - }) + const haystacks = [app.name, app.description, app.longDescription ?? '', ...(app.tags ?? [])]; + return haystacks.some((v) => v.toLowerCase().includes(q)); + }); } function isSupportedSearchResponseVersion(version: string): boolean { - if (SUPPORTED_SEARCH_RESPONSE_VERSIONS.has(version)) return true - // Allow "1.x" as forward-compatible minor bumps. - if (version.startsWith('1.')) return true - return false + if (SUPPORTED_SEARCH_RESPONSE_VERSIONS.has(version)) return true; + if (version.startsWith('1.')) return true; + return false; } async function fetchRemoteSearch(source: SourceRecord, urlTemplate: string, query: string): Promise { - const encoded = encodeURIComponent(query) - const url = urlTemplate.replace(/%s/g, encoded) - - const response = await requestWithTimeout(url, { - method: 'GET', - headers: { Accept: 'application/json' }, - }) + const encoded = encodeURIComponent(query); + const url = urlTemplate.replace(/%s/g, encoded); - if (!response.ok) { - - return [] - } + try { + const fetcher = createSearchFetcher(url); + const response = await fetcher.fetch({}); + const { version, data } = response as { version: string; data: MiniappManifest[] }; - const json: unknown = await response.json() - const parsed = EcosystemSearchResponseSchema.safeParse(json) - if (!parsed.success) { - - return [] - } + if (!isSupportedSearchResponseVersion(version)) { + return []; + } - const { version, data } = parsed.data as { version: string; data: MiniappManifest[] } - if (!isSupportedSearchResponseVersion(version)) { - - return [] + return data + .map((app) => ({ + ...app, + sourceUrl: source.url, + sourceName: source.name, + })) + .filter((app) => isValidAppId(app.id)); + } catch { + return []; } - - // Attach source metadata and validate appId. - return data - .map((app) => ({ - ...app, - sourceUrl: source.url, - sourceName: source.name, - })) - .filter((app) => { - if (isValidAppId(app.id)) return true - - return false - }) } export async function searchRemoteApps(query: string): Promise { - const q = query.trim() - if (!q) return [] + const q = query.trim(); + if (!q) return []; - const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state) + const enabledSources = ecosystemSelectors.getEnabledSources(ecosystemStore.state); const cachedPayloads = await Promise.all( enabledSources.map(async (source) => ({ source, - cached: await loadSourcePayload(source.url), + payload: await fetchSourceWithCache(source.url), })), - ) + ); const searchTargets = cachedPayloads - .map(({ source, cached }) => ({ source, urlTemplate: cached?.payload.search?.urlTemplate })) - .filter((t): t is { source: SourceRecord; urlTemplate: string } => typeof t.urlTemplate === 'string' && t.urlTemplate.length > 0) + .map(({ source, payload }) => ({ source, urlTemplate: payload?.search?.urlTemplate })) + .filter( + (t): t is { source: SourceRecord; urlTemplate: string } => + typeof t.urlTemplate === 'string' && t.urlTemplate.length > 0, + ); const results = await Promise.all( searchTargets.map(({ source, urlTemplate }) => fetchRemoteSearch(source, urlTemplate, q)), - ) + ); - const merged: MiniappManifest[] = [] - const seen = new Set() + const merged: MiniappManifest[] = []; + const seen = new Set(); for (const list of results) { for (const app of list) { - if (seen.has(app.id)) continue - seen.add(app.id) - merged.push(app) + if (seen.has(app.id)) continue; + seen.add(app.id); + merged.push(app); } } - return merged + return merged; } diff --git a/src/services/ecosystem/types.ts b/src/services/ecosystem/types.ts index 1102adbff..1683e7ff7 100644 --- a/src/services/ecosystem/types.ts +++ b/src/services/ecosystem/types.ts @@ -252,17 +252,8 @@ export interface EcosystemSource { apps: MiniappManifest[]; } -/** 订阅源记录 - 本地存储格式 */ -export interface SourceRecord { - url: string; - name: string; - enabled: boolean; - lastUpdated: string; - /** 图标 URL,默认使用 https 锁图标 */ - icon?: string; - /** 是否为内置源 */ - builtin?: boolean; -} +/** 订阅源记录 - 从 store 重新导出 */ +export type { SourceRecord, SourceStatus } from '@/stores/ecosystem'; /** * My Apps - Local installed app record diff --git a/src/stackflow/activities/SettingsSourcesActivity.tsx b/src/stackflow/activities/SettingsSourcesActivity.tsx index 9d74e4ae4..4b2838efc 100644 --- a/src/stackflow/activities/SettingsSourcesActivity.tsx +++ b/src/stackflow/activities/SettingsSourcesActivity.tsx @@ -9,9 +9,19 @@ import { AppScreen } from '@stackflow/plugin-basic-ui'; import { useTranslation } from 'react-i18next'; import { useStore } from '@tanstack/react-store'; import { cn } from '@/lib/utils'; -import { IconPlus, IconTrash, IconRefresh, IconCheck, IconX, IconWorld, IconArrowLeft } from '@tabler/icons-react'; +import { + IconPlus, + IconTrash, + IconRefresh, + IconCheck, + IconX, + IconWorld, + IconArrowLeft, + IconLoader2, + IconAlertCircle, +} from '@tabler/icons-react'; import { ecosystemStore, ecosystemActions, type SourceRecord } from '@/stores/ecosystem'; -import { refreshSources } from '@/services/ecosystem/registry'; +import { refreshSources, refreshSource } from '@/services/ecosystem/registry'; import { useFlow } from '../stackflow'; export const SettingsSourcesActivity: ActivityComponentType = () => { @@ -50,6 +60,7 @@ export const SettingsSourcesActivity: ActivityComponentType = () => { setNewName(''); setIsAdding(false); setError(null); + void refreshSource(newUrl); }; const handleRemove = (url: string) => { @@ -174,33 +185,45 @@ interface SourceItemProps { } function SourceItem({ source, onToggle, onRemove }: SourceItemProps) { - const isDefault = source.url === '/ecosystem.json'; + const { t } = useTranslation('common'); + const isDefault = source.url.includes('ecosystem.json'); + + const statusIcon = { + idle: null, + loading: , + success: , + error: , + }[source.status]; return (
- {/* Icon */}
- {/* Info */}

{source.name}

- {isDefault && 官方} + {isDefault && ( + {t('sources.official')} + )} + {statusIcon}

{source.url}

-

- 更新于 {new Date(source.lastUpdated).toLocaleDateString()} -

+
+

+ {t('sources.updatedAt', { date: new Date(source.lastUpdated).toLocaleString() })} +

+ {source.status === 'error' && source.errorMessage && ( +

{source.errorMessage}

+ )} +
- {/* Actions */}
- {/* Toggle */} - {/* Remove (not for default) */} {!isDefault && (