feat(community): 增加签名目录与七类发行入口
This commit is contained in:
@@ -30,6 +30,7 @@ const selectionSnapshot = ref<string | null>(null)
|
||||
interface Command { id: string; label: string; hint: string; run: () => void | Promise<void> }
|
||||
|
||||
const builtinCommands = computed<Command[]>(() => [
|
||||
{ id: 'community', label: t('社区目录', 'Community catalog'), hint: t('导航', 'Navigation'), run: () => router.push('/community') },
|
||||
{ id: 'themes', label: t('主题管理', 'Manage themes'), hint: t('导航', 'Navigation'), run: () => router.push('/themes') },
|
||||
{ id: 'tasks', label: t('任务列表', 'Tasks'), hint: t('导航', 'Navigation'), run: () => router.push('/tasks') },
|
||||
{ id: 'workspace', label: t('打开工作区', 'Open workspace'), hint: t('导航', 'Navigation'), run: () => router.push('/workspace') },
|
||||
|
||||
@@ -20,6 +20,7 @@ const navItems = computed(() => [
|
||||
{ name: 'plugins', icon: Connection, label: 'Plugin' },
|
||||
{ name: 'mcp-servers', icon: Monitor, label: 'MCP' },
|
||||
{ name: 'themes', icon: Brush, label: t('主题', 'Themes') },
|
||||
{ name: 'community', icon: Connection, label: t('社区', 'Community') },
|
||||
{ name: 'benchmarks', icon: Monitor, label: 'Benchmark' },
|
||||
{ name: 'logs', icon: Document, label: t('日志', 'Logs') },
|
||||
{ name: 'settings', icon: Setting, label: t('设置', 'Settings') },
|
||||
@@ -42,7 +43,8 @@ function toggleExpanded() {
|
||||
<template>
|
||||
<aside class="primary-sidebar" :class="{ expanded }">
|
||||
<nav class="nav-list">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
v-for="item in navItems"
|
||||
:key="item.name"
|
||||
class="nav-item"
|
||||
@@ -52,7 +54,7 @@ function toggleExpanded() {
|
||||
>
|
||||
<AppIcon class="nav-icon" :icon="item.icon" :size="20" />
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<button class="nav-item collapse-button" type="button" :title="expanded ? t('收起导航', 'Collapse navigation') : t('展开导航', 'Expand navigation')" @click="toggleExpanded">
|
||||
@@ -89,6 +91,9 @@ function toggleExpanded() {
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** 社区协议 v1;签名元数据与安装运行状态分离。 */
|
||||
export type PackageKind = 'theme' | 'skill' | 'plugin' | 'mcp' | 'persona' | 'template' | 'model'
|
||||
export interface CommunityKey { key_id: string; namespace: string; public_key: string; revoked: boolean }
|
||||
export interface CommunitySource { id: string; url: string; enabled: boolean; keys: CommunityKey[]; fetchedAt?: string }
|
||||
export interface CommunityRelease {
|
||||
schema_version: 1; namespace: string; package_id: string; type: PackageKind; version: string
|
||||
name: string; author_id: string; license: string; description: string; sha256: string; size: number
|
||||
platforms: string[]; architectures: string[]; min_app_version: string; max_app_version: string | null
|
||||
dependencies: Record<string, string>; permissions: string[]; changelog: string; published_at: string
|
||||
key_id: string; signature: string; release_id: string; withdrawn: boolean; download_path: string
|
||||
}
|
||||
export interface CommunityCatalog { schema_version: 1; items: CommunityRelease[]; total: number; offset: number }
|
||||
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref } from 'vue'
|
||||
import type { CommunityKey, CommunityRelease, CommunitySource, PackageKind } from '@/contracts/community'
|
||||
import { cachedCatalog, discoverKeys, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService'
|
||||
import AppDialog from '@/components/common/AppDialog.vue'
|
||||
|
||||
const kinds: { id: PackageKind | ''; label: string }[] = [
|
||||
{ id: '', label: '全部' }, { id: 'theme', label: '主题' }, { id: 'skill', label: 'Skill' },
|
||||
{ id: 'plugin', label: 'Plugin' }, { id: 'mcp', label: 'MCP 配置' }, { id: 'persona', label: '人设' },
|
||||
{ id: 'template', label: '笔记模板' }, { id: 'model', label: '模型方案' },
|
||||
]
|
||||
const sources = ref(loadSources()), selectedSource = ref(sources.value[0]?.id ?? '')
|
||||
const url = ref(''), query = ref(''), kind = ref<PackageKind | ''>('')
|
||||
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
|
||||
const candidateKeys = ref<CommunityKey[]>([]), candidateUrl = ref('')
|
||||
const busy = ref(false), error = ref(''), notice = ref(''), offline = ref(false)
|
||||
let controller: AbortController | undefined
|
||||
let version = 0
|
||||
const candidates = ref<{ key: string; value: string }[]>([])
|
||||
function refreshCandidates() {
|
||||
candidates.value = Object.keys(localStorage).filter(key => key.startsWith('community-candidate:')).map(key => ({ key, value: localStorage.getItem(key) ?? '' }))
|
||||
}
|
||||
refreshCandidates()
|
||||
function removeCandidate(key: string) { localStorage.removeItem(key); refreshCandidates() }
|
||||
function cancel() { version++; controller?.abort(); busy.value = false }
|
||||
onBeforeUnmount(cancel)
|
||||
function source(): CommunitySource {
|
||||
const value = sources.value.find(item => item.id === selectedSource.value)
|
||||
if (!value) throw new Error('请先添加并选择一个来源')
|
||||
return value
|
||||
}
|
||||
async function run(action: (signal: AbortSignal, current: () => boolean) => Promise<void>) {
|
||||
cancel(); const request = ++version
|
||||
controller = new AbortController(); busy.value = true; error.value = ''; notice.value = ''
|
||||
try { await action(controller.signal, () => request === version) }
|
||||
catch (reason) { if (request === version) error.value = reason instanceof Error ? reason.message : String(reason) }
|
||||
finally { if (request === version) busy.value = false }
|
||||
}
|
||||
function inspectSource() {
|
||||
const snapshot = url.value.trim()
|
||||
void run(async (signal, current) => { const keys = await discoverKeys(snapshot, signal); if (current()) { candidateKeys.value = keys; candidateUrl.value = snapshot } })
|
||||
}
|
||||
function trustSource() {
|
||||
const existing = sources.value.find(item => item.url === candidateUrl.value)
|
||||
const value: CommunitySource = { id: existing?.id ?? crypto.randomUUID(), url: candidateUrl.value, enabled: true, keys: candidateKeys.value, fetchedAt: new Date().toISOString() }
|
||||
sources.value = [...sources.value.filter(item => item.id !== value.id), value]
|
||||
saveSources(sources.value); selectedSource.value = value.id; candidateUrl.value = ''; candidateKeys.value = []
|
||||
search()
|
||||
}
|
||||
function search() {
|
||||
void run(async (signal, current) => {
|
||||
const selected = source()
|
||||
try {
|
||||
const result = await fetchCatalog(selected, query.value, kind.value, signal)
|
||||
if (current()) { items.value = result.items; offline.value = false; notice.value = result.total > result.items.length ? `展示前 ${result.items.length} 项,请缩小搜索范围。` : '' }
|
||||
} catch (reason) {
|
||||
const cached = cachedCatalog(selected)
|
||||
if (current() && cached) { items.value = cached.items; offline.value = true }
|
||||
throw reason
|
||||
}
|
||||
})
|
||||
}
|
||||
function install() {
|
||||
if (!detail.value) return
|
||||
const selected = detail.value, selectedRegistry = source()
|
||||
void run(async (signal, current) => {
|
||||
const result = await installRelease(selectedRegistry, selected, signal)
|
||||
if (current()) { notice.value = result; refreshCandidates() }
|
||||
})
|
||||
}
|
||||
function toggleSource() {
|
||||
const selected = source(); selected.enabled = !selected.enabled; saveSources(sources.value)
|
||||
items.value = []; cancel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="community-page">
|
||||
<h1>社区目录</h1>
|
||||
<p>连接您选择的来源。安装后仍需独立启用和授权;关闭社区不影响本地编辑。</p>
|
||||
<section class="community-controls" aria-label="社区来源">
|
||||
<label>来源地址 <input v-model="url" placeholder="https://community.example.org" :disabled="busy" /></label>
|
||||
<button class="btn" :disabled="busy || !url.trim()" @click="inspectSource">检查来源与公钥</button>
|
||||
<label>已添加来源 <select v-model="selectedSource" @change="search"><option value="">请选择</option><option v-for="item in sources" :key="item.id" :value="item.id">{{ item.url }}{{ item.enabled ? '' : '(已停用)' }}</option></select></label>
|
||||
<button class="btn" :disabled="!selectedSource || busy" @click="toggleSource">启用 / 停用来源</button>
|
||||
</section>
|
||||
<section class="community-controls" aria-label="搜索目录">
|
||||
<label>关键词 <input v-model="query" @keydown.enter="search" /></label>
|
||||
<label>类别 <select v-model="kind"><option v-for="item in kinds" :key="item.id" :value="item.id">{{ item.label }}</option></select></label>
|
||||
<button class="btn btn-primary" :disabled="busy || !selectedSource" @click="search">搜索 / 刷新</button>
|
||||
<button v-if="busy" class="btn" @click="cancel">取消</button>
|
||||
</section>
|
||||
<p v-if="busy" role="status">正在处理…</p>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<p v-if="notice" role="status">{{ notice }}</p>
|
||||
<p v-if="offline">当前为离线缓存,仅供浏览;安装需要重新核对撤回和签名状态。</p>
|
||||
<p v-if="!busy && !items.length">尚无发行记录。添加来源后搜索目录。</p>
|
||||
<div class="community-grid">
|
||||
<button v-for="item in items" :key="item.release_id" class="community-card" @click="detail = item">
|
||||
<strong>{{ item.name }}</strong><span>{{ item.type }} · {{ item.version }}</span>
|
||||
<span>{{ item.description }}</span><span>{{ item.namespace }}/{{ item.package_id }} · {{ item.license }}</span>
|
||||
<span v-if="item.withdrawn">已撤回</span>
|
||||
</button>
|
||||
</div>
|
||||
<section v-if="candidates.length">
|
||||
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设、MCP 或模型运行配置。</p>
|
||||
<details v-for="item in candidates" :key="item.key"><summary>{{ item.key.replace('community-candidate:', '') }}</summary><pre>{{ item.value }}</pre><button class="btn" @click="removeCandidate(item.key)">删除候选</button></details>
|
||||
</section>
|
||||
<AppDialog v-if="candidateUrl" label="核对来源公钥" @close="candidateUrl = ''">
|
||||
<p>{{ candidateUrl }}</p><p>请与来源维护者公布的公钥核对。确认后固定这些公钥;密钥改变时不会自动信任。</p>
|
||||
<pre>{{ JSON.stringify(candidateKeys, null, 2) }}</pre>
|
||||
<button class="btn btn-primary" :disabled="!candidateKeys.length" @click="trustSource">确认并固定公钥</button>
|
||||
</AppDialog>
|
||||
<AppDialog v-if="detail" label="发行详情与安装" @close="detail = null">
|
||||
<template v-if="detail">
|
||||
<h2>{{ detail.name }} {{ detail.version }}</h2><p>{{ detail.description }}</p>
|
||||
<dl><dt>作者 / 来源</dt><dd>{{ detail.author_id }} / {{ detail.namespace }}</dd><dt>许可证</dt><dd>{{ detail.license }}</dd><dt>大小 / 摘要</dt><dd>{{ detail.size }} 字节<br />{{ detail.sha256 }}</dd><dt>兼容平台</dt><dd>{{ detail.platforms.join(', ') }} / {{ detail.architectures.join(', ') }}</dd><dt>权限</dt><dd>{{ detail.permissions.join(', ') || '无' }}</dd><dt>依赖</dt><dd>{{ JSON.stringify(detail.dependencies) }}</dd></dl>
|
||||
<pre>{{ detail.changelog }}</pre>
|
||||
<p>安装不会自动启用包或其依赖。人设、模板、MCP 与模型方案仅保存为可检查的候选。</p>
|
||||
<button class="btn btn-primary" :disabled="busy || detail.withdrawn || offline" @click="install">校验并安装</button>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.community-page { padding: var(--space-xl); overflow: auto; min-width: 0; }
|
||||
.community-controls { display: flex; flex-wrap: wrap; align-items: end; gap: var(--space-md); margin-block: var(--space-lg); }
|
||||
label { display: grid; gap: var(--space-xs); }
|
||||
input, select { color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); padding: var(--space-sm); }
|
||||
.community-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: var(--space-md); }
|
||||
.community-card { display: grid; gap: var(--space-sm); text-align: left; padding: var(--space-lg); color: var(--color-text-primary); background: var(--color-background-secondary); border: 1px solid var(--color-border-subtle); border-radius: var(--radius-md); overflow-wrap: anywhere; }
|
||||
pre, dd { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100%; }
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { useWorkspaceStore } from '@/stores/workspace'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const routes = [
|
||||
{ path: '/community', name: 'community', component: () => import('@/features/community/CommunityView.vue'), meta: { title: '社区目录' } },
|
||||
{ path: '/benchmarks', name: 'benchmarks', component: () => import('@/features/benchmarks/BenchmarkView.vue'), meta: { title: 'Benchmark' } },
|
||||
{ path: '/logs', name: 'logs', component: () => import('@/features/logs/LogsView.vue'), meta: { title: '运行日志' } },
|
||||
{ path: '/media', name: 'media', component: () => import('@/features/media/MediaView.vue'), meta: { title: '音视频转写', requiresVault: true } },
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import vector from './fixtures/community-python-vector.json'
|
||||
import type { CommunityRelease, CommunitySource } from '@/contracts/community'
|
||||
import { discoverKeys, fetchCatalog, verifyRelease } from './communityService'
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
const release = vector.release as CommunityRelease
|
||||
const bytes = Uint8Array.from(atob(vector.archive_base64), c => c.charCodeAt(0))
|
||||
|
||||
describe('社区 Python / TypeScript 签名契约', () => {
|
||||
it('校验 Python canonical JSON 与真实 Ed25519 签名', async () => {
|
||||
vi.stubGlobal('crypto', webcrypto)
|
||||
await expect(verifyRelease(release, vector.key, bytes)).resolves.toBeUndefined()
|
||||
})
|
||||
it('权限篡改、错误摘要和撤回均拒绝', async () => {
|
||||
vi.stubGlobal('crypto', webcrypto)
|
||||
await expect(verifyRelease({ ...release, permissions: ['network.request'] }, vector.key, bytes)).rejects.toThrow('签名')
|
||||
await expect(verifyRelease(release, vector.key, new Uint8Array([0]))).rejects.toThrow('摘要')
|
||||
await expect(verifyRelease({ ...release, withdrawn: true }, vector.key, bytes)).rejects.toThrow('撤回')
|
||||
await expect(verifyRelease(release, { ...vector.key, revoked: true }, bytes)).rejects.toThrow('撤回')
|
||||
})
|
||||
it('不携带凭据、拒绝重定向并支持取消', async () => {
|
||||
const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ schema_version: 1, keys: [] })))
|
||||
vi.stubGlobal('fetch', fetch)
|
||||
await discoverKeys('https://example.org')
|
||||
expect(fetch.mock.calls[0]?.[1]).toMatchObject({ credentials: 'omit', redirect: 'error', referrerPolicy: 'no-referrer' })
|
||||
await expect(discoverKeys('file:///tmp/catalog')).rejects.toThrow('HTTPS')
|
||||
})
|
||||
it('停用来源不发请求', async () => {
|
||||
const source: CommunitySource = { id: 'fixture', url: 'https://example.org', enabled: false, keys: [] }
|
||||
const fetch = vi.fn(); vi.stubGlobal('fetch', fetch)
|
||||
await expect(fetchCatalog(source)).rejects.toThrow('停用')
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/** 只请求用户配置来源;公钥固定、签名及摘要检查先于任何安装 API。 */
|
||||
import type { CommunityCatalog, CommunityRelease, CommunitySource, CommunityKey } from '@/contracts/community'
|
||||
import { valid, gt, lt } from 'semver'
|
||||
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<Uint8Array> {
|
||||
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 discoverKeys(url: string, signal?: AbortSignal): Promise<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 || !Array.isArray(value.keys)) throw new Error('不支持的社区来源协议')
|
||||
return value.keys
|
||||
}
|
||||
|
||||
export async function fetchCatalog(source: CommunitySource, q = '', kind = '', signal?: AbortSignal): Promise<CommunityCatalog> {
|
||||
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<string, unknown>)[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<ArrayBuffer>))).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<string> {
|
||||
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<ArrayBuffer>], `${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' ? '已安装,尚未启用' : '已保存为候选,尚未应用'
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"release": {
|
||||
"schema_version": 1,
|
||||
"namespace": "examples",
|
||||
"package_id": "test-package",
|
||||
"type": "persona",
|
||||
"version": "1.0.0",
|
||||
"name": "受控示例",
|
||||
"author_id": "author",
|
||||
"license": "MIT",
|
||||
"description": "用于隔离验收",
|
||||
"sha256": "d4a3dc84580c311e877f0b9c2f4022c14cb8923f757cb2309a55b75d0dd242f5",
|
||||
"size": 167,
|
||||
"platforms": [
|
||||
"windows"
|
||||
],
|
||||
"architectures": [
|
||||
"x86_64"
|
||||
],
|
||||
"min_app_version": "0.2.0",
|
||||
"max_app_version": null,
|
||||
"dependencies": {},
|
||||
"permissions": [],
|
||||
"changelog": "初始版本",
|
||||
"published_at": "2026-09-07T00:00:00Z",
|
||||
"key_id": "test-key",
|
||||
"signature": "PwaYH7leaURJJryfBzmZXWNoNgZqCxsM16iYpIgCx5cvMNzm2tltlV5gz49ykNOW3WH/1u8SEGwdgiTnvNzkCg==",
|
||||
"release_id": "fixture",
|
||||
"withdrawn": false,
|
||||
"download_path": "/catalog/v1/releases/fixture/archive"
|
||||
},
|
||||
"archive_base64": "UEsDBBQAAAAAAEOBJ11uLuwILQAAAC0AAAAMAAAAcGVyc29uYS5qc29ueyJzeXN0ZW1fcHJvbXB0IjogIlx1NTNkN1x1NjNhN1x1NjgzN1x1NGY4YiJ9UEsBAhQAFAAAAAAAQ4EnXW4u7AgtAAAALQAAAAwAAAAAAAAAAAAAAIABAAAAAHBlcnNvbmEuanNvblBLBQYAAAAAAQABADoAAABXAAAAAAA=",
|
||||
"key": {
|
||||
"key_id": "test-key",
|
||||
"namespace": "examples",
|
||||
"public_key": "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=",
|
||||
"revoked": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user