dsh-chat-embed 0.5.4 → 0.6.1

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.
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  | `locale/{en,zh}.json` | 插件管理器里显示的标题与简介 |
21
21
  | `cordis.patch.yml` | 组合补丁:向 profile 插入 `id: chat-embed` 的 loader 行 |
22
22
  | `scripts/verify-bundle.mjs` | 门禁:语法 / 清单 / loader 契约 / 服务注册表 / 显示资源 |
23
- | `test/` | 纯 Node 契约测试(伪造 DOM + 伪造桌面桥),57 项 |
23
+ | `test/` | 纯 Node 契约测试(伪造 DOM + 伪造桌面桥 + 伪造 asar),81 项 |
24
24
  | `docs/architecture.md` | 内嵌契约、席位架构、壳补丁、升级复验清单(**接到项目先读这份**) |
25
25
  | `tools/shell-patch/` | 可选的桌面壳补丁:持久分区(跨重启免登录)+ 关闭磁盘缓存 |
26
26
  | `清理聊天缓存.cmd` / `restore-shell-patch.cmd` | 缓存回收 / 还原原版壳 |
@@ -80,7 +80,7 @@
80
80
  > 仅桌面版可用(需要 Electron 的 `<webview>` 和 `window.dshDesktop.browser`)。
81
81
  > 当前实测版本:`@deepseek-ai/dsh-desktop 0.2.2-rc.1`;包声明了 `engines.dsh`,版本不符时插件管理器会拒绝安装。
82
82
 
83
- **方式一:npm(发布之后)**
83
+ **方式一:npm(已发布 `0.5.4`)**
84
84
 
85
85
  ```jsonc
86
86
  // 在 dsh 会话里交给插件管理器执行
@@ -177,6 +177,23 @@ node tools/shell-patch/patch-asar.cjs --help # 查看参数
177
177
 
178
178
  > 通过 npm 安装时脚本位于 `<profile>/node_modules/<包名>/tools/shell-patch/`;也可以在「⚙ → 跨重启保持登录」里点「复制补丁说明」拿到步骤。
179
179
 
180
+ ### 补丁状态与自检(0.6.0 起)
181
+
182
+ 设置菜单里的「跨重启保持登录」有四种状态,**只有未开启时才出现风险提示与操作按钮**:
183
+
184
+ | 状态 | 含义 | 菜单里提供 |
185
+ | --- | --- | --- |
186
+ | 已开启(`persist:` 分区) | 补丁生效,重启后仍登录 | —(已是最优) |
187
+ | 未开启(内存分区) | 从没打过补丁 | 风险提示 + 复制补丁说明 + 检查补丁配置 |
188
+ | **已失效**(升级覆盖) | **打过补丁,但桌面壳升级覆盖了 `app.asar`** | 同上;这一状态专门告诉你"该重打了" |
189
+ | 尚未检测 | 还没打开过聊天页 | 同上 |
190
+
191
+ 「检查补丁配置」调用 Host 侧只读探针(`GET /dsh-chat-embed/patch-check`),报告安装目录、`app.asar` 是否可写、是否已打补丁、补丁点是否还在(壳版本是否兼容)、备份与待替换文件是否存在、`app.asar` 的大小与内容哈希;报告同时复制到剪贴板,方便直接贴出去。
192
+
193
+ **不需要自己装 Node.js**:探针会找出桌面版自带的 Node 运行时(`~/.dsh/dsh-runtimes/*/dependencies/node/`),并把一条完整可复制的命令写进报告——含正确的 Node 路径、补丁脚本路径和 `--resources` 安装目录(自定义/便携安装也能用)。
194
+
195
+ Host 侧还注册了只读工具 **`dsh_chat_embed_patch_check`**:可以直接问 agent「我的聊天为什么重启后要重新登录」,它会自己去查这些事实,而不是让你跑命令再贴输出。
196
+
180
197
  补丁做两件事:`persist:` 固定分区(登录落盘)+ `cache:false`(不落 Chromium 缓存)。
181
198
  没有补丁也能用,只是每次重启要重新登录。细节与风险见 [已知限制](#已知限制)。
182
199
 
@@ -196,8 +213,8 @@ node tools/shell-patch/patch-asar.cjs --help # 查看参数
196
213
  dsh-chat-embed/
197
214
  ├── package.json # 包名/版本、dsh.bundle.patch、dsh.client、exports/files
198
215
  ├── cordis.patch.yml # 组合补丁:插入 id=chat-embed 的 loader 行
199
- ├── index.js # Host 半侧(空壳,扩展点)
200
- ├── client.js # Client 半侧(约 3545 行,手写产物)
216
+ ├── index.js # Host 半侧:补丁探针(HTTP + agent 工具)
217
+ ├── client.js # Client 半侧(约 3912 行,手写产物)
201
218
  ├── locale/{en,zh}.json # 插件管理器显示用 meta
202
219
  ├── icon.svg # 插件图标(插件管理器显示)
203
220
  ├── assets/
@@ -209,7 +226,8 @@ dsh-chat-embed/
209
226
  │ └── pack-dist.mjs # 产出 dist/*.tgz(维护者)
210
227
  ├── test/
211
228
  │ ├── fake-env.mjs # 伪造 DOM / 伪造桌面桥
212
- │ └── client.test.mjs # 62 项契约测试
229
+ │ ├── client.test.mjs # 客户端契约测试
230
+ │ └── host.test.mjs # Host 探针与工具测试
213
231
  ├── docs/
214
232
  │ ├── architecture.md # 架构与内嵌契约(先读这份)
215
233
  │ └── publishing.md # 维护者:发版、分发与列表投稿
@@ -246,7 +264,7 @@ git clone <repo-url> dsh-chat-embed
246
264
  cd dsh-chat-embed
247
265
 
248
266
  # 无需安装依赖;直接跑门禁与测试
249
- npm run check # 语法 + 清单 + 显示资源 + 服务注册表 + 62 项测试
267
+ npm run check # 语法 + 清单 + 显示资源 + 服务注册表 + 81 项测试
250
268
  npm test # 只跑测试
251
269
  ```
252
270
 
@@ -297,6 +315,8 @@ plugin_manager { "action": "install_bundle", "target": "<本仓库克隆路径>"
297
315
 
298
316
  | 版本 | 变化 |
299
317
  | --- | --- |
318
+ | `0.6.1` | 探针找出桌面版自带的 Node 运行时,自检报告与「复制补丁说明」直接给出可执行命令(含 `--resources`,支持自定义安装目录);无需用户另装 Node |
319
+ | `0.6.0` | 补丁可观测性:四态检测(未打 / 已开启 / **升级后失效** / 未检测)、未开启时的风险提示、菜单内「检查补丁配置」自检(Host 只读探针 `GET /dsh-chat-embed/patch-check`)、agent 工具 `dsh_chat_embed_patch_check`;Host 半侧改为读取真实 `app.asar`(`original-fs`,绕过 Electron 的 asar 目录拦截) |
300
320
  | `0.5.4` | 语言切换即时生效:翻译器改为 locale 绑定的单一来源,监听 `locale/change` 重渲染(不再依赖未注入的 `props.t`);补语言切换测试 |
301
321
  | `0.5.3` | 修复 `locale` 探针(`t` 作用域错误)并补测试;诊断读数在测试环境下的 `<html lang>` 兼容 |
302
322
  | `0.5.2` | 面板文案跟随 DSH 语言设置(中文 / English 即时切换,缺翻译回落中文);诊断读数一并本地化;新增 `locale` 探针 |
package/client.js CHANGED
@@ -103,7 +103,7 @@ window.__ModuleLoader__.load({
103
103
  let openRightTabImpl = () => {}
104
104
 
105
105
  /** Build identity, surfaced in the panel so a packaged app can be diagnosed. */
106
- const BUILD = '0.5.4'
106
+ const BUILD = '0.6.1'
107
107
 
108
108
  /** Where a re-applied instance finds the one it must take over from. */
109
109
  const INSTANCE_KEY = '__DSH_CHAT_EMBED_INSTANCE__'
@@ -257,9 +257,16 @@ window.__ModuleLoader__.load({
257
257
  persistLabel: 'Login across restarts',
258
258
  persistOn: 'on (persist: partition)',
259
259
  persistOff: 'off (memory partition)',
260
+ persistLost: 'lost (the shell update replaced the patch)',
260
261
  persistUnknown: 'not detected yet',
261
262
  persistHint: 'Needs the shell patch; a plugin cannot change how the shell names partitions.',
263
+ persistRisk:
264
+ 'Experimental: the patch rewrites the app.asar of the desktop shell and needs write access to its install folder. A shell update replaces it, so the patch has to be applied again.',
262
265
  persistCopy: 'Copy the patch instructions',
266
+ persistCheck: 'Check the patch setup',
267
+ persistCheckRunning: 'Checking…',
268
+ persistReportCopied: 'Self-check copied',
269
+ persistReportTitle: 'Shell patch self-check',
263
270
  copyDiag: 'Copy diagnostics',
264
271
  copied: 'Copied',
265
272
  copyFailed: 'Copy failed',
@@ -332,9 +339,15 @@ window.__ModuleLoader__.load({
332
339
  persistLabel: '跨重启保持登录',
333
340
  persistOn: '已开启(persist: 分区)',
334
341
  persistOff: '未开启(内存分区)',
342
+ persistLost: '已失效(桌面壳升级覆盖了补丁)',
335
343
  persistUnknown: '尚未检测',
336
344
  persistHint: '需要桌面壳补丁;插件无法改变壳的分区命名。',
345
+ persistRisk: '实验性功能:补丁会改写桌面壳的 app.asar,需要安装目录的写权限;桌面壳升级会覆盖补丁,需要重新打一次。',
337
346
  persistCopy: '复制补丁说明',
347
+ persistCheck: '检查补丁配置',
348
+ persistCheckRunning: '正在检查…',
349
+ persistReportCopied: '自检报告已复制',
350
+ persistReportTitle: '壳补丁自检报告',
338
351
  copyDiag: '复制诊断信息',
339
352
  copied: '已复制',
340
353
  copyFailed: '复制失败',
@@ -646,6 +659,13 @@ window.__ModuleLoader__.load({
646
659
  /** localStorage key remembering how the last guest was partitioned. */
647
660
  const PERSIST_KEY = 'dsh-chat-embed:persist'
648
661
 
662
+ /**
663
+ * localStorage key remembering that a persistent partition was seen once.
664
+ * A shell update replaces the patch, so "was on, now off" is a lost patch
665
+ * rather than a first run: the difference is what the settings row reports.
666
+ */
667
+ const PERSIST_SEEN_KEY = 'dsh-chat-embed:persist-seen'
668
+
649
669
  /** How many entries the ring buffer keeps. */
650
670
  const LOG_LIMIT = 200
651
671
 
@@ -2327,26 +2347,67 @@ window.__ModuleLoader__.load({
2327
2347
  * case, anything else is memory-only. The answer is remembered so the
2328
2348
  * settings menu can show it right after a restart, before any guest mounts.
2329
2349
  *
2330
- * @returns {'on' | 'off' | 'unknown'}
2350
+ * `lost` reports the specific case that needs an action: a persistent
2351
+ * partition was seen before, and this run does not get one — the desktop
2352
+ * shell was updated and replaced the patch.
2353
+ *
2354
+ * @returns {'on' | 'off' | 'lost' | 'unknown'}
2331
2355
  */
2332
2356
  function persistenceState() {
2333
- for (const provider of PROVIDERS) {
2334
- const record = guests.get(provider.id)
2335
- if (record === undefined || typeof record.partition !== 'string' || record.partition === '') continue
2336
- const state = record.partition.startsWith('persist:') ? 'on' : 'off'
2337
- storePersistence(state)
2338
- return state
2357
+ const live = livePartitionState()
2358
+ if (live === 'on') {
2359
+ storePersistence('on')
2360
+ storePersistenceSeen(true)
2361
+ return 'on'
2362
+ }
2363
+ if (live === 'off') {
2364
+ storePersistence('off')
2365
+ return readPersistedSeen() ? 'lost' : 'off'
2339
2366
  }
2340
2367
  try {
2341
2368
  if (typeof localStorage === 'undefined' || localStorage === null) return 'unknown'
2342
2369
  const stored = localStorage.getItem(PERSIST_KEY)
2343
- return stored === 'on' || stored === 'off' ? stored : 'unknown'
2370
+ if (stored === 'on') return 'on'
2371
+ if (stored === 'off') return readPersistedSeen() ? 'lost' : 'off'
2372
+ return readPersistedSeen() ? 'lost' : 'unknown'
2344
2373
  } catch (error) {
2345
2374
  void error
2346
2375
  return 'unknown'
2347
2376
  }
2348
2377
  }
2349
2378
 
2379
+ /** @returns {'on' | 'off' | 'unknown'} what the mounted guests actually received */
2380
+ function livePartitionState() {
2381
+ for (const provider of PROVIDERS) {
2382
+ const record = guests.get(provider.id)
2383
+ if (record === undefined || typeof record.partition !== 'string' || record.partition === '') continue
2384
+ return record.partition.startsWith('persist:') ? 'on' : 'off'
2385
+ }
2386
+ return 'unknown'
2387
+ }
2388
+
2389
+ /** @returns {boolean} whether a persistent partition was ever observed */
2390
+ function readPersistedSeen() {
2391
+ try {
2392
+ if (typeof localStorage === 'undefined' || localStorage === null) return false
2393
+ return localStorage.getItem(PERSIST_SEEN_KEY) === '1'
2394
+ } catch (error) {
2395
+ void error
2396
+ return false
2397
+ }
2398
+ }
2399
+
2400
+ /** @param {boolean} seen */
2401
+ function storePersistenceSeen(seen) {
2402
+ try {
2403
+ if (typeof localStorage === 'undefined' || localStorage === null) return
2404
+ if (seen) localStorage.setItem(PERSIST_SEEN_KEY, '1')
2405
+ else localStorage.removeItem(PERSIST_SEEN_KEY)
2406
+ } catch (error) {
2407
+ void error
2408
+ }
2409
+ }
2410
+
2350
2411
  /** @param {'on' | 'off'} state */
2351
2412
  function storePersistence(state) {
2352
2413
  try {
@@ -2357,6 +2418,148 @@ window.__ModuleLoader__.load({
2357
2418
  }
2358
2419
  }
2359
2420
 
2421
+ /** @param {(key: string) => string} t @param {string} state @returns {string} */
2422
+ function persistenceText(t, state) {
2423
+ if (state === 'on') return t('persistOn')
2424
+ if (state === 'off') return t('persistOff')
2425
+ if (state === 'lost') return t('persistLost')
2426
+ return t('persistUnknown')
2427
+ }
2428
+
2429
+ /**
2430
+ * The clipboard copy behind "copy the patch instructions": what to run,
2431
+ * where the tool is, and how to replace the archive safely.
2432
+ *
2433
+ * @param {(key: string) => string} t
2434
+ * @returns {string}
2435
+ */
2436
+ /**
2437
+ * The clipboard copy behind "copy the patch instructions".
2438
+ *
2439
+ * The Host's probe supplies the exact command — including the Node binary
2440
+ * that ships with the desktop app, so the user is not asked to install one.
2441
+ *
2442
+ * @param {(key: string) => string} t
2443
+ * @param {any} [probe]
2444
+ * @returns {string}
2445
+ */
2446
+ function patchInstructions(t, probe) {
2447
+ const info = probe !== undefined && probe !== null ? probe : {}
2448
+ const command =
2449
+ typeof info.patchCommand === 'string' && info.patchCommand !== ''
2450
+ ? info.patchCommand
2451
+ : 'node tools/shell-patch/patch-asar.cjs build'
2452
+ const lines = [t('persistLabel') + ':' + t('persistHint'), '']
2453
+ lines.push('1) 执行下面这条命令(Desk 自带的 Node 运行时;无需另装 Node):')
2454
+ lines.push(' ' + command)
2455
+ lines.push(' 脚本会自动探测安装目录并逐字节校验,生成 app.asar.new。')
2456
+ lines.push('')
2457
+ lines.push('2) 完全退出 DSH 后替换(任选其一):')
2458
+ lines.push(' - 运行 tools/shell-patch/watchdog.ps1,退出瞬间自动替换;')
2459
+ lines.push(' - 手动把 resources\\app.asar.new 改名为 app.asar。')
2460
+ lines.push('')
2461
+ lines.push('3) 重启 DSH,回到这里应显示「' + t('persistOn') + '」。')
2462
+ lines.push('')
2463
+ lines.push('回滚:restore-shell-patch.cmd(把 app.asar.orig 换回去)。')
2464
+ lines.push('Windows 之外的安装目录请用 --resources 指定。')
2465
+ return lines.join('\n')
2466
+ }
2467
+
2468
+ /**
2469
+ * Host-side probe state for the "check the patch setup" button. The Host
2470
+ * half answers, so a renderer without Node access can still report where
2471
+ * the shell lives and whether the archive is writable.
2472
+ *
2473
+ * @type {{ running: boolean, report: string }}
2474
+ */
2475
+ const patchCheck = { running: false, report: '' }
2476
+
2477
+ /** One-shot cache of the Host probe, so both actions read the same facts. */
2478
+ const patchProbe = { loaded: false, value: undefined }
2479
+
2480
+ /**
2481
+ * Ask the Host half for the shell facts and render a copyable report.
2482
+ * Nothing here writes: the button only inspects.
2483
+ *
2484
+ * @param {(key: string) => string} t
2485
+ * @returns {Promise<void>}
2486
+ */
2487
+ /**
2488
+ * Ask the Host for the shell facts once per session. Both the copy action
2489
+ * and the self-check read it, so the instructions never disagree with the
2490
+ * report.
2491
+ *
2492
+ * @returns {Promise<any>} the probe, or undefined when the route is absent
2493
+ */
2494
+ async function fetchProbe() {
2495
+ if (patchProbe.loaded) return patchProbe.value
2496
+ try {
2497
+ // The Host half serves this route; a relative fetch therefore works in
2498
+ // both the desktop app and the Web GUI, under any deployment prefix.
2499
+ const response = await fetch('./dsh-chat-embed/patch-check', {
2500
+ method: 'GET',
2501
+ headers: { accept: 'application/json' },
2502
+ cache: 'no-store',
2503
+ })
2504
+ patchProbe.value = response.ok ? await response.json() : undefined
2505
+ } catch (error) {
2506
+ patchProbe.value = undefined
2507
+ void error
2508
+ }
2509
+ patchProbe.loaded = true
2510
+ return patchProbe.value
2511
+ }
2512
+
2513
+ async function runPatchCheck(t) {
2514
+ if (patchCheck.running) return
2515
+ patchCheck.running = true
2516
+ notify()
2517
+ let report = ''
2518
+ try {
2519
+ patchProbe.loaded = false
2520
+ const probe = await fetchProbe()
2521
+ report = formatPatchReport(t, probe)
2522
+ } catch (error) {
2523
+ report = formatPatchReport(t, undefined) + '\n' + t('copyFailed') + ': ' + messageOf(error)
2524
+ }
2525
+ patchCheck.running = false
2526
+ patchCheck.report = report
2527
+ notify()
2528
+ void copyText(report)
2529
+ }
2530
+
2531
+ /**
2532
+ * @param {(key: string) => string} t
2533
+ * @param {any} probe
2534
+ * @returns {string}
2535
+ */
2536
+ function formatPatchReport(t, probe) {
2537
+ const lines = [t('persistReportTitle')]
2538
+ lines.push(t('persistLabel') + ': ' + persistenceText(t, persistenceState()))
2539
+ if (probe === undefined || probe === null) {
2540
+ lines.push(t('persistHint'))
2541
+ return lines.join('\n')
2542
+ }
2543
+ const row = (label, value) => {
2544
+ if (value === undefined || value === null || value === '') return
2545
+ lines.push(label + ': ' + String(value))
2546
+ }
2547
+ row('resources', probe.resources)
2548
+ row('app.asar', probe.asar)
2549
+ row('app.asar.orig', probe.backup === true ? '有' : '无')
2550
+ row('app.asar.new', probe.staged === true ? '有(待替换)' : '无')
2551
+ row('可写', probe.writable === true ? '是' : '否')
2552
+ row('补丁点', probe.patchSite === true ? '存在' : probe.patchSite === false ? '不存在(壳版本不匹配)' : '未知')
2553
+ row('已打补丁', probe.patched === true ? '是' : probe.patched === false ? '否' : '未知')
2554
+ row('node', probe.node)
2555
+ row('补丁命令', probe.patchCommand)
2556
+ if (probe.nodeBinary === undefined) {
2557
+ lines.push('(没有找到可用的 Node 运行时,需要先安装 Node.js 22 或更新)')
2558
+ }
2559
+ row('说明', probe.detail)
2560
+ return lines.join('\n')
2561
+ }
2562
+
2360
2563
  /** @returns {{ state: string, detail: string, seats: any[] }} */
2361
2564
  function rightSidebarStatus() {
2362
2565
  return { state: rightTypeState.state, detail: rightTypeState.detail, seats: rightSeatAttempts.slice() }
@@ -2579,6 +2782,9 @@ window.__ModuleLoader__.load({
2579
2782
  '.dce-menu-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
2580
2783
  '.dce-menu-value{flex:0 0 auto;font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a6)}',
2581
2784
  '.dce-menu-sep{margin:5px 0 3px;border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24))}',
2785
+ '.dce-menu-note{margin:4px 8px 6px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-tertiary,#9aa0a6);white-space:pre-wrap;word-break:break-word}',
2786
+ '.dce-menu-note[data-persist-risk="true"]{color:var(--dsw-alias-label-warning,#c98b16)}',
2787
+ '.dce-menu-note[data-patch-report="true"]{max-height:180px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
2582
2788
  '.dce-menu-item{display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;box-sizing:border-box;padding:6px 8px;border:0;border-radius:6px;background:transparent;color:inherit;font-size:13px;text-align:left;cursor:pointer}',
2583
2789
  '.dce-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
2584
2790
  '.dce-right-title{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
@@ -3236,28 +3442,42 @@ window.__ModuleLoader__.load({
3236
3442
  h(
3237
3443
  'span',
3238
3444
  { className: 'dce-menu-value', title: t('persistHint') },
3239
- persistenceState() === 'on'
3240
- ? t('persistOn')
3241
- : persistenceState() === 'off'
3242
- ? t('persistOff')
3243
- : t('persistUnknown'),
3445
+ persistenceText(t, persistenceState()),
3244
3446
  ),
3245
3447
  ),
3246
- persistenceState() === 'off'
3247
- ? h(
3448
+ persistenceState() === 'on'
3449
+ ? null
3450
+ : h('div', { className: 'dce-menu-note', 'data-persist-risk': 'true' }, t('persistRisk')),
3451
+ persistenceState() === 'on'
3452
+ ? null
3453
+ : h(
3248
3454
  'button',
3249
3455
  {
3250
3456
  type: 'button',
3251
3457
  className: 'dce-menu-item',
3458
+ 'data-patch-help': 'true',
3252
3459
  onClick: () => {
3253
- void copyText(
3254
- '跨重启保持登录需要可选的桌面壳补丁(在插件包目录或仓库根目录执行):\n node tools/shell-patch/patch-asar.cjs build\n关闭 DSH 后替换 resources\\app.asar,watchdog 会在退出时自动完成。\n详见 README「已知限制 §1」。',
3255
- )
3460
+ void fetchProbe().then((probe) => copyText(patchInstructions(t, probe)))
3256
3461
  },
3257
3462
  },
3258
3463
  h('span', { className: 'dce-menu-label' }, t('persistCopy')),
3259
- )
3260
- : null,
3464
+ ),
3465
+ h(
3466
+ 'button',
3467
+ {
3468
+ type: 'button',
3469
+ className: 'dce-menu-item',
3470
+ 'data-patch-check': 'true',
3471
+ disabled: patchCheck.running,
3472
+ onClick: () => {
3473
+ void runPatchCheck(t)
3474
+ },
3475
+ },
3476
+ h('span', { className: 'dce-menu-label' }, patchCheck.running ? t('persistCheckRunning') : t('persistCheck')),
3477
+ ),
3478
+ patchCheck.report === ''
3479
+ ? null
3480
+ : h('div', { className: 'dce-menu-note', 'data-patch-report': 'true' }, patchCheck.report),
3261
3481
  h(
3262
3482
  'button',
3263
3483
  {
@@ -3528,6 +3748,15 @@ window.__ModuleLoader__.load({
3528
3748
  updateSurface,
3529
3749
  getLogging,
3530
3750
  persistenceState,
3751
+ readPersistedSeen,
3752
+ storePersistenceSeen,
3753
+ livePartitionState,
3754
+ patchInstructions,
3755
+ fetchProbe,
3756
+ patchProbe,
3757
+ formatPatchReport,
3758
+ runPatchCheck,
3759
+ patchCheck,
3531
3760
  setLogging,
3532
3761
  clearLog,
3533
3762
  readLogEntries,
@@ -3,7 +3,7 @@
3
3
  本文记录 `client.js` 里每一条**反直觉**的实现决策、支撑它的证据,以及升级桌面壳时必须重新验证的清单。
4
4
 
5
5
  目标运行环境:**`@deepseek-ai/dsh-desktop 0.2.2-rc.1`(Electron / Chromium)**。
6
- 当前实现:**v0.5.4**(`client.js` 约 3545 行,62 项契约测试)。
6
+ 当前实现:**v0.6.1**(`client.js` 约 3912 行,81 项契约测试)。
7
7
 
8
8
  > **架构一句话**:聊天页直接渲染在**席位的布局盒**里(`.dce-panel.dce-inline`,`width/height:100%`),几何完全交给 CSS;席位只有两个 —— 中央主面板(`main`)和右栏聊天 tab —— 二者互斥,**哪边点哪边接管**,切换时 guest 重建一次并回到上次会话。
9
9
 
@@ -325,6 +325,15 @@ idle → acquiring → navigating → ready
325
325
  | `layout` | `layout` 服务的键、`hasMainPanel('chat-embed')`、左栏导航 DOM 条目 |
326
326
  | `log` | 调试日志开关与最近 50 条环形缓冲;`{ enabled: true/false }` 开关,`{ clear: true }` 清空。默认关闭,关闭时不打印也不缓冲 |
327
327
  | `locale` | 当前活跃语言(`active`)、可用语言列表、`<html lang>`,以及面板文案当前解析出的样例(`sample` / `zhSample` / `enSample`) |
328
+
329
+ Host 半侧另有两条只读通道,专门用来回答"为什么重启后要重新登录":
330
+
331
+ | 通道 | 内容 |
332
+ | --- | --- |
333
+ | `GET /dsh-chat-embed/patch-check` | 安装目录、`app.asar` 是否可写、是否已打补丁、补丁点是否还存在、备份与待替换文件、大小与内容哈希、**可执行的补丁命令**(含自带 Node 运行时路径与 `--resources`) |
334
+ | agent 工具 `dsh_chat_embed_patch_check` | 同一份读数,供模型直接查询(只读,无需审批) |
335
+
336
+ > 读取 `app.asar` 必须走 Electron 的 `original-fs`:被补丁过的 `node:fs` 会把以 `.asar` 结尾的路径当成目录,`readFileSync` 直接 ENOENT。探针优先用 `original-fs`,普通 Node 下回落到 `node:fs`。
328
337
  | `openPanel` | 选中中央面板(等价于点左栏图标;探针路径不等于真实点击,见下) |
329
338
  | `openRight` | 展开右栏并打开聊天 tab,报告每一步的错误 |
330
339
  | `prefer` | 把席位偏好切到 `main` / `right`,返回解析后的席位与宿主 |
@@ -5,13 +5,13 @@
5
5
  ## 版本与提交约定
6
6
 
7
7
  - 同时改 `package.json` 的 `version` 与 `client.js` 的 `BUILD`,两者必须一致;
8
- - 门禁必须绿:`npm run check`(`verify-bundle` + 62 项契约测试);
8
+ - 门禁必须绿:`npm run check`(`verify-bundle` + 81 项契约测试);
9
9
  - 代码注释/提交信息英文,文档中文。
10
10
 
11
11
  ## 发版步骤
12
12
 
13
13
  ```powershell
14
- npm run check # 门禁:verify-bundle + 62 项测试(prepublishOnly 也会跑)
14
+ npm run check # 门禁:verify-bundle + 81 项测试(prepublishOnly 也会跑)
15
15
  npm run pack:dist # 产出 dist\dsh-chat-embed-<版本>.tgz
16
16
  gh release create v0.5.0 dist\dsh-chat-embed-0.5.0.tgz --title "v0.5.0" --notes-file dist\release-notes.md
17
17
  ```
@@ -31,7 +31,8 @@ Get-FileHash $env:TEMP\check\dsh-chat-embed-0.5.0.tgz -Algorithm SHA256
31
31
  | --- | --- |
32
32
  | 仓库 | <https://github.com/poluce/dsh-chat-embed> |
33
33
  | Release | <https://github.com/poluce/dsh-chat-embed/releases> |
34
- | 分发包 | `dsh-chat-embed-0.5.0.tgz`(15 个文件,约 60 kB) |
34
+ | 分发包 | `dsh-chat-embed-0.6.1.tgz`(16 个文件,约 70 kB) |
35
+ | npm | `dsh-chat-embed@0.6.1`(<https://www.npmjs.com/package/dsh-chat-embed>,维护者 `poluce_auto`) |
35
36
  | 商店图标 | `assets/icon-180.png`(180×180 PNG) |
36
37
  | 商店截图 | `assets/screenshots/` + 仓库根 `screenshots.json`(6 张) |
37
38
 
@@ -50,7 +51,7 @@ dsh plugin --profile desktop add git+https://github.com/poluce/dsh-chat-embed.gi
50
51
 
51
52
  1. 注册账号并完成邮箱验证:<https://www.npmjs.com/signup>;
52
53
  2. 本机登录:`npm login`(浏览器确认或一次性密码;开了 2FA 输入验证码);
53
- 3. 发布:`npm publish`(`prepublishOnly` 自动跑门禁;包名 `dsh-chat-embed` 已确认可用);
54
+ 3. 发布:`npm publish`(`prepublishOnly` 自动跑门禁;包名 `dsh-chat-embed` 已发布 `0.5.4`);
54
55
  4. 使用者随后只需:`dsh plugin --profile desktop add dsh-chat-embed`。
55
56
 
56
57
  ## 发布前后检查项
@@ -59,7 +60,10 @@ dsh plugin --profile desktop add git+https://github.com/poluce/dsh-chat-embed.gi
59
60
  - 改包名(例如加 npm 作用域)必须同步改 `cordis.patch.yml` 里 loader 行的 `name`(必须等于包名);`id` 保持唯一即可;
60
61
  - `repository` / `homepage` / `bugs` 当前指向 `github.com/poluce/dsh-chat-embed`;换账号或换仓库时改这三处;
61
62
  - 发布后用一份干净 profile 验证:`dsh plugin --profile <新名字> add <包名>`,然后打开左栏入口;
62
- - 可选壳补丁(跨重启免登录)不是安装必需项;不装补丁时功能完整,只是重启后要重新登录。
63
+ - 可选壳补丁(跨重启免登录)不是安装必需项;不装补丁时功能完整,只是重启后要重新登录;
64
+ - npm 首次发布新包时会出现 `0.0.0-stage` 占位版本(staged publishing 机制),审核/发布完成后 `latest` 指向正式版本;占位版本可忽略;
65
+ - 账号 2FA 使用通行密钥(passkey),CLI 发布需要交互式终端完成 WebAuthn,或在 npm 网页创建 **Bypass 2FA 的 Granular Token** 后用 `npm publish --//registry.npmjs.org/:_authToken=<token>`;
66
+ - 带 Bypass 2FA 的直接发布能力 npm 计划 2027 年 1 月收紧,届时改用 trusted publishing 或 staged 流程。
63
67
 
64
68
  ## 提交到插件列表(awesome-dsh-plugin)
65
69
 
package/index.js CHANGED
@@ -1,25 +1,419 @@
1
1
  /**
2
2
  * dsh-chat-embed — Host half.
3
3
  *
4
- * The whole feature lives in the Client half (`client.js`): the desktop
5
- * webview, the lease handshake, the panel UI. This half only has to exist so
6
- * the loader row named by `cordis.patch.yml` has a Node-side export to mount;
7
- * `dsh.client` in `package.json` publishes the browser half beside it.
4
+ * The feature itself lives in the Client half (`client.js`): the desktop
5
+ * webview, the lease handshake, the panel UI. This half exists for two
6
+ * reasons, and nothing else:
8
7
  *
9
- * Keep this module inert. It must not import browser globals, and anything it
10
- * registers must be cleaned up through `ctx.effect` so uninstalling the bundle
11
- * leaves the Host process exactly as it was.
8
+ * 1. the loader row named by `cordis.patch.yml` needs a Node-side export;
9
+ * 2. the optional shell patch needs a **Host-side** answer, because the
10
+ * patched archive lives on the Host's disk and only the Host can read
11
+ * and test it. The panel asks over the exposed channel, so a user can
12
+ * see why cross-restart login is off without being told to run a
13
+ * command and read its output.
14
+ *
15
+ * Keep this module inert beyond that probe. It must not import browser
16
+ * globals, and anything it registers must be cleaned up through `ctx.effect`
17
+ * so uninstalling the bundle leaves the Host process exactly as it was.
12
18
  */
13
19
 
20
+ import { createHash } from 'node:crypto'
21
+ import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
22
+ import { homedir } from 'node:os'
23
+ import { dirname, join } from 'node:path'
24
+ import { createRequire } from 'node:module'
25
+ import { fileURLToPath } from 'node:url'
26
+
14
27
  export const name = 'dsh-chat-embed'
15
28
 
16
- /** @param {import('@deepseek-ai/cordis').Context} ctx */
29
+ /** Route the panel fetches for the read-only shell probe. */
30
+ const PROBE_PATH = '/dsh-chat-embed/patch-check'
31
+
32
+ /** Strings that identify the patch in a rebuilt archive. */
33
+ const PATCH_MARK = 'persist:dsh-sidebar-'
34
+ const CACHE_MARK = '{ cache: false }'
35
+
36
+ /** Strings that identify an unpatched archive's patch sites. */
37
+ const PATCH_SITE_MARK = 'partition = `dsh-sidebar-browser-'
38
+ const CACHE_SITE_MARK = 'this.configureSession(session.fromPartition(partition))'
39
+
40
+ /**
41
+ * Candidate `resources` directories. `DSH_RESOURCES` wins so a custom install
42
+ * or a non-Windows layout can be pointed at explicitly.
43
+ *
44
+ * @returns {string[]} absolute candidates, most specific first
45
+ */
46
+ function resourcesCandidates() {
47
+ const candidates = []
48
+ // Electron hands the running app its own resources directory: the exact
49
+ // answer, and the only one that works for a non-standard install location.
50
+ if (typeof process.resourcesPath === 'string' && process.resourcesPath !== '') {
51
+ candidates.push(process.resourcesPath)
52
+ }
53
+ // A plain Node process started beside the app binary can derive it.
54
+ if (typeof process.execPath === 'string' && process.execPath !== '') {
55
+ candidates.push(join(dirname(process.execPath), 'resources'))
56
+ }
57
+ candidates.push(process.env.DSH_RESOURCES)
58
+ if (process.platform === 'win32') {
59
+ const localAppData = process.env.LOCALAPPDATA
60
+ if (localAppData !== undefined && localAppData !== '') {
61
+ candidates.push(join(localAppData, 'Programs', 'DeepSeek Harness', 'resources'))
62
+ }
63
+ candidates.push('C:\\Program Files\\DeepSeek Harness\\resources')
64
+ candidates.push('C:\\Program Files (x86)\\DeepSeek Harness\\resources')
65
+ } else if (process.platform === 'darwin') {
66
+ candidates.push('/Applications/DeepSeek Harness.app/Contents/Resources')
67
+ } else {
68
+ candidates.push('/opt/DeepSeek Harness/resources')
69
+ candidates.push(join(homedir(), 'DeepSeek Harness', 'resources'))
70
+ }
71
+ return candidates.filter((entry) => typeof entry === 'string' && entry !== '')
72
+ }
73
+
74
+ /** @returns {string | undefined} the directory that actually holds app.asar */
75
+ function detectResources() {
76
+ for (const candidate of resourcesCandidates()) {
77
+ if (existsSync(join(candidate, 'app.asar'))) return candidate
78
+ }
79
+ return undefined
80
+ }
81
+
82
+ /** @param {string} file @returns {boolean} */
83
+ function isWritable(file) {
84
+ try {
85
+ accessSync(file, constants.W_OK)
86
+ return true
87
+ } catch (error) {
88
+ void error
89
+ return false
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Read a file with the real Node implementation.
95
+ *
96
+ * Inside Electron the `node:fs` binding is patched to treat a path ending in
97
+ * `.asar` as a directory, so `readFileSync('<install>/app.asar')` raises ENOENT
98
+ * even though the bytes are all there. `original-fs` is Electron's unpatched
99
+ * copy; outside Electron the ordinary binding is already literal and is used.
100
+ *
101
+ * @param {string} file
102
+ * @returns {Buffer}
103
+ */
104
+ function readRealFile(file) {
105
+ const fallback = () => readFileSync(file)
106
+ try {
107
+ const original = createRequire(import.meta.url)('original-fs')
108
+ if (original !== undefined && original !== null && typeof original.readFileSync === 'function') {
109
+ return original.readFileSync(file)
110
+ }
111
+ } catch (error) {
112
+ void error
113
+ }
114
+ return fallback()
115
+ }
116
+
117
+ /** @param {string} file @returns {number} the real byte length */
118
+ function statRealSize(file) {
119
+ try {
120
+ const original = createRequire(import.meta.url)('original-fs')
121
+ if (original !== undefined && original !== null && typeof original.statSync === 'function') {
122
+ return original.statSync(file).size
123
+ }
124
+ } catch (error) {
125
+ void error
126
+ }
127
+ return statSync(file).size
128
+ }
129
+
130
+ /**
131
+ * Read one entry out of an asar archive.
132
+ *
133
+ * The container is `u32 @0 = 4`, `u32 @4 = headerLen + 8`, `u32 @8 = headerLen + 4`,
134
+ * `u32 @12 = headerLen`, then the JSON header, padding, then the file data.
135
+ *
136
+ * @param {string} file
137
+ * @param {string} entry slash-separated path inside the archive
138
+ * @returns {string | undefined} the entry as UTF-8, or undefined when absent
139
+ */
140
+ function readAsarEntry(file, entry) {
141
+ const buffer = readRealFile(file)
142
+ const headerLength = buffer.readUInt32LE(12)
143
+ const header = JSON.parse(buffer.toString('utf8', 16, 16 + headerLength))
144
+ const padding = (4 - (headerLength % 4)) % 4
145
+ const dataStart = 16 + headerLength + padding
146
+ let node = header
147
+ for (const segment of entry.split('/')) {
148
+ if (node === undefined || node.files === undefined) return undefined
149
+ node = node.files[segment]
150
+ }
151
+ if (node === undefined || node.files !== undefined) return undefined
152
+ if (node.unpacked === true) return undefined
153
+ const offset = dataStart + Number(node.offset)
154
+ return buffer.toString('utf8', offset, offset + Number(node.size))
155
+ }
156
+
157
+ /**
158
+ * Locate a Node binary that can run the patch tool.
159
+ *
160
+ * DSH runs every profile on its own packaged runtime, so the archive can be
161
+ * patched without the user installing Node. The order is: the runtime this
162
+ * Host is already running on, the managed runtime directory, then a system
163
+ * `node` on PATH.
164
+ *
165
+ * @returns {string | undefined} an absolute path, or undefined when none exists
166
+ */
167
+ function findNodeBinary() {
168
+ const candidates = []
169
+ // A Node-hosted dsh reports its own executable.
170
+ if (typeof process.execPath === 'string' && process.execPath !== '' && /node(.exe)?$/i.test(process.execPath)) {
171
+ candidates.push(process.execPath)
172
+ }
173
+ // The managed runtime the desktop app installs beside its own data.
174
+ const home = process.env.DSH_HOME !== undefined && process.env.DSH_HOME !== '' ? process.env.DSH_HOME : join(homedir(), '.dsh')
175
+ const runtimes = join(home, 'dsh-runtimes')
176
+ try {
177
+ for (const entry of readdirSync(runtimes)) {
178
+ const base = join(runtimes, entry, 'dependencies', 'node', 'bin')
179
+ candidates.push(join(base, process.platform === 'win32' ? 'node.exe' : 'node'))
180
+ }
181
+ } catch (error) {
182
+ void error
183
+ }
184
+ if (process.platform === 'win32' && process.env.ProgramFiles !== undefined) {
185
+ candidates.push(join(process.env.ProgramFiles, 'nodejs', 'node.exe'))
186
+ } else {
187
+ candidates.push('/usr/local/bin/node')
188
+ candidates.push('/usr/bin/node')
189
+ }
190
+ for (const candidate of candidates) {
191
+ if (typeof candidate === 'string' && candidate !== '' && existsSync(candidate)) return candidate
192
+ }
193
+ return undefined
194
+ }
195
+
196
+ /**
197
+ * @param {string} node absolute path to a Node binary
198
+ * @param {string} script absolute path to patch-asar.cjs
199
+ * @param {string} [resources] install directory, so a non-standard location works
200
+ * @returns {string} a copy-pasteable command line
201
+ */
202
+ function patchCommand(node, script, resources) {
203
+ const quote = (value) => (value.includes(' ') ? '"' + value + '"' : value)
204
+ const parts = [quote(node), quote(script), 'build']
205
+ // The tool only searches the usual install locations. When the Host found the
206
+ // shell somewhere else (a portable or custom install), it must say where.
207
+ if (typeof resources === 'string' && resources !== '') parts.push('--resources', quote(resources))
208
+ return parts.join(' ')
209
+ }
210
+
211
+ /**
212
+ * Locate the patch tool inside this package, so the reported command points at
213
+ * a file that actually exists on the user's disk.
214
+ *
215
+ * @returns {string | undefined}
216
+ */
217
+ function findPatchScript() {
218
+ const here = dirname(fileURLToPath(import.meta.url))
219
+ const candidate = join(here, 'tools', 'shell-patch', 'patch-asar.cjs')
220
+ return existsSync(candidate) ? candidate : undefined
221
+ }
222
+
223
+ /**
224
+ * Inspect the desktop shell and the optional patch. Read-only: no file here is
225
+ * written, so this is safe to call from a diagnostics button.
226
+ *
227
+ * @param {string} [resources]
228
+ * @returns {object} a report the panel formats and copies
229
+ */
230
+ function inspectShell(resources) {
231
+ const explicit = typeof resources === 'string' && resources !== ''
232
+ const resolved = explicit ? resources : detectResources()
233
+ if (resolved === undefined) {
234
+ return {
235
+ found: false,
236
+ platform: process.platform,
237
+ node: process.version,
238
+ detail: '没有找到 DSH 安装目录;用 DSH_RESOURCES 或 --resources 指定',
239
+ }
240
+ }
241
+ const asar = join(resolved, 'app.asar')
242
+ if (!existsSync(asar)) {
243
+ return {
244
+ found: false,
245
+ platform: process.platform,
246
+ node: process.version,
247
+ resources: resolved,
248
+ asar,
249
+ detail: '该目录下没有 app.asar;路径不对,或桌面壳安装不完整',
250
+ }
251
+ }
252
+ const staged = join(resolved, 'app.asar.new')
253
+ const backup = join(resolved, 'app.asar.orig')
254
+ const report = {
255
+ found: true,
256
+ platform: process.platform,
257
+ node: process.version,
258
+ resources: resolved,
259
+ asar,
260
+ exists: true,
261
+ writable: existsSync(asar) ? isWritable(asar) : isWritable(resolved),
262
+ backup: existsSync(backup),
263
+ staged: existsSync(staged),
264
+ patched: undefined,
265
+ patchSite: undefined,
266
+ detail: '',
267
+ }
268
+ try {
269
+ report.asarBytes = statRealSize(asar)
270
+ report.asarHash = createHash('sha256').update(readRealFile(asar)).digest('hex').slice(0, 16)
271
+ } catch (error) {
272
+ report.detail = '读取 app.asar 失败:' + firstLine(error)
273
+ return report
274
+ }
275
+ try {
276
+ const main = readAsarEntry(asar, 'lib/main.js')
277
+ if (main === undefined) {
278
+ report.detail = 'app.asar 中找不到 lib/main.js;壳结构已变化'
279
+ return report
280
+ }
281
+ report.patched = main.includes(PATCH_MARK)
282
+ report.cachePatch = main.includes(CACHE_MARK)
283
+ report.patchSite = main.includes(PATCH_SITE_MARK) || main.includes(PATCH_MARK)
284
+ if (report.patched !== true && report.patchSite === false) {
285
+ report.detail = '补丁点不存在:当前桌面壳版本的分区写法与补丁预期不一致,需要更新补丁'
286
+ } else if (report.patched !== true && report.cachePatch !== true) {
287
+ report.detail = '尚未打补丁'
288
+ }
289
+ } catch (error) {
290
+ report.detail = '解析 app.asar 失败:' + firstLine(error)
291
+ }
292
+ // The user needs a Node binary to run the patch tool. The desktop app ships
293
+ // its own runtime, so nobody has to install one — report the exact command
294
+ // instead of telling them to install Node first.
295
+ report.patchScript = findPatchScript()
296
+ report.nodeBinary = findNodeBinary()
297
+ report.patchCommand =
298
+ report.nodeBinary === undefined ? undefined : patchCommand(report.nodeBinary, report.patchScript, resolved)
299
+ return report
300
+ }
301
+
302
+ /** @param {unknown} error @returns {string} */
303
+ function firstLine(error) {
304
+ const text = error instanceof Error ? error.message : String(error)
305
+ return text.split('\n')[0].slice(0, 200)
306
+ }
307
+
308
+ /**
309
+ * Serve the read-only probe over the Web GUI's HTTP carrier, so the panel can
310
+ * read it with a plain relative `fetch` — no Remote RPC endpoint, no shell
311
+ * bridge, and the same code path in the desktop app and the Web GUI.
312
+ *
313
+ * The route is GET-only and writes nothing; it reports where the shell is and
314
+ * what the archive contains, which is what a user needs to file a useful bug
315
+ * report when cross-restart login stops working.
316
+ *
317
+ * @param {import('@deepseek-ai/cordis').Context} ctx
318
+ * @returns {(() => void) | undefined} a disposer, or undefined without a webserver
319
+ */
320
+ function installHttpProbe(ctx) {
321
+ const server = typeof ctx.get === 'function' ? ctx.get('webServer') : undefined
322
+ if (server === undefined || server === null || typeof server.register !== 'function') return undefined
323
+ const route = {
324
+ kind: 'exact',
325
+ path: PROBE_PATH,
326
+ resource(request) {
327
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
328
+ return new Response('method not allowed', { status: 405 })
329
+ }
330
+ let body
331
+ try {
332
+ body = JSON.stringify(inspectShell())
333
+ } catch (error) {
334
+ body = JSON.stringify({ found: false, detail: firstLine(error) })
335
+ }
336
+ return new Response(request.method === 'HEAD' ? null : body, {
337
+ status: 200,
338
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
339
+ })
340
+ },
341
+ }
342
+ try {
343
+ const dispose = server.register(route)
344
+ return typeof dispose === 'function' ? dispose : () => {}
345
+ } catch (error) {
346
+ // Another composition already claimed the path, or the carrier rejected the
347
+ // shape. The probe is optional: the panel still reports the local state.
348
+ void error
349
+ return undefined
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Register the patch checker as an agent tool, so the model can answer
355
+ * "why does my login not survive a restart?" by looking instead of asking the
356
+ * user to run a command and paste its output.
357
+ *
358
+ * The tool is read-only and needs no approval: it reports paths, byte counts
359
+ * and whether the patch marks are present, which the user could read off the
360
+ * disk themselves.
361
+ *
362
+ * @param {import('@deepseek-ai/cordis').Context} ctx
363
+ * @returns {(() => void) | undefined} a disposer, or undefined without a tool registry
364
+ */
365
+ function installTool(ctx) {
366
+ const tools = typeof ctx.get === 'function' ? ctx.get('tools') : undefined
367
+ if (tools === undefined || tools === null || typeof tools.register !== 'function') return undefined
368
+ const definition = {
369
+ name: 'dsh_chat_embed_patch_check',
370
+ description:
371
+ 'Report whether the DSH desktop shell has the optional patch that keeps embedded chat logins across restarts: the install directory, whether app.asar is writable, whether it is already patched, whether the patch can still be applied to this shell version, and whether a backup exists. Read-only.',
372
+ parameters: {
373
+ resources: {
374
+ type: 'string',
375
+ description:
376
+ 'Optional install directory that contains app.asar. Omit to let the plugin detect the running shell.',
377
+ },
378
+ },
379
+ output: {
380
+ schema: { type: 'object' },
381
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
382
+ },
383
+ async execute(args) {
384
+ const requested = args !== undefined && args !== null && typeof args.resources === 'string' ? args.resources : undefined
385
+ return inspectShell(requested)
386
+ },
387
+ }
388
+ try {
389
+ const dispose = tools.register(definition)
390
+ return typeof dispose === 'function' ? dispose : () => {}
391
+ } catch (error) {
392
+ void error
393
+ return undefined
394
+ }
395
+ }
396
+
397
+ /**
398
+ * Register the Host-side patch probe and the agent tool.
399
+ *
400
+ * @param {import('@deepseek-ai/cordis').Context} ctx
401
+ */
17
402
  export function apply(ctx) {
18
- // Nothing to bind on the Host side yet.
19
- //
20
- // Extension point: if a later version needs to persist settings (chat URL,
21
- // user-agent mode), declare `export const Config` here and read it in the
22
- // Client half through its own `dsh.client` declaration. Secrets belong to
23
- // `ctx.credentials`, never to this plugin's configuration.
24
- void ctx
403
+ if (ctx === undefined || ctx === null || typeof ctx.effect !== 'function') return
404
+ ctx.effect(() => installHttpProbe(ctx), 'dsh-chat-embed: shell patch probe')
405
+ ctx.effect(() => installTool(ctx), 'dsh-chat-embed: shell patch tool')
406
+ }
407
+
408
+ /** Exposed for tests and for the patch checker to reuse without a Context. */
409
+ export const internals = {
410
+ inspectShell,
411
+ detectResources,
412
+ readAsarEntry,
413
+ readRealFile,
414
+ resourcesCandidates,
415
+ findNodeBinary,
416
+ findPatchScript,
417
+ patchCommand,
418
+ PROBE_PATH,
25
419
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-chat-embed",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "author": "poluce <fishsummer@126.com>",
5
5
  "description": "Multi-tab AI chat embed for the DeepSeek Harness desktop app: DeepSeek, 豆包, ChatGPT, MiniMax, Kimi, Gemini and Grok, each signed in separately.",
6
6
  "type": "module",