@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/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.5.2",
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": [