diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 18e314b..a656e17 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -296,3 +296,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 回执查询不把已提交状态伪装成取消回滚。提交前取消可能留下不可见的孤立归档对象,暂存版本/回执事务回滚;提交后取消保留原回执。未增加对象 GC 或跨重启任务列表。 - 33 项扩展回归、4 项请求生命周期测试、7 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过;日志 `.build/extension-cancel-tests.log`。请求生命周期包含真实 socket 取消关闭测试,新增对象写入后/回执记录后/提交后的取消回执区分。 - 完整任务进度列表、跨重启 UI 恢复、安装执行编排与沙箱仍未完成;整体目标继续进行。 + + +## 增量:桌面暂存列表和安装预览界面 + +- 新增仅本地主窗口可用的 extension_staged 分页命令,界面从 Host 持久安装库加载暂存包,每页 20 项;暂存完成后刷新,重启不依赖 renderer 缓存重建包列表。 +- DesktopPackages 对话框允许输入根包配置,调用 Host 完整预览,展示依赖拓扑顺序、请求权限和检查后的配置。当前 Vault、请求代次和选中根包共同约束返回结果,切换 Vault 关闭预览并丢弃迟到响应。 +- 5 项前端针对性测试、两套 TypeScript 项目检查和 Rust 全目标 Clippy -D warnings 通过。新增组件测试验证使用 Host 列表、精确 Vault/配置参数、只发预览命令,以及切库后不显示旧权限结果。 +- 页面明确安装执行尚未开放;依赖包配置编辑、自动取得缺失依赖、正式确认执行、沙箱与真实端到端验收仍未完成。整体生产化目标继续进行。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index ff862a8..88a2ed9 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -23,6 +23,7 @@ fn main() { "extension_stage_prepare", "extension_stage_cancel", "extension_stage_status", + "extension_staged", "record_get", "record_write", "sync_login", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index 2dc5e05..21514bd 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -56,6 +56,7 @@ "allow-extension-stage", "allow-extension-stage-prepare", "allow-extension-stage-cancel", - "allow-extension-stage-status" + "allow-extension-stage-status", + "allow-extension-staged" ] } diff --git a/frontend/src-tauri/permissions/autogenerated/extension_staged.toml b/frontend/src-tauri/permissions/autogenerated/extension_staged.toml new file mode 100644 index 0000000..f88cac0 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/extension_staged.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-extension-staged" +description = "Enables the extension_staged command without any pre-configured scope." +commands.allow = ["extension_staged"] + +[[permission]] +identifier = "deny-extension-staged" +description = "Denies the extension_staged command without any pre-configured scope." +commands.deny = ["extension_staged"] diff --git a/frontend/src-tauri/src/extension_commands.rs b/frontend/src-tauri/src/extension_commands.rs index 807e68c..e8a0412 100644 --- a/frontend/src-tauri/src/extension_commands.rs +++ b/frontend/src-tauri/src/extension_commands.rs @@ -225,6 +225,28 @@ pub fn extension_stage_status( serde_json::to_value(receipt).map_err(|_| "EXTENSION_STATUS_FAILED".into()) } +#[tauri::command] +pub async fn extension_staged( + window: WebviewWindow, + host: State<'_, Host>, + offset: u32, + limit: u32, +) -> Result { + main_window(&window)?; + let extensions = host.extensions.clone(); + tauri::async_runtime::spawn_blocking(move || { + let store = extensions.lock().map_err(|_| "HOST_BUSY")?; + let items = store + .as_ref() + .ok_or("EXTENSIONS_NOT_READY")? + .staged(offset, limit) + .map_err(|e| e.code)?; + serde_json::to_value(items).map_err(|_| "EXTENSION_LIST_FAILED".into()) + }) + .await + .map_err(|_| "EXTENSION_LIST_FAILED".to_string())? +} + #[cfg(test)] mod tests { use super::*; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index c5410ba..0a650f8 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -855,6 +855,7 @@ fn main() { extension_stage_prepare, extension_stage_cancel, extension_stage_status, + extension_staged, record_get, record_write, sync_login, diff --git a/frontend/src/features/community/CommunityView.spec.ts b/frontend/src/features/community/CommunityView.spec.ts index b6bbbf4..3cb2044 100644 --- a/frontend/src/features/community/CommunityView.spec.ts +++ b/frontend/src/features/community/CommunityView.spec.ts @@ -10,7 +10,7 @@ beforeEach(() => { vi.clearAllMocks(); localStorage.clear() }) it('requires explicit confirmation and does not save when Host rejects it', async () => { service.discover.mockResolvedValue({ source_id: 'catalog', keys: [{ key_id: 'key', namespace: 'examples', public_key: 'public', revoked: false }] }) service.review.mockResolvedValue([{ review_id: 'review', fingerprint: 'visible-digest', previous: null, proposed: { namespace: 'examples', key_id: 'key' } }]) - const wrapper = mount(CommunityView, { global: { stubs: { AppDialog: { template: '
' } } } }) + const wrapper = mount(CommunityView, { global: { stubs: { DesktopPackages: true, AppDialog: { template: '
' } } } }) await wrapper.get('input').setValue('https://catalog.example') await wrapper.findAll('button').find(b => b.text() === '检查来源与公钥')!.trigger('click') await flushPromises() diff --git a/frontend/src/features/community/CommunityView.vue b/frontend/src/features/community/CommunityView.vue index c72e62e..2397552 100644 --- a/frontend/src/features/community/CommunityView.vue +++ b/frontend/src/features/community/CommunityView.vue @@ -4,6 +4,7 @@ import type { CommunityKey, CommunityRelease, CommunitySource, PackageKind } fro import { cachedCatalog, discoverSource, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService' import { isDesktop } from '@/services/platform/desktop' import { reviewTrust, confirmTrust, type TrustReview } from '@/services/extensionTrustService' +import DesktopPackages from './DesktopPackages.vue' import AppDialog from '@/components/common/AppDialog.vue' const kinds: { id: PackageKind | ''; label: string }[] = [ @@ -11,6 +12,7 @@ const kinds: { id: PackageKind | ''; label: string }[] = [ { id: 'plugin', label: 'Plugin' }, { id: 'mcp', label: 'MCP 配置' }, { id: 'persona', label: '人设' }, { id: 'template', label: '笔记模板' }, { id: 'model', label: '模型方案' }, ] +const stagedRefresh = ref(0) const sources = ref(loadSources()), selectedSource = ref(sources.value[0]?.id ?? '') const url = ref(''), query = ref(''), kind = ref('') const items = ref([]), detail = ref(null) @@ -76,7 +78,7 @@ function install() { const selected = detail.value, selectedRegistry = source() void run(async (signal, current) => { const result = await installRelease(selectedRegistry, selected, signal) - if (current() || (signal.aborted && controller?.signal === signal)) { notice.value = result; refreshCandidates() } + if (current() || (signal.aborted && controller?.signal === signal)) { notice.value = result; refreshCandidates(); stagedRefresh.value++ } }) } function toggleSource() { @@ -121,6 +123,7 @@ function toggleSource() { +

已保存的声明式候选

这些候选尚未应用到人设、MCP 或模型运行配置。

{{ item.key.replace('community-candidate:', '') }}
{{ item.value }}
diff --git a/frontend/src/features/community/DesktopPackages.spec.ts b/frontend/src/features/community/DesktopPackages.spec.ts new file mode 100644 index 0000000..f748e2d --- /dev/null +++ b/frontend/src/features/community/DesktopPackages.spec.ts @@ -0,0 +1,41 @@ +// @vitest-environment jsdom +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, expect, it, vi } from 'vitest' +const native = vi.hoisted(() => ({ invoke: vi.fn(), workspace: { vaultId: 'vault-one' } })) +vi.mock('@/services/platform/desktop', () => ({ hostInvoke: native.invoke })) +vi.mock('@/stores/workspace', async () => { + const { reactive } = await import('vue') + native.workspace = reactive(native.workspace) + return { useWorkspaceStore: () => native.workspace } +}) +import DesktopPackages from './DesktopPackages.vue' +const item = { package_key: 'package-key', source: 'https://example.com/', namespace: 'examples', package_id: 'reviewer', version: '1.0.0', state: 'staged' } +beforeEach(() => { native.invoke.mockReset(); native.workspace.vaultId = 'vault-one' }) +function component() { return mount(DesktopPackages, { props: { refreshKey: 0 }, global: { stubs: { AppDialog: { template: '
' } } } }) } +it('loads durable staged metadata and previews without issuing install commands', async () => { + native.invoke.mockImplementation(async command => command === 'extension_staged' ? [item] : { + fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, permissions: ['notes.read'] }] }, changes: [{ target: { configuration: {} }, expected_revision: null }], + }) + const wrapper = component(); await flushPromises() + expect(native.invoke).toHaveBeenCalledWith('extension_staged', { offset: 0, limit: 20 }) + await wrapper.findAll('button').find(b => b.text() === '查看安装预览')!.trigger('click') + await wrapper.findAll('button').find(b => b.text() === '检查依赖、权限与配置')!.trigger('click') + await flushPromises() + expect(native.invoke).toHaveBeenLastCalledWith('extension_install_preview', { request: { root_key: 'package-key', vault_id: 'vault-one', configurations: { 'package-key': {} } } }) + expect(wrapper.text()).toContain('notes.read') + expect(wrapper.text()).toContain('安装执行暂未开放') + expect(native.invoke.mock.calls.every(call => ['extension_staged', 'extension_install_preview'].includes(call[0]))).toBe(true) + wrapper.unmount() +}) +it('discards delayed preview after switching Vault', async () => { + let resolve!: (value: unknown) => void + native.invoke.mockImplementation(command => command === 'extension_staged' ? Promise.resolve([item]) : new Promise(done => { resolve = done })) + const wrapper = component(); await flushPromises() + await wrapper.findAll('button').find(b => b.text() === '查看安装预览')!.trigger('click') + await wrapper.findAll('button').find(b => b.text() === '检查依赖、权限与配置')!.trigger('click') + native.workspace.vaultId = 'vault-two'; await flushPromises() + resolve({ dependencies: { packages: [{ ...item, permissions: ['stale-permission'] }] }, changes: [] }); await flushPromises() + expect(wrapper.text()).not.toContain('stale-permission') + expect(wrapper.find('textarea').exists()).toBe(false) + wrapper.unmount() +}) diff --git a/frontend/src/features/community/DesktopPackages.vue b/frontend/src/features/community/DesktopPackages.vue new file mode 100644 index 0000000..49fae03 --- /dev/null +++ b/frontend/src/features/community/DesktopPackages.vue @@ -0,0 +1,96 @@ + + +