@jaychang1989/dsh-webchat 0.6.0 → 0.7.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.en.md CHANGED
@@ -42,7 +42,7 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
42
42
  ## Use
43
43
 
44
44
  1. Click the "DeepSeek 网页 / DeepSeek Web" entry — the page loads in the center column; there is no second click.
45
- 2. Sign in to DeepSeek once; that holds for the rest of the run.
45
+ 2. Sign in to DeepSeek once — the plugin keeps that login across restarts (see below).
46
46
 
47
47
  Click again to collapse the panel. Switching to another panel (Plugins, Automation Tasks, the task board, a session …) only hides the guest, never unmounts it, so coming back neither reloads the page nor drops the session.
48
48
 
@@ -52,10 +52,18 @@ Click again to collapse the panel. Switching to another panel (Plugins, Automati
52
52
  - Node.js >= 22
53
53
  - The desktop app: only it provides the native guest bridge; plain `dsh web` uses the window fallback
54
54
 
55
+ ## Staying signed in
56
+
57
+ The host hands browser guests a **process-lifetime** partition (a fresh random name per run, with no `persist:` prefix), so cookies and site storage would die with the app — DSH's own side-card browser behaves the same way. This plugin takes that over: its host half runs inside the **Electron main process**, which is the only place a partition's cookies can be read (**HttpOnly ones included**; a renderer can never see them). It snapshots the cookies and the page's localStorage while you use the page, and puts them back **before** the page loads on the next run.
58
+
59
+ - Location: `%USERPROFILE%\.dsh\dsh-webchat\session.json`
60
+ - **Deleting that file logs the plugin's guest out** — it keeps nothing else behind.
61
+ - The file holds live session credentials in **plain text**. It sits in your own profile directory (user-private by default), but it is not as protected as a browser's encrypted cookie store.
62
+ - If DeepSeek changes how it stores the session you may have to sign in once more; the snapshot is then taken again automatically.
63
+
55
64
  ## Known limitations
56
65
 
57
- - **Restarting the desktop app means signing in to DeepSeek again.** The host hands out a **process-lifetime** guest partition (a fresh random name per run, with no `persist:` prefix), so the session is not written to disk. That is the host's mechanism, not a choice this plugin makes.
58
- - The DeepSeek web front end has its own rate limiting and sign-in flow; the plugin only hosts it and does not mediate its requests.
66
+ - The DeepSeek web front end has its own rate limiting and sign-in flow; the plugin only hosts it and keeps the session, and does not mediate its requests.
59
67
  - Links the page opens are handled inside the guest; the plugin adds no navigation policy of its own.
60
68
 
61
69
  ## Troubleshooting
@@ -66,7 +74,8 @@ Click again to collapse the panel. Switching to another panel (Plugins, Automati
66
74
  | The center column says "载入失败:…" | The host refused the guest (the bridge threw). The text carries the host's reason |
67
75
  | The row opens a browser window instead of a panel | This renderer had no `dshDesktop.browser` (a plain web profile, for instance), so the fallback ran |
68
76
  | The row opens but the center column is blank | The panel fills the cell the shell allocates; in a very small window, or with the sidebar dragged extremely narrow, that cell can have no area |
69
- | It asks for a login again after a restart | See the limitations above — it is the host's partition behaviour |
77
+ | It asks for a login again after a restart | Check `session` in `GET /api/dsh-webchat/state`: an `electron` value other than `ready` means the host half cannot reach Electron (so nothing can be saved), and `saved: null` means no snapshot exists yet. Use the page for a moment and look again ~30s later |
78
+ | You want to sign out for good | Delete `%USERPROFILE%\.dsh\dsh-webchat\session.json` |
70
79
  | The page shows "Abnormal usage environment" | DeepSeek's front end checks `navigator.userAgent` for the string `electron` — which the desktop default carries — and then recommends its official product. Since 0.5.2 the guest presents a plain Chrome user agent and the dialog no longer appears |
71
80
 
72
81
  ## Development and tests
@@ -75,7 +84,7 @@ Click again to collapse the panel. Switching to another panel (Plugins, Automati
75
84
  node --test
76
85
  ```
77
86
 
78
- Nineteen cases. The host half is driven through a fake context and fake request/response objects; the browser half really executes against a thin React test double plus a DOM stand-in: it checks that the plugin registers its nav row and its page under the slot contract (one shared id), that the label follows the language, that the icon honours the size the shell asks for, and the whole guest lifecycle — reserving a lease, building the webview the way the host requires (`about:blank#<lease>`), setting the user agent before navigating on `dom-ready`, **hiding the guest without detaching it when the panel unmounts and reusing it on remount**, and releasing the lease on plugin disposal; without a bridge it checks the window fallback and its reporting.
87
+ Thirty-four cases. The host half is driven through a fake context, fake request/response objects and injectable Electron/cookie stand-ins: the snapshot round-trip through disk, cookie capture and restore (HttpOnly included, one refused cookie not aborting the rest), the session routes' method guards and partition validation, and a host without Electron reporting why without hurting the page. The browser half really executes against a thin React test double plus a DOM stand-in: the slot contract (one shared id, order, the label thunk, the icon honouring the requested size), the lease and the `about:blank#<lease>` webview, the user agent landing before navigation, **hiding the guest without detaching it on unmount and reusing it on remount**, the login restore writing storage once and reloading once, snapshots while in use and on disposal, and the window fallback.
79
88
 
80
89
  There is no build step — `lib/index.js` and `lib/client.js` are the hand-written runtime, and the package has **zero runtime dependencies**. The guest mechanism is written up in [MAINTAINING.md](./MAINTAINING.md) (Chinese).
81
90
 
package/README.md CHANGED
@@ -52,10 +52,18 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
52
52
  - Node.js >= 22
53
53
  - 桌面端(DSH Desktop):只有它提供原生访客桥接;纯 `dsh web` 会走窗口降级
54
54
 
55
+ ## 登录状态
56
+
57
+ 桌面端给浏览器访客的分区是**进程内**的(每次运行随机命名、不带 `persist:`),所以 cookie 和站点存储本来会随退出一起消失——DSH 自带的侧栏浏览器也是这样。本插件把这个接管了:宿主半区运行在 **Electron 主进程里**,因此能读到该分区的 cookie(**含 HttpOnly**,渲染进程永远看不到),它会在你使用过程中把 cookie 与页面的 localStorage 快照下来,下次启动时**先回灌、再加载页面**。
58
+
59
+ - 文件位置:`%USERPROFILE%\.dsh\dsh-webchat\session.json`
60
+ - **删掉这个文件就等于退出登录**(本插件不会再有别的残留)。
61
+ - 文件里是**明文**会话凭据。它在你自己的用户目录下(默认只有你的账户可读),但确实不如浏览器那种加密 cookie 库。
62
+ - 若 DeepSeek 更换登录态的存储方式,可能要重新登录一次——之后会自动重新快照。
63
+
55
64
  ## 已知限制
56
65
 
57
- - **重启桌面端后需要重新登录 DeepSeek。** 宿主给访客分配的是**进程内**分区(每次运行随机命名、不带 `persist:`),登录态不落盘。这是宿主的机制,插件无法改变。
58
- - 官方网页端有自己的风控与登录流程,插件只负责把它承载起来,不介入其请求。
66
+ - 官方网页端有自己的风控与登录流程,插件只负责把它承载起来并保留登录态,不介入其请求。
59
67
  - 页面里指向外部的链接在访客内处理;插件不追加自己的导航策略。
60
68
 
61
69
  ## 排查
@@ -66,7 +74,8 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
66
74
  | 中栏提示「载入失败:…」 | 宿主拒绝了访客(桥接返回异常)。文本里带着宿主给的原因 |
67
75
  | 这一行点了但中栏没有页面,反而弹出浏览器窗口 | 说明当前渲染进程拿不到 `dshDesktop.browser`(例如在纯 web 环境),插件走了降级路径 |
68
76
  | 这一行点了但中栏是空白 | 面板显示的空间是 shell 分配的那个格子;若窗口极小或侧栏被拖到极窄,格子可能没有面积 |
69
- | 重启后要求重新登录 | 见上面的「已知限制」,属于宿主分区机制 |
77
+ | 重启后要求重新登录 | 先看 `GET /api/dsh-webchat/state` 的 `session`:`electron` 不是 `ready` 说明宿主半区拿不到 Electron(登录态无法保存),`saved` 为 `null` 说明还没产生过快照。正常情况下用一次、等 30 秒再看,文件就会出现 |
78
+ | 想彻底退出登录 | 删掉 `%USERPROFILE%\.dsh\dsh-webchat\session.json` |
70
79
  | 页面弹出「使用环境异常」 | DeepSeek 前端会检查 `navigator.userAgent` 里是否含 `electron`(桌面端默认 UA 就含),命中就提示"建议使用官方产品"。0.5.2 起访客改用普通 Chrome UA,不再触发 |
71
80
 
72
81
  ## 开发与测试
@@ -75,9 +84,9 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
75
84
  node --test
76
85
  ```
77
86
 
78
- 19 个用例。宿主半区用假 context 与假 request/response 驱动;浏览器半区用一层薄的 React 测试替身 + DOM 桩**真实执行**:验证它按槽位契约注册导航行与页面(同一个 id)、标签随语言变化、图标遵循 shell 要求的尺寸,以及访客生命周期——申请租约、按宿主约定生成 `about:blank#<lease>` 的 webview、`dom-ready` 后先改 UA 再导航、**卸载只隐藏不卸载访客、重挂复用同一个**、插件卸载时释放租约;无桥接环境下验证降级为请求宿主开窗并如实反馈。
87
+ 34 个用例。宿主半区用假 context、假 request/response 与可注入的 Electron/cookie 替身驱动:快照落盘往返、cookie 捕获与回灌(含 `HttpOnly`、拒绝一个不合法 cookie 不影响其余)、两条会话路由的方法守卫与分区校验、无 Electron 时如实报错而不影响页面加载。浏览器半区用一层薄的 React 测试替身 + DOM 桩**真实执行**:槽位注册契约(同一个 id、order、label thunk、按 size 出图标)、租约与 `about:blank#<lease>` 的 webview、`dom-ready` 后先改 UA 再导航、**卸载只隐藏不摘除 / 重挂复用同一访客 / 只申请一次租约**、登录态恢复只回灌一次并只刷新一次、使用中与卸载时的快照、无桥接时的降级。
79
88
 
80
- 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码(React 取自浏览器的模块表),包内**零运行时依赖**。访客机制与槽位契约见 [MAINTAINING.md](./MAINTAINING.md)。
89
+ 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码(React 取自浏览器的模块表),包内**零运行时依赖**。访客机制、槽位契约与登录态保存见 [MAINTAINING.md](./MAINTAINING.md)。
81
90
 
82
91
  ## 来源与许可
83
92
 
package/lib/client.js CHANGED
@@ -43,6 +43,11 @@ window.__ModuleLoader__.load({
43
43
  const STORAGE_IDENTITY = "dsh-webchat"
44
44
  /** Host route used by the window fallback. */
45
45
  const OPEN_ROUTE = "/api/dsh-webchat/open"
46
+ /** Host routes that keep the login alive across restarts. */
47
+ const RESTORE_ROUTE = "/api/dsh-webchat/session/restore"
48
+ const SAVE_ROUTE = "/api/dsh-webchat/session/save"
49
+ /** How often the guest's own storage is snapshotted while it is open. */
50
+ const SAVE_INTERVAL_MS = 30000
46
51
 
47
52
  const STYLE_ID = "dsh-webchat-style"
48
53
  const OVERLAY_ATTR = "data-dsh-webchat-overlay"
@@ -202,6 +207,79 @@ window.__ModuleLoader__.load({
202
207
  return box
203
208
  }
204
209
 
210
+ /** Read the guest page's own storage as `[[key, value], …]`. */
211
+ function readStorage(element) {
212
+ return element.executeJavaScript(
213
+ "JSON.stringify(Object.keys(localStorage).map(function (key) { return [key, localStorage.getItem(key)] }))",
214
+ )
215
+ }
216
+
217
+ /** Write `[[key, value], …]` back into the guest page's storage. */
218
+ function writeStorage(element, entries) {
219
+ return element.executeJavaScript(
220
+ "(function () { var entries = " + JSON.stringify(entries) + ";"
221
+ + " for (var i = 0; i < entries.length; i++) { try { localStorage.setItem(entries[i][0], entries[i][1]) } catch (error) {} }"
222
+ + " return entries.length })()",
223
+ )
224
+ }
225
+
226
+ /**
227
+ * Ask the host to copy the saved cookies onto this run's partition. This
228
+ * runs before the first navigation, so the page's first request already
229
+ * carries them.
230
+ * @param partition - the partition the shell issued for the lease.
231
+ * @returns the saved site storage, or null when there is none usable.
232
+ */
233
+ async function restoreGuestSession(partition) {
234
+ try {
235
+ const response = await fetch(RESTORE_ROUTE, {
236
+ method: "POST",
237
+ headers: { "content-type": "application/json" },
238
+ body: JSON.stringify({ partition }),
239
+ })
240
+ const payload = await response.json()
241
+ if (payload === null || typeof payload !== "object" || payload.ok !== true) {
242
+ console.warn("[dsh-webchat] session restore:", payload === null ? "no answer" : payload.error || "failed")
243
+ return null
244
+ }
245
+ return Array.isArray(payload.storage) && payload.storage.length > 0 ? payload.storage : null
246
+ } catch (error) {
247
+ console.warn("[dsh-webchat] session restore failed:", error)
248
+ return null
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Ask the host to refresh the snapshot. Cookies are read host-side;
254
+ * `withStorage` additionally ships the page's storage, which the unload
255
+ * beacon deliberately skips (the host then keeps the previous one).
256
+ * @param withStorage - whether to include the guest's storage.
257
+ * @param target - the guest record; defaults to the live one, which
258
+ * disposal clears before its last save.
259
+ */
260
+ async function saveGuestSession(withStorage, target) {
261
+ const record = target === undefined ? guest : target
262
+ if (record === null || record === undefined) return
263
+ const body = { partition: record.partition }
264
+ if (withStorage) {
265
+ try {
266
+ const parsed = JSON.parse(await readStorage(record.element))
267
+ if (Array.isArray(parsed)) body.storage = parsed
268
+ } catch (error) {
269
+ // Not ready, or nothing stored: cookies still get saved.
270
+ }
271
+ }
272
+ try {
273
+ await fetch(SAVE_ROUTE, {
274
+ method: "POST",
275
+ headers: { "content-type": "application/json" },
276
+ body: JSON.stringify(body),
277
+ })
278
+ } catch (error) {
279
+ // Best effort: a snapshot that fails costs a login, not the page.
280
+ }
281
+ }
282
+
205
283
  /**
206
284
  * Reserve a guest and attach its webview, once per app run.
207
285
  * @returns a promise resolving once the attempt settled.
@@ -216,6 +294,8 @@ window.__ModuleLoader__.load({
216
294
  || typeof reservation.lease !== "string" || typeof reservation.partition !== "string") {
217
295
  throw new Error("the desktop browser bridge returned no reservation")
218
296
  }
297
+ // Cookies land on the partition before anything is requested.
298
+ const storage = await restoreGuestSession(reservation.partition)
219
299
  const element = document.createElement("webview")
220
300
  element.setAttribute("name", reservation.lease)
221
301
  element.setAttribute("partition", reservation.partition)
@@ -237,9 +317,50 @@ window.__ModuleLoader__.load({
237
317
  failure = messageOf(error)
238
318
  })
239
319
  }, { once: true })
320
+ // Site storage can only be written while the page is on its own
321
+ // origin, so it is restored after the first load and then the
322
+ // page is asked to load once more — this time already signed in.
323
+ // Until that settles, snapshots are skipped: the freshly loaded
324
+ // page still has empty storage, and saving that would erase the
325
+ // very snapshot being restored.
326
+ let storageState = storage === null ? "none" : "pending"
327
+ element.addEventListener("did-finish-load", function () {
328
+ if (storageState !== "pending") return
329
+ storageState = "restoring"
330
+ Promise.resolve(writeStorage(element, storage))
331
+ .then(function () {
332
+ storageState = "done"
333
+ element.reload()
334
+ })
335
+ .catch(function (error) {
336
+ storageState = "done"
337
+ console.warn("[dsh-webchat] could not restore site storage:", error)
338
+ })
339
+ })
340
+ element.addEventListener("did-finish-load", function () {
341
+ if (storageState === "pending" || storageState === "restoring") return
342
+ void saveGuestSession(true)
343
+ })
344
+ const onUnload = function () {
345
+ // Cookies are the part that matters and the host reads them
346
+ // itself, so the beacon carries no payload.
347
+ try {
348
+ navigator.sendBeacon(SAVE_ROUTE, JSON.stringify({ partition: reservation.partition }))
349
+ } catch (error) {
350
+ // The page is going away; nothing left to do.
351
+ }
352
+ }
353
+ window.addEventListener("beforeunload", onUnload)
240
354
  const container = overlayContainer()
241
355
  container.appendChild(element)
242
- guest = { lease: reservation.lease, element, container }
356
+ guest = {
357
+ lease: reservation.lease,
358
+ partition: reservation.partition,
359
+ element,
360
+ container,
361
+ onUnload,
362
+ timer: setInterval(function () { void saveGuestSession(true) }, SAVE_INTERVAL_MS),
363
+ }
243
364
  failure = ""
244
365
  } catch (error) {
245
366
  failure = messageOf(error)
@@ -255,6 +376,10 @@ window.__ModuleLoader__.load({
255
376
  if (guest === null) return
256
377
  const releasing = guest
257
378
  guest = null
379
+ if (releasing.timer !== undefined) clearInterval(releasing.timer)
380
+ if (releasing.onUnload !== undefined) window.removeEventListener("beforeunload", releasing.onUnload)
381
+ // Last chance to keep the login: the page is about to be torn down.
382
+ void saveGuestSession(true, releasing)
258
383
  releasing.container.remove()
259
384
  if (bridge !== undefined) {
260
385
  try { void bridge.release(releasing.lease) } catch (error) { /* already gone */ }
package/lib/index.js CHANGED
@@ -1,35 +1,35 @@
1
1
  /**
2
- * dsh-webchat — minimal launcher half.
2
+ * dsh-webchat — host half.
3
3
  *
4
- * The DeepSeek web app IS the client: chat.deepseek.com already ships the model
5
- * picker, deep think, smart search, history and attachments. This plugin does
6
- * not reimplement any of that any more — it opens that page in a window and
7
- * gets out of the way. Everything the previous release did around it (a custom
8
- * chat panel, transcript storage, /api/dsh-webchat engine routes, the
9
- * webchat_status/send/recover/import/transfer tools, transfer distillation)
10
- * has been removed.
4
+ * Two jobs:
11
5
  *
12
- * Why a window and not a pane: chat.deepseek.com sends
13
- * `Content-Security-Policy: frame-ancestors 'none'`, so it cannot be framed,
14
- * and the desktop build runs with `webviewTag: false`, so it cannot be a
15
- * `<webview>` either. A real window is the only faithful way to show the
16
- * official page.
6
+ * 1. **Session survival.** The browser half renders chat.deepseek.com inside
7
+ * the DSH window through the shell's native browser guest, and the shell
8
+ * gives every guest a process-lifetime partition — so the DeepSeek login is
9
+ * gone after every restart. This half runs in the Electron main process, the
10
+ * only place that can read a partition's cookies (HttpOnly ones included),
11
+ * so it snapshots them to disk and puts them back on the next run. The
12
+ * browser half captures the page's own storage and calls these routes.
17
13
  *
18
- * Three strategies are tried in order, so the button always does something:
19
- * 1. `app-window` — a BrowserWindow created by this process (the DSH
20
- * desktop app is Electron). Focused instead of
21
- * duplicated when one is already open.
22
- * 2. `app-window-shell` — a chromeless Edge/Chrome window (`--app=`) with its
23
- * own user-data-dir, for hosts where (1) is denied.
24
- * 3. `system-browser` — the OS default browser.
14
+ * 2. **A window fallback.** Where no guest bridge exists (a plain `dsh web`
15
+ * profile) the page cannot be embedded at all, so the browser half asks this
16
+ * half to open it in a window. Three strategies are tried in order:
17
+ * - `app-window` — a BrowserWindow created by this process (the DSH
18
+ * desktop app is Electron), focused instead of
19
+ * duplicated when one is already open;
20
+ * - `app-window-shell` — a chromeless Edge/Chrome window (`--app=`) with
21
+ * its own user-data-dir;
22
+ * - `system-browser` — the OS default browser.
25
23
  *
26
- * The browser half (./client) renders the single button that calls this.
24
+ * Routes: `GET /state` (diagnostics), `POST /open` (the fallback),
25
+ * `POST /session/restore` and `POST /session/save` (the snapshot).
27
26
  */
28
27
 
29
28
  import { spawn } from 'node:child_process'
30
- import { existsSync } from 'node:fs'
29
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
31
30
  import { createRequire } from 'node:module'
32
- import { join } from 'node:path'
31
+ import { homedir } from 'node:os'
32
+ import { dirname, join } from 'node:path'
33
33
 
34
34
  /**
35
35
  * A CommonJS `require` anchored at this module. Electron hands `electron` to
@@ -47,10 +47,188 @@ export const inject = ['webServer']
47
47
  /** The page this plugin exists to open. */
48
48
  export const PAGE_URL = 'https://chat.deepseek.com/'
49
49
 
50
- /** Route family; the browser half spells the same two paths. */
50
+ /** Route family; the browser half spells the same paths. */
51
51
  export const ROUTES = {
52
52
  state: '/api/dsh-webchat/state',
53
53
  open: '/api/dsh-webchat/open',
54
+ restore: '/api/dsh-webchat/session/restore',
55
+ save: '/api/dsh-webchat/session/save',
56
+ }
57
+
58
+ /**
59
+ * The shell hands browser guests a **process-lifetime** session partition
60
+ * (`dsh-sidebar-browser-<uuid>`, no `persist:` prefix, fresh name every run), so
61
+ * cookies and site storage die with the app — DSH's own side-card browser
62
+ * behaves the same way. The plugin cannot ask for another partition: the main
63
+ * process compares `params.partition` against the lease it issued.
64
+ *
65
+ * So this half keeps the guest's session alive across restarts by hand. It runs
66
+ * in the Electron main process, which is what makes it possible at all: only
67
+ * there can `session.cookies` be read, and that includes HttpOnly cookies, which
68
+ * a renderer can never see. Site storage is captured by the browser half.
69
+ *
70
+ * The snapshot holds live session credentials in plain text under the user's own
71
+ * profile directory (that directory is user-private by default). Deleting the
72
+ * file logs the plugin's guest out.
73
+ */
74
+ export const SESSION_FILE = () => sessionFilePath === null
75
+ ? join(homedir(), '.dsh', 'dsh-webchat', 'session.json')
76
+ : sessionFilePath
77
+
78
+ /** Overridable snapshot path, so tests never touch the real profile directory. */
79
+ let sessionFilePath = null
80
+
81
+ /** Point the snapshot somewhere else. Test seam; production leaves it unset. */
82
+ export function setSessionFile(path) {
83
+ sessionFilePath = path
84
+ }
85
+
86
+ /** Overridable Electron lookup, so the routes' glue is testable outside Electron. */
87
+ let electronLoader = null
88
+
89
+ /** Replace the Electron lookup. Test seam; production leaves it unset. */
90
+ export function setElectronLoader(loader) {
91
+ electronLoader = loader
92
+ }
93
+
94
+ /** A partition name the shell issues for a browser guest, and nothing else. */
95
+ const GUEST_PARTITION = /^dsh-sidebar-browser-[0-9a-f-]{8,}$/
96
+
97
+ /** Electron reachability, probed once and reported through the state route. */
98
+ let electronStatus = 'unknown'
99
+
100
+ /** Read a JSON request body; a malformed or oversized body reads as null. */
101
+ function readJsonBody(req, limit = 2 * 1024 * 1024) {
102
+ return new Promise((resolve) => {
103
+ let size = 0
104
+ const chunks = []
105
+ req.on('data', (chunk) => {
106
+ size += chunk.length
107
+ if (size > limit) {
108
+ req.destroy()
109
+ resolve(null)
110
+ return
111
+ }
112
+ chunks.push(chunk)
113
+ })
114
+ req.on('end', () => {
115
+ try {
116
+ resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'))
117
+ } catch (error) {
118
+ resolve(null)
119
+ }
120
+ })
121
+ req.on('error', () => resolve(null))
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Read the saved snapshot.
127
+ * @param file - snapshot path.
128
+ * @returns the snapshot, or null when there is nothing usable on disk.
129
+ */
130
+ export function readSnapshot(file) {
131
+ try {
132
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
133
+ if (parsed === null || typeof parsed !== 'object') return null
134
+ return {
135
+ cookies: Array.isArray(parsed.cookies) ? parsed.cookies : [],
136
+ storage: Array.isArray(parsed.storage) ? parsed.storage : [],
137
+ savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : '',
138
+ }
139
+ } catch (error) {
140
+ return null
141
+ }
142
+ }
143
+
144
+ /** Write the snapshot atomically, user-private, so a crash cannot truncate it. */
145
+ export function writeSnapshot(file, snapshot) {
146
+ mkdirSync(dirname(file), { recursive: true })
147
+ const temp = `${file}.tmp`
148
+ writeFileSync(temp, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 })
149
+ renameSync(temp, file)
150
+ }
151
+
152
+ /**
153
+ * Copy saved cookies onto a partition's session.
154
+ * @param cookies - `session.cookies` of the live partition.
155
+ * @param snapshot - a snapshot from {@link readSnapshot}.
156
+ * @returns how many cookies the session accepted.
157
+ */
158
+ export async function restoreCookies(cookies, snapshot) {
159
+ let restored = 0
160
+ for (const cookie of snapshot.cookies) {
161
+ if (cookie === null || typeof cookie !== 'object') continue
162
+ if (typeof cookie.name !== 'string' || typeof cookie.value !== 'string') continue
163
+ const domain = typeof cookie.domain === 'string' ? cookie.domain : ''
164
+ if (domain === '') continue
165
+ const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/'
166
+ const details = {
167
+ url: `${cookie.secure === true ? 'https' : 'http'}://${domain.replace(/^\./, '')}${path}`,
168
+ name: cookie.name,
169
+ value: cookie.value,
170
+ domain,
171
+ path,
172
+ secure: cookie.secure === true,
173
+ httpOnly: cookie.httpOnly === true,
174
+ }
175
+ if (typeof cookie.expirationDate === 'number') details.expirationDate = cookie.expirationDate
176
+ if (typeof cookie.sameSite === 'string') details.sameSite = cookie.sameSite
177
+ try {
178
+ await cookies.set(details)
179
+ restored += 1
180
+ } catch (error) {
181
+ // A cookie the running Electron refuses (bad domain, expired, …) is not
182
+ // worth failing the whole restore for.
183
+ }
184
+ }
185
+ return restored
186
+ }
187
+
188
+ /**
189
+ * Serialize a partition's cookies, HttpOnly ones included.
190
+ * @param cookies - `session.cookies` of the live partition.
191
+ * @returns plain objects safe to write to disk.
192
+ */
193
+ export async function captureCookies(cookies) {
194
+ const list = await cookies.get({})
195
+ return list.map((cookie) => ({
196
+ name: cookie.name,
197
+ value: cookie.value,
198
+ domain: cookie.domain,
199
+ path: cookie.path,
200
+ secure: cookie.secure === true,
201
+ httpOnly: cookie.httpOnly === true,
202
+ ...(typeof cookie.expirationDate === 'number' ? { expirationDate: cookie.expirationDate } : {}),
203
+ ...(typeof cookie.sameSite === 'string' ? { sameSite: cookie.sameSite } : {}),
204
+ }))
205
+ }
206
+
207
+ /**
208
+ * The live session of a guest partition, through the Electron main process.
209
+ * @param partition - the partition name the shell issued for the lease.
210
+ * @returns the Electron session.
211
+ * @throws when this process cannot reach Electron or refuses the partition.
212
+ */
213
+ async function partitionSession(partition) {
214
+ if (!GUEST_PARTITION.test(partition)) throw new Error('dsh-webchat: not a browser-guest partition')
215
+ const electron = await electronApi()
216
+ if (electron.session === undefined) throw new Error('dsh-webchat: this process exposes no Electron session API')
217
+ return electron.session.fromPartition(partition)
218
+ }
219
+
220
+ /**
221
+ * Probe Electron once, for the state route's diagnostics.
222
+ * @returns a short status string.
223
+ */
224
+ export async function electronProbe() {
225
+ try {
226
+ const electron = await electronApi()
227
+ electronStatus = electron.session === undefined ? 'no session API' : 'ready'
228
+ } catch (error) {
229
+ electronStatus = messageOf(error)
230
+ }
231
+ return electronStatus
54
232
  }
55
233
 
56
234
  /**
@@ -102,23 +280,28 @@ function messageOf(error) {
102
280
  * loader resolves bare specifiers itself and does not always hand `electron`
103
281
  * to Electron's own resolver, so a CommonJS require — which Electron serves
104
282
  * natively — is the fallback.
283
+ *
284
+ * Either `BrowserWindow` (the window strategy) or `session` (the guest session
285
+ * snapshot) makes the module usable, so the two callers check their own need.
105
286
  * @returns the Electron module.
106
- * @throws with every probe failure joined, when neither path yields a window API.
287
+ * @throws with every probe failure joined, when neither path yields Electron.
107
288
  */
108
289
  async function electronApi() {
109
- const usable = (mod) => (mod !== null && typeof mod === 'object' && mod.BrowserWindow !== undefined ? mod : undefined)
290
+ if (electronLoader !== null) return electronLoader()
291
+ const usable = (mod) => (mod !== null && typeof mod === 'object'
292
+ && (mod.BrowserWindow !== undefined || mod.session !== undefined) ? mod : undefined)
110
293
  const failures = []
111
294
  try {
112
295
  const fromImport = usable(await import('electron'))
113
296
  if (fromImport !== undefined) return fromImport
114
- failures.push('esm import resolved but exposed no BrowserWindow')
297
+ failures.push('esm import resolved but exposed neither BrowserWindow nor session')
115
298
  } catch (error) {
116
299
  failures.push(`esm import failed: ${messageOf(error)}`)
117
300
  }
118
301
  try {
119
302
  const fromRequire = usable(requireHere('electron'))
120
303
  if (fromRequire !== undefined) return fromRequire
121
- failures.push('cjs require resolved but exposed no BrowserWindow')
304
+ failures.push('cjs require resolved but exposed neither BrowserWindow nor session')
122
305
  } catch (error) {
123
306
  failures.push(`cjs require failed: ${messageOf(error)}`)
124
307
  }
@@ -163,7 +346,9 @@ function shellProfileDir() {
163
346
 
164
347
  /** Open — or focus — a window owned by this process. */
165
348
  async function openAppWindow() {
166
- const { BrowserWindow } = await electronApi()
349
+ const electron = await electronApi()
350
+ const { BrowserWindow } = electron
351
+ if (BrowserWindow === undefined) throw new Error('this process exposes no Electron BrowserWindow')
167
352
  const slot = windowSlot()
168
353
  if (slot.current !== null && slot.current.isDestroyed() === false) {
169
354
  slot.current.focus()
@@ -263,12 +448,25 @@ export function apply(ctx) {
263
448
  return
264
449
  }
265
450
  const slot = windowSlot()
451
+ const file = SESSION_FILE()
452
+ const snapshot = readSnapshot(file)
266
453
  json(res, 200, {
267
454
  ok: true,
268
455
  url: PAGE_URL,
269
456
  appWindowOpen: slot.current !== null && slot.current.isDestroyed() === false,
270
457
  last: lastAttempt,
271
458
  attempts: lastAttempts,
459
+ // Counts only: this endpoint is unauthenticated, so the cookie values
460
+ // themselves never leave the snapshot file.
461
+ session: {
462
+ file,
463
+ electron: electronStatus,
464
+ saved: snapshot === null ? null : {
465
+ cookies: snapshot.cookies.length,
466
+ storage: snapshot.storage.length,
467
+ savedAt: snapshot.savedAt,
468
+ },
469
+ },
272
470
  })
273
471
  },
274
472
  }), 'dsh-webchat: state route')
@@ -286,4 +484,76 @@ export function apply(ctx) {
286
484
  json(res, result.ok ? 200 : 502, result)
287
485
  },
288
486
  }), 'dsh-webchat: open route')
487
+
488
+ // The guest's session lives in a process-lifetime partition, so the browser
489
+ // half asks this side to put the saved cookies back before it navigates…
490
+ ctx.effect(() => ctx.webServer.register({
491
+ kind: 'exact',
492
+ path: ROUTES.restore,
493
+ handler: async (req, res) => {
494
+ if (req.method !== 'POST') {
495
+ res.writeHead(405, { allow: 'POST' })
496
+ res.end()
497
+ return
498
+ }
499
+ const body = await readJsonBody(req)
500
+ const partition = body !== null && typeof body.partition === 'string' ? body.partition : ''
501
+ // Validate before anything else: this half must never touch a session the
502
+ // shell did not hand out for a browser guest.
503
+ if (!GUEST_PARTITION.test(partition)) {
504
+ json(res, 400, { ok: false, error: 'dsh-webchat: not a browser-guest partition' })
505
+ return
506
+ }
507
+ const snapshot = readSnapshot(SESSION_FILE())
508
+ if (snapshot === null) {
509
+ json(res, 200, { ok: true, cookies: 0, storage: null, savedAt: '' })
510
+ return
511
+ }
512
+ try {
513
+ const session = await partitionSession(partition)
514
+ const restored = await restoreCookies(session.cookies, snapshot)
515
+ json(res, 200, { ok: true, cookies: restored, storage: snapshot.storage, savedAt: snapshot.savedAt })
516
+ } catch (error) {
517
+ // No snapshot is worse than an unreachable session: the guest must still
518
+ // load, so this answers with a reason instead of a failure status.
519
+ json(res, 200, { ok: false, error: messageOf(error), storage: null })
520
+ }
521
+ },
522
+ }), 'dsh-webchat: session restore route')
523
+
524
+ // …and asks it to refresh that snapshot as the page is used.
525
+ ctx.effect(() => ctx.webServer.register({
526
+ kind: 'exact',
527
+ path: ROUTES.save,
528
+ handler: async (req, res) => {
529
+ if (req.method !== 'POST') {
530
+ res.writeHead(405, { allow: 'POST' })
531
+ res.end()
532
+ return
533
+ }
534
+ const body = await readJsonBody(req)
535
+ const partition = body !== null && typeof body.partition === 'string' ? body.partition : ''
536
+ if (!GUEST_PARTITION.test(partition)) {
537
+ json(res, 400, { ok: false, error: 'dsh-webchat: not a browser-guest partition' })
538
+ return
539
+ }
540
+ try {
541
+ const session = await partitionSession(partition)
542
+ const cookies = await captureCookies(session.cookies)
543
+ const file = SESSION_FILE()
544
+ const previous = readSnapshot(file)
545
+ // A save without site storage (the unload beacon) must not erase what an
546
+ // earlier save captured.
547
+ const storage = body !== null && Array.isArray(body.storage)
548
+ ? body.storage
549
+ : (previous === null ? [] : previous.storage)
550
+ writeSnapshot(file, { version: 1, savedAt: new Date().toISOString(), cookies, storage })
551
+ json(res, 200, { ok: true, cookies: cookies.length, storage: storage.length })
552
+ } catch (error) {
553
+ json(res, 502, { ok: false, error: messageOf(error) })
554
+ }
555
+ },
556
+ }), 'dsh-webchat: session save route')
557
+
558
+ void electronProbe()
289
559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaychang1989/dsh-webchat",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Opens the official DeepSeek web app (chat.deepseek.com) inside DeepSeek Harness: one sidebar entry renders the real page in the center column, through the same native browser guest the built-in side-card browser uses. No chat UI, no agent tools, no runtime dependencies.",
5
5
  "keywords": [
6
6
  "dsh",