feat(plugin-host): add combined lifecycle controls (#2476)

## Summary

- Add combined `Enable and Load` and `Disable and Unload` controls to
Plugin Host Debug.
- Keep enablement persistence and runtime lifecycle changes in one
sequential store action.
- Add tests for ordering, busy state, and failure handling.
- Localize the new controls and fallback error messages in English and
Simplified Chinese.

## Verification

- `pnpm exec vitest run --config packages/stage-ui/vitest.config.ts
--project node src/stores/devtools/plugin-host-debug.test.ts`
- `pnpm -F @proj-airi/i18n test`
- `pnpm -F @proj-airi/i18n build`
- `pnpm run build:packages`
- `pnpm typecheck`
- `pnpm lint`
- Manually tested the bundled `devtools-sample-plugin` in the Electron
Plugin Host Debug page. Both combined actions updated the persisted
registry and runtime session state without restarting the app.

## Visual changes

| Before | After |
|---|---|
| ![Plugin Host Debug
before](https://github.com/user-attachments/assets/c9b3a6ec-699b-444a-ba01-50bbc2ffa3ad)
| ![Plugin Host Debug
after](https://github.com/user-attachments/assets/889dec79-a157-42a8-98f5-906d2e0b41f3)
|
| Plugin Host Debug | Plugin Host Debug with combined lifecycle controls
|
This commit is contained in:
leafyy
2026-09-07 19:04:17 +08:00
committed by GitHub
parent 0fd71bc1ab
commit 52a9f429ed
5 changed files with 209 additions and 0 deletions
@@ -1641,6 +1641,13 @@ pages:
markdown-stress: markdown-stress:
title: Markdown Stress title: Markdown Stress
description: Stress markdown parsing and rendering with heavy chat payloads description: Stress markdown parsing and rendering with heavy chat payloads
plugin-host:
actions:
enable-and-load: Enable and Load
disable-and-unload: Disable and Unload
errors:
enable-and-load: 'Failed to enable and load plugin {extensionId}.'
disable-and-unload: 'Failed to disable and unload plugin {extensionId}.'
use-magic-keys: use-magic-keys:
title: useMagicKeys Tool title: useMagicKeys Tool
description: Test shortcuts description: Test shortcuts
@@ -1555,6 +1555,13 @@ pages:
markdown-stress: markdown-stress:
title: Markdown Stress title: Markdown Stress
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力 description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
plugin-host:
actions:
enable-and-load: 启用并加载
disable-and-unload: 停用并卸载
errors:
enable-and-load: '启用并加载插件 {extensionId} 失败。'
disable-and-unload: '停用并卸载插件 {extensionId} 失败。'
use-magic-keys: use-magic-keys:
title: useMagicKeys 工具 title: useMagicKeys 工具
description: 测试快捷键 description: 测试快捷键
@@ -9,9 +9,11 @@ import { Section } from '@proj-airi/stage-ui/components'
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug' import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
import { Button, Callout, GhostButton, Input } from '@proj-airi/ui' import { Button, Callout, GhostButton, Input } from '@proj-airi/ui'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
const store = usePluginHostInspectorStore() const store = usePluginHostInspectorStore()
const { t } = useI18n()
const filter = ref('') const filter = ref('')
const selectedExtensionId = ref('') const selectedExtensionId = ref('')
@@ -150,6 +152,24 @@ async function unloadPlugin(plugin: PluginManifestSummary) {
} }
} }
async function enableAndLoadPlugin(plugin: PluginManifestSummary) {
try {
await store.enableAndLoad({ extensionId: plugin.extensionId, path: plugin.path })
}
catch (error) {
toast.error(errorMessageFrom(error) ?? t('settings.pages.system.sections.section.developer.sections.section.plugin-host.errors.enable-and-load', { extensionId: plugin.extensionId }))
}
}
async function disableAndUnloadPlugin(plugin: PluginManifestSummary) {
try {
await store.disableAndUnload({ extensionId: plugin.extensionId, path: plugin.path })
}
catch (error) {
toast.error(errorMessageFrom(error) ?? t('settings.pages.system.sections.section.developer.sections.section.plugin-host.errors.disable-and-unload', { extensionId: plugin.extensionId }))
}
}
async function loadSelectedPlugin() { async function loadSelectedPlugin() {
const extensionId = selectedExtensionId.value.trim() const extensionId = selectedExtensionId.value.trim()
if (!extensionId) { if (!extensionId) {
@@ -304,6 +324,22 @@ onMounted(async () => {
</span> </span>
</div> </div>
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']"> <div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
<Button
size="sm"
:label="t('settings.pages.system.sections.section.developer.sections.section.plugin-host.actions.enable-and-load')"
icon="i-solar:play-bold-duotone"
:disabled="store.loading || (plugin.enabled && plugin.loaded)"
:loading="store.loading"
@click="enableAndLoadPlugin(plugin)"
/>
<GhostButton
size="sm"
:label="t('settings.pages.system.sections.section.developer.sections.section.plugin-host.actions.disable-and-unload')"
icon="i-solar:stop-bold-duotone"
:disabled="store.loading || (!plugin.enabled && !plugin.loaded)"
:loading="store.loading"
@click="disableAndUnloadPlugin(plugin)"
/>
<Button <Button
size="sm" size="sm"
@@ -0,0 +1,135 @@
import type { PluginHostDebugSnapshot, PluginRegistrySnapshot } from './plugin-host-debug'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { usePluginHostInspectorStore } from './plugin-host-debug'
function createRegistry(enabled: boolean, loaded: boolean): PluginRegistrySnapshot {
return {
root: 'extensions/v1',
plugins: [{
extensionId: 'sample-plugin',
entrypoints: { electron: './index.mjs' },
path: 'sample-plugin/extension.airi.json',
enabled,
loaded,
autoReload: false,
isNew: false,
}],
}
}
function createBridge(registry: PluginRegistrySnapshot) {
const snapshot: PluginHostDebugSnapshot = {
registry,
sessions: registry.plugins.filter(plugin => plugin.loaded).map(plugin => ({
id: 'sample-session',
extensionId: plugin.extensionId,
phase: 'ready',
runtime: 'electron',
moduleId: plugin.extensionId,
})),
kits: [],
modules: [],
capabilities: [],
refreshedAt: 1,
}
return {
list: vi.fn(async () => registry),
setEnabled: vi.fn(async () => registry),
setAutoReload: vi.fn(async () => registry),
loadEnabled: vi.fn(async () => registry),
load: vi.fn(async () => registry),
unload: vi.fn(async () => registry),
inspect: vi.fn(async () => snapshot),
}
}
describe.each([
{ action: 'enableAndLoad' as const, runtimeAction: 'load' as const, enabled: true },
{ action: 'disableAndUnload' as const, runtimeAction: 'unload' as const, enabled: false },
])('$action', ({ action, runtimeAction, enabled }) => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('saves enablement before changing runtime state and stays busy through inspection', async () => {
const finalRegistry = createRegistry(enabled, enabled)
const bridge = createBridge(finalRegistry)
const save = Promise.withResolvers<PluginRegistrySnapshot>()
const runtime = Promise.withResolvers<PluginRegistrySnapshot>()
const inspection = Promise.withResolvers<void>()
const inspect = bridge.inspect.getMockImplementation()!
bridge.setEnabled.mockReturnValueOnce(save.promise)
bridge[runtimeAction].mockReturnValueOnce(runtime.promise)
bridge.inspect.mockImplementationOnce(async () => {
await inspection.promise
return inspect()
})
const store = usePluginHostInspectorStore()
store.setBridge(bridge)
const operation = store[action]({
extensionId: 'sample-plugin',
path: 'sample-plugin/extension.airi.json',
})
expect(store.loading).toBe(true)
expect(bridge.setEnabled).toHaveBeenCalledExactlyOnceWith({
extensionId: 'sample-plugin',
path: 'sample-plugin/extension.airi.json',
enabled,
})
expect(bridge[runtimeAction]).not.toHaveBeenCalled()
const savedRegistry = createRegistry(enabled, !enabled)
save.resolve(savedRegistry)
await vi.waitFor(() => expect(bridge[runtimeAction]).toHaveBeenCalledExactlyOnceWith({ extensionId: 'sample-plugin' }))
expect(store.registry).toEqual(savedRegistry)
expect(store.loading).toBe(true)
expect(bridge.inspect).not.toHaveBeenCalled()
runtime.resolve(finalRegistry)
await vi.waitFor(() => expect(bridge.inspect).toHaveBeenCalledOnce())
expect(store.registry).toEqual(finalRegistry)
expect(store.loading).toBe(true)
inspection.resolve()
await expect(operation).resolves.toEqual(finalRegistry)
expect(store.sessions).toHaveLength(enabled ? 1 : 0)
expect(store.refreshedAt).toBe(1)
expect(store.loading).toBe(false)
expect(store.error).toBeUndefined()
})
it('does not change runtime state when saving enablement fails', async () => {
const bridge = createBridge(createRegistry(!enabled, !enabled))
bridge.setEnabled.mockRejectedValueOnce(new Error('Could not save enablement.'))
const store = usePluginHostInspectorStore()
store.setBridge(bridge)
await expect(store[action]({ extensionId: 'sample-plugin' })).rejects.toThrow('Could not save enablement.')
expect(bridge[runtimeAction]).not.toHaveBeenCalled()
expect(bridge.inspect).not.toHaveBeenCalled()
expect(store.error).toBe('Could not save enablement.')
expect(store.loading).toBe(false)
})
it('keeps the saved enablement visible when the runtime operation fails', async () => {
const savedRegistry = createRegistry(enabled, !enabled)
const bridge = createBridge(savedRegistry)
bridge[runtimeAction].mockRejectedValueOnce(new Error('Runtime operation failed.'))
const store = usePluginHostInspectorStore()
store.setBridge(bridge)
await expect(store[action]({ extensionId: 'sample-plugin' })).rejects.toThrow('Runtime operation failed.')
expect(bridge.setEnabled).toHaveBeenCalledOnce()
expect(store.registry).toEqual(savedRegistry)
expect(store.error).toBe('Runtime operation failed.')
expect(store.loading).toBe(false)
})
})
@@ -206,6 +206,28 @@ export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-deb
return nextRegistry return nextRegistry
} }
/** Keeps enablement visible if loading fails, so the user can retry loading. */
async function enableAndLoad(payload: { extensionId: string, path?: string }) {
return withBridge(async (activeBridge) => {
assignRegistry(await activeBridge.setEnabled({ ...payload, enabled: true }))
const nextRegistry = await activeBridge.load({ extensionId: payload.extensionId })
assignRegistry(nextRegistry)
assignInspection(await activeBridge.inspect())
return nextRegistry
})
}
/** Keeps the plugin disabled for future startup even if stopping its current session fails. */
async function disableAndUnload(payload: { extensionId: string, path?: string }) {
return withBridge(async (activeBridge) => {
assignRegistry(await activeBridge.setEnabled({ ...payload, enabled: false }))
const nextRegistry = await activeBridge.unload({ extensionId: payload.extensionId })
assignRegistry(nextRegistry)
assignInspection(await activeBridge.inspect())
return nextRegistry
})
}
return { return {
registry, registry,
sessions, sessions,
@@ -229,5 +251,7 @@ export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-deb
loadEnabled, loadEnabled,
load, load,
unload, unload,
enableAndLoad,
disableAndUnload,
} }
}) })