Quellcode durchsuchen

fix(desktop): separate default port and replace plugin update prompt

07akioni vor 1 Woche
Ursprung
Commit
8345ac129e

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md
-2026-09-10-desktop-web-wrapper.md: 7a79f50763270058cd3a611f6b158209d138e6e3
-2026-09-10-desktop-web-wrapper.zh.md: b7c5b3ea7df00b89c59e26115342db2a05c3e837
+2026-09-10-desktop-web-wrapper.md: f37976f9216eadb78673f00a22c1fae1c78bce11
+2026-09-10-desktop-web-wrapper.zh.md: 401a9e6fdb82ef1a6e415c3bdf5cbb7226458439

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.md

@@ -12,7 +12,7 @@ Separate Desktop composition and request transport require their own configurati
 
 
 The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads the authenticated URL reported by the child. Child IPC carries readiness and shutdown; application requests travel directly over HTTP.
 The private Desktop Host invokes the CLI's shared profile runner against the independently owned Desktop profile. The complete Web composition owns authentication, HTTP routes, client assets, RPC, and response streaming. Electron loads the authenticated URL reported by the child. Child IPC carries readiness and shutdown; application requests travel directly over HTTP.
 
 
-The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Desktop preserves Web defaults and user configuration. Native directory selection is a Desktop overlay with an observable UI purpose; shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
+The shared runner owns profile and Harness-home patches, proxy setup, telemetry defaults, module fallbacks, configuration reload, and application lifecycle. Web and Desktop share application mechanisms while owning their deployment defaults. Desktop uses a separate default listener port so both applications can run concurrently; profile configuration can override it. Native directory selection is a Desktop overlay with an observable UI purpose; shell windows, menus, plugin management, recovery, and updates remain Electron responsibilities.
 
 
 The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled Node.js and pnpm, and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
 The [bundled-runtime decision](2026-09-08-desktop-bundled-runtime-and-external-plugins.md) retains separate runtime and plugin storage, bundled Node.js and pnpm, and explicit package ownership. The [in-place decision](2026-09-09-desktop-in-place-profile.md) retains package transactions and partial-failure recovery. The public CLI continues to reject the reserved Desktop profile.
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-10-desktop-web-wrapper.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 
 私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 加载子进程报告的认证 URL。子进程 IPC 承载就绪与关闭;应用请求直接通过 HTTP 传输。
 私有 Desktop Host 针对独立归属的 Desktop profile 调用 CLI 的共享 profile runner。完整 Web 组合负责认证、HTTP 路由、客户端资源、RPC 与响应流。Electron 加载子进程报告的认证 URL。子进程 IPC 承载就绪与关闭;应用请求直接通过 HTTP 传输。
 
 
-共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Desktop 保留 Web 默认值与用户配置。原生目录选择是具有可见 UI 用途的 Desktop overlay;壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
+共享 runner 负责 profile 与 Harness-home patch、代理设置、遥测默认值、模块补全、配置重载及应用生命周期。Web 与 Desktop 共享应用机制,各自决定部署默认值。Desktop 使用独立的默认监听端口,使两个应用可以同时运行;profile 配置可以覆盖该端口。原生目录选择是具有可见 UI 用途的 Desktop overlay;壳窗口、菜单、插件管理、恢复及更新仍由 Electron 负责。
 
 
 [内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 Node.js 和 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
 [内置运行时决策](2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)保留独立运行时与插件存储、内置 Node.js 和 pnpm,以及明确的包归属。[原位修改决策](2026-09-09-desktop-in-place-profile.zh.md)保留包事务与部分失败恢复。公开 CLI 继续拒绝保留的 Desktop profile。
 
 

+ 1 - 1
apps/desktop-host/src/index.ts

@@ -17,7 +17,7 @@ async function main(): Promise<void> {
     profile: 'desktop',
     profile: 'desktop',
     resolvedProfile: { profile, installAnchor },
     resolvedProfile: { profile, installAnchor },
     patchFiles: [fileURLToPath(new URL('../config/desktop.cordis.patch.yml', import.meta.url))],
     patchFiles: [fileURLToPath(new URL('../config/desktop.cordis.patch.yml', import.meta.url))],
-    args: ['--no-open'],
+    args: ['--no-open', '--port', '19387'],
   })
   })
   const stop = async (): Promise<void> => {
   const stop = async (): Promise<void> => {
     // Startup failure is reported by main; shutdown only owns a tree that booted.
     // Startup failure is reported by main; shutdown only owns a tree that booted.

+ 2 - 2
apps/desktop/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
 #   pnpm run verify-translation-pairing --write apps/desktop/README.md
-README.md: 8812ac195f8965785b9c5acda5b977e2b200a82d
-README.zh.md: c6f763085828595196634aca62b51886944a2899
+README.md: ee1789c58d7d15946ebb06e1ecb148ededd652ea
+README.zh.md: 8ac973aa96b3c3a4e8c40b97f79bfc85ba7e7909

+ 2 - 2
apps/desktop/README.md

@@ -2,7 +2,7 @@
 
 
 English | [中文](README.zh.md)
 English | [中文](README.zh.md)
 
 
-The desktop application is an Electron shell around the complete dsh Web application. A bundled upstream Node.js child starts the shared profile runner, and Electron loads its authenticated HTTP URL. Web owns client assets, API routes, and response streaming; Node IPC carries child readiness and shutdown.
+The desktop application is an Electron shell around the complete dsh Web application. A bundled upstream Node.js child starts the shared profile runner, and Electron loads its authenticated HTTP URL. Web owns client assets, API routes, and response streaming; Node IPC carries child readiness and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
 
 
 ## Key technical decisions
 ## Key technical decisions
 
 
@@ -34,7 +34,7 @@ The signed `resources/dsh/desktop-runtime.json` binds the shell version, bundled
 1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links while preserving unrelated files, then starts the actual backend once. Unchanged startups reuse the profile without scanning installed plugin manifests.
 1. The main window displays a local loading page before profile preparation or backend startup. A fresh profile creates its manifest and shared package links while preserving unrelated files, then starts the actual backend once. Unchanged startups reuse the profile without scanning installed plugin manifests.
 2. A compatible application upgrade refreshes shared links in the current profile without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
 2. A compatible application upgrade refreshes shared links in the current profile without checking plugin peer requirements. Plugin files, configuration, versions, and lockfile remain in place; pnpm does not run.
 3. Changes to bundled Node, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
 3. Changes to bundled Node, platform, or architecture preserve installed plugins. Native incompatibilities surface during loading and can be repaired through pnpm.
-4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. Update versions and ranges pass to pnpm. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
+4. Plugin add, update, and remove operations use bundled pnpm with its normal user and profile configuration. Desktop does not override the registry, npmrc, cache, or store, and new profiles add no build allowlist or strict-build setting. The plugin-management page has an inline version form with cancellation; versions and ranges pass to pnpm, including the installed version for a reinstall. Package specs pass to pnpm, including local directories, Git, tarballs, and aliases. Relative paths resolve from the Desktop profile directory. Packages declaring `dsh.bundle.patch` activate as bundles; ordinary dependencies remain installed without activation. Desktop does not scan plugin dependency graphs or validate patch files before Host startup. Custom profile metadata and bundle order are retained. Unreadable installed metadata does not block listing, disabling, or removing dependencies; the list uses the dependency spec when the installed version is unavailable.
 5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
 5. Plugin changes stop the backend before modifying the current profile. Successful preparation starts the Host. Package or Host startup failures retain modified files and report the error. Desktop creates no staging directories, activation journals, or rollback copies.
 
 
 The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.
 The loading page does not depend on the Host. Errors offer restart and reinstallation guidance. Disabling plugins and resetting Desktop are offered when runtime resources support profile recovery, including development mode; early initialization failures expose restart alone. The plugin manager remains available through the application menu. Plugin changes have no automatic rollback.

+ 2 - 2
apps/desktop/README.zh.md

@@ -2,7 +2,7 @@
 
 
 [English](README.md) | 中文
 [English](README.md) | 中文
 
 
-桌面应用是完整 dsh Web 应用外的一层 Electron 壳。内置上游 Node.js 子进程启动共享 profile runner,Electron 加载其带认证的 HTTP URL。Web 负责客户端资源、API 路由与响应流;Node IPC 承载子进程就绪与关闭。
+桌面应用是完整 dsh Web 应用外的一层 Electron 壳。内置上游 Node.js 子进程启动共享 profile runner,Electron 加载其带认证的 HTTP URL。Web 负责客户端资源、API 路由与响应流;Node IPC 承载子进程就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
 
 
 ## 关键技术决策
 ## 关键技术决策
 
 
@@ -34,7 +34,7 @@ Electron 根据应用 locale 选择类型化的英文或中文桌面壳文案,
 1. 主窗口在 profile 准备或后端启动前显示本地加载页。新 profile 创建清单和共享包链接,保留无关文件,然后启动一次实际后端。未变化的启动复用 profile,不扫描已安装插件的清单。
 1. 主窗口在 profile 准备或后端启动前显示本地加载页。新 profile 创建清单和共享包链接,保留无关文件,然后启动一次实际后端。未变化的启动复用 profile,不扫描已安装插件的清单。
 2. 兼容的应用升级在当前 profile 中刷新共享链接,不检查插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。
 2. 兼容的应用升级在当前 profile 中刷新共享链接,不检查插件的 peer 要求。插件文件、配置、版本和锁文件留在原处;不运行 pnpm。
 3. 内置 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
 3. 内置 Node 版本、平台或架构变化时保留已安装插件。原生兼容性问题在加载时报错,可通过 pnpm 修复。
-4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。更新版本及范围交给 pnpm。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
+4. 插件添加、更新和删除使用内置 pnpm 及其正常的用户和 profile 配置。Desktop 不覆盖 registry、npmrc、缓存或 store,新 profile 不添加构建许可列表或严格构建设置。插件管理页提供可取消的行内版本表单;版本和范围交给 pnpm,也允许提交已安装版本以重装。包规格交给 pnpm,包括本地目录、Git、tarball 和别名。相对路径从 Desktop profile 目录解析。声明 `dsh.bundle.patch` 的包作为 bundle 启用;普通依赖安装后不自动启用。Desktop 不扫描插件依赖图,也不在 Host 启动前验证 patch 文件。自定义 profile 元数据和 bundle 顺序会保留。已安装元数据不可读时,仍能列出、禁用和删除依赖;无法读取已安装版本时,列表使用依赖规格。
 5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
 5. 插件变更在直接修改当前 profile 前停止后端。准备成功后启动 Host。包操作或 Host 启动失败会保留已修改文件并报告错误。Desktop 不创建 staging 目录、激活日志或回滚副本。
 
 
 加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚。
 加载页不依赖 Host。错误页提供重启和重装指导。运行时资源支持 profile 恢复时,即可禁用插件和重置 Desktop,包括开发模式;早期初始化失败只提供重启。应用菜单仍提供插件管理器入口。插件修改不自动回滚。

+ 8 - 0
apps/desktop/renderer/plugin-manager.html

@@ -29,6 +29,14 @@
           <button id="install" type="submit"></button>
           <button id="install" type="submit"></button>
         </div>
         </div>
       </form>
       </form>
+      <form id="update-form" hidden aria-labelledby="update-label">
+        <label id="update-label" for="update-version"></label>
+        <div class="install-row">
+          <input id="update-version" name="update-version" autocomplete="off" required>
+          <button id="update-submit" type="submit"></button>
+          <button id="update-cancel" class="quiet" type="button"></button>
+        </div>
+      </form>
       <p id="status" role="status" aria-live="polite"></p>
       <p id="status" role="status" aria-live="polite"></p>
       <section aria-labelledby="installed-heading">
       <section aria-labelledby="installed-heading">
         <h2 id="installed-heading"></h2>
         <h2 id="installed-heading"></h2>

+ 25 - 3
apps/desktop/renderer/plugin-manager.js

@@ -24,6 +24,12 @@ async function main() {
   const form = document.querySelector('#install-form')
   const form = document.querySelector('#install-form')
   const input = document.querySelector('#package-spec')
   const input = document.querySelector('#package-spec')
   const refresh = document.querySelector('#refresh')
   const refresh = document.querySelector('#refresh')
+  const updateForm = document.querySelector('#update-form')
+  const updateVersion = document.querySelector('#update-version')
+  let updateName = ''
+  let updateTrigger
+  document.querySelector('#update-submit').textContent = messages.update
+  document.querySelector('#update-cancel').textContent = messages.cancel
 
 
   function setBusy(busy, statusMessage = '') {
   function setBusy(busy, statusMessage = '') {
     for (const control of document.querySelectorAll('button, input')) control.disabled = busy
     for (const control of document.querySelectorAll('button, input')) control.disabled = busy
@@ -53,9 +59,13 @@ async function main() {
       update.type = 'button'
       update.type = 'button'
       update.textContent = messages.update
       update.textContent = messages.update
       update.addEventListener('click', () => {
       update.addEventListener('click', () => {
-        const next = window.prompt(message('targetVersion', { name: plugin.name }), plugin.version)?.trim()
-        if (next === undefined || next === '' || next === plugin.version) return
-        void run(() => api.plugins.update(plugin.name, next), message('updating', { name: plugin.name }))
+        updateName = plugin.name
+        updateTrigger = update
+        document.querySelector('#update-label').textContent = message('targetVersion', { name: plugin.name })
+        updateVersion.value = plugin.version
+        updateForm.hidden = false
+        updateVersion.focus()
+        updateVersion.select()
       })
       })
       const actions = document.createElement('span')
       const actions = document.createElement('span')
       actions.className = 'package-actions'
       actions.className = 'package-actions'
@@ -106,6 +116,18 @@ async function main() {
       input.value = ''
       input.value = ''
     }, message('installing', { spec }))
     }, message('installing', { spec }))
   })
   })
+  updateForm.addEventListener('submit', (event) => {
+    event.preventDefault()
+    const version = updateVersion.value.trim()
+    if (version === '') return
+    const name = updateName
+    updateForm.hidden = true
+    void run(() => api.plugins.update(name, version), message('updating', { name }))
+  })
+  document.querySelector('#update-cancel').addEventListener('click', () => {
+    updateForm.hidden = true
+    updateTrigger.focus()
+  })
   document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
   document.querySelector('#retry').addEventListener('click', () => void run(() => api.backend.retry(), messages.retry))
   document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
   document.querySelector('#disable-all').addEventListener('click', () => void run(() => api.plugins.disableAll(), messages.changingActivation))
   refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))
   refresh.addEventListener('click', () => void load(messages.refreshing, messages.refreshed))

+ 2 - 0
apps/desktop/src/locale.ts

@@ -40,6 +40,7 @@ export const en = {
   noPlugins: 'No Desktop plugins are installed.',
   noPlugins: 'No Desktop plugins are installed.',
   remove: 'Remove',
   remove: 'Remove',
   update: 'Update',
   update: 'Update',
+  cancel: 'Cancel',
   targetVersion: 'Enter the target version for {name}',
   targetVersion: 'Enter the target version for {name}',
   removing: 'Removing {name}…',
   removing: 'Removing {name}…',
   updating: 'Updating {name}…',
   updating: 'Updating {name}…',
@@ -93,6 +94,7 @@ export const zh = {
   noPlugins: '还没有安装桌面插件。',
   noPlugins: '还没有安装桌面插件。',
   remove: '移除',
   remove: '移除',
   update: '更新',
   update: '更新',
+  cancel: '取消',
   targetVersion: '输入 {name} 的目标版本',
   targetVersion: '输入 {name} 的目标版本',
   removing: '正在移除 {name}…',
   removing: '正在移除 {name}…',
   updating: '正在更新 {name}…',
   updating: '正在更新 {name}…',

+ 50 - 0
apps/desktop/tests/plugin-manager.spec.ts

@@ -31,3 +31,53 @@ it('keeps disabled packages visible and offers recovery without a running backen
     await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove')
     await expect.poll(() => document.querySelector('#plugins li')?.textContent).toBe('example-plugin1.0.0DisableUpdateRemove')
   } finally { dom.window.close() }
   } finally { dom.window.close() }
 })
 })
+
+it.each(['en', 'zh-CN'])('updates through the inline form, including the installed version (%s)', async (language) => {
+  const dom = new JSDOM(readFileSync(new URL('../renderer/plugin-manager.html', import.meta.url), 'utf8'), { runScripts: 'outside-only' })
+  const locale = resolveDesktopLocale(language)
+  const update = vi.fn(async () => {})
+  Object.defineProperty(dom.window, 'dshDesktop', { value: {
+    locale: async () => locale,
+    backend: { status: async () => ({ phase: 'ready' }) },
+    plugins: { list: async () => [{ name: 'example-plugin', version: '1.0.0', enabled: true }], update },
+  } })
+  try {
+    runInContext(readFileSync(new URL('../renderer/plugin-manager.js', import.meta.url), 'utf8'), dom.getInternalVMContext())
+    const document = dom.window.document
+    await expect.poll(() => document.querySelector('#plugins li')?.textContent).toContain('example-plugin')
+    const updateButton = () => document.querySelectorAll<HTMLButtonElement>('#plugins li button')[1]!
+    const form = document.querySelector<HTMLFormElement>('#update-form')!
+    const version = document.querySelector<HTMLInputElement>('#update-version')!
+    updateButton().click()
+    expect(form.hidden).toBe(false)
+    expect(version.value).toBe('1.0.0')
+    expect(document.activeElement).toBe(version)
+    expect(document.querySelector('#update-label')?.textContent).toBe(locale.messages.targetVersion.replace('{name}', 'example-plugin'))
+    expect(document.querySelector('#update-cancel')?.textContent).toBe(locale.messages.cancel)
+    if (language === 'en') {
+      expect({ label: document.querySelector('#update-label')?.textContent,
+        actions: [...form.querySelectorAll('button')].map(button => button.textContent) }).toMatchInlineSnapshot(`
+          {
+            "actions": [
+              "Update",
+              "Cancel",
+            ],
+            "label": "Enter the target version for example-plugin",
+          }
+        `)
+    }
+    document.querySelector<HTMLButtonElement>('#update-cancel')!.click()
+    expect(form.hidden).toBe(true)
+    expect(document.activeElement).toBe(updateButton())
+    expect(update).not.toHaveBeenCalled()
+    for (const target of ['1.0.0', '^2.0.0']) {
+      updateButton().click()
+      version.value = target
+      form.requestSubmit()
+      await expect.poll(() => document.querySelector('#status')?.textContent).toBe(locale.messages.operationComplete)
+      expect(update).toHaveBeenLastCalledWith('example-plugin', target)
+      expect(form.hidden).toBe(true)
+    }
+    expect(update).toHaveBeenCalledTimes(2)
+  } finally { dom.window.close() }
+})