feat: 开放扩展安装确认与原生启用
This commit is contained in:
@@ -114,7 +114,7 @@ fn host_capabilities(host: State<'_, Host>) -> serde_json::Value {
|
||||
.ok()
|
||||
.and_then(|mut core| core.as_mut().map(|c| c.available()))
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":true,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"})
|
||||
serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":true,"credentials":true,"extensions":cfg!(windows),"release":"preview","product":"OpenNexus"})
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
|
||||
@@ -24,9 +24,9 @@ it('uses themed surfaces for the section, empty state, and staged package rows',
|
||||
expect(populated.get('.package-list > li').classes()).toContain('item-card')
|
||||
populated.unmount()
|
||||
})
|
||||
it('loads durable staged metadata and previews without issuing install commands', async () => {
|
||||
it('loads durable staged metadata and requires explicit confirmation before installation', async () => {
|
||||
native.invoke.mockImplementation(async command => command === 'extension_staged' ? [item] : {
|
||||
fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, permissions: ['notes.read'] }] }, changes: [{ target: { configuration: {} }, expected_revision: null }],
|
||||
fingerprint: 'fingerprint', dependencies: { packages: [{ ...item, kind: 'plugin', permissions: ['notes.read'] }] }, changes: [{ target: { slot: 'slot', package_key: 'package-key', configuration: {} }, expected_revision: null }],
|
||||
})
|
||||
const wrapper = component(); await flushPromises()
|
||||
expect(native.invoke).toHaveBeenCalledWith('extension_staged', { offset: 0, limit: 20 })
|
||||
@@ -35,10 +35,36 @@ it('loads durable staged metadata and previews without issuing install commands'
|
||||
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(wrapper.text()).toContain('确认安装并启用')
|
||||
expect(native.invoke.mock.calls.every(call => ['extension_staged', 'extension_install_preview'].includes(call[0]))).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
it('confirms the reviewed payload then starts the native runtime', async () => {
|
||||
vi.stubGlobal('crypto', { randomUUID: () => 'operation-id' })
|
||||
native.invoke.mockImplementation(async (command) => {
|
||||
if (command === 'extension_staged') return [item]
|
||||
if (command === 'extension_install_preview') return {
|
||||
fingerprint: 'fingerprint',
|
||||
dependencies: { packages: [{ ...item, kind: 'plugin', permissions: [] }] },
|
||||
changes: [{ target: { slot: 'slot', package_key: 'package-key', configuration: {} }, expected_revision: null }],
|
||||
}
|
||||
if (command === 'extension_stage_prepare') return 'request-id'
|
||||
return { status: 'ready' }
|
||||
})
|
||||
const wrapper = component(); await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '查看安装预览')!.trigger('click')
|
||||
await wrapper.findAll('button').find(button => button.text() === '检查依赖、权限与配置')!.trigger('click')
|
||||
await flushPromises()
|
||||
await wrapper.findAll('button').find(button => button.text() === '确认安装并启用')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(native.invoke).toHaveBeenCalledWith('extension_install_confirm', { request: {
|
||||
request_id: 'request-id', operation_id: 'operation-id', fingerprint: 'fingerprint',
|
||||
root_key: 'package-key', vault_id: 'vault-one', configurations: { 'package-key': {} },
|
||||
} })
|
||||
expect(native.invoke).toHaveBeenCalledWith('extension_enable', { slot: 'slot', vaultId: 'vault-one', installOperationId: 'operation-id' })
|
||||
expect(wrapper.text()).toContain('安装和运行健康检查已完成')
|
||||
wrapper.unmount(); vi.unstubAllGlobals()
|
||||
})
|
||||
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 }))
|
||||
|
||||
@@ -6,9 +6,10 @@ 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 }> }
|
||||
interface Preview { fingerprint: string; dependencies: { packages: Array<{ package_key: string; namespace: string; package_id: string; kind: string; version: string; permissions: string[] }> }; changes: Array<{ target: { slot: string; package_key: string; 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)
|
||||
const completed = ref('')
|
||||
let generation = 0
|
||||
const errors: Record<string, string> = {
|
||||
VAULT_CHANGED: '笔记库已切换,请重新预览。', VAULT_NOT_OPEN: '请先打开笔记库。',
|
||||
@@ -31,7 +32,7 @@ async function refresh() {
|
||||
} 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 = '' }
|
||||
function choose(item: Package) { selected.value = item; configuration.value = '{}'; preview.value = null; error.value = ''; completed.value = '' }
|
||||
async function inspect() {
|
||||
if (!selected.value || !workspace.vaultId) return
|
||||
const vaultId = workspace.vaultId, rootKey = selected.value.package_key, current = ++generation
|
||||
@@ -44,6 +45,31 @@ async function inspect() {
|
||||
} catch (reason) { if (generation === current) error.value = message(reason) }
|
||||
finally { if (generation === current) busy.value = false }
|
||||
}
|
||||
async function install() {
|
||||
if (!selected.value || !preview.value || !workspace.vaultId) return
|
||||
const vaultId = workspace.vaultId, rootKey = selected.value.package_key, reviewed = preview.value
|
||||
const current = ++generation; busy.value = true; error.value = ''; completed.value = ''
|
||||
try {
|
||||
const parsed = JSON.parse(configuration.value)
|
||||
const requestId = await hostInvoke<string>('extension_stage_prepare')
|
||||
const operationId = crypto.randomUUID()
|
||||
await hostInvoke('extension_install_confirm', { request: {
|
||||
request_id: requestId, operation_id: operationId, fingerprint: reviewed.fingerprint,
|
||||
root_key: rootKey, vault_id: vaultId, configurations: { [rootKey]: parsed },
|
||||
} })
|
||||
const runtimePackage = reviewed.dependencies.packages.find(item => item.kind === 'plugin' || item.kind === 'mcp')
|
||||
if (!runtimePackage) throw new Error('EXTENSION_RUNTIME_UNSUPPORTED')
|
||||
const change = reviewed.changes.find(item => item.target.package_key === runtimePackage.package_key)
|
||||
if (!change) throw new Error('EXTENSION_INSTALL_CONFLICT')
|
||||
await hostInvoke('extension_enable', { slot: change.target.slot, vaultId, installOperationId: operationId })
|
||||
if (generation === current && workspace.vaultId === vaultId) {
|
||||
completed.value = '安装和运行健康检查已完成。'
|
||||
preview.value = null
|
||||
await refresh()
|
||||
}
|
||||
} 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 })
|
||||
@@ -85,6 +111,7 @@ onBeforeUnmount(() => ++generation)
|
||||
<p>未声明配置的包请保留空对象,不要填写密码或令牌。</p>
|
||||
<button class="btn" :disabled="busy || !workspace.vaultId" @click="inspect">检查依赖、权限与配置</button>
|
||||
<p v-if="error" role="alert">{{ error }}</p>
|
||||
<p v-if="completed" class="notice-banner" role="status">{{ completed }}</p>
|
||||
<div v-if="preview">
|
||||
<h3>按安装顺序排列的包</h3>
|
||||
<ul><li v-for="item in preview.dependencies.packages" :key="item.package_key">
|
||||
@@ -92,7 +119,8 @@ onBeforeUnmount(() => ++generation)
|
||||
<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>
|
||||
<p>确认后将按以上摘要安装,并只在原生沙箱运行健康后提交活动版本。</p>
|
||||
<button class="btn primary" :disabled="busy" @click="install">确认安装并启用</button>
|
||||
</div>
|
||||
</AppDialog>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user