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.
@@ -26,6 +26,7 @@ import { appendLog, clearLog, readLog, logFilePath, defaultLogFilePath, customLo
26
26
  * 不用 `/bin/sh -c`:Windows 无 /bin/sh,且 lsof/nohup 仅 POSIX 存在。
27
27
  * 由 `spawn(process.execPath, ['-e', SCRIPT, port], { detached, stdio:'ignore' })` 孵化,
28
28
  * 宿主进程被 kill 后仍能完成「停旧 → 等端口释放 → 拉起新 dsh web」。
29
+ * 端口探测不到监听进程时不再假装重启(记一行日志后退出,见脚本内注释)。
29
30
  */
30
31
  const HOST_RESTART_SCRIPT = String.raw `const C = require('node:child_process')
31
32
  const F = require('node:fs')
@@ -52,7 +53,16 @@ function findPids() {
52
53
  return String(out.stdout || '').split(/\s+/).filter(Boolean)
53
54
  }
54
55
 
55
- for (const pid of findPids()) {
56
+ const targets = findPids()
57
+ // 一个监听进程都没找到 = 端口探测本身就失败了(netstat 输出格式 / 本地化差异),
58
+ // 旧宿主仍占着端口。此时再拉一个 dsh web 只会在日志里以 EADDRINUSE 静默退出,
59
+ // 用户却看到「重启完成」——不制造这种假象,留一行可诊断的记录后退出。
60
+ if (targets.length === 0) {
61
+ logLine('restart aborted: no listener found on port ' + port)
62
+ process.exit(0)
63
+ }
64
+
65
+ for (const pid of targets) {
56
66
  try {
57
67
  if (win) C.spawnSync('taskkill', ['/pid', pid, '/t', '/f'])
58
68
  else process.kill(Number(pid), 'SIGTERM')
@@ -65,6 +75,14 @@ let tries = 0
65
75
  setTimeout(waitForStop, 250)
66
76
  })()
67
77
 
78
+ function logLine(msg) {
79
+ try {
80
+ const dir = P.join(O.homedir(), '.dsh', 'logs')
81
+ F.mkdirSync(dir, { recursive: true })
82
+ F.appendFileSync(P.join(dir, 'dsh-web-' + port + '.log'), '[' + new Date().toISOString() + '] ' + msg + '\n')
83
+ } catch {}
84
+ }
85
+
68
86
  function startHost() {
69
87
  const dir = P.join(O.homedir(), '.dsh', 'logs')
70
88
  F.mkdirSync(dir, { recursive: true })
@@ -76,6 +94,13 @@ function startHost() {
76
94
  })
77
95
  child.unref()
78
96
  }`;
97
+ /**
98
+ * 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
99
+ * 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
100
+ */
101
+ export function isDesktopHost() {
102
+ return process.env.ELECTRON_RUN_AS_NODE === '1';
103
+ }
79
104
  const PROFILE_RE = /^[A-Za-z0-9_-]+$/;
80
105
  const BODY_LIMIT_BYTES = 4 * 1024;
81
106
  const COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
@@ -1014,6 +1039,8 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
1014
1039
  env: mutationEnv(settings),
1015
1040
  // 覆盖更新:npm 通道命令显式 @latest(pnpm add 无版本对已存在依赖是幂等的,不加版本不会真更新)
1016
1041
  updateNpm: mode === 'update',
1042
+ // 运行中 loader:安装成功后把新插件热挂进 loader,非桌面端 + 纯服务端插件无需重启即生效
1043
+ loader,
1017
1044
  // 自定义安装入口渠道(NPM 包 / GitHub 源码 / DSH 命令卡片):日志/报错据此溯源
1018
1045
  installChannel,
1019
1046
  });
@@ -1060,7 +1087,7 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
1060
1087
  // 卸载的 mutation 目标是 npm 包名;待重启行要显示 owner/repo(与安装行一致),非法/缺失时回退包名
1061
1088
  displayTarget: githubRepoOf(repo) ?? undefined,
1062
1089
  // 运行中 loader:卸载成功后主动移除条目,立即生效、无需重启
1063
- uninstallLoader: loader,
1090
+ loader,
1064
1091
  timeoutMs: COMMAND_TIMEOUT_MS,
1065
1092
  env: mutationEnv(loadSettings(profile)),
1066
1093
  });
@@ -1133,6 +1160,15 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
1133
1160
  handler: (request, response) => {
1134
1161
  if (!requireTrustedPost(request, response))
1135
1162
  return;
1163
+ // 桌面端:宿主由应用壳托管,插件重启不了它。Windows 上 `taskkill /T` 会把发起
1164
+ // 重启的助手一起结束(助手是宿主的子进程),壳再把这次外部 kill 判成宿主崩溃
1165
+ // 弹「应用无法启动或已意外停止」;退一步说,脱离壳拉起的 `dsh web` 也服务不了
1166
+ // 桌面 UI(缺壳注入的 desktop:boot)。故不 kill、不拉新宿主,由客户端改提示
1167
+ // 用户从菜单/托盘退出后重新打开。
1168
+ if (isDesktopHost()) {
1169
+ sendJson(response, 200, { ok: false, desktop: true });
1170
+ return;
1171
+ }
1136
1172
  // 当前宿主监听端口来自请求 Host 头(localhost:7923),解析失败回退 7923
1137
1173
  const host = request.headers.host ?? '';
1138
1174
  const portMatch = host.match(/:(\d+)$/);
@@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path';
14
14
  import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.js';
15
15
  import { npmTooLowMarker } from './npm-check.js';
16
16
  import { cleanLine, estimateProgress } from '../profile/progress.js';
17
- import { removeLoadedEntry } from '../loader.js';
17
+ import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.js';
18
18
  import { addPendingRestart, clearPendingRestart } from '../profile/pending-restart.js';
19
19
  import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.js';
20
20
  import { appendLog } from '../log.js';
@@ -230,7 +230,10 @@ function channelLabel(channel) {
230
230
  * Progress is visible through `getTask(id)` / `activeTask()` until done.
231
231
  */
232
232
  export function startPluginMutation(options) {
233
- const task = { 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 };
233
+ // 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
234
+ // 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
235
+ const hadClientUi = options.action === 'remove' ? hasClientUi(options.profile, options.target) : undefined;
236
+ const task = { 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 };
234
237
  nextTaskId = nextTaskId >= Number.MAX_SAFE_INTEGER ? 1 : nextTaskId + 1;
235
238
  tasks.set(task.id, task);
236
239
  if (tasks.size > MAX_TASKS) {
@@ -326,7 +329,7 @@ function pumpQueue() {
326
329
  * Never rejects; failures surface through the result / task state.
327
330
  */
328
331
  function spawnMutation(options) {
329
- const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, uninstallLoader, globalNpm, updateNpm } = options;
332
+ const { action, profile, target, timeoutMs = 5 * 60 * 1000, env, task, displayTarget, loader, globalNpm, updateNpm } = options;
330
333
  // 全局 npm 安装:直接 spawn 本机 npm(`npm install -g <pkgs>`),不是 dsh plugin 命令
331
334
  const isGlobalNpm = (globalNpm ?? []).length > 0;
332
335
  const invocation = isGlobalNpm
@@ -518,21 +521,35 @@ function spawnMutation(options) {
518
521
  // 全局 npm 安装:装的是本机 CLI 工具(如 dsh-tui),不进任何 profile,无需宿主重启
519
522
  task.needsRestart = false;
520
523
  }
521
- else if (action === 'remove' && uninstallLoader) {
522
- // 卸载成功 → 主动从运行中 loader 停用该包条目;
523
- // 仅当宿主从未加载过它(磁盘已干净)才判「无需重启」;
524
- // 只要曾在 loader 中存活(停用过 live entry)就仍要求重启——
525
- // 带 UI 的插件(侧边栏面板等宿主启动时渲染的槽位)disable 后不会
526
- // 主动消失,重启才能立即摘除面板
527
- const removed = await removeLoadedEntry(uninstallLoader, target);
528
- task.needsRestart = !removed;
529
- if (removed)
530
- clearPendingRestart(displayTarget ?? target);
531
- else
524
+ else if (action === 'remove' && loader) {
525
+ // 卸载成功 → 主动从运行中 loader 停用该包条目:
526
+ // - 停用成功:服务端即时卸载;带客户端 UI 的插件面板要页面刷新才摘除(无需重启宿主)
527
+ // - 停用失败 / 超时:回退「待重启」兜底清理(刷新解决不了,登记常驻提醒)
528
+ const removal = await removeLoadedEntry(loader, target);
529
+ task.needsRestart = !removal.ok;
530
+ task.needsReload = removal.ok && removal.live && task.hadClientUi === true;
531
+ if (task.needsRestart)
532
532
  addPendingRestart(displayTarget ?? target, 'uninstall');
533
+ else
534
+ clearPendingRestart(displayTarget ?? target);
535
+ }
536
+ else if (action !== 'remove' && !isUpdate && loader) {
537
+ // 安装成功 → 尝试把新插件热挂进运行中 loader,让插件无需重启宿主即生效。
538
+ // 挂上了:服务端即时生效;带客户端 UI 的插件还需页面重新加载(宿主按请求现场
539
+ // 拼装 __DSH_BOOT__ 注入清单,网页端与桌面端一致,刷新即可)。
540
+ // 挂不上 / 非 dsh 插件 / 无法定位包名:回退「待重启」提醒(保证不静默失效)。
541
+ const { name } = verifyInstalledEntry(profile, target);
542
+ if (name !== null && isDshPlugin(profile, name) && await mountLoadedEntry(loader, name)) {
543
+ task.needsReload = hasClientUi(profile, name);
544
+ task.needsRestart = false;
545
+ }
546
+ else {
547
+ task.needsRestart = true;
548
+ addPendingRestart(displayTarget ?? target, 'install');
549
+ }
533
550
  }
534
551
  else {
535
- // 安装/更新成功(或卸载但无 loader 引用)→ 登记待重启:
552
+ // 更新(ESM 缓存下热挂取不到新代码)/ 无 loader 的安装或卸载 → 登记待重启:
536
553
  // 插件要宿主重启才会挂载/卸载干净,重启前一直提醒;展示目标用 displayTarget(owner/repo)
537
554
  task.needsRestart = true;
538
555
  addPendingRestart(displayTarget ?? target, action === 'remove' ? 'uninstall' : 'install');
@@ -3,8 +3,9 @@
3
3
  * Website: https://dsh-plugin.org
4
4
  * GitHub: https://github.com/dshplugin/dsh-plugin-hub
5
5
  *
6
- * 运行中 loader 的读取与停用:卸载成功后对匹配条目做 live-disable,
7
- * 使卸载立即生效(刷新页面不再加载已卸载的 client bundle,也无需重启)。
6
+ * 运行中 loader 的读取、停用与热挂载:卸载成功后对匹配条目做 live-disable、
7
+ * 安装成功后把新插件条目热挂进运行中的 loader,让两类操作都尽量立即生效
8
+ * (刷新页面即可,无需重启宿主)。
8
9
  */
9
10
  import { readFileSync } from 'node:fs';
10
11
  import { join } from 'node:path';
@@ -77,15 +78,16 @@ function nameMatches(entryName, target) {
77
78
  * `loader.entries()`(含嵌套子树),对每个匹配条目
78
79
  * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
79
80
  * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
80
- * 返回 true = 无需重启(仅当宿主从未加载过它:磁盘已干净);
81
- * 其余一律 false = 需要重启:要么 disable 失败需「待重启清理」兜底,
82
- * 要么 disable 成功但该插件曾在 loader 中存活——带 UI 的插件(侧边栏面板等
83
- * 宿主启动时渲染的槽位)disable 后不会主动摘除,不重启面板一直残留。
81
+ * 返回 `{ ok, live }`:
82
+ * - `{ ok: true, live: false }`:宿主从未加载过它(磁盘已干净),无需刷新/重启;
83
+ * - `{ ok: true, live: true }`:曾在 loader 中存活且已 live-disable —— 服务端即时卸载;
84
+ * 带客户端 UI 的插件面板要页面刷新才摘除(由调用方按 `hadClientUi` 决定 needsReload);
85
+ * - `{ ok: false, live: true }`:disable 失败 / 超时,需宿主重启兜底清理。
84
86
  * 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
85
87
  */
86
88
  export async function removeLoadedEntry(loader, name) {
87
89
  if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/'))
88
- return false;
90
+ return { ok: false, live: true };
89
91
  // 5s 超时兜底:fiber dispose 卡住时不能让卸载任务(乃至整个队列)被拖死
90
92
  const removal = (async () => {
91
93
  const entries = dumpLoaderEntries(loader);
@@ -106,32 +108,77 @@ export async function removeLoadedEntry(loader, name) {
106
108
  catch (error) {
107
109
  console.log(`[hub-uninstall] disable ${name} attempt ${attempt + 1} failed: ${error instanceof Error ? error.message : String(error)}`);
108
110
  if (attempt === 2)
109
- return false;
111
+ return { ok: false, live: true };
110
112
  await new Promise((resolvePromise) => setTimeout(resolvePromise, 200));
111
113
  }
112
114
  }
113
115
  }
114
116
  if (!found) {
115
- // 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需重启
117
+ // 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需刷新/重启
116
118
  console.log(`[hub-uninstall] no live entry for ${name}; nothing to disable`);
117
- return true;
119
+ return { ok: true, live: false };
118
120
  }
119
121
  // 找到并停用了 live entry:逻辑层已卸载(刷新页面不再加载其 client bundle),
120
- // 但带 UI 的插件(侧边栏面板等)是宿主启动时渲染的槽位,disable 不会主动摘除,
121
- // 不重启面板会一直残留 → 返回「需要重启」,弹窗给出「立即重启」,面板立即消失
122
- console.log(`[hub-uninstall] disabled live entry for ${name}; UI may persist until restart`);
123
- return false;
122
+ // 带 UI 的插件面板由调用方按 hadClientUi 决定是否需要刷新摘除
123
+ console.log(`[hub-uninstall] disabled live entry for ${name}`);
124
+ return { ok: true, live: true };
124
125
  })();
125
126
  const result = await Promise.race([
126
127
  removal.catch((error) => {
127
128
  console.log(`[hub-uninstall] disable failed: ${error instanceof Error ? error.message : String(error)}`);
128
- return false;
129
+ return { ok: false, live: true };
129
130
  }),
130
- new Promise((resolve) => setTimeout(() => resolve(false), 5_000)),
131
+ new Promise((resolve) => setTimeout(() => resolve({ ok: false, live: true }), 5_000)),
131
132
  ]);
132
- console.log(`[hub-uninstall] disable ${name} -> ${result ? 'ok/clean' : 'timed out or failed'}`);
133
+ console.log(`[hub-uninstall] disable ${name} -> ${result.ok ? (result.live ? 'disabled live entry' : 'nothing live') : 'timed out or failed'}`);
133
134
  return result;
134
135
  }
136
+ /**
137
+ * 安装成功后把新插件热挂进运行中 loader(等价 profile bundle patch 的
138
+ * `- insert: - name: <pkg>` 行),让插件无需重启宿主即可生效。
139
+ * 做法:在 Loader 根组新建条目并启动 —— Loader.write() 为空实现,不写盘;
140
+ * 重启后由 profile 的 bundles 清单重新挂载,不会重复。
141
+ * `Entry._init` 的 import 失败只 `logger.error` 后 return(不抛),条目会建出来但
142
+ * fiber 为空 / 条目可能被标记 disabled —— 必须用 `loader.resolve(id)` 校验真的起来了。
143
+ * 返回 true = 已在运行中(本次新建或此前已加载)。
144
+ */
145
+ export async function mountLoadedEntry(loader, name) {
146
+ if (isEntryLoaded(loader, name))
147
+ return true;
148
+ const mounting = (async () => {
149
+ const id = await loader.create({ name });
150
+ const entry = loader.resolve(id);
151
+ if (entry?.fiber !== undefined && entry?.fiber !== null && !entry.options?.disabled) {
152
+ console.log(`[hub-install] hot mounted ${name} (entry ${id})`);
153
+ return true;
154
+ }
155
+ console.log(`[hub-install] hot mount ${name}: entry ${id} created but not running (import failed?)`);
156
+ return false;
157
+ })();
158
+ // 10s 超时兜底:import 卡住时不能让装完收尾(乃至整个队列)被拖死
159
+ return Promise.race([
160
+ mounting.catch((error) => {
161
+ console.log(`[hub-install] hot mount ${name} failed: ${error instanceof Error ? error.message : String(error)}`);
162
+ return false;
163
+ }),
164
+ new Promise((resolve) => setTimeout(() => resolve(false), 10_000)),
165
+ ]);
166
+ }
167
+ /**
168
+ * 已安装包是否带客户端 UI(package.json 的 `dsh.client` 声明):client-modules 据它把
169
+ * 客户端 bundle 注入宿主页面。带 UI 的插件热挂载后需要页面重新加载才会渲染,
170
+ * 网页端与桌面端一致 —— `__DSH_BOOT__` 注入清单由宿主服务端按请求现场拼装,
171
+ * 壳不缓存,所以桌面端刷新页面同样能拿到新清单,无需重开 App。
172
+ */
173
+ export function hasClientUi(profile, name) {
174
+ try {
175
+ const pkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'node_modules', name, 'package.json'), 'utf8'));
176
+ return pkg.dsh?.client !== undefined && pkg.dsh?.client !== null;
177
+ }
178
+ catch {
179
+ return false;
180
+ }
181
+ }
135
182
  /**
136
183
  * 判断某已安装包是否是真正的 dsh 插件(宿主会加载 / 值得提示「待重启」):
137
184
  * 1) 包名已写进 profile 的 `dsh.profile.bundles`(宿主启动时按清单加载);
@@ -143,10 +190,9 @@ export function isDshPlugin(profile, name) {
143
190
  // 1) profile 级 bundles 清单:包名在清单里 → 宿主启动会加载它
144
191
  try {
145
192
  const profilePkg = JSON.parse(readFileSync(join(profileDirectory(profile), 'package.json'), 'utf8'));
146
- if (Array.isArray(profilePkg.dsh?.profile?.bundles)) {
147
- if (profilePkg.dsh.profile.bundles.includes(name))
148
- return true;
149
- }
193
+ const bundles = profilePkg.dsh?.profile?.bundles;
194
+ if (Array.isArray(bundles) && bundles.includes(name))
195
+ return true;
150
196
  }
151
197
  catch { /* profile 读取失败按无 bundles 处理 */ }
152
198
  // 2) 包内声明:dsh 字段(bundle patch / profile 配置)或 dsh 关键字
@@ -1,5 +1,10 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { readProfileArg, type LoaderHandle } from '../services/install/install.ts';
3
+ /**
4
+ * 宿主是否由桌面应用壳托管(`ELECTRON_RUN_AS_NODE=1`:壳用 Electron 二进制以 Node 模式跑宿主)。
5
+ * 桌面端的宿主归应用壳管,插件重启不了它,也不该去 kill —— 详见 /restart 路由。
6
+ */
7
+ export declare function isDesktopHost(): boolean;
3
8
  export interface WebRoute {
4
9
  kind: 'exact';
5
10
  path: string;
@@ -37,8 +37,15 @@ export interface InstallTask {
37
37
  progress: number;
38
38
  /** Newest output lines first (consumer shows the tail). */
39
39
  lines: string[];
40
- /** 完成后是否仍需宿主重启才生效:卸载时 loader 已即时移除 → false;否则 true(弹窗据此给重启选项) */
40
+ /** 完成后是否仍需宿主重启才生效:仅「热挂载失败 / 非 dsh 插件 / 更新(ESM 缓存取不到新代码)/
41
+ * 无 loader」等刷新解决不了的兜底场景为 true(弹窗给「立即重启」);其余一律 false。 */
41
42
  needsRestart: boolean;
43
+ /** 完成后是否只需刷新页面即生效(插件已热挂进运行中 loader,仅剩客户端 UI 待页面重新加载,
44
+ * 网页端与桌面端一致):true 时关闭结果弹窗即自动刷新页面,不给手动刷新按钮。 */
45
+ needsReload?: boolean;
46
+ /** 卸载入队时该包是否带客户端 UI(`dsh.client`):卸载后包已从磁盘删除,只能入队前快照,
47
+ * 卸载成功且曾存活时据此决定是否需要刷新页面摘除插件面板。 */
48
+ hadClientUi?: boolean;
42
49
  }
43
50
  /** 子进程启动参数(复用宿主 dsh 入口,或回退到 PATH 上的 `dsh`)。 */
44
51
  export interface Invocation {
@@ -55,8 +55,9 @@ export declare function startPluginMutation(options: {
55
55
  globalNpm?: string[];
56
56
  /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
57
57
  displayTarget?: string;
58
- /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
59
- uninstallLoader?: LoaderHandle;
58
+ /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目,两类操作都尽量立即生效
59
+ * (缺失时安装/卸载仍需重启) */
60
+ loader?: LoaderHandle;
60
61
  /** 入队前已尝试的安装方式(npm registry 反查等):失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
61
62
  attempts?: string[];
62
63
  /** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
@@ -93,8 +94,8 @@ export declare function runPluginMutation(options: {
93
94
  task?: InstallTask;
94
95
  /** 待重启行的展示目标(owner/repo):卸载时用于把 npm 包名映射回仓库名 */
95
96
  displayTarget?: string;
96
- /** 运行中 loader:卸载成功后主动移除条目、立即生效(缺失时卸载仍需重启清理) */
97
- uninstallLoader?: LoaderHandle;
97
+ /** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目(缺失时仍需重启) */
98
+ loader?: LoaderHandle;
98
99
  /** 全局 npm 安装(官方 `npm install -g <pkgs>`):非空时执行全局安装,不进任何 profile、无需宿主重启 */
99
100
  globalNpm?: string[];
100
101
  }): Promise<InstallResult>;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 运行中 loader 的最小接口:卸载成功后即时移除条目用。
3
- * 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader 服务)的读取面与移除面。
2
+ * 运行中 loader 的最小接口:卸载即时停用 / 安装即时热挂载用。
3
+ * 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader 服务)的读取、新建与移除面。
4
4
  * 移除方式:对匹配条目 live-disable(`entry.update({ disabled: true })`),
5
5
  * 而不是 `loader.remove(id)` —— disable 走 Entry.update 的 disabled 分支,不触发
6
6
  * tree.write()(不会把运行态条目烘焙回 cordis.yml),也不依赖嵌套子树的 id 解析。
@@ -21,6 +21,23 @@ export interface LoaderHandle {
21
21
  }>;
22
22
  /** 停止并移除一个条目(官方 Loader API:resolve → EntryGroup.remove → tree.write)。 */
23
23
  remove(id: string): Promise<void>;
24
+ /** 在运行中 loader 里新建一个条目并启动它。等价于 profile bundle patch 的
25
+ * `- insert: - name: <pkg>` 行;Loader.write() 为空实现,不会写盘(重启后由
26
+ * profile 的 bundles 清单重新挂载,不会重复挂载)。返回新条目的 id。 */
27
+ create(options: {
28
+ id?: string;
29
+ name: string;
30
+ }): Promise<string>;
31
+ /** 按 id 解析条目:热挂载后确认 fiber 真的起来了(import 失败时条目在、但 fiber 为空)。 */
32
+ resolve(id: string): {
33
+ id: string;
34
+ options?: {
35
+ name?: string;
36
+ disabled?: boolean | null;
37
+ };
38
+ fiber?: unknown;
39
+ subgroup?: unknown;
40
+ };
24
41
  }
25
42
  /** 运行中 loader 的全部条目(id + options.name),诊断用。 */
26
43
  export declare function dumpLoaderEntries(loader: LoaderHandle | undefined): Array<{
@@ -38,13 +55,34 @@ export declare function isEntryLoaded(loader: LoaderHandle | undefined, name: st
38
55
  * `loader.entries()`(含嵌套子树),对每个匹配条目
39
56
  * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
40
57
  * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
41
- * 返回 true = 无需重启(仅当宿主从未加载过它:磁盘已干净);
42
- * 其余一律 false = 需要重启:要么 disable 失败需「待重启清理」兜底,
43
- * 要么 disable 成功但该插件曾在 loader 中存活——带 UI 的插件(侧边栏面板等
44
- * 宿主启动时渲染的槽位)disable 后不会主动摘除,不重启面板一直残留。
58
+ * 返回 `{ ok, live }`:
59
+ * - `{ ok: true, live: false }`:宿主从未加载过它(磁盘已干净),无需刷新/重启;
60
+ * - `{ ok: true, live: true }`:曾在 loader 中存活且已 live-disable —— 服务端即时卸载;
61
+ * 带客户端 UI 的插件面板要页面刷新才摘除(由调用方按 `hadClientUi` 决定 needsReload);
62
+ * - `{ ok: false, live: true }`:disable 失败 / 超时,需宿主重启兜底清理。
45
63
  * 宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
46
64
  */
47
- export declare function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean>;
65
+ export declare function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<{
66
+ ok: boolean;
67
+ live: boolean;
68
+ }>;
69
+ /**
70
+ * 安装成功后把新插件热挂进运行中 loader(等价 profile bundle patch 的
71
+ * `- insert: - name: <pkg>` 行),让插件无需重启宿主即可生效。
72
+ * 做法:在 Loader 根组新建条目并启动 —— Loader.write() 为空实现,不写盘;
73
+ * 重启后由 profile 的 bundles 清单重新挂载,不会重复。
74
+ * `Entry._init` 的 import 失败只 `logger.error` 后 return(不抛),条目会建出来但
75
+ * fiber 为空 / 条目可能被标记 disabled —— 必须用 `loader.resolve(id)` 校验真的起来了。
76
+ * 返回 true = 已在运行中(本次新建或此前已加载)。
77
+ */
78
+ export declare function mountLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean>;
79
+ /**
80
+ * 已安装包是否带客户端 UI(package.json 的 `dsh.client` 声明):client-modules 据它把
81
+ * 客户端 bundle 注入宿主页面。带 UI 的插件热挂载后需要页面重新加载才会渲染,
82
+ * 网页端与桌面端一致 —— `__DSH_BOOT__` 注入清单由宿主服务端按请求现场拼装,
83
+ * 壳不缓存,所以桌面端刷新页面同样能拿到新清单,无需重开 App。
84
+ */
85
+ export declare function hasClientUi(profile: string, name: string): boolean;
48
86
  /**
49
87
  * 判断某已安装包是否是真正的 dsh 插件(宿主会加载 / 值得提示「待重启」):
50
88
  * 1) 包名已写进 profile 的 `dsh.profile.bundles`(宿主启动时按清单加载);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "A community plugin marketplace for DeepSeek Harness, built to the official plugin spec — browse, search and install 10000+ human-curated community plugins without leaving the app. · DeepSeek Harness 社区插件市场(遵循官方开发规范):10000+ 人工精选社区插件,每日更新。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -103,10 +103,15 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
103
103
  /** 安装/卸载完成后的结果视图:停留弹窗内,点「完成」关闭 */
104
104
  const [installDone, setInstallDone] = useState(false)
105
105
  const [uninstallDone, setUninstallDone] = useState(false)
106
- /** 结果视图是否给「立即重启」:服务端任务终态带出(卸载时 loader 已即时移除 → false 只给「完成」;
107
- * true 时通知中心待重启条目同步常驻,直到用户点「立即重启」真正重启后才消失) */
108
- const [installNeedsRestart, setInstallNeedsRestart] = useState(true)
109
- const [uninstallNeedsRestart, setUninstallNeedsRestart] = useState(true)
106
+ /** 结果视图是否给「立即重启」:服务端任务终态带出。仅刷新解决不了的兜底场景
107
+ * (热挂载失败 / 非 dsh 插件 / 更新)为 true;其余(热挂载成功、卸载已即时停用)为 false,
108
+ * 结果视图只给「完成」,关闭时自动刷新页面生效 */
109
+ const [installNeedsRestart, setInstallNeedsRestart] = useState(false)
110
+ const [uninstallNeedsRestart, setUninstallNeedsRestart] = useState(false)
111
+ /** 结果视图是否显示「关闭后自动刷新」说明:插件已热挂进运行中 loader(安装)、或卸载后
112
+ * 插件面板仍残留在当前页面(卸载)。关闭弹窗即整页刷新,不给手动刷新按钮。 */
113
+ const [installNeedsReload, setInstallNeedsReload] = useState(false)
114
+ const [uninstallNeedsReload, setUninstallNeedsReload] = useState(false)
110
115
  /** 结果视图「立即重启」:请求宿主重启后进入等待,服务回来后整页刷新 */
111
116
  const [restarting, setRestarting] = useState(false)
112
117
  /** 操作失败完整信息 + 所属插件仓库 + 失败类型(决定弹窗标题「安装失败/卸载失败」)+ 实际执行的安装命令 + 尝试过的安装方式(issue 预填用) */
@@ -129,23 +134,39 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
129
134
  // 挂载与每次切换视图时比对全局 CSS 清单,把缺失的样式补注入回去 —— 用户无需再手动点击恢复。
130
135
  useEffect(() => { ensurePluginCss() }, [view])
131
136
 
137
+ /** 后台任务(弹窗已关)完成且只需刷新页面即生效时,等结果 Toast 读完再整页刷新:
138
+ * 新插件(或卸载后残留)的客户端资源要重新加载页面才会生效,无需重启宿主。 */
139
+ const scheduleAutoReload = () => {
140
+ window.setTimeout(() => window.location.reload(), 1600)
141
+ }
142
+
132
143
  const queue = useTaskQueue({
133
144
  t,
134
145
  // 界面语言带给服务端:网络预检等错误消息按用户语言提示(中文界面给中文,英文界面给英文)
135
146
  langKey,
136
147
  refreshInstalled: catalog.refreshInstalled,
137
- onInstallDone: (viaModal, repo, needsRestart, update) => {
148
+ onInstallDone: (viaModal, repo, needsRestart, update, needsReload) => {
149
+ const reload = needsReload === true
138
150
  setInstallNeedsRestart(needsRestart)
151
+ setInstallNeedsReload(reload)
139
152
  if (viaModal) setInstallDone(true)
140
- else setToast({ id: Date.now(), kind: 'done' })
153
+ // 弹窗外的后台安装:给结果 toast;需要页面重载时(热挂载的客户端 UI)读完后自动刷新
154
+ else setToast({
155
+ id: Date.now(),
156
+ kind: reload ? 'installedReload' : needsRestart ? 'done' : 'installedLive',
157
+ })
158
+ if (!viaModal && reload) scheduleAutoReload()
141
159
  // 安装成功也写入通知记录:通知中心里成功与失败都能看到。
142
160
  // action 区分「安装成功」与「更新成功」(更新 = 覆盖重装,同一执行通道)
143
161
  setNotifications(addSuccess({ kind: 'install', action: update ? 'update' : 'install', repo: repo ?? '' }))
144
162
  },
145
- onUninstallDone: (viaModal, repo, needsRestart) => {
163
+ onUninstallDone: (viaModal, repo, needsRestart, needsReload) => {
164
+ const reload = needsReload === true
146
165
  setUninstallNeedsRestart(needsRestart)
166
+ setUninstallNeedsReload(reload)
147
167
  if (viaModal) setUninstallDone(true)
148
168
  else setToast({ id: Date.now(), kind: 'removed' })
169
+ if (!viaModal && reload) scheduleAutoReload()
149
170
  setNotifications(addSuccess({ kind: 'uninstall', repo: repo ?? '' }))
150
171
  },
151
172
  onError: (message, repo, kind, command, attempts, update) => {
@@ -311,16 +332,27 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
311
332
  if (!ok) setToast({ id: Date.now(), kind: 'revealFail' })
312
333
  }
313
334
 
314
- /** 「立即重启」:POST 同源 /restart,宿主进程自杀重启;随后轮询服务恢复后整页刷新。 */
335
+ /** 「立即重启」:POST 同源 /restart,宿主进程自杀重启;随后轮询服务恢复后整页刷新。
336
+ * 只在刷新解决不了的兜底场景(热挂载失败 / 非 dsh 插件 / 更新)出现。
337
+ * 桌面端宿主归应用壳管(服务端回 `desktop: true` 且不重启):不轮询,改提示退出重开。 */
315
338
  const requestRestart = async () => {
316
339
  if (restarting) return
317
340
  setRestarting(true)
341
+ let desktop = false
318
342
  try {
319
343
  // 响应可能在宿主进程被 kill 前返回,也可能直接断连 —— 两种都属正常
320
- await fetch('/dsh-plugin-hub/restart', { method: 'POST' })
344
+ const res = await fetch('/dsh-plugin-hub/restart', { method: 'POST' })
345
+ const data = await res.json() as { desktop?: boolean }
346
+ desktop = data.desktop === true
321
347
  } catch {
322
348
  /* 服务已终止,无需处理 */
323
349
  }
350
+ if (desktop) {
351
+ // 宿主不会重启,轮询等不到新服务:直接给出正确口径
352
+ setRestarting(false)
353
+ setToast({ id: Date.now(), kind: 'restartDesktop' })
354
+ return
355
+ }
324
356
  // 轮询同源端点,服务恢复后整页 reload(让新挂载的 bundle 完全初始化)
325
357
  let attempts = 0
326
358
  const poll = async () => {
@@ -618,7 +650,13 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
618
650
  cliOnly: confirmPlugin.install?.webInstallable === false,
619
651
  submitting: queue.submitting,
620
652
  needsRestart: installNeedsRestart,
621
- onClose: () => setConfirmPlugin(null),
653
+ needsReload: installNeedsReload,
654
+ // 结果弹窗关闭即生效:安装热挂载的插件要页面重载才渲染客户端 UI → 自动刷新,无需用户点按钮
655
+ onClose: () => {
656
+ const reload = installDone && installNeedsReload
657
+ setConfirmPlugin(null)
658
+ if (reload) window.location.reload()
659
+ },
622
660
  onCopy: () => copyCommand(confirmPlugin),
623
661
  onInstall: () => queue.installNow(confirmPlugin, confirmIsUpdate ? { update: true } : undefined),
624
662
  // 重启:结果视图「立即重启」也会中断进行中的任务 → 先弹重启确认弹窗
@@ -638,10 +676,13 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
638
676
  cliOnly: false,
639
677
  submitting: queue.submitting,
640
678
  needsRestart: installNeedsRestart,
679
+ needsReload: installNeedsReload,
641
680
  onClose: () => {
681
+ const reload = installDone && installNeedsReload
642
682
  setInstallDone(false)
643
683
  setConfirmCustomTarget(null)
644
684
  setConfirmCustomChannel(null)
685
+ if (reload) window.location.reload()
645
686
  },
646
687
  onCopy: () => {
647
688
  doCopy(`pnpm add ${confirmCustomTarget}`)
@@ -669,10 +710,13 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
669
710
  cliOnly: false,
670
711
  submitting: queue.submitting,
671
712
  needsRestart: installNeedsRestart,
713
+ needsReload: installNeedsReload,
672
714
  onClose: () => {
715
+ const reload = installDone && installNeedsReload
673
716
  setInstallDone(false)
674
717
  setConfirmGlobalNpm(null)
675
718
  setConfirmCustomChannel(null)
719
+ if (reload) window.location.reload()
676
720
  },
677
721
  onCopy: () => {
678
722
  doCopy(`npm install -g ${confirmGlobalNpm.pkgs.join(' ')}`)
@@ -692,10 +736,14 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
692
736
  restarting,
693
737
  submitting: queue.submitting,
694
738
  needsRestart: uninstallNeedsRestart,
739
+ needsReload: uninstallNeedsReload,
740
+ // 卸载后插件面板要页面重载才摘除(带 UI 的插件)→ 关闭结果弹窗即自动刷新
695
741
  onClose: () => {
742
+ const reload = uninstallDone && uninstallNeedsReload
696
743
  setUninstallDone(false)
697
744
  setUninstallPlugin(null)
698
745
  setUninstallItem(null)
746
+ if (reload) window.location.reload()
699
747
  },
700
748
  onCancel: () => {
701
749
  setUninstallPlugin(null)
@@ -753,7 +801,7 @@ export function PluginHubSection({ t: _hostT, locale }: SectionProps) {
753
801
  onRunDiagnostics: openDiagnostics,
754
802
  onClose: () => setErrorMsg(null),
755
803
  }),
756
- // 待重启确认弹窗:已安装列表行内「重启」按钮点击后弹出,稍后重启 / 立即重启
804
+ // 待重启确认弹窗:已安装列表行内「重启」按钮点击后弹出(兜底场景),单个「立即重启」,关闭即视为稍后
757
805
  showRestartConfirm && h(RestartConfirmModal, {
758
806
  t,
759
807
  restarting,
@@ -122,11 +122,8 @@ export function NotificationsModal({ records, tasks, pendingRestarts, t, env, on
122
122
  h('span', { className: styles.pendingRowStatus },
123
123
  p.kind === 'uninstall' ? t('restartPendingHintUninstall') : t('restartPendingHint')),
124
124
  h('span', { className: styles.pendingRowActions },
125
- h('button', {
126
- className: styles.restartLater,
127
- disabled: restarting,
128
- onClick: onClose,
129
- }, t('restartLater')),
125
+ // 兜底项:刷新解决不了(热挂载失败 / 非 dsh 插件 / 更新)才登记待重启;
126
+ // 关闭本弹窗即视为「稍后」,只在真正需要时给单个「立即重启」
130
127
  h('button', {
131
128
  className: styles.restartNow,
132
129
  disabled: restarting,