/** 只请求用户配置来源;公钥固定、签名及摘要检查先于任何安装 API。 */ import type { CommunityCatalog, CommunityRelease, CommunitySource, CommunityKey } from '@/contracts/community' import { valid, gt, lt } from 'semver' import { invoke } from '@tauri-apps/api/core' import { isDesktop } from './platform/desktop' import appPackage from '../../package.json' import { decodeThemePackage, inspectThemePackage, installTheme } from './themePackageService' import { installSkill } from './skillService' import { installPlugin } from './pluginService' const sourceStorage = 'community-sources-v1' export function loadSources(): CommunitySource[] { try { return JSON.parse(localStorage.getItem(sourceStorage) ?? '[]') as CommunitySource[] } catch { return [] } } export function saveSources(sources: CommunitySource[]) { localStorage.setItem(sourceStorage, JSON.stringify(sources)) } function sourceUrl(source: CommunitySource, path: string) { const base = new URL(source.url) if (base.username || base.password || base.search || base.hash || (base.protocol !== 'https:' && !(base.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname)))) throw new Error('来源必须是 HTTPS;本机开发可用 HTTP。') const url = new URL(path, base) if (url.origin !== base.origin || !url.pathname.startsWith('/catalog/v1/')) throw new Error('发行地址不属于已固定的社区来源') return url } async function download(source: CommunitySource, path: string, maxSize: number, signal?: AbortSignal): Promise { const controller = new AbortController() const abort = () => controller.abort() signal?.addEventListener('abort', abort, { once: true }) if (signal?.aborted) abort() const timeout = setTimeout(abort, 30000) try { const response = await fetch(sourceUrl(source, path), { credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer', signal: controller.signal }) if (!response.ok || !response.body) throw new Error(`社区请求失败 (${response.status})`) const reader = response.body.getReader(), chunks: Uint8Array[] = [] let size = 0 try { while (true) { const part = await reader.read() if (part.done) break size += part.value.length if (size > maxSize) throw new Error('社区响应超过大小限制') chunks.push(part.value) } } finally { await reader.cancel().catch(() => undefined) } const bytes = new Uint8Array(size) let offset = 0 for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length } return bytes } finally { clearTimeout(timeout); signal?.removeEventListener('abort', abort) } } export async function discoverSource(url: string, signal?: AbortSignal): Promise<{ source_id: string; keys: CommunityKey[] }> { const source: CommunitySource = { id: 'candidate', url, enabled: true, keys: [] } const value = JSON.parse(new TextDecoder().decode(await download(source, '/catalog/v1/sources', 1024 * 1024, signal))) if (value.schema_version !== 1 || typeof value.source_id !== 'string' || !value.source_id || !Array.isArray(value.keys) || value.keys.length > 64) throw new Error('不支持的社区来源协议') return { source_id: value.source_id, keys: value.keys } } export async function discoverKeys(url: string, signal?: AbortSignal): Promise { return (await discoverSource(url, signal)).keys } export async function fetchCatalog(source: CommunitySource, q = '', kind = '', signal?: AbortSignal): Promise { if (!source.enabled) throw new Error('来源已停用') const value = JSON.parse(new TextDecoder().decode(await download(source, `/catalog/v1/packages?q=${encodeURIComponent(q)}${kind ? `&type=${encodeURIComponent(kind)}` : ''}&limit=100`, 2 * 1024 * 1024, signal))) if (value.schema_version !== 1 || !Array.isArray(value.items)) throw new Error('不支持的社区目录协议') // 缓存只用于离线浏览;安装仍会重新拉取发行与撤回状态。 localStorage.setItem(`community-cache:${source.id}`, JSON.stringify(value)) return value } export function cachedCatalog(source: CommunitySource): CommunityCatalog | null { try { return JSON.parse(localStorage.getItem(`community-cache:${source.id}`) ?? 'null') } catch { return null } } function canonical(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]` if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical((value as Record)[key])}`).join(',')}}` return JSON.stringify(value) } const bytes64 = (value: string) => Uint8Array.from(atob(value), char => char.charCodeAt(0)) const signedFields = ['schema_version', 'namespace', 'package_id', 'type', 'version', 'name', 'author_id', 'license', 'description', 'sha256', 'size', 'platforms', 'architectures', 'min_app_version', 'max_app_version', 'dependencies', 'permissions', 'changelog', 'published_at', 'key_id'] as const export async function verifyRelease(release: CommunityRelease, pinned: CommunityKey, bytes: Uint8Array) { if (release.withdrawn || pinned.revoked || pinned.key_id !== release.key_id || pinned.namespace !== release.namespace) throw new Error('发行或签名密钥已撤回,或来源不匹配') if (!valid(release.version) || !valid(release.min_app_version) || gt(release.min_app_version, appPackage.version) || (release.max_app_version && (!valid(release.max_app_version) || lt(release.max_app_version, appPackage.version)))) throw new Error('发行版本与当前应用不兼容') const key = await crypto.subtle.importKey('raw', bytes64(pinned.public_key), { name: 'Ed25519' }, false, ['verify']) const metadata = Object.fromEntries(signedFields.map(field => [field, release[field]])) if (!await crypto.subtle.verify('Ed25519', key, bytes64(release.signature), new TextEncoder().encode(canonical(metadata)))) throw new Error('发行签名无效') const digest = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes as Uint8Array))).map(x => x.toString(16).padStart(2, '0')).join('') if (digest !== release.sha256 || bytes.length !== release.size) throw new Error('发行内容摘要或长度无效') } export async function installRelease(source: CommunitySource, selected: CommunityRelease, signal?: AbortSignal): Promise { if (isDesktop()) { signal?.throwIfAborted() if (!source.enabled || selected.withdrawn) throw new Error('来源已停用或发行已撤回') const release = { ...selected } as Partial delete release.release_id; delete release.withdrawn; delete release.download_path const operationId = crypto.randomUUID() const requestId = await invoke('extension_stage_prepare') const cancel = () => { void invoke('extension_stage_cancel', { requestId }).catch(() => undefined) } signal?.addEventListener('abort', cancel, { once: true }) try { if (signal?.aborted) { cancel(); signal.throwIfAborted() } await invoke('extension_stage', { request: { request_id: requestId, operation_id: operationId, source: source.url, release } }) return '已校验并暂存到桌面安装库,尚未安装或启用' } catch (error) { if (signal?.aborted) { const receipt = await invoke<{ state: string } | null>('extension_stage_status', { operationId }).catch(() => null) if (receipt?.state === 'staged') return '取消前已完成暂存,尚未安装或启用' } throw error } finally { signal?.removeEventListener('abort', cancel); cancel() } } const catalog = await fetchCatalog(source, '', selected.type, signal) const release = catalog.items.find(item => item.release_id === selected.release_id) if (!release || release.sha256 !== selected.sha256 || release.withdrawn) throw new Error('发行已变更或撤回,请刷新目录') const liveKeys = await discoverKeys(source.url, signal) const pinned = source.keys.find(key => key.key_id === release.key_id && key.namespace === release.namespace) const live = liveKeys.find(key => key.key_id === release.key_id) if (!pinned || !live || live.revoked || live.public_key !== pinned.public_key) throw new Error('签名密钥未固定或已变更,需重新检查来源') const bytes = await download(source, release.download_path, release.type === 'theme' ? 5 * 1024 * 1024 : 10 * 1024 * 1024, signal) await verifyRelease(release, pinned, bytes) signal?.throwIfAborted() if (Object.keys(release.dependencies).length) throw new Error('该包存在依赖,请先在扩展管理中核对依赖版本;不会自动启用依赖。') if (release.type === 'theme') { const inspection = await inspectThemePackage(await decodeThemePackage(bytes)) if (!inspection.compatible || inspection.manifest.theme_id !== release.package_id || inspection.manifest.version !== release.version) throw new Error('主题包类型或身份校验失败') await installTheme(inspection.manifest, inspection.css ?? '') } else if (release.type === 'skill' || release.type === 'plugin') { const file = new File([bytes as Uint8Array], `${release.package_id}.zip`, { type: 'application/zip' }) await (release.type === 'skill' ? installSkill(file) : installPlugin(file)) } else { // 声明式包仅存为候选,用户可预览/删除;不会替换全局人设或启动模型/MCP。 const { unzipSync } = await import('fflate') let total = 0, count = 0 const files = unzipSync(bytes, { filter: entry => { total += entry.originalSize if (++count > 2048 || total > 50 * 1024 * 1024) throw new Error('包展开超限') return entry.name.endsWith(`${release.type}.json`) } }) const values = Object.values(files) if (values.length !== 1) throw new Error('类型清单不唯一') const candidate = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(values[0])) localStorage.setItem(`community-candidate:${release.namespace}/${release.package_id}`, JSON.stringify({ release, candidate })) } return release.type === 'theme' || release.type === 'skill' || release.type === 'plugin' ? '已安装,尚未启用' : '已保存为候选,尚未应用' }