@jaychang1989/dsh-webchat 0.5.2 → 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 +20 -8
- package/README.md +20 -8
- package/lib/client.js +407 -382
- package/lib/index.js +299 -29
- package/package.json +6 -2
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,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
|
|
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
|
|
287
|
+
* @throws with every probe failure joined, when neither path yields Electron.
|
|
107
288
|
*/
|
|
108
289
|
async function electronApi() {
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|
|
@@ -48,7 +48,11 @@
|
|
|
48
48
|
"patch": "./cordis.patch.yml"
|
|
49
49
|
},
|
|
50
50
|
"client": {
|
|
51
|
-
"platform": "web"
|
|
51
|
+
"platform": "web",
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-ui-layout",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
55
|
+
]
|
|
52
56
|
}
|
|
53
57
|
},
|
|
54
58
|
"files": [
|