dsh-chat-embed 0.5.4 → 0.6.0

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
@@ -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,21 @@ 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
+ Host 侧还注册了只读工具 **`dsh_chat_embed_patch_check`**:可以直接问 agent「我的聊天为什么重启后要重新登录」,它会自己去查这些事实,而不是让你跑命令再贴输出。
194
+
180
195
  补丁做两件事:`persist:` 固定分区(登录落盘)+ `cache:false`(不落 Chromium 缓存)。
181
196
  没有补丁也能用,只是每次重启要重新登录。细节与风险见 [已知限制](#已知限制)。
182
197
 
@@ -196,8 +211,8 @@ node tools/shell-patch/patch-asar.cjs --help # 查看参数
196
211
  dsh-chat-embed/
197
212
  ├── package.json # 包名/版本、dsh.bundle.patch、dsh.client、exports/files
198
213
  ├── cordis.patch.yml # 组合补丁:插入 id=chat-embed 的 loader 行
199
- ├── index.js # Host 半侧(空壳,扩展点)
200
- ├── client.js # Client 半侧(约 3545 行,手写产物)
214
+ ├── index.js # Host 半侧:补丁探针(HTTP + agent 工具)
215
+ ├── client.js # Client 半侧(约 3870 行,手写产物)
201
216
  ├── locale/{en,zh}.json # 插件管理器显示用 meta
202
217
  ├── icon.svg # 插件图标(插件管理器显示)
203
218
  ├── assets/
@@ -209,7 +224,8 @@ dsh-chat-embed/
209
224
  │ └── pack-dist.mjs # 产出 dist/*.tgz(维护者)
210
225
  ├── test/
211
226
  │ ├── fake-env.mjs # 伪造 DOM / 伪造桌面桥
212
- │ └── client.test.mjs # 62 项契约测试
227
+ │ ├── client.test.mjs # 客户端契约测试
228
+ │ └── host.test.mjs # Host 探针与工具测试
213
229
  ├── docs/
214
230
  │ ├── architecture.md # 架构与内嵌契约(先读这份)
215
231
  │ └── publishing.md # 维护者:发版、分发与列表投稿
@@ -246,7 +262,7 @@ git clone <repo-url> dsh-chat-embed
246
262
  cd dsh-chat-embed
247
263
 
248
264
  # 无需安装依赖;直接跑门禁与测试
249
- npm run check # 语法 + 清单 + 显示资源 + 服务注册表 + 62 项测试
265
+ npm run check # 语法 + 清单 + 显示资源 + 服务注册表 + 76 项测试
250
266
  npm test # 只跑测试
251
267
  ```
252
268
 
@@ -297,6 +313,7 @@ plugin_manager { "action": "install_bundle", "target": "<本仓库克隆路径>"
297
313
 
298
314
  | 版本 | 变化 |
299
315
  | --- | --- |
316
+ | `0.6.0` | 补丁可观测性:四态检测(未打 / 已开启 / **升级后失效** / 未检测)、未开启时的风险提示、菜单内「检查补丁配置」自检(Host 只读探针 `GET /dsh-chat-embed/patch-check`)、agent 工具 `dsh_chat_embed_patch_check`;Host 半侧改为读取真实 `app.asar`(`original-fs`,绕过 Electron 的 asar 目录拦截) |
300
317
  | `0.5.4` | 语言切换即时生效:翻译器改为 locale 绑定的单一来源,监听 `locale/change` 重渲染(不再依赖未注入的 `props.t`);补语言切换测试 |
301
318
  | `0.5.3` | 修复 `locale` 探针(`t` 作用域错误)并补测试;诊断读数在测试环境下的 `<html lang>` 兼容 |
302
319
  | `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.0'
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,108 @@ 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
+ function patchInstructions(t) {
2437
+ return [
2438
+ t('persistLabel') + ':' + t('persistHint'),
2439
+ '',
2440
+ '1) 在插件包目录(或仓库根目录)执行:',
2441
+ ' node tools/shell-patch/patch-asar.cjs build',
2442
+ ' 脚本会自动探测安装目录并逐字节校验,生成 app.asar.new。',
2443
+ '',
2444
+ '2) 完全退出 DSH 后替换(任选其一):',
2445
+ ' - 运行 tools/shell-patch/watchdog.ps1,退出瞬间自动替换;',
2446
+ ' - 手动把 resources\\app.asar.new 改名为 app.asar。',
2447
+ '',
2448
+ '3) 重启 DSH,回到这里应显示「' + t('persistOn') + '」。',
2449
+ '',
2450
+ '回滚:restore-shell-patch.cmd(把 app.asar.orig 换回去)。',
2451
+ 'Windows 之外的安装目录请用 --resources 指定。',
2452
+ ].join('\n')
2453
+ }
2454
+
2455
+ /**
2456
+ * Host-side probe state for the "check the patch setup" button. The Host
2457
+ * half answers, so a renderer without Node access can still report where
2458
+ * the shell lives and whether the archive is writable.
2459
+ *
2460
+ * @type {{ running: boolean, report: string }}
2461
+ */
2462
+ const patchCheck = { running: false, report: '' }
2463
+
2464
+ /**
2465
+ * Ask the Host half for the shell facts and render a copyable report.
2466
+ * Nothing here writes: the button only inspects.
2467
+ *
2468
+ * @param {(key: string) => string} t
2469
+ * @returns {Promise<void>}
2470
+ */
2471
+ async function runPatchCheck(t) {
2472
+ if (patchCheck.running) return
2473
+ patchCheck.running = true
2474
+ notify()
2475
+ let report = ''
2476
+ try {
2477
+ // The Host half serves this route; a relative fetch therefore works in
2478
+ // both the desktop app and the Web GUI, under any deployment prefix.
2479
+ const response = await fetch('./dsh-chat-embed/patch-check', {
2480
+ method: 'GET',
2481
+ headers: { accept: 'application/json' },
2482
+ cache: 'no-store',
2483
+ })
2484
+ const probe = response.ok ? await response.json() : undefined
2485
+ report = formatPatchReport(t, probe)
2486
+ } catch (error) {
2487
+ report = formatPatchReport(t, undefined) + '\n' + t('copyFailed') + ': ' + messageOf(error)
2488
+ }
2489
+ patchCheck.running = false
2490
+ patchCheck.report = report
2491
+ notify()
2492
+ void copyText(report)
2493
+ }
2494
+
2495
+ /**
2496
+ * @param {(key: string) => string} t
2497
+ * @param {any} probe
2498
+ * @returns {string}
2499
+ */
2500
+ function formatPatchReport(t, probe) {
2501
+ const lines = [t('persistReportTitle')]
2502
+ lines.push(t('persistLabel') + ': ' + persistenceText(t, persistenceState()))
2503
+ if (probe === undefined || probe === null) {
2504
+ lines.push(t('persistHint'))
2505
+ return lines.join('\n')
2506
+ }
2507
+ const row = (label, value) => {
2508
+ if (value === undefined || value === null || value === '') return
2509
+ lines.push(label + ': ' + String(value))
2510
+ }
2511
+ row('resources', probe.resources)
2512
+ row('app.asar', probe.asar)
2513
+ row('app.asar.orig', probe.backup === true ? '有' : '无')
2514
+ row('app.asar.new', probe.staged === true ? '有(待替换)' : '无')
2515
+ row('可写', probe.writable === true ? '是' : '否')
2516
+ row('补丁点', probe.patchSite === true ? '存在' : probe.patchSite === false ? '不存在(壳版本不匹配)' : '未知')
2517
+ row('已打补丁', probe.patched === true ? '是' : probe.patched === false ? '否' : '未知')
2518
+ row('node', probe.node)
2519
+ row('说明', probe.detail)
2520
+ return lines.join('\n')
2521
+ }
2522
+
2360
2523
  /** @returns {{ state: string, detail: string, seats: any[] }} */
2361
2524
  function rightSidebarStatus() {
2362
2525
  return { state: rightTypeState.state, detail: rightTypeState.detail, seats: rightSeatAttempts.slice() }
@@ -2579,6 +2742,9 @@ window.__ModuleLoader__.load({
2579
2742
  '.dce-menu-label{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
2580
2743
  '.dce-menu-value{flex:0 0 auto;font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a6)}',
2581
2744
  '.dce-menu-sep{margin:5px 0 3px;border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,.24))}',
2745
+ '.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}',
2746
+ '.dce-menu-note[data-persist-risk="true"]{color:var(--dsw-alias-label-warning,#c98b16)}',
2747
+ '.dce-menu-note[data-patch-report="true"]{max-height:180px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
2582
2748
  '.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
2749
  '.dce-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12))}',
2584
2750
  '.dce-right-title{display:inline-flex;align-items:center;gap:6px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
@@ -3236,28 +3402,42 @@ window.__ModuleLoader__.load({
3236
3402
  h(
3237
3403
  'span',
3238
3404
  { className: 'dce-menu-value', title: t('persistHint') },
3239
- persistenceState() === 'on'
3240
- ? t('persistOn')
3241
- : persistenceState() === 'off'
3242
- ? t('persistOff')
3243
- : t('persistUnknown'),
3405
+ persistenceText(t, persistenceState()),
3244
3406
  ),
3245
3407
  ),
3246
- persistenceState() === 'off'
3247
- ? h(
3408
+ persistenceState() === 'on'
3409
+ ? null
3410
+ : h('div', { className: 'dce-menu-note', 'data-persist-risk': 'true' }, t('persistRisk')),
3411
+ persistenceState() === 'on'
3412
+ ? null
3413
+ : h(
3248
3414
  'button',
3249
3415
  {
3250
3416
  type: 'button',
3251
3417
  className: 'dce-menu-item',
3418
+ 'data-patch-help': 'true',
3252
3419
  onClick: () => {
3253
- void copyText(
3254
- '跨重启保持登录需要可选的桌面壳补丁(在插件包目录或仓库根目录执行):\n node tools/shell-patch/patch-asar.cjs build\n关闭 DSH 后替换 resources\\app.asar,watchdog 会在退出时自动完成。\n详见 README「已知限制 §1」。',
3255
- )
3420
+ void copyText(patchInstructions(t))
3256
3421
  },
3257
3422
  },
3258
3423
  h('span', { className: 'dce-menu-label' }, t('persistCopy')),
3259
- )
3260
- : null,
3424
+ ),
3425
+ h(
3426
+ 'button',
3427
+ {
3428
+ type: 'button',
3429
+ className: 'dce-menu-item',
3430
+ 'data-patch-check': 'true',
3431
+ disabled: patchCheck.running,
3432
+ onClick: () => {
3433
+ void runPatchCheck(t)
3434
+ },
3435
+ },
3436
+ h('span', { className: 'dce-menu-label' }, patchCheck.running ? t('persistCheckRunning') : t('persistCheck')),
3437
+ ),
3438
+ patchCheck.report === ''
3439
+ ? null
3440
+ : h('div', { className: 'dce-menu-note', 'data-patch-report': 'true' }, patchCheck.report),
3261
3441
  h(
3262
3442
  'button',
3263
3443
  {
@@ -3528,6 +3708,13 @@ window.__ModuleLoader__.load({
3528
3708
  updateSurface,
3529
3709
  getLogging,
3530
3710
  persistenceState,
3711
+ readPersistedSeen,
3712
+ storePersistenceSeen,
3713
+ livePartitionState,
3714
+ patchInstructions,
3715
+ formatPatchReport,
3716
+ runPatchCheck,
3717
+ patchCheck,
3531
3718
  setLogging,
3532
3719
  clearLog,
3533
3720
  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.0**(`client.js` 约 3871 行,76 项契约测试)。
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` 是否可写、是否已打补丁、补丁点是否还存在、备份与待替换文件、大小与内容哈希 |
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` + 76 项契约测试);
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 + 76 项测试(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.0.tgz`(16 个文件,约 70 kB) |
35
+ | npm | `dsh-chat-embed@0.6.0`(<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,335 @@
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, 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
+
14
26
  export const name = 'dsh-chat-embed'
15
27
 
16
- /** @param {import('@deepseek-ai/cordis').Context} ctx */
28
+ /** Route the panel fetches for the read-only shell probe. */
29
+ const PROBE_PATH = '/dsh-chat-embed/patch-check'
30
+
31
+ /** Strings that identify the patch in a rebuilt archive. */
32
+ const PATCH_MARK = 'persist:dsh-sidebar-'
33
+ const CACHE_MARK = '{ cache: false }'
34
+
35
+ /** Strings that identify an unpatched archive's patch sites. */
36
+ const PATCH_SITE_MARK = 'partition = `dsh-sidebar-browser-'
37
+ const CACHE_SITE_MARK = 'this.configureSession(session.fromPartition(partition))'
38
+
39
+ /**
40
+ * Candidate `resources` directories. `DSH_RESOURCES` wins so a custom install
41
+ * or a non-Windows layout can be pointed at explicitly.
42
+ *
43
+ * @returns {string[]} absolute candidates, most specific first
44
+ */
45
+ function resourcesCandidates() {
46
+ const candidates = []
47
+ // Electron hands the running app its own resources directory: the exact
48
+ // answer, and the only one that works for a non-standard install location.
49
+ if (typeof process.resourcesPath === 'string' && process.resourcesPath !== '') {
50
+ candidates.push(process.resourcesPath)
51
+ }
52
+ // A plain Node process started beside the app binary can derive it.
53
+ if (typeof process.execPath === 'string' && process.execPath !== '') {
54
+ candidates.push(join(dirname(process.execPath), 'resources'))
55
+ }
56
+ candidates.push(process.env.DSH_RESOURCES)
57
+ if (process.platform === 'win32') {
58
+ const localAppData = process.env.LOCALAPPDATA
59
+ if (localAppData !== undefined && localAppData !== '') {
60
+ candidates.push(join(localAppData, 'Programs', 'DeepSeek Harness', 'resources'))
61
+ }
62
+ candidates.push('C:\\Program Files\\DeepSeek Harness\\resources')
63
+ candidates.push('C:\\Program Files (x86)\\DeepSeek Harness\\resources')
64
+ } else if (process.platform === 'darwin') {
65
+ candidates.push('/Applications/DeepSeek Harness.app/Contents/Resources')
66
+ } else {
67
+ candidates.push('/opt/DeepSeek Harness/resources')
68
+ candidates.push(join(homedir(), 'DeepSeek Harness', 'resources'))
69
+ }
70
+ return candidates.filter((entry) => typeof entry === 'string' && entry !== '')
71
+ }
72
+
73
+ /** @returns {string | undefined} the directory that actually holds app.asar */
74
+ function detectResources() {
75
+ for (const candidate of resourcesCandidates()) {
76
+ if (existsSync(join(candidate, 'app.asar'))) return candidate
77
+ }
78
+ return undefined
79
+ }
80
+
81
+ /** @param {string} file @returns {boolean} */
82
+ function isWritable(file) {
83
+ try {
84
+ accessSync(file, constants.W_OK)
85
+ return true
86
+ } catch (error) {
87
+ void error
88
+ return false
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Read a file with the real Node implementation.
94
+ *
95
+ * Inside Electron the `node:fs` binding is patched to treat a path ending in
96
+ * `.asar` as a directory, so `readFileSync('<install>/app.asar')` raises ENOENT
97
+ * even though the bytes are all there. `original-fs` is Electron's unpatched
98
+ * copy; outside Electron the ordinary binding is already literal and is used.
99
+ *
100
+ * @param {string} file
101
+ * @returns {Buffer}
102
+ */
103
+ function readRealFile(file) {
104
+ const fallback = () => readFileSync(file)
105
+ try {
106
+ const original = createRequire(import.meta.url)('original-fs')
107
+ if (original !== undefined && original !== null && typeof original.readFileSync === 'function') {
108
+ return original.readFileSync(file)
109
+ }
110
+ } catch (error) {
111
+ void error
112
+ }
113
+ return fallback()
114
+ }
115
+
116
+ /** @param {string} file @returns {number} the real byte length */
117
+ function statRealSize(file) {
118
+ try {
119
+ const original = createRequire(import.meta.url)('original-fs')
120
+ if (original !== undefined && original !== null && typeof original.statSync === 'function') {
121
+ return original.statSync(file).size
122
+ }
123
+ } catch (error) {
124
+ void error
125
+ }
126
+ return statSync(file).size
127
+ }
128
+
129
+ /**
130
+ * Read one entry out of an asar archive.
131
+ *
132
+ * The container is `u32 @0 = 4`, `u32 @4 = headerLen + 8`, `u32 @8 = headerLen + 4`,
133
+ * `u32 @12 = headerLen`, then the JSON header, padding, then the file data.
134
+ *
135
+ * @param {string} file
136
+ * @param {string} entry slash-separated path inside the archive
137
+ * @returns {string | undefined} the entry as UTF-8, or undefined when absent
138
+ */
139
+ function readAsarEntry(file, entry) {
140
+ const buffer = readRealFile(file)
141
+ const headerLength = buffer.readUInt32LE(12)
142
+ const header = JSON.parse(buffer.toString('utf8', 16, 16 + headerLength))
143
+ const padding = (4 - (headerLength % 4)) % 4
144
+ const dataStart = 16 + headerLength + padding
145
+ let node = header
146
+ for (const segment of entry.split('/')) {
147
+ if (node === undefined || node.files === undefined) return undefined
148
+ node = node.files[segment]
149
+ }
150
+ if (node === undefined || node.files !== undefined) return undefined
151
+ if (node.unpacked === true) return undefined
152
+ const offset = dataStart + Number(node.offset)
153
+ return buffer.toString('utf8', offset, offset + Number(node.size))
154
+ }
155
+
156
+ /**
157
+ * Inspect the desktop shell and the optional patch. Read-only: no file here is
158
+ * written, so this is safe to call from a diagnostics button.
159
+ *
160
+ * @param {string} [resources]
161
+ * @returns {object} a report the panel formats and copies
162
+ */
163
+ function inspectShell(resources) {
164
+ const explicit = typeof resources === 'string' && resources !== ''
165
+ const resolved = explicit ? resources : detectResources()
166
+ if (resolved === undefined) {
167
+ return {
168
+ found: false,
169
+ platform: process.platform,
170
+ node: process.version,
171
+ detail: '没有找到 DSH 安装目录;用 DSH_RESOURCES 或 --resources 指定',
172
+ }
173
+ }
174
+ const asar = join(resolved, 'app.asar')
175
+ if (!existsSync(asar)) {
176
+ return {
177
+ found: false,
178
+ platform: process.platform,
179
+ node: process.version,
180
+ resources: resolved,
181
+ asar,
182
+ detail: '该目录下没有 app.asar;路径不对,或桌面壳安装不完整',
183
+ }
184
+ }
185
+ const staged = join(resolved, 'app.asar.new')
186
+ const backup = join(resolved, 'app.asar.orig')
187
+ const report = {
188
+ found: true,
189
+ platform: process.platform,
190
+ node: process.version,
191
+ resources: resolved,
192
+ asar,
193
+ exists: true,
194
+ writable: existsSync(asar) ? isWritable(asar) : isWritable(resolved),
195
+ backup: existsSync(backup),
196
+ staged: existsSync(staged),
197
+ patched: undefined,
198
+ patchSite: undefined,
199
+ detail: '',
200
+ }
201
+ try {
202
+ report.asarBytes = statRealSize(asar)
203
+ report.asarHash = createHash('sha256').update(readRealFile(asar)).digest('hex').slice(0, 16)
204
+ } catch (error) {
205
+ report.detail = '读取 app.asar 失败:' + firstLine(error)
206
+ return report
207
+ }
208
+ try {
209
+ const main = readAsarEntry(asar, 'lib/main.js')
210
+ if (main === undefined) {
211
+ report.detail = 'app.asar 中找不到 lib/main.js;壳结构已变化'
212
+ return report
213
+ }
214
+ report.patched = main.includes(PATCH_MARK)
215
+ report.cachePatch = main.includes(CACHE_MARK)
216
+ report.patchSite = main.includes(PATCH_SITE_MARK) || main.includes(PATCH_MARK)
217
+ if (report.patched !== true && report.patchSite === false) {
218
+ report.detail = '补丁点不存在:当前桌面壳版本的分区写法与补丁预期不一致,需要更新补丁'
219
+ } else if (report.patched !== true && report.cachePatch !== true) {
220
+ report.detail = '尚未打补丁'
221
+ }
222
+ } catch (error) {
223
+ report.detail = '解析 app.asar 失败:' + firstLine(error)
224
+ }
225
+ return report
226
+ }
227
+
228
+ /** @param {unknown} error @returns {string} */
229
+ function firstLine(error) {
230
+ const text = error instanceof Error ? error.message : String(error)
231
+ return text.split('\n')[0].slice(0, 200)
232
+ }
233
+
234
+ /**
235
+ * Serve the read-only probe over the Web GUI's HTTP carrier, so the panel can
236
+ * read it with a plain relative `fetch` — no Remote RPC endpoint, no shell
237
+ * bridge, and the same code path in the desktop app and the Web GUI.
238
+ *
239
+ * The route is GET-only and writes nothing; it reports where the shell is and
240
+ * what the archive contains, which is what a user needs to file a useful bug
241
+ * report when cross-restart login stops working.
242
+ *
243
+ * @param {import('@deepseek-ai/cordis').Context} ctx
244
+ * @returns {(() => void) | undefined} a disposer, or undefined without a webserver
245
+ */
246
+ function installHttpProbe(ctx) {
247
+ const server = typeof ctx.get === 'function' ? ctx.get('webServer') : undefined
248
+ if (server === undefined || server === null || typeof server.register !== 'function') return undefined
249
+ const route = {
250
+ kind: 'exact',
251
+ path: PROBE_PATH,
252
+ resource(request) {
253
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
254
+ return new Response('method not allowed', { status: 405 })
255
+ }
256
+ let body
257
+ try {
258
+ body = JSON.stringify(inspectShell())
259
+ } catch (error) {
260
+ body = JSON.stringify({ found: false, detail: firstLine(error) })
261
+ }
262
+ return new Response(request.method === 'HEAD' ? null : body, {
263
+ status: 200,
264
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
265
+ })
266
+ },
267
+ }
268
+ try {
269
+ const dispose = server.register(route)
270
+ return typeof dispose === 'function' ? dispose : () => {}
271
+ } catch (error) {
272
+ // Another composition already claimed the path, or the carrier rejected the
273
+ // shape. The probe is optional: the panel still reports the local state.
274
+ void error
275
+ return undefined
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Register the patch checker as an agent tool, so the model can answer
281
+ * "why does my login not survive a restart?" by looking instead of asking the
282
+ * user to run a command and paste its output.
283
+ *
284
+ * The tool is read-only and needs no approval: it reports paths, byte counts
285
+ * and whether the patch marks are present, which the user could read off the
286
+ * disk themselves.
287
+ *
288
+ * @param {import('@deepseek-ai/cordis').Context} ctx
289
+ * @returns {(() => void) | undefined} a disposer, or undefined without a tool registry
290
+ */
291
+ function installTool(ctx) {
292
+ const tools = typeof ctx.get === 'function' ? ctx.get('tools') : undefined
293
+ if (tools === undefined || tools === null || typeof tools.register !== 'function') return undefined
294
+ const definition = {
295
+ name: 'dsh_chat_embed_patch_check',
296
+ description:
297
+ '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.',
298
+ parameters: {
299
+ resources: {
300
+ type: 'string',
301
+ description:
302
+ 'Optional install directory that contains app.asar. Omit to let the plugin detect the running shell.',
303
+ },
304
+ },
305
+ output: {
306
+ schema: { type: 'object' },
307
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
308
+ },
309
+ async execute(args) {
310
+ const requested = args !== undefined && args !== null && typeof args.resources === 'string' ? args.resources : undefined
311
+ return inspectShell(requested)
312
+ },
313
+ }
314
+ try {
315
+ const dispose = tools.register(definition)
316
+ return typeof dispose === 'function' ? dispose : () => {}
317
+ } catch (error) {
318
+ void error
319
+ return undefined
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Register the Host-side patch probe and the agent tool.
325
+ *
326
+ * @param {import('@deepseek-ai/cordis').Context} ctx
327
+ */
17
328
  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
329
+ if (ctx === undefined || ctx === null || typeof ctx.effect !== 'function') return
330
+ ctx.effect(() => installHttpProbe(ctx), 'dsh-chat-embed: shell patch probe')
331
+ ctx.effect(() => installTool(ctx), 'dsh-chat-embed: shell patch tool')
25
332
  }
333
+
334
+ /** Exposed for tests and for the patch checker to reuse without a Context. */
335
+ export const internals = { inspectShell, detectResources, readAsarEntry, readRealFile, resourcesCandidates, PROBE_PATH }
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.0",
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",