@jaychang1989/dsh-webchat 0.6.0 → 0.7.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.en.md +19 -5
- package/README.md +19 -5
- package/lib/client.js +155 -1
- package/lib/index.js +330 -29
- package/package.json +1 -1
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
|
|
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,23 @@ 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 through **two channels**, because the host half does not always reach Electron:
|
|
58
|
+
|
|
59
|
+
1. **Site storage** (localStorage) and the **page's own cookies**: carried by the guest itself, generically — no key names, no Electron.
|
|
60
|
+
2. **Partition cookies, HttpOnly included**: only the Electron main process can read those. Used when reachable; `session.electron` in `GET /api/dsh-webchat/state` says plainly whether it is.
|
|
61
|
+
|
|
62
|
+
The order: reserve the lease → restore first (cookies land before the first navigation, so the page's very first request already carries them) → write storage and the page's cookies once the first load finishes → reload once → snapshot as the page is used.
|
|
63
|
+
|
|
64
|
+
- Location: `%USERPROFILE%\.dsh\dsh-webchat\session.json`
|
|
65
|
+
- **Deleting that file logs the plugin's guest out** — it keeps nothing else behind.
|
|
66
|
+
- 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.
|
|
67
|
+
- If DeepSeek keeps the session in an HttpOnly cookie *and* the host half cannot reach Electron, only the storage part can be kept — signing in again would then still happen.
|
|
68
|
+
|
|
55
69
|
## Known limitations
|
|
56
70
|
|
|
57
|
-
-
|
|
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.
|
|
71
|
+
- 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
72
|
- Links the page opens are handled inside the guest; the plugin adds no navigation policy of its own.
|
|
60
73
|
|
|
61
74
|
## Troubleshooting
|
|
@@ -66,7 +79,8 @@ Click again to collapse the panel. Switching to another panel (Plugins, Automati
|
|
|
66
79
|
| The center column says "载入失败:…" | The host refused the guest (the bridge threw). The text carries the host's reason |
|
|
67
80
|
| 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
81
|
| 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 |
|
|
82
|
+
| 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 |
|
|
83
|
+
| You want to sign out for good | Delete `%USERPROFILE%\.dsh\dsh-webchat\session.json` |
|
|
70
84
|
| 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
85
|
|
|
72
86
|
## Development and tests
|
|
@@ -75,7 +89,7 @@ Click again to collapse the panel. Switching to another panel (Plugins, Automati
|
|
|
75
89
|
node --test
|
|
76
90
|
```
|
|
77
91
|
|
|
78
|
-
|
|
92
|
+
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
93
|
|
|
80
94
|
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
95
|
|
package/README.md
CHANGED
|
@@ -52,10 +52,23 @@ 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:
|
|
58
|
+
|
|
59
|
+
1. **站点存储**(localStorage)与**页面自己的 cookie**:由页面侧通用搬运,不依赖任何键名,也不依赖 Electron;
|
|
60
|
+
2. **分区 cookie(含 HttpOnly)**:只有宿主的 Electron 主进程读得到。能拿到就走这条;`GET /api/dsh-webchat/state` 的 `session.electron` 会如实说明。
|
|
61
|
+
|
|
62
|
+
顺序:拿到租约 → 回灌(cookie 在首次导航前写回,页面第一个请求就带着它)→ 首次加载完成后再写入存储与 cookie → 刷新一次 → 之后按使用情况快照。
|
|
63
|
+
|
|
64
|
+
- 文件位置:`%USERPROFILE%\.dsh\dsh-webchat\session.json`
|
|
65
|
+
- **删掉这个文件就等于退出登录**(本插件不会再有别的残留)。
|
|
66
|
+
- 文件里是**明文**会话凭据。它在你自己的用户目录下(默认只有你的账户可读),但确实不如浏览器那种加密 cookie 库。
|
|
67
|
+
- 如果 DeepSeek 的登录态是 HttpOnly cookie、而宿主又拿不到 Electron,就只能保留存储部分——那种情况下重登仍会发生。
|
|
68
|
+
|
|
55
69
|
## 已知限制
|
|
56
70
|
|
|
57
|
-
-
|
|
58
|
-
- 官方网页端有自己的风控与登录流程,插件只负责把它承载起来,不介入其请求。
|
|
71
|
+
- 官方网页端有自己的风控与登录流程,插件只负责把它承载起来并保留登录态,不介入其请求。
|
|
59
72
|
- 页面里指向外部的链接在访客内处理;插件不追加自己的导航策略。
|
|
60
73
|
|
|
61
74
|
## 排查
|
|
@@ -66,7 +79,8 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
|
|
|
66
79
|
| 中栏提示「载入失败:…」 | 宿主拒绝了访客(桥接返回异常)。文本里带着宿主给的原因 |
|
|
67
80
|
| 这一行点了但中栏没有页面,反而弹出浏览器窗口 | 说明当前渲染进程拿不到 `dshDesktop.browser`(例如在纯 web 环境),插件走了降级路径 |
|
|
68
81
|
| 这一行点了但中栏是空白 | 面板显示的空间是 shell 分配的那个格子;若窗口极小或侧栏被拖到极窄,格子可能没有面积 |
|
|
69
|
-
| 重启后要求重新登录 |
|
|
82
|
+
| 重启后要求重新登录 | 先看 `GET /api/dsh-webchat/state` 的 `session`:`electron` 不是 `ready` 说明宿主半区拿不到 Electron(登录态无法保存),`saved` 为 `null` 说明还没产生过快照。正常情况下用一次、等 30 秒再看,文件就会出现 |
|
|
83
|
+
| 想彻底退出登录 | 删掉 `%USERPROFILE%\.dsh\dsh-webchat\session.json` |
|
|
70
84
|
| 页面弹出「使用环境异常」 | DeepSeek 前端会检查 `navigator.userAgent` 里是否含 `electron`(桌面端默认 UA 就含),命中就提示"建议使用官方产品"。0.5.2 起访客改用普通 Chrome UA,不再触发 |
|
|
71
85
|
|
|
72
86
|
## 开发与测试
|
|
@@ -75,9 +89,9 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
|
|
|
75
89
|
node --test
|
|
76
90
|
```
|
|
77
91
|
|
|
78
|
-
|
|
92
|
+
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
93
|
|
|
80
|
-
没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码(React
|
|
94
|
+
没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码(React 取自浏览器的模块表),包内**零运行时依赖**。访客机制、槽位契约与登录态保存见 [MAINTAINING.md](./MAINTAINING.md)。
|
|
81
95
|
|
|
82
96
|
## 来源与许可
|
|
83
97
|
|
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,107 @@ window.__ModuleLoader__.load({
|
|
|
202
207
|
return box
|
|
203
208
|
}
|
|
204
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Read everything the guest page itself can reach: its storage and its
|
|
212
|
+
* own cookie string. That string cannot carry HttpOnly cookies — only the
|
|
213
|
+
* host half can, and only when it reaches Electron — but it is what keeps
|
|
214
|
+
* a login alive when it cannot.
|
|
215
|
+
* @param element - the live webview.
|
|
216
|
+
* @returns the guest state as JSON text.
|
|
217
|
+
*/
|
|
218
|
+
function readGuestState(element) {
|
|
219
|
+
return element.executeJavaScript(
|
|
220
|
+
"JSON.stringify({ storage: Object.keys(localStorage).map(function (key) { return [key, localStorage.getItem(key)] }), cookie: document.cookie })",
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Write a guest state back into the page: storage keys, then cookies.
|
|
226
|
+
* Cookies are re-scoped to the site root, which is where a login cookie
|
|
227
|
+
* belongs; the original attributes are not recoverable from
|
|
228
|
+
* `document.cookie`.
|
|
229
|
+
* @param element - the live webview.
|
|
230
|
+
* @param state - `{ storage, cookie }`.
|
|
231
|
+
*/
|
|
232
|
+
function writeGuestState(element, state) {
|
|
233
|
+
return element.executeJavaScript(
|
|
234
|
+
"(function () { var state = " + JSON.stringify(state) + ";"
|
|
235
|
+
+ " var storage = Array.isArray(state.storage) ? state.storage : [];"
|
|
236
|
+
+ " for (var i = 0; i < storage.length; i++) { try { localStorage.setItem(storage[i][0], storage[i][1]) } catch (error) {} }"
|
|
237
|
+
+ " if (typeof state.cookie === 'string' && state.cookie !== '') {"
|
|
238
|
+
+ " var parts = state.cookie.split('; ');"
|
|
239
|
+
+ " for (var j = 0; j < parts.length; j++) {"
|
|
240
|
+
+ " var pair = parts[j];"
|
|
241
|
+
+ " if (pair.indexOf('=') === -1) continue;"
|
|
242
|
+
+ " try { document.cookie = pair + '; path=/' } catch (error) {}"
|
|
243
|
+
+ " } }"
|
|
244
|
+
+ " return true })()",
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Ask the host to put the saved session back before the page is
|
|
250
|
+
* navigated: cookies through the shell's session API where that is
|
|
251
|
+
* reachable, and the guest's own storage and cookie string either way.
|
|
252
|
+
* @param partition - the partition the shell issued for the lease.
|
|
253
|
+
* @returns `{ storage, cookie }` to apply, or null when there is nothing.
|
|
254
|
+
*/
|
|
255
|
+
async function restoreGuestSession(partition) {
|
|
256
|
+
try {
|
|
257
|
+
const response = await fetch(RESTORE_ROUTE, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
headers: { "content-type": "application/json" },
|
|
260
|
+
body: JSON.stringify({ partition }),
|
|
261
|
+
})
|
|
262
|
+
const payload = await response.json()
|
|
263
|
+
if (payload === null || typeof payload !== "object") return null
|
|
264
|
+
if (payload.ok !== true) {
|
|
265
|
+
console.warn("[dsh-webchat] session cookies:", payload.error || "not restored")
|
|
266
|
+
}
|
|
267
|
+
const storage = Array.isArray(payload.storage) ? payload.storage : []
|
|
268
|
+
const cookie = typeof payload.cookie === "string" ? payload.cookie : ""
|
|
269
|
+
if (storage.length === 0 && cookie === "") return null
|
|
270
|
+
return { storage, cookie }
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.warn("[dsh-webchat] session restore failed:", error)
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Ask the host to refresh the snapshot. `withState` ships what only the
|
|
279
|
+
* guest can read; the unload beacon deliberately skips it (the host then
|
|
280
|
+
* keeps the previous values).
|
|
281
|
+
* @param withState - whether to include the guest's own state.
|
|
282
|
+
* @param target - the guest record; defaults to the live one, which
|
|
283
|
+
* disposal clears before its last save.
|
|
284
|
+
*/
|
|
285
|
+
async function saveGuestSession(withState, target) {
|
|
286
|
+
const record = target === undefined ? guest : target
|
|
287
|
+
if (record === null || record === undefined) return
|
|
288
|
+
const body = { partition: record.partition }
|
|
289
|
+
if (withState) {
|
|
290
|
+
try {
|
|
291
|
+
const state = JSON.parse(await readGuestState(record.element))
|
|
292
|
+
if (state !== null && typeof state === "object") {
|
|
293
|
+
if (Array.isArray(state.storage)) body.storage = state.storage
|
|
294
|
+
if (typeof state.cookie === "string") body.cookie = state.cookie
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
// Not ready: the host still snapshots whatever it can reach.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
try {
|
|
301
|
+
await fetch(SAVE_ROUTE, {
|
|
302
|
+
method: "POST",
|
|
303
|
+
headers: { "content-type": "application/json" },
|
|
304
|
+
body: JSON.stringify(body),
|
|
305
|
+
})
|
|
306
|
+
} catch (error) {
|
|
307
|
+
// Best effort: a snapshot that fails costs a login, not the page.
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
205
311
|
/**
|
|
206
312
|
* Reserve a guest and attach its webview, once per app run.
|
|
207
313
|
* @returns a promise resolving once the attempt settled.
|
|
@@ -216,6 +322,9 @@ window.__ModuleLoader__.load({
|
|
|
216
322
|
|| typeof reservation.lease !== "string" || typeof reservation.partition !== "string") {
|
|
217
323
|
throw new Error("the desktop browser bridge returned no reservation")
|
|
218
324
|
}
|
|
325
|
+
// Cookies land on the partition before anything is requested; the
|
|
326
|
+
// guest's own storage and cookie string come back with them.
|
|
327
|
+
const restored = await restoreGuestSession(reservation.partition)
|
|
219
328
|
const element = document.createElement("webview")
|
|
220
329
|
element.setAttribute("name", reservation.lease)
|
|
221
330
|
element.setAttribute("partition", reservation.partition)
|
|
@@ -237,9 +346,50 @@ window.__ModuleLoader__.load({
|
|
|
237
346
|
failure = messageOf(error)
|
|
238
347
|
})
|
|
239
348
|
}, { once: true })
|
|
349
|
+
// Storage and cookies can only be written while the page is on
|
|
350
|
+
// its own origin, so both are restored after the first load and
|
|
351
|
+
// the page is then asked to load once more — this time already
|
|
352
|
+
// signed in. Until that settles, snapshots are skipped: the
|
|
353
|
+
// freshly loaded page still has empty storage, and saving that
|
|
354
|
+
// would erase the very snapshot being restored.
|
|
355
|
+
let storageState = restored === null ? "none" : "pending"
|
|
356
|
+
element.addEventListener("did-finish-load", function () {
|
|
357
|
+
if (storageState !== "pending") return
|
|
358
|
+
storageState = "restoring"
|
|
359
|
+
Promise.resolve(writeGuestState(element, restored))
|
|
360
|
+
.then(function () {
|
|
361
|
+
storageState = "done"
|
|
362
|
+
element.reload()
|
|
363
|
+
})
|
|
364
|
+
.catch(function (error) {
|
|
365
|
+
storageState = "done"
|
|
366
|
+
console.warn("[dsh-webchat] could not restore site storage:", error)
|
|
367
|
+
})
|
|
368
|
+
})
|
|
369
|
+
element.addEventListener("did-finish-load", function () {
|
|
370
|
+
if (storageState === "pending" || storageState === "restoring") return
|
|
371
|
+
void saveGuestSession(true)
|
|
372
|
+
})
|
|
373
|
+
const onUnload = function () {
|
|
374
|
+
// Cookies are the part that matters and the host reads them
|
|
375
|
+
// itself, so the beacon carries no payload.
|
|
376
|
+
try {
|
|
377
|
+
navigator.sendBeacon(SAVE_ROUTE, JSON.stringify({ partition: reservation.partition }))
|
|
378
|
+
} catch (error) {
|
|
379
|
+
// The page is going away; nothing left to do.
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
window.addEventListener("beforeunload", onUnload)
|
|
240
383
|
const container = overlayContainer()
|
|
241
384
|
container.appendChild(element)
|
|
242
|
-
guest = {
|
|
385
|
+
guest = {
|
|
386
|
+
lease: reservation.lease,
|
|
387
|
+
partition: reservation.partition,
|
|
388
|
+
element,
|
|
389
|
+
container,
|
|
390
|
+
onUnload,
|
|
391
|
+
timer: setInterval(function () { void saveGuestSession(true) }, SAVE_INTERVAL_MS),
|
|
392
|
+
}
|
|
243
393
|
failure = ""
|
|
244
394
|
} catch (error) {
|
|
245
395
|
failure = messageOf(error)
|
|
@@ -255,6 +405,10 @@ window.__ModuleLoader__.load({
|
|
|
255
405
|
if (guest === null) return
|
|
256
406
|
const releasing = guest
|
|
257
407
|
guest = null
|
|
408
|
+
if (releasing.timer !== undefined) clearInterval(releasing.timer)
|
|
409
|
+
if (releasing.onUnload !== undefined) window.removeEventListener("beforeunload", releasing.onUnload)
|
|
410
|
+
// Last chance to keep the login: the page is about to be torn down.
|
|
411
|
+
void saveGuestSession(true, releasing)
|
|
258
412
|
releasing.container.remove()
|
|
259
413
|
if (bridge !== undefined) {
|
|
260
414
|
try { void bridge.release(releasing.lease) } catch (error) { /* already gone */ }
|
package/lib/index.js
CHANGED
|
@@ -1,35 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-webchat —
|
|
2
|
+
* dsh-webchat — host half.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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,192 @@ 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
|
|
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
|
+
// The page's own `document.cookie` string, kept by the browser half. It
|
|
138
|
+
// cannot carry HttpOnly cookies, but it is what survives when this process
|
|
139
|
+
// has no Electron session API at all.
|
|
140
|
+
cookie: typeof parsed.cookie === 'string' ? parsed.cookie : '',
|
|
141
|
+
savedAt: typeof parsed.savedAt === 'string' ? parsed.savedAt : '',
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Write the snapshot atomically, user-private, so a crash cannot truncate it. */
|
|
149
|
+
export function writeSnapshot(file, snapshot) {
|
|
150
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
151
|
+
const temp = `${file}.tmp`
|
|
152
|
+
writeFileSync(temp, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 })
|
|
153
|
+
renameSync(temp, file)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Copy saved cookies onto a partition's session.
|
|
158
|
+
* @param cookies - `session.cookies` of the live partition.
|
|
159
|
+
* @param snapshot - a snapshot from {@link readSnapshot}.
|
|
160
|
+
* @returns how many cookies the session accepted.
|
|
161
|
+
*/
|
|
162
|
+
export async function restoreCookies(cookies, snapshot) {
|
|
163
|
+
let restored = 0
|
|
164
|
+
for (const cookie of snapshot.cookies) {
|
|
165
|
+
if (cookie === null || typeof cookie !== 'object') continue
|
|
166
|
+
if (typeof cookie.name !== 'string' || typeof cookie.value !== 'string') continue
|
|
167
|
+
const domain = typeof cookie.domain === 'string' ? cookie.domain : ''
|
|
168
|
+
if (domain === '') continue
|
|
169
|
+
const path = typeof cookie.path === 'string' && cookie.path !== '' ? cookie.path : '/'
|
|
170
|
+
const details = {
|
|
171
|
+
url: `${cookie.secure === true ? 'https' : 'http'}://${domain.replace(/^\./, '')}${path}`,
|
|
172
|
+
name: cookie.name,
|
|
173
|
+
value: cookie.value,
|
|
174
|
+
domain,
|
|
175
|
+
path,
|
|
176
|
+
secure: cookie.secure === true,
|
|
177
|
+
httpOnly: cookie.httpOnly === true,
|
|
178
|
+
}
|
|
179
|
+
if (typeof cookie.expirationDate === 'number') details.expirationDate = cookie.expirationDate
|
|
180
|
+
if (typeof cookie.sameSite === 'string') details.sameSite = cookie.sameSite
|
|
181
|
+
try {
|
|
182
|
+
await cookies.set(details)
|
|
183
|
+
restored += 1
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// A cookie the running Electron refuses (bad domain, expired, …) is not
|
|
186
|
+
// worth failing the whole restore for.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return restored
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Serialize a partition's cookies, HttpOnly ones included.
|
|
194
|
+
* @param cookies - `session.cookies` of the live partition.
|
|
195
|
+
* @returns plain objects safe to write to disk.
|
|
196
|
+
*/
|
|
197
|
+
export async function captureCookies(cookies) {
|
|
198
|
+
const list = await cookies.get({})
|
|
199
|
+
return list.map((cookie) => ({
|
|
200
|
+
name: cookie.name,
|
|
201
|
+
value: cookie.value,
|
|
202
|
+
domain: cookie.domain,
|
|
203
|
+
path: cookie.path,
|
|
204
|
+
secure: cookie.secure === true,
|
|
205
|
+
httpOnly: cookie.httpOnly === true,
|
|
206
|
+
...(typeof cookie.expirationDate === 'number' ? { expirationDate: cookie.expirationDate } : {}),
|
|
207
|
+
...(typeof cookie.sameSite === 'string' ? { sameSite: cookie.sameSite } : {}),
|
|
208
|
+
}))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The live session of a guest partition, through the Electron main process.
|
|
213
|
+
* @param partition - the partition name the shell issued for the lease.
|
|
214
|
+
* @returns the Electron session.
|
|
215
|
+
* @throws when this process cannot reach Electron or refuses the partition.
|
|
216
|
+
*/
|
|
217
|
+
async function partitionSession(partition) {
|
|
218
|
+
if (!GUEST_PARTITION.test(partition)) throw new Error('dsh-webchat: not a browser-guest partition')
|
|
219
|
+
const electron = await electronApi()
|
|
220
|
+
if (electron.session === undefined) throw new Error('dsh-webchat: this process exposes no Electron session API')
|
|
221
|
+
return electron.session.fromPartition(partition)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Probe Electron once, for the state route's diagnostics.
|
|
226
|
+
* @returns a short status string.
|
|
227
|
+
*/
|
|
228
|
+
export async function electronProbe() {
|
|
229
|
+
try {
|
|
230
|
+
const electron = await electronApi()
|
|
231
|
+
electronStatus = electron.session === undefined ? 'no session API' : 'ready'
|
|
232
|
+
} catch (error) {
|
|
233
|
+
electronStatus = messageOf(error)
|
|
234
|
+
}
|
|
235
|
+
return electronStatus
|
|
54
236
|
}
|
|
55
237
|
|
|
56
238
|
/**
|
|
@@ -102,23 +284,37 @@ function messageOf(error) {
|
|
|
102
284
|
* loader resolves bare specifiers itself and does not always hand `electron`
|
|
103
285
|
* to Electron's own resolver, so a CommonJS require — which Electron serves
|
|
104
286
|
* natively — is the fallback.
|
|
287
|
+
*
|
|
288
|
+
* Either `BrowserWindow` (the window strategy) or `session` (the guest session
|
|
289
|
+
* snapshot) makes the module usable, so the two callers check their own need.
|
|
105
290
|
* @returns the Electron module.
|
|
106
|
-
* @throws with every probe failure joined, when neither path yields
|
|
291
|
+
* @throws with every probe failure joined, when neither path yields Electron.
|
|
107
292
|
*/
|
|
108
293
|
async function electronApi() {
|
|
109
|
-
|
|
294
|
+
if (electronLoader !== null) return electronLoader()
|
|
295
|
+
// Electron's ESM namespace can carry the real API under `default` when its
|
|
296
|
+
// named exports are not statically detectable, and this loader hands back
|
|
297
|
+
// exactly that shape, so both forms are accepted.
|
|
298
|
+
const usable = (mod) => {
|
|
299
|
+
if (mod === null || typeof mod !== 'object') return undefined
|
|
300
|
+
if (mod.BrowserWindow !== undefined || mod.session !== undefined) return mod
|
|
301
|
+
const inner = mod.default
|
|
302
|
+
if (inner !== null && typeof inner === 'object'
|
|
303
|
+
&& (inner.BrowserWindow !== undefined || inner.session !== undefined)) return inner
|
|
304
|
+
return undefined
|
|
305
|
+
}
|
|
110
306
|
const failures = []
|
|
111
307
|
try {
|
|
112
308
|
const fromImport = usable(await import('electron'))
|
|
113
309
|
if (fromImport !== undefined) return fromImport
|
|
114
|
-
failures.push('esm import resolved but exposed
|
|
310
|
+
failures.push('esm import resolved but exposed neither BrowserWindow nor session')
|
|
115
311
|
} catch (error) {
|
|
116
312
|
failures.push(`esm import failed: ${messageOf(error)}`)
|
|
117
313
|
}
|
|
118
314
|
try {
|
|
119
315
|
const fromRequire = usable(requireHere('electron'))
|
|
120
316
|
if (fromRequire !== undefined) return fromRequire
|
|
121
|
-
failures.push('cjs require resolved but exposed
|
|
317
|
+
failures.push('cjs require resolved but exposed neither BrowserWindow nor session')
|
|
122
318
|
} catch (error) {
|
|
123
319
|
failures.push(`cjs require failed: ${messageOf(error)}`)
|
|
124
320
|
}
|
|
@@ -163,7 +359,9 @@ function shellProfileDir() {
|
|
|
163
359
|
|
|
164
360
|
/** Open — or focus — a window owned by this process. */
|
|
165
361
|
async function openAppWindow() {
|
|
166
|
-
const
|
|
362
|
+
const electron = await electronApi()
|
|
363
|
+
const { BrowserWindow } = electron
|
|
364
|
+
if (BrowserWindow === undefined) throw new Error('this process exposes no Electron BrowserWindow')
|
|
167
365
|
const slot = windowSlot()
|
|
168
366
|
if (slot.current !== null && slot.current.isDestroyed() === false) {
|
|
169
367
|
slot.current.focus()
|
|
@@ -263,12 +461,25 @@ export function apply(ctx) {
|
|
|
263
461
|
return
|
|
264
462
|
}
|
|
265
463
|
const slot = windowSlot()
|
|
464
|
+
const file = SESSION_FILE()
|
|
465
|
+
const snapshot = readSnapshot(file)
|
|
266
466
|
json(res, 200, {
|
|
267
467
|
ok: true,
|
|
268
468
|
url: PAGE_URL,
|
|
269
469
|
appWindowOpen: slot.current !== null && slot.current.isDestroyed() === false,
|
|
270
470
|
last: lastAttempt,
|
|
271
471
|
attempts: lastAttempts,
|
|
472
|
+
// Counts only: this endpoint is unauthenticated, so the cookie values
|
|
473
|
+
// themselves never leave the snapshot file.
|
|
474
|
+
session: {
|
|
475
|
+
file,
|
|
476
|
+
electron: electronStatus,
|
|
477
|
+
saved: snapshot === null ? null : {
|
|
478
|
+
cookies: snapshot.cookies.length,
|
|
479
|
+
storage: snapshot.storage.length,
|
|
480
|
+
savedAt: snapshot.savedAt,
|
|
481
|
+
},
|
|
482
|
+
},
|
|
272
483
|
})
|
|
273
484
|
},
|
|
274
485
|
}), 'dsh-webchat: state route')
|
|
@@ -286,4 +497,94 @@ export function apply(ctx) {
|
|
|
286
497
|
json(res, result.ok ? 200 : 502, result)
|
|
287
498
|
},
|
|
288
499
|
}), 'dsh-webchat: open route')
|
|
500
|
+
|
|
501
|
+
// The guest's session lives in a process-lifetime partition, so the browser
|
|
502
|
+
// half asks this side to put the saved cookies back before it navigates…
|
|
503
|
+
ctx.effect(() => ctx.webServer.register({
|
|
504
|
+
kind: 'exact',
|
|
505
|
+
path: ROUTES.restore,
|
|
506
|
+
handler: async (req, res) => {
|
|
507
|
+
if (req.method !== 'POST') {
|
|
508
|
+
res.writeHead(405, { allow: 'POST' })
|
|
509
|
+
res.end()
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
const body = await readJsonBody(req)
|
|
513
|
+
const partition = body !== null && typeof body.partition === 'string' ? body.partition : ''
|
|
514
|
+
// Validate before anything else: this half must never touch a session the
|
|
515
|
+
// shell did not hand out for a browser guest.
|
|
516
|
+
if (!GUEST_PARTITION.test(partition)) {
|
|
517
|
+
json(res, 400, { ok: false, error: 'dsh-webchat: not a browser-guest partition' })
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
const snapshot = readSnapshot(SESSION_FILE())
|
|
521
|
+
if (snapshot === null) {
|
|
522
|
+
json(res, 200, { ok: true, cookies: 0, storage: null, cookie: '', savedAt: '' })
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
let restored = 0
|
|
526
|
+
let error = ''
|
|
527
|
+
try {
|
|
528
|
+
const session = await partitionSession(partition)
|
|
529
|
+
restored = await restoreCookies(session.cookies, snapshot)
|
|
530
|
+
} catch (failure) {
|
|
531
|
+
// Without a session API the HttpOnly cookies cannot come back, but the
|
|
532
|
+
// guest still gets its storage and its own cookie string, so this is
|
|
533
|
+
// reported rather than fatal.
|
|
534
|
+
error = messageOf(failure)
|
|
535
|
+
}
|
|
536
|
+
json(res, 200, {
|
|
537
|
+
ok: error === '',
|
|
538
|
+
error,
|
|
539
|
+
cookies: restored,
|
|
540
|
+
storage: snapshot.storage,
|
|
541
|
+
cookie: snapshot.cookie,
|
|
542
|
+
savedAt: snapshot.savedAt,
|
|
543
|
+
})
|
|
544
|
+
},
|
|
545
|
+
}), 'dsh-webchat: session restore route')
|
|
546
|
+
|
|
547
|
+
// …and asks it to refresh that snapshot as the page is used.
|
|
548
|
+
ctx.effect(() => ctx.webServer.register({
|
|
549
|
+
kind: 'exact',
|
|
550
|
+
path: ROUTES.save,
|
|
551
|
+
handler: async (req, res) => {
|
|
552
|
+
if (req.method !== 'POST') {
|
|
553
|
+
res.writeHead(405, { allow: 'POST' })
|
|
554
|
+
res.end()
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
const body = await readJsonBody(req)
|
|
558
|
+
const partition = body !== null && typeof body.partition === 'string' ? body.partition : ''
|
|
559
|
+
if (!GUEST_PARTITION.test(partition)) {
|
|
560
|
+
json(res, 400, { ok: false, error: 'dsh-webchat: not a browser-guest partition' })
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
const file = SESSION_FILE()
|
|
564
|
+
const previous = readSnapshot(file)
|
|
565
|
+
// A save without site storage (the unload beacon) must not erase what an
|
|
566
|
+
// earlier save captured.
|
|
567
|
+
const storage = body !== null && Array.isArray(body.storage)
|
|
568
|
+
? body.storage
|
|
569
|
+
: (previous === null ? [] : previous.storage)
|
|
570
|
+
const cookie = body !== null && typeof body.cookie === 'string'
|
|
571
|
+
? body.cookie
|
|
572
|
+
: (previous === null ? '' : previous.cookie)
|
|
573
|
+
// The snapshot is written even when this process cannot reach Electron:
|
|
574
|
+
// site storage and the page's own cookie string are carried by the guest
|
|
575
|
+
// and are still worth keeping.
|
|
576
|
+
let cookies = []
|
|
577
|
+
let error = ''
|
|
578
|
+
try {
|
|
579
|
+
const session = await partitionSession(partition)
|
|
580
|
+
cookies = await captureCookies(session.cookies)
|
|
581
|
+
} catch (failure) {
|
|
582
|
+
error = messageOf(failure)
|
|
583
|
+
}
|
|
584
|
+
writeSnapshot(file, { version: 1, savedAt: new Date().toISOString(), cookies, storage, cookie })
|
|
585
|
+
json(res, 200, { ok: true, cookies: cookies.length, storage: storage.length, cookie: cookie !== '', error })
|
|
586
|
+
},
|
|
587
|
+
}), 'dsh-webchat: session save route')
|
|
588
|
+
|
|
589
|
+
void electronProbe()
|
|
289
590
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jaychang1989/dsh-webchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|