dsh-plugin 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,8 +5,9 @@
5
5
  *
6
6
  * Dialog layer for the Plugin Hub: the install-confirm dialog, the uninstall
7
7
  * dialog and the global toast. Both dialogs lock themselves while a mutation
8
- * or restart is running, then switch to a result view offering an immediate
9
- * restart or a "later" deferral.
8
+ * is running, then switch to a result view with a single action — "done" for
9
+ * the common case (the page refreshes on close so the change applies), or a
10
+ * single "restart now" for the rare changes a refresh cannot pick up.
10
11
  */
11
12
  import { createElement as h } from 'react'
12
13
  import type { MouseEvent } from 'react'
@@ -19,17 +20,21 @@ import { pluginDetailUrl, pluginIssueUrl, pluginSiteUrl } from '../../logic/urls
19
20
  import { classifyFailure, missingRegistryPackageOf, npmTooLowVersion, pnpmPolicyHintOf, pnpmPolicyHintParams, unreachableTargetOf, registryHostOf } from '../../logic/failures.ts'
20
21
 
21
22
  /** 完成结果视图:绿色对勾 + 标题/描述;
22
- * needsRestart=true(插件需重启才生效)→ 「稍后重启 / 立即重启」按钮对,点稍后重启后
23
- * 通知中心待重启条目常驻(服务端登记,内存态),直到用户点「立即重启」真正重启后才消失;
24
- * needsRestart=false(卸载已即时生效)→ 仅「完成」关闭。 */
23
+ * 终态只有两种:
24
+ * - needsRestart=true(刷新解决不了:热挂载失败 / 非 dsh 插件 / 更新)→ 仅一个「立即重启」
25
+ * 按钮(重启会中断进行中的任务,故仍需用户确认),关闭即放弃;
26
+ * - 其余(含 needsReload=true 的「已热挂载、仅剩客户端 UI」)→ 仅「完成」按钮:关闭结果弹窗时
27
+ * 上层自动刷新页面让插件生效,不再给「稍后/立即刷新」手动选择。 */
25
28
  function ResultView({
26
- title, desc, t, restarting, needsRestart, onRestart, onClose,
29
+ title, desc, t, restarting, needsRestart, needsReload, onRestart, onClose,
27
30
  }: {
28
31
  title: string
29
32
  desc: string
30
33
  t: Translate
31
34
  restarting: boolean
32
35
  needsRestart: boolean
36
+ /** 仅刷新页面即生效(插件已热挂载):给一句自动刷新说明,按钮仍是「完成」 */
37
+ needsReload?: boolean
33
38
  onRestart: () => void
34
39
  onClose: () => void
35
40
  }) {
@@ -56,13 +61,15 @@ function ResultView({
56
61
  ? h('div', null, [
57
62
  h('div', { className: styles.resultRestarting }, restarting ? t('restarting') : t('restartHint')),
58
63
  h('div', { className: styles.modalActions },
59
- h('button', { className: styles.restartLater, onClick: onClose, disabled: restarting }, t('restartLater')),
60
64
  h('button', { className: styles.restartNowWarning, onClick: onRestart, disabled: restarting },
61
65
  restarting ? t('restarting') : t('restartNow')),
62
66
  ),
63
67
  ])
64
- : h('div', { className: styles.modalActions },
65
- h('button', { className: styles.restartNow, onClick: onClose }, t('done'))),
68
+ : h('div', null, [
69
+ needsReload ? h('div', { className: styles.resultRestarting }, t('reloadHint')) : null,
70
+ h('div', { className: styles.modalActions },
71
+ h('button', { className: styles.restartNow, onClick: onClose }, t('done'))),
72
+ ]),
66
73
  )
67
74
  }
68
75
 
@@ -85,8 +92,10 @@ export interface InstallModalProps {
85
92
  cliOnly: boolean
86
93
  /** 安装请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
87
94
  submitting: boolean
88
- /** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false → 仅「完成」 */
95
+ /** 完成结果是否需重启才生效:true → 仅给「立即重启」;false → 仅「完成」(关闭即自动刷新) */
89
96
  needsRestart: boolean
97
+ /** 完成结果是否只需刷新页面即生效(插件已热挂载):true → 结果视图显示自动刷新说明 */
98
+ needsReload: boolean
90
99
  onClose: () => void
91
100
  onCopy: () => void
92
101
  onInstall: () => void
@@ -99,7 +108,7 @@ export interface InstallModalProps {
99
108
  * 目录插件与命令行安装(customTarget 模式)共用同一套确认/进度/结果流程。
100
109
  */
101
110
  export function InstallModal(props: InstallModalProps) {
102
- const { plugin, customTarget, globalNpm, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, onClose, onCopy, onInstall, onRestart } = props
111
+ const { plugin, customTarget, globalNpm, done, task, t, langPath, restarting, submitting, update, cliOnly, needsRestart, needsReload, onClose, onCopy, onInstall, onRestart } = props
103
112
  const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
104
113
  // 进行中标题带上插件名(中文「XX 插件安装中」;英文状态词在前更自然),并用状态色区分;
105
114
  // 命令行安装无插件元数据 → 直接用输入的目标展示;全局 npm 安装 → 包列表空格拼接
@@ -139,6 +148,7 @@ export function InstallModal(props: InstallModalProps) {
139
148
  t,
140
149
  restarting,
141
150
  needsRestart,
151
+ needsReload,
142
152
  onRestart,
143
153
  onClose,
144
154
  })
@@ -219,8 +229,11 @@ export interface UninstallModalProps {
219
229
  restarting: boolean
220
230
  /** 卸载请求在途(fetch 等待响应):此时任务尚未入队,需禁用确认按钮防止二次点击 */
221
231
  submitting: boolean
222
- /** 完成结果是否需重启才生效:true → 「稍后重启 / 立即重启」;false(loader 已即时移除)→ 仅「完成」 */
232
+ /** 完成结果是否需重启才生效:true(停用失败等刷新解决不了的场景)→ 仅给「立即重启」;
233
+ * false(loader 已即时停用)→ 仅「完成」,关闭即自动刷新页面摘除插件面板 */
223
234
  needsRestart: boolean
235
+ /** 卸载的插件带客户端 UI 且已即时停用:面板要刷新页面才摘除 → 结果视图给自动刷新说明 */
236
+ needsReload?: boolean
224
237
  onClose: () => void
225
238
  onCancel: () => void
226
239
  onCopyCommand: () => void
@@ -230,7 +243,7 @@ export interface UninstallModalProps {
230
243
 
231
244
  /** 卸载确认弹窗:确认/进行中(后台队列,可关闭);完成后切换为结果视图(成功即生效,仅「完成」关闭)。 */
232
245
  export function UninstallModal(props: UninstallModalProps) {
233
- const { plugin, done, task, t, langPath, restarting, submitting, needsRestart, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
246
+ const { plugin, done, task, t, langPath, restarting, submitting, needsRestart, needsReload, onClose, onCancel, onCopyCommand, onConfirm, onRestart } = props
234
247
  const busy = submitting || (task !== null && (task.status === 'pending' || task.status === 'running'))
235
248
  // 进行中标题带上插件名,与安装弹窗一致,并用状态色区分
236
249
  const name = plugin.displayName ?? plugin.slug
@@ -266,6 +279,7 @@ export function UninstallModal(props: UninstallModalProps) {
266
279
  t,
267
280
  restarting,
268
281
  needsRestart,
282
+ needsReload,
269
283
  onRestart,
270
284
  onClose,
271
285
  })
@@ -503,8 +517,11 @@ export function Toast({ toast, t }: { toast: ToastState; t: Translate }) {
503
517
  : toast.kind === 'fail' ? t('installFail')
504
518
  : toast.kind === 'removed' ? t('uninstallDone')
505
519
  : toast.kind === 'revealFail' ? t('openFolderFail')
506
- : t('uninstallFail')
507
- const fail = toast.kind === 'fail' || toast.kind === 'removeFail' || toast.kind === 'revealFail'
520
+ : toast.kind === 'restartDesktop' ? t('toastRestartDesktop')
521
+ : toast.kind === 'installedLive' ? t('toastInstalledLive')
522
+ : toast.kind === 'installedReload' ? t('toastInstalledReload')
523
+ : t('uninstallFail')
524
+ const fail = toast.kind === 'fail' || toast.kind === 'removeFail' || toast.kind === 'revealFail' || toast.kind === 'restartDesktop'
508
525
  return h('div', {
509
526
  key: toast.id,
510
527
  className: fail ? `${styles.toast} ${styles.toastFail}` : styles.toast,
@@ -512,7 +529,7 @@ export function Toast({ toast, t }: { toast: ToastState; t: Translate }) {
512
529
  }
513
530
 
514
531
  /** 待重启确认弹窗:已安装列表行内「重启」按钮点击后弹出,
515
- * 与通知中心待重启条目同一交互(说明 + 稍后重启 / 立即重启),
532
+ * 与通知中心待重启条目同一交互(红色警告说明 + 单个「立即重启」),
516
533
  * 避免行内按钮误触直接触发宿主重启。 */
517
534
  export function RestartConfirmModal({ t, restarting, onClose, onRestartNow }: {
518
535
  t: Translate
@@ -541,12 +558,7 @@ export function RestartConfirmModal({ t, restarting, onClose, onRestartNow }: {
541
558
  ),
542
559
  h('div', { className: styles.failPrepareHint }, t('restartHint')),
543
560
  h('div', { className: styles.modalActions },
544
- h('button', {
545
- className: styles.restartLater,
546
- type: 'button',
547
- disabled: restarting,
548
- onClick: onClose,
549
- }, t('restartLater')),
561
+ // 关闭本弹窗即视为「稍后」,只保留单个「立即重启」
550
562
  h('button', {
551
563
  className: styles.restartNowWarning,
552
564
  type: 'button',
@@ -48,10 +48,13 @@ export interface QueueTask {
48
48
  command?: string
49
49
  /** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败时 issue 预填一并贴给作者 */
50
50
  attempts?: string[]
51
- /** 完成结果是否需要重启才生效(服务端任务终态带出;默认 true 保持老行为:弹窗给重启选项)。
52
- * 卸载时 loader 已即时移除 → false,结果视图只显示「完成」;true 时弹窗给「稍后重启/立即重启」,
53
- * 通知中心待重启条目(服务端登记)同步常驻,直到用户点「立即重启」。 */
51
+ /** 完成结果是否需要重启才生效(服务端任务终态带出)。默认 true 保底:数据缺失时不静默视为已生效。
52
+ * 仅刷新解决不了的兜底场景(热挂载失败 / 非 dsh 插件 / 更新)为 true,结果视图给「立即重启」;
53
+ * 其余(热挂载成功、卸载已即时停用、全局安装)为 false,结果视图仅「完成」。 */
54
54
  needsRestart: boolean
55
+ /** 完成结果是否只需刷新页面即生效(插件已热挂进运行中 loader,或卸载后插件面板仍残留在当前页面):
56
+ * true 时关闭结果弹窗 / Toast 消失后由上层自动刷新页面,不给手动刷新按钮。 */
57
+ needsReload?: boolean
55
58
  }
56
59
 
57
60
  /** 客户端待重启项:镜像服务端内存列表 + 本地补齐的展示信息(简介/版本)。 */
@@ -71,10 +74,11 @@ export interface TaskQueueOptions {
71
74
  langKey?: LocaleId
72
75
  refreshInstalled: () => void
73
76
  /** 任务成功完成:viaModal 表示任务对应当前打开弹窗(弹窗切结果视图),否则走 Toast;repo 供成功通知记录;
74
- * needsRestart 表示该任务完成后是否仍需宿主重启(结果视图据此给「重启 / 仅完成」)。
77
+ * needsRestart 表示该任务完成后是否仍需宿主重启(结果视图据此给「重启 / 仅完成」);
78
+ * needsReload 表示是否只需刷新页面即生效(热挂载成功、或卸载后插件面板仍残留)。
75
79
  * update 表示实际执行的是更新(覆盖重装)而非首次安装:通知中心据此显示「更新成功」而非「安装成功」。 */
76
- onInstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean, update?: boolean) => void
77
- onUninstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean) => void
80
+ onInstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean, update?: boolean, needsReload?: boolean) => void
81
+ onUninstallDone: (viaModal: boolean, repo: string | null, needsRestart: boolean, needsReload?: boolean) => void
78
82
  /** 任务失败:完整输出或兜底文案 + 所属插件仓库 + 操作类型(安装/卸载)+ 实际执行的安装命令(issue 预填用,可缺省)+ 尝试过的安装方式(npm 反查/执行命令,可缺省)+ 是否更新。 */
79
83
  onError: (message: string, repo: string | null, kind: 'install' | 'uninstall', command?: string, attempts?: string[], update?: boolean) => void
80
84
  /** 当前打开的安装/卸载弹窗插件:用于在弹窗内匹配进行中的任务。 */
@@ -207,13 +211,13 @@ export function useTaskQueue(opts: TaskQueueOptions) {
207
211
  if (q.kind === 'uninstall') {
208
212
  // 卸载成功:清掉本地版本记录
209
213
  void syncInstalledVersion(q.repo, undefined, undefined)
210
- onUninstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart)
214
+ onUninstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart, q.needsReload ?? false)
211
215
  } else {
212
216
  // 安装成功:记录安装时的目录信号(版本 + 仓库更新时间),供「有更新」比对;
213
217
  // 同时缓存展示信息,待重启列表(服务端已登记)合并时直接补齐简介/版本
214
218
  void syncInstalledVersion(q.repo, q.version, q.updatedAt)
215
219
  pendingInfoRef.current.set(q.target, { desc: q.desc, version: q.version })
216
- onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart, q.action === 'update')
220
+ onInstallDone(modalTaskRef.current === q.id, q.repo, q.needsRestart, q.action === 'update', q.needsReload ?? false)
217
221
  }
218
222
  } else {
219
223
  // 失败:完整展示全部输出行(最新在前,逆序为日志阅读顺序),不裁剪
@@ -261,16 +265,17 @@ export function useTaskQueue(opts: TaskQueueOptions) {
261
265
  try {
262
266
  const res = await fetch(`/dsh-plugin-hub/status?task=${q.id}`, { cache: 'no-store' })
263
267
  if (res.ok) {
264
- const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown } }
268
+ const data = await res.json() as { task?: { status?: string; lines?: string[]; attempts?: string[]; needsRestart?: unknown; needsReload?: unknown } }
265
269
  const task = data.task
266
270
  if (task !== undefined) {
267
271
  status = task.status ?? 'failed'
268
272
  lines = task.lines ?? []
269
273
  attempts = Array.isArray(task.attempts) ? task.attempts : undefined
270
- // 终态带出的「是否需要重启」覆盖本地(服务端 done 时按 loader 即时移除情况设置)
274
+ // 终态带出的「是否需要重启 / 是否只需刷新」覆盖本地(服务端 done 时按 loader 热挂/即时移除情况设置)
271
275
  const needsRestart = typeof task.needsRestart === 'boolean' ? task.needsRestart : q.needsRestart
276
+ const needsReload = typeof task.needsReload === 'boolean' ? task.needsReload : (q.needsReload ?? false)
272
277
  if (status === 'done') {
273
- const settled = { ...q, needsRestart }
278
+ const settled = { ...q, needsRestart, needsReload }
274
279
  // 卸载成功走进度过渡动画;安装成功直接收尾
275
280
  if (q.kind === 'uninstall') settleDone(settled, lines)
276
281
  else finishQueueTask(true, settled, lines)
@@ -263,11 +263,17 @@ export const zh = {
263
263
  requestTimeout: '请求超时:无法访问 GitHub 或网络不稳定,请稍后重试',
264
264
  confirmCancel: '取消',
265
265
  doneBtn: '完成',
266
- // 结果视图:部分插件需重启后才挂载,提供「立即重启 / 稍后重启」选择
266
+ // 结果视图兜底:仅当「刷新解决不了」时(热挂载失败 / 非 dsh 插件 / 更新)才出现单个「立即重启」;
267
+ // 关闭弹窗即视为「稍后」,不再单独给「稍后重启」按钮
267
268
  restartNow: '立即重启',
268
- restartLater: '稍后重启',
269
269
  restarting: '正在重启…',
270
270
  restartHint: '重启会中断正在进行的安装/卸载任务,其进度将丢失。部分插件(如插件市场、顶层 bundle)需重启后才会生效。',
271
+ // 桌面端宿主归应用壳管、插件重启不了它:点「立即重启」时改提示用户退出重开
272
+ toastRestartDesktop: '桌面端的宿主由应用管理,请在应用菜单(或托盘)选择退出后重新打开,重启后生效',
273
+ // 装完即生效:插件已热挂进运行中 loader,结果弹窗关闭时自动刷新页面(无需用户点按钮)
274
+ reloadHint: '插件已加载,关闭本窗口后会自动刷新页面生效。',
275
+ toastInstalledLive: '安装成功,已立即生效',
276
+ toastInstalledReload: '安装成功,正在自动刷新页面…',
271
277
  done: '完成',
272
278
  // 待重启:装完没重启的插件常驻提醒,直到宿主真正重启
273
279
  restartPendingHint: '安装成功,重启后生效',
@@ -645,11 +651,18 @@ export const en = {
645
651
  requestTimeout: 'Request timed out: GitHub may be unreachable or the network is unstable — try again later',
646
652
  confirmCancel: 'Cancel',
647
653
  doneBtn: 'Done',
648
- // Result view: some plugins need a restart to mount — offer restart now / later
654
+ // Result-view fallback: the single "Restart now" only appears when a refresh cannot help
655
+ // (hot-mount failure / non-dsh plugin / update); closing the modal means "later"
649
656
  restartNow: 'Restart now',
650
- restartLater: 'Later',
651
657
  restarting: 'Restarting…',
652
658
  restartHint: 'Restarting interrupts any in-progress install / uninstall task — its progress will be lost. Some plugins (such as plugin markets / top-level bundles) only take effect after a restart.',
659
+ // Desktop: the app shell owns the host, so plugins cannot restart it — tell the user to quit and reopen
660
+ toastRestartDesktop: 'In the desktop app the host is managed by the app: quit from the app menu (or tray) and open it again — the change takes effect after that restart',
661
+ // Applied without a restart: the install was hot-mounted into the running loader;
662
+ // the page auto-refreshes on close (no manual refresh button)
663
+ reloadHint: 'The plugin is loaded — closing this window will refresh the page automatically.',
664
+ toastInstalledLive: 'Installed — active now',
665
+ toastInstalledReload: 'Installed — refreshing the page automatically…',
653
666
  done: 'Done',
654
667
  restartPendingHint: 'Installed — takes effect after restart',
655
668
  restartPendingHintUninstall: 'Removed — cleaned up after restart',
@@ -77,7 +77,7 @@ export interface TaskState {
77
77
  lines: string[]
78
78
  }
79
79
 
80
- export type ToastKind = 'copied' | 'errCopied' | 'done' | 'fail' | 'removed' | 'removeFail' | 'revealFail'
80
+ export type ToastKind = 'copied' | 'errCopied' | 'done' | 'fail' | 'removed' | 'removeFail' | 'revealFail' | 'restartDesktop' | 'installedLive' | 'installedReload'
81
81
 
82
82
  export interface ToastState {
83
83
  id: number
@@ -28,6 +28,7 @@ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLo
28
28
  * 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
29
29
  * 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
30
30
  * 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
31
+ * 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
31
32
  */
32
33
  const HOST_RESTART_SCRIPT = String.raw`const C = require('node:child_process')
33
34
  const F = require('node:fs')
@@ -54,7 +55,16 @@ function findPids() {
54
55
  return String(out.stdout || '').split(/\s+/).filter(Boolean)
55
56
  }
56
57
 
57
- for (const pid of findPids()) {
58
+ const targets = findPids()
59
+ // 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
60
+ // 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
61
+ // 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
62
+ if (targets.length === 0) {
63
+ logLine('restart aborted: no listener found on port ' + port)
64
+ process.exit(0)
65
+ }
66
+
67
+ for (const pid of targets) {
58
68
  try {
59
69
  if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
60
70
  else process.kill(Number(pid), 'SIGTERM')
@@ -67,6 +77,14 @@ let tries = 0
67
77
  setTimeout(waitForStop, 250)
68
78
  })()
69
79
 
80
+ function logLine(msg) {
81
+ try {
82
+ const dir = P.join(O.homedir(), '.dsh', 'logs')
83
+ F.mkdirSync(dir, { recursive: true })
84
+ F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
85
+ } catch {}
86
+ }
87
+
70
88
  function startHost() {
71
89
  const dir = P.join(O.homedir(), '.dsh', 'logs')
72
90
  F.mkdirSync(dir, { recursive: true })
@@ -79,6 +97,14 @@ function startHost() {
79
97
  child.unref()
80
98
  }`
81
99
 
100
+ /**
101
+ * 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
102
+ * 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
103
+ */
104
+ export function isDesktopHost(): boolean {
105
+ return process.env.ELECTRON_RUN_AS_NODE === '1'
106
+ }
107
+
82
108
  export interface WebRoute {
83
109
  kind: 'exact'
84
110
  path: string
@@ -999,6 +1025,8 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
999
1025
  env: mutationEnv(settings),
1000
1026
  // 覆盖更新:npm 通道命令显式 @latest(pnpm add 无版本对已存在依赖是幂等的,不加版本不会真更新)
1001
1027
  updateNpm: mode === 'update',
1028
+ // 运行中 loader:安装成功后把新插件热挂进 loader,非桌面端 + 纯服务端插件无需重启即生效
1029
+ loader,
1002
1030
  // 自定义安装入口渠道(NPM 包 / GitHub 源码 / DSH 命令卡片):日志/报错据此溯源
1003
1031
  installChannel,
1004
1032
  })
@@ -1043,7 +1071,7 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
1043
1071
  // 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
1044
1072
  displayTarget: githubRepoOf(repo) ?? undefined,
1045
1073
  // 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
1046
- uninstallLoader: loader,
1074
+ loader,
1047
1075
  timeoutMs: COMMAND_TIMEOUT_MS,
1048
1076
  env: mutationEnv(loadSettings(profile)),
1049
1077
  })
@@ -1109,6 +1137,15 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
1109
1137
  path: '/dsh-plugin-hub/restart',
1110
1138
  handler: (request, response) => {
1111
1139
  if (!requireTrustedPost(request, response)) return
1140
+ // 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
1141
+ // 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
1142
+ // 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
1143
+ // 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
1144
+ // 用户从菜单/托盘退出后重新打开。
1145
+ if (isDesktopHost()) {
1146
+ sendJson(response, 200, { ok: false, desktop: true })
1147
+ return
1148
+ }
1112
1149
  // 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
1113
1150
  const host = request.headers.host ?? ''
1114
1151
  const portMatch = host.match(/:(\d+)$/)
@@ -39,8 +39,15 @@ export interface InstallTask {
39
39
  progress: number
40
40
  /** Newest output lines first (consumer shows the tail). */
41
41
  lines: string[]
42
- /** 完成后是否仍需宿主重启才生效:卸载时 loader 已即时移除 → false;否则 true(弹窗据此给重启选项) */
42
+ /** 完成后是否仍需宿主重启才生效:仅「热挂载失败 / 非 dsh 插件 / 更新(ESM 缓存取不到新代码)/
43
+ * 无 loader」等刷新解决不了的兜底场景为 true(弹窗给「立即重启」);其余一律 false。 */
43
44
  needsRestart: boolean
45
+ /** 完成后是否只需刷新页面即生效(插件已热挂进运行中 loader,仅剩客户端 UI 待页面重新加载,
46
+ * 网页端与桌面端一致):true 时关闭结果弹窗即自动刷新页面,不给手动刷新按钮。 */
47
+ needsReload?: boolean
48
+ /** 卸载入队时该包是否带客户端 UI(`dsh.client`):卸载后包已从磁盘删除,只能入队前快照,
49
+ * 卸载成功且曾存活时据此决定是否需要刷新页面摘除插件面板。 */
50
+ hadClientUi?: boolean
44
51
  }
45
52
 
46
53
  /** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
@@ -16,7 +16,7 @@ import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.
16
16
  import { npmTooLowMarker } from './npm-check.ts'
17
17
  import { cleanLine, estimateProgress } from '../profile/progress.ts'
18
18
  import type { LoaderHandle } from '../loader.ts'
19
- import { removeLoadedEntry } from '../loader.ts'
19
+ import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.ts'
20
20
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.ts'
21
21
  import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
22
22
  import { appendLog } from '../log.ts'
@@ -256,8 +256,9 @@ export function startPluginMutation(options: {
256
256
  globalNpm?: string[]
257
257
  /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
258
258
  displayTarget?: string
259
- /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
260
- uninstallLoader?: LoaderHandle
259
+ /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目,两类操作都尽量立即生效
260
+ * (缺失时安装/卸载仍需重启) */
261
+ loader?: LoaderHandle
261
262
  /** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
262
263
  attempts?: string[]
263
264
  /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
@@ -266,7 +267,10 @@ export function startPluginMutation(options: {
266
267
  * 任务输出行与系统日志据此溯源「从哪个入口发起」;目录插件安装不传 */
267
268
  installChannel?: 'npm' | 'git' | 'dsh'
268
269
  }): InstallTask {
269
- const task: InstallTask = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, globalNpm: options.globalNpm, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false, installChannel: options.installChannel }
270
+ // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
271
+ // 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
272
+ const hadClientUi = options.action === 'remove' ? hasClientUi(options.profile, options.target) : undefined
273
+ const task: InstallTask = { id: nextTaskId, target: options.target, displayTarget: options.displayTarget, globalNpm: options.globalNpm, action: options.action, status: 'pending', timedOut: false, exitCode: null, progress: 0, lines: [], attempts: options.attempts ?? [], needsRestart: false, hadClientUi, installChannel: options.installChannel }
270
274
  nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1
271
275
  tasks.set(task.id, task)
272
276
  if (tasks.size > MAX_TASKS) {
@@ -367,14 +371,14 @@ function spawnMutation(options: {
367
371
  task?: InstallTask
368
372
  /** 待重启行的展示目标(owner/repo):与 mutation 目标解耦——卸载的 mutation 目标是 npm 包名,展示要回退到仓库名 */
369
373
  displayTarget?: string
370
- /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
371
- uninstallLoader?: LoaderHandle
374
+ /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目(缺失时仍需重启) */
375
+ loader?: LoaderHandle
372
376
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
373
377
  globalNpm?: string[]
374
378
  /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
375
379
  updateNpm?: boolean
376
380
  }): Promise<InstallResult> {
377
- const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader, globalNpm, updateNpm } = options
381
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm } = options
378
382
  // 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
379
383
  const isGlobalNpm = (globalNpm ?? []).length > 0
380
384
  const invocation = isGlobalNpm
@@ -555,18 +559,30 @@ function spawnMutation(options: {
555
559
  if (isGlobalNpm) {
556
560
  // 全局 npm 安装:装的是本机 CLI 工具(如 dsh-tui),不进任何 profile,无需宿主重启
557
561
  task.needsRestart = false
558
- } else if (action === 'remove' && uninstallLoader) {
559
- // 卸载成功 → 主动从运行中 loader 停用该包条目;
560
- // 仅当宿主从未加载过它(磁盘已干净)才判「无需重启」;
561
- // 只要曾在 loader 中存活(停用过 live entry)就仍要求重启——
562
- // 带 UI 的插件(侧边栏面板等宿主启动时渲染的槽位)disable 后不会
563
- // 主动消失,重启才能立即摘除面板
564
- const removed = await removeLoadedEntry(uninstallLoader, target)
565
- task.needsRestart = !removed
566
- if (removed) clearPendingRestart(displayTarget ?? target)
567
- else addPendingRestart(displayTarget ?? target, 'uninstall')
562
+ } else if (action === 'remove' && loader) {
563
+ // 卸载成功 → 主动从运行中 loader 停用该包条目:
564
+ // - 停用成功:服务端即时卸载;带客户端 UI 的插件面板要页面刷新才摘除(无需重启宿主)
565
+ // - 停用失败 / 超时:回退「待重启」兜底清理(刷新解决不了,登记常驻提醒)
566
+ const removal = await removeLoadedEntry(loader, target)
567
+ task.needsRestart = !removal.ok
568
+ task.needsReload = removal.ok && removal.live && task.hadClientUi === true
569
+ if (task.needsRestart) addPendingRestart(displayTarget ?? target, 'uninstall')
570
+ else clearPendingRestart(displayTarget ?? target)
571
+ } else if (action !== 'remove' && !isUpdate && loader) {
572
+ // 安装成功 → 尝试把新插件热挂进运行中 loader,让插件无需重启宿主即生效。
573
+ // 挂上了:服务端即时生效;带客户端 UI 的插件还需页面重新加载(宿主按请求现场
574
+ // 拼装 __DSH_BOOT__ 注入清单,网页端与桌面端一致,刷新即可)。
575
+ // 挂不上 / 非 dsh 插件 / 无法定位包名:回退「待重启」提醒(保证不静默失效)。
576
+ const { name } = verifyInstalledEntry(profile, target)
577
+ if (name !== null && isDshPlugin(profile, name) && await mountLoadedEntry(loader, name)) {
578
+ task.needsReload = hasClientUi(profile, name)
579
+ task.needsRestart = false
580
+ } else {
581
+ task.needsRestart = true
582
+ addPendingRestart(displayTarget ?? target, 'install')
583
+ }
568
584
  } else {
569
- // 安装/更新成功(或卸载但无 loader 引用)→ 登记待重启:
585
+ // 更新(ESM 缓存下热挂取不到新代码)/ 无 loader 的安装或卸载 → 登记待重启:
570
586
  // 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
571
587
  task.needsRestart = true
572
588
  addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install')
@@ -660,8 +676,8 @@ export async function runPluginMutation(options: {
660
676
  task?: InstallTask
661
677
  /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
662
678
  displayTarget?: string
663
- /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
664
- uninstallLoader?: LoaderHandle
679
+ /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目(缺失时仍需重启) */
680
+ loader?: LoaderHandle
665
681
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
666
682
  globalNpm?: string[]
667
683
  }): Promise<InstallResult> {