feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -296,3 +296,11 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
|||||||
- 回执查询不把已提交状态伪装成取消回滚。提交前取消可能留下不可见的孤立归档对象,暂存版本/回执事务回滚;提交后取消保留原回执。未增加对象 GC 或跨重启任务列表。
|
- 回执查询不把已提交状态伪装成取消回滚。提交前取消可能留下不可见的孤立归档对象,暂存版本/回执事务回滚;提交后取消保留原回执。未增加对象 GC 或跨重启任务列表。
|
||||||
- 33 项扩展回归、4 项请求生命周期测试、7 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过;日志 `.build/extension-cancel-tests.log`。请求生命周期包含真实 socket 取消关闭测试,新增对象写入后/回执记录后/提交后的取消回执区分。
|
- 33 项扩展回归、4 项请求生命周期测试、7 项前端针对性测试、全目标 Clippy 和两套 TypeScript 项目检查通过;日志 `.build/extension-cancel-tests.log`。请求生命周期包含真实 socket 取消关闭测试,新增对象写入后/回执记录后/提交后的取消回执区分。
|
||||||
- 完整任务进度列表、跨重启 UI 恢复、安装执行编排与沙箱仍未完成;整体目标继续进行。
|
- 完整任务进度列表、跨重启 UI 恢复、安装执行编排与沙箱仍未完成;整体目标继续进行。
|
||||||
|
|
||||||
|
|
||||||
|
## 增量:桌面暂存列表和安装预览界面
|
||||||
|
|
||||||
|
- 新增仅本地主窗口可用的 extension_staged 分页命令,界面从 Host 持久安装库加载暂存包,每页 20 项;暂存完成后刷新,重启不依赖 renderer 缓存重建包列表。
|
||||||
|
- DesktopPackages 对话框允许输入根包配置,调用 Host 完整预览,展示依赖拓扑顺序、请求权限和检查后的配置。当前 Vault、请求代次和选中根包共同约束返回结果,切换 Vault 关闭预览并丢弃迟到响应。
|
||||||
|
- 5 项前端针对性测试、两套 TypeScript 项目检查和 Rust 全目标 Clippy -D warnings 通过。新增组件测试验证使用 Host 列表、精确 Vault/配置参数、只发预览命令,以及切库后不显示旧权限结果。
|
||||||
|
- 页面明确安装执行尚未开放;依赖包配置编辑、自动取得缺失依赖、正式确认执行、沙箱与真实端到端验收仍未完成。整体生产化目标继续进行。
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ fn main() {
|
|||||||
"extension_stage_prepare",
|
"extension_stage_prepare",
|
||||||
"extension_stage_cancel",
|
"extension_stage_cancel",
|
||||||
"extension_stage_status",
|
"extension_stage_status",
|
||||||
|
"extension_staged",
|
||||||
"record_get",
|
"record_get",
|
||||||
"record_write",
|
"record_write",
|
||||||
"sync_login",
|
"sync_login",
|
||||||
|
|||||||
@@ -56,6 +56,7 @@
|
|||||||
"allow-extension-stage",
|
"allow-extension-stage",
|
||||||
"allow-extension-stage-prepare",
|
"allow-extension-stage-prepare",
|
||||||
"allow-extension-stage-cancel",
|
"allow-extension-stage-cancel",
|
||||||
"allow-extension-stage-status"
|
"allow-extension-stage-status",
|
||||||
|
"allow-extension-staged"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -225,6 +225,28 @@ pub fn extension_stage_status(
|
|||||||
serde_json::to_value(receipt).map_err(|_| "EXTENSION_STATUS_FAILED".into())
|
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<Value, String> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -855,6 +855,7 @@ fn main() {
|
|||||||
extension_stage_prepare,
|
extension_stage_prepare,
|
||||||
extension_stage_cancel,
|
extension_stage_cancel,
|
||||||
extension_stage_status,
|
extension_stage_status,
|
||||||
|
extension_staged,
|
||||||
record_get,
|
record_get,
|
||||||
record_write,
|
record_write,
|
||||||
sync_login,
|
sync_login,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ beforeEach(() => { vi.clearAllMocks(); localStorage.clear() })
|
|||||||
it('requires explicit confirmation and does not save when Host rejects it', async () => {
|
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.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' } }])
|
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: '<section><slot /></section>' } } } })
|
const wrapper = mount(CommunityView, { global: { stubs: { DesktopPackages: true, AppDialog: { template: '<section><slot /></section>' } } } })
|
||||||
await wrapper.get('input').setValue('https://catalog.example')
|
await wrapper.get('input').setValue('https://catalog.example')
|
||||||
await wrapper.findAll('button').find(b => b.text() === '检查来源与公钥')!.trigger('click')
|
await wrapper.findAll('button').find(b => b.text() === '检查来源与公钥')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { CommunityKey, CommunityRelease, CommunitySource, PackageKind } fro
|
|||||||
import { cachedCatalog, discoverSource, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService'
|
import { cachedCatalog, discoverSource, fetchCatalog, installRelease, loadSources, saveSources } from '@/services/communityService'
|
||||||
import { isDesktop } from '@/services/platform/desktop'
|
import { isDesktop } from '@/services/platform/desktop'
|
||||||
import { reviewTrust, confirmTrust, type TrustReview } from '@/services/extensionTrustService'
|
import { reviewTrust, confirmTrust, type TrustReview } from '@/services/extensionTrustService'
|
||||||
|
import DesktopPackages from './DesktopPackages.vue'
|
||||||
import AppDialog from '@/components/common/AppDialog.vue'
|
import AppDialog from '@/components/common/AppDialog.vue'
|
||||||
|
|
||||||
const kinds: { id: PackageKind | ''; label: string }[] = [
|
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: 'plugin', label: 'Plugin' }, { id: 'mcp', label: 'MCP 配置' }, { id: 'persona', label: '人设' },
|
||||||
{ id: 'template', label: '笔记模板' }, { id: 'model', label: '模型方案' },
|
{ id: 'template', label: '笔记模板' }, { id: 'model', label: '模型方案' },
|
||||||
]
|
]
|
||||||
|
const stagedRefresh = ref(0)
|
||||||
const sources = ref(loadSources()), selectedSource = ref(sources.value[0]?.id ?? '')
|
const sources = ref(loadSources()), selectedSource = ref(sources.value[0]?.id ?? '')
|
||||||
const url = ref(''), query = ref(''), kind = ref<PackageKind | ''>('')
|
const url = ref(''), query = ref(''), kind = ref<PackageKind | ''>('')
|
||||||
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
|
const items = ref<CommunityRelease[]>([]), detail = ref<CommunityRelease | null>(null)
|
||||||
@@ -76,7 +78,7 @@ function install() {
|
|||||||
const selected = detail.value, selectedRegistry = source()
|
const selected = detail.value, selectedRegistry = source()
|
||||||
void run(async (signal, current) => {
|
void run(async (signal, current) => {
|
||||||
const result = await installRelease(selectedRegistry, selected, signal)
|
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() {
|
function toggleSource() {
|
||||||
@@ -121,6 +123,7 @@ function toggleSource() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<DesktopPackages v-if="isDesktop()" :refresh-key="stagedRefresh" />
|
||||||
<section v-if="candidates.length">
|
<section v-if="candidates.length">
|
||||||
<h2>已保存的声明式候选</h2><p>这些候选尚未应用到人设、MCP 或模型运行配置。</p>
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -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: '<section><slot /></section>' } } } }) }
|
||||||
|
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()
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { hostInvoke } from '@/services/platform/desktop'
|
||||||
|
import { useWorkspaceStore } from '@/stores/workspace'
|
||||||
|
import AppDialog from '@/components/common/AppDialog.vue'
|
||||||
|
const props = defineProps<{ refreshKey: number }>()
|
||||||
|
const workspace = useWorkspaceStore()
|
||||||
|
interface Package { package_key: string; source: string; namespace: string; package_id: string; version: string; state: string }
|
||||||
|
interface Preview { fingerprint: string; dependencies: { packages: Array<{ package_key: string; namespace: string; package_id: string; version: string; permissions: string[] }> }; changes: Array<{ target: { configuration: unknown }; expected_revision: string | null }> }
|
||||||
|
const packages = ref<Package[]>([]), page = ref(0), busy = ref(false), error = ref('')
|
||||||
|
const selected = ref<Package | null>(null), configuration = ref('{}'), preview = ref<Preview | null>(null)
|
||||||
|
let generation = 0
|
||||||
|
const errors: Record<string, string> = {
|
||||||
|
VAULT_CHANGED: '笔记库已切换,请重新预览。', VAULT_NOT_OPEN: '请先打开笔记库。',
|
||||||
|
EXTENSION_DEPENDENCY_MISSING: '依赖尚未暂存,请先从同一来源获取依赖包。',
|
||||||
|
EXTENSION_SOURCE_UNTRUSTED: '请先检查并确认该来源的公钥。',
|
||||||
|
EXTENSION_CONFIG_INVALID: '配置不符合包的声明,请检查配置内容。',
|
||||||
|
EXTENSION_CONFIG_SECRET: '配置包含秘密字段,请勿将凭据填入包配置。',
|
||||||
|
EXTENSION_KEY_REVOKED: '签名键已撤销,不能继续安装。',
|
||||||
|
EXTENSION_RELEASE_WITHDRAWN: '此版本已撤回,不能继续安装。',
|
||||||
|
}
|
||||||
|
function message(reason: unknown) {
|
||||||
|
const code = reason instanceof Error ? reason.message : String(reason)
|
||||||
|
return errors[code] ?? `检查失败:${code}`
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
const current = ++generation; busy.value = true; error.value = ''
|
||||||
|
try {
|
||||||
|
const result = await hostInvoke<Package[]>('extension_staged', { offset: page.value * 20, limit: 20 })
|
||||||
|
if (generation === current) packages.value = result
|
||||||
|
} catch (reason) { if (generation === current) error.value = message(reason) }
|
||||||
|
finally { if (generation === current) busy.value = false }
|
||||||
|
}
|
||||||
|
function choose(item: Package) { selected.value = item; configuration.value = '{}'; preview.value = null; error.value = '' }
|
||||||
|
async function inspect() {
|
||||||
|
if (!selected.value || !workspace.vaultId) return
|
||||||
|
const vaultId = workspace.vaultId, rootKey = selected.value.package_key, current = ++generation
|
||||||
|
busy.value = true; error.value = ''; preview.value = null
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configuration.value)
|
||||||
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('配置必须是 JSON 对象。')
|
||||||
|
const result = await hostInvoke<Preview>('extension_install_preview', { request: { root_key: rootKey, vault_id: vaultId, configurations: { [rootKey]: parsed } } })
|
||||||
|
if (generation === current && workspace.vaultId === vaultId && selected.value?.package_key === rootKey) preview.value = result
|
||||||
|
} catch (reason) { if (generation === current) error.value = message(reason) }
|
||||||
|
finally { if (generation === current) busy.value = false }
|
||||||
|
}
|
||||||
|
function close() { ++generation; selected.value = null; preview.value = null; busy.value = false }
|
||||||
|
watch(() => workspace.vaultId, close)
|
||||||
|
watch(configuration, () => { preview.value = null })
|
||||||
|
watch(() => props.refreshKey, () => { page.value = 0; close(); void refresh() })
|
||||||
|
onMounted(refresh)
|
||||||
|
onBeforeUnmount(() => ++generation)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="desktop-packages" aria-label="桌面已暂存包">
|
||||||
|
<h2>桌面已暂存包</h2>
|
||||||
|
<p>暂存包保存在桌面安装库中,重启后仍可查看。暂存不代表已经安装或获得运行权限。</p>
|
||||||
|
<button class="btn" :disabled="busy" @click="refresh">刷新暂存列表</button>
|
||||||
|
<p v-if="error" role="alert">{{ error }}</p>
|
||||||
|
<p v-if="busy" role="status">正在检查…</p>
|
||||||
|
<p v-if="!busy && !packages.length">本页没有暂存包。</p>
|
||||||
|
<ul><li v-for="item in packages" :key="item.package_key">
|
||||||
|
<strong>{{ item.namespace }}/{{ item.package_id }} · {{ item.version }}</strong>
|
||||||
|
<p>{{ item.source }}</p><button class="btn" :disabled="busy" @click="choose(item)">查看安装预览</button>
|
||||||
|
</li></ul>
|
||||||
|
<button class="btn" :disabled="busy || page === 0" @click="page--; refresh()">上一页</button>
|
||||||
|
<span>第 {{ page + 1 }} 页</span>
|
||||||
|
<button class="btn" :disabled="busy || packages.length < 20" @click="page++; refresh()">下一页</button>
|
||||||
|
<AppDialog v-if="selected" label="桌面安装预览" @close="close">
|
||||||
|
<h2>{{ selected.package_id }} · {{ selected.version }}</h2>
|
||||||
|
<p v-if="!workspace.vaultId">请先打开要使用此包的笔记库。</p>
|
||||||
|
<label>包配置(JSON)<textarea v-model="configuration" :disabled="busy" rows="6" spellcheck="false" /></label>
|
||||||
|
<p>未声明配置的包请保留空对象,不要填写密码或令牌。</p>
|
||||||
|
<button class="btn" :disabled="busy || !workspace.vaultId" @click="inspect">检查依赖、权限与配置</button>
|
||||||
|
<p v-if="error" role="alert">{{ error }}</p>
|
||||||
|
<div v-if="preview">
|
||||||
|
<h3>按安装顺序排列的包</h3>
|
||||||
|
<ul><li v-for="item in preview.dependencies.packages" :key="item.package_key">
|
||||||
|
{{ item.namespace }}/{{ item.package_id }} · {{ item.version }}
|
||||||
|
<p>请求权限:{{ item.permissions.join('、') || '无' }}</p>
|
||||||
|
</li></ul>
|
||||||
|
<details><summary>检查配置</summary><pre>{{ JSON.stringify(preview.changes.map(change => change.target.configuration), null, 2) }}</pre></details>
|
||||||
|
<p>依赖和配置检查完成。安装执行暂未开放,此预览不会启用包。</p>
|
||||||
|
</div>
|
||||||
|
</AppDialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.desktop-packages { margin-block: var(--space-xl); }
|
||||||
|
li { margin-block: var(--space-md); overflow-wrap: anywhere; }
|
||||||
|
label { display: grid; gap: var(--space-xs); }
|
||||||
|
textarea { width: 100%; color: var(--color-text-primary); background: var(--color-background-secondary); }
|
||||||
|
pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user