@tianyulinfeng1/dsh-plugin-deepseek-chat 1.1.1 → 1.1.2

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/client.js CHANGED
@@ -1,942 +1,948 @@
1
- /**
2
- * Browser half of dsh-plugin-deepseek-chat (v1.1.1).
3
- *
4
- * Registers one global left-sidebar panel — the same mechanism the Plugins and
5
- * Scheduled tasks entries use:
6
- * `sidebar.panellist` (list) contributes the icon row, and the root `main`
7
- * keyed slot with the same key renders the panel page.
8
- *
9
- * The page hosts chat.deepseek.com in an Electron `<webview>`. The site sends
10
- * `content-security-policy: frame-ancestors 'none'`, so an iframe cannot carry
11
- * it; the desktop shell exposes `globalThis.dshDesktop.browser`, the same guest
12
- * lease bridge the built-in sidebar browser uses: acquire a lease → create a
13
- * webview named by the lease with its approved partition → load the URL.
14
- *
15
- * Stable login session (v1.1.1)
16
- * -----------------------------
17
- * The desktop shell caches ONE process-lifetime session per `workspace` string
18
- * passed to acquire(), and release() destroys only the guest, never the
19
- * partition's cookies. So the workspace MUST be an absolute constant:
20
- * `panel:deepseek-chat`. v1.1.0 keyed it to an async account probe, which
21
- * transiently fails during reconnects — every change handed the panel a fresh
22
- * cookie jar and forced another QR scan. That regression is fixed here.
23
- *
24
- * Account awareness (display only)
25
- * --------------------------------
26
- * DSH's own sign-in is a platform.deepseek.com OAuth grant; the chat site uses
27
- * a different, site-owned Token. There is no official exchange, and the
28
- * renderer bridge exposes no token/cookie API, so a pure plugin cannot reuse
29
- * the DSH token for the web chat (see docs/login-persistence.md §6). All this
30
- * file does with the account is DISPLAY it:
31
- *
32
- * - a soft probe reads the Client `remote.account` namespace
33
- * (`getState`/`getProfile`, generated `{ ok, value }` envelopes) and shows
34
- * the state/name in a small strip;
35
- * - the account state NEVER feeds the lease identity, the storage key, an
36
- * epoch, or the guest lifecycle — no rebuild, no partition change, ever;
37
- * - a transient `unknown` is ignored; only a confirmed `signed-out` updates
38
- * the strip; a missing/late Remote never blocks the guest from starting.
39
- *
40
- * Failure containment is still the hard rule of this file. DSH treats any
41
- * uncaught error thrown by a plugin as a crash of its slot entry, and a crash
42
- * inside the `root` slot unmounts the entire application UI. Electron throws
43
- * from webview methods such as `getURL()` / `loadURL()` as soon as the element
44
- * leaves the document, and React runs effect cleanups after the DOM removal —
45
- * so every guest call is attached-checked and wrapped, every event handler
46
- * catches, a React error boundary keeps a render failure inside this panel, and
47
- * a failure removes the dead guest before showing the in-panel Retry card.
48
- */
49
- window.__ModuleLoader__.load({
50
- id: "dsh-plugin-deepseek-chat",
51
- factory: (require) => {
52
- var module = { exports: {} };
53
- var exports = module.exports;
54
- Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
55
- const react = require("react");
56
-
57
- const PANEL_ID = "deepseek-chat";
58
- /**
59
- * DSH runtime version sent as `x-client-version` on Platform account
60
- * reads. The boot manifest carries the exact connected release; the
61
- * minimum supported release is the fallback for a page without it.
62
- */
63
- const HOST_VERSION_FALLBACK = "0.2.2-rc.1";
64
- const CHAT_URL = "https://chat.deepseek.com/";
65
- /**
66
- * Constant storage identities for the guest partition and the
67
- * last-conversation memory. The main process keeps one partition per
68
- * `workspace` string for the process lifetime; both values MUST NOT
69
- * depend on account state, or a transient probe failure would swap the
70
- * cookie jar and force a fresh QR scan. v1.1.1 regression fix.
71
- */
72
- const PARTITION_BASE = "panel:deepseek-chat";
73
- const STORAGE_BASE = "dsh-plugin-deepseek-chat:last-url";
74
- /** Paths that are not a conversation: a brand-new chat, or an auth page. */
75
- const NON_CONVERSATION_PATHS = new Set(["/", "/sign_in", "/login", "/signup", "/auth"]);
76
-
77
- const english = typeof navigator !== "undefined" && /^en\b/i.test(navigator.language ?? "");
78
- const COPY = english ? {
79
- label: "DeepSeek Chat",
80
- loading: "Loading DeepSeek Chat…",
81
- unsupported: "The embedded chat needs the desktop app.",
82
- failed: "The chat page could not be opened",
83
- crashed: "The embedded chat stopped responding",
84
- timeout: "The embedded chat did not start",
85
- retry: "Retry",
86
- probing: "Syncing the DSH account…",
87
- synced: "Synced with the DSH account",
88
- syncedAs: (name) => `Synced with the DSH account · ${name}`,
89
- unsigned: "No DSH account signed in — the chat panel is not synced",
90
- unknown: "Account state unavailable — conversation kept locally"
91
- } : {
92
- label: "DeepSeek 聊天",
93
- loading: "正在加载 DeepSeek 聊天…",
94
- unsupported: "内嵌聊天窗口需要桌面版 App。",
95
- failed: "聊天页面打开失败",
96
- crashed: "内嵌聊天进程已退出",
97
- timeout: "内嵌聊天页面没有启动",
98
- retry: "重试",
99
- probing: "正在同步 DSH 登录账号…",
100
- synced: "已与 DSH 登录账号同步",
101
- syncedAs: (name) => `已与 DSH 登录账号同步 · ${name}`,
102
- unsigned: "未登录 DSH 账号,聊天面板暂未同步",
103
- unknown: "账号状态不可用,会话按本地模式保存"
104
- };
105
-
106
- /** Normalized account states the panel can distinguish. */
107
- const ACCOUNT_UNKNOWN = "unknown";
108
- const ACCOUNT_SIGNED_IN = "signed-in";
109
- const ACCOUNT_SIGNED_OUT = "signed-out";
110
-
111
- /**
112
- * ONE stable guest partition for the whole process.
113
- *
114
- * The desktop shell keys a process-lifetime partition Map by the
115
- * `workspace` string passed to acquire() and reuses the same session for
116
- * every acquire() with that key. release() destroys only the guest, never
117
- * the partition's cookies. Consequently, changing this value — even once
118
- * — hands the panel a fresh cookie jar and forces the user to scan the
119
- * chat login QR code again. It MUST NOT depend on the DSH account probe:
120
- * that probe is asynchronous, runs every 5 seconds, and can transiently
121
- * return "unknown" during a reconnect or session switch.
122
- * @returns the fixed workspace identity for guest leases.
123
- */
124
- function partitionKeyFor() {
125
- return PARTITION_BASE;
126
- }
127
-
128
- /**
129
- * ONE stable conversation-memory key. Account status changes must not
130
- * move it either, or a transient probe failure would silently lose the
131
- * remembered conversation.
132
- * @returns the fixed localStorage key.
133
- */
134
- function storageKeyFor() {
135
- return STORAGE_BASE;
136
- }
137
-
138
- /**
139
- * Parse a chat.deepseek.com page URL.
140
- * @param value - candidate URL.
141
- * @returns the parsed URL for the site origin, or undefined.
142
- */
143
- function parseChatUrl(value) {
144
- if (typeof value !== "string" || value.length === 0 || value.length > 4096) return void 0;
145
- try {
146
- const url = new URL(value);
147
- if (url.protocol !== "https:" || url.hostname !== "chat.deepseek.com") return void 0;
148
- if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
149
- return url;
150
- } catch {
151
- return void 0;
152
- }
153
- }
154
-
155
- /**
156
- * Destinations a guest popup may open. The chat site and the Platform
157
- * login pages are the only hosts this panel deliberately navigates to;
158
- * an arbitrary URL from the open-request bridge is never loaded.
159
- * @param value - candidate URL.
160
- * @returns the normalized URL, or undefined.
161
- */
162
- function parsePopupUrl(value) {
163
- if (typeof value !== "string" || value.length === 0 || value.length > 4096) return void 0;
164
- try {
165
- const url = new URL(value);
166
- if (url.protocol !== "https:") return void 0;
167
- if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
168
- if (url.hostname !== "chat.deepseek.com" && url.hostname !== "platform.deepseek.com") return void 0;
169
- return url.toString();
170
- } catch {
171
- return void 0;
172
- }
173
- }
174
-
175
- /**
176
- * Whether a URL deep enough to be a conversation page.
177
- * @param url - parsed chat URL, possibly undefined.
178
- * @returns true for a conversation, false for the new-chat root or an auth page.
179
- */
180
- function isConversation(url) {
181
- if (url === void 0) return false;
182
- const path = url.pathname.replace(/\/+$/u, "") || "/";
183
- return !NON_CONVERSATION_PATHS.has(path);
184
- }
185
-
186
- /**
187
- * Read the conversation remembered from the last visit for one account.
188
- * @param storageKey - account-scoped storage key.
189
- * @returns the URL to open; the site root when nothing usable is remembered.
190
- */
191
- function rememberedTarget(storageKey) {
192
- try {
193
- const saved = parseChatUrl(window.localStorage.getItem(storageKey));
194
- return isConversation(saved) ? saved.toString() : CHAT_URL;
195
- } catch {
196
- return CHAT_URL;
197
- }
198
- }
199
-
200
- /**
201
- * Remember where the guest currently is.
202
- *
203
- * A brand-new chat clears the memory so the next visit starts clean. An auth
204
- * page never overwrites it, so a login detour does not lose the conversation.
205
- * @param raw - current page URL reported by the guest.
206
- * @param storageKey - account-scoped storage key.
207
- */
208
- function rememberTarget(raw, storageKey) {
209
- const url = parseChatUrl(raw);
210
- if (url === void 0) return;
211
- try {
212
- const path = url.pathname.replace(/\/+$/u, "") || "/";
213
- if (isConversation(url)) window.localStorage.setItem(storageKey, url.toString());
214
- else if (path === "/") window.localStorage.removeItem(storageKey);
215
- } catch {
216
- /* storage unavailable: the panel still works, it just forgets */
217
- }
218
- }
219
-
220
- // ── account sync store ────────────────────────────────────────────────
221
- /**
222
- * One module-level snapshot shared by every mounted panel. The account
223
- * Remote is display/awareness only; it never feeds the lease identity or
224
- * the guest lifecycle, so a flaky probe can never recreate the webview.
225
- */
226
- let accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
227
- const accountListeners = new Set();
228
-
229
- function notifyAccount() {
230
- for (const listener of [...accountListeners]) {
231
- try {
232
- listener(accountSnapshot);
233
- } catch {
234
- /* a dead panel must not break another one */
235
- }
236
- }
237
- }
238
-
239
- /** Subscribe a mounted panel; returns its disposer. */
240
- function subscribeAccount(listener) {
241
- accountListeners.add(listener);
242
- return () => {
243
- accountListeners.delete(listener);
244
- };
245
- }
246
-
247
- /**
248
- * Publish a probed account state. This ONLY updates the account strip:
249
- * it never bumps an epoch and never rebuilds the guest. A transient
250
- * "unknown" during reconnect is deliberately ignored so the displayed
251
- * state does not flicker; a confirmed sign-out is shown immediately.
252
- * @param status - normalized account state.
253
- */
254
- function publishAccount(status) {
255
- const previous = accountSnapshot;
256
- // Ignore a transient unknown once we know a real state: it is usually
257
- // a reconnect, not a sign-out, and must not disturb the panel.
258
- const settled = status === ACCOUNT_UNKNOWN && previous.probed && previous.status !== ACCOUNT_UNKNOWN
259
- ? previous.status
260
- : status;
261
- accountSnapshot = {
262
- status: settled,
263
- // Preserve the name through a transient unknown; clear it only on a
264
- // confirmed sign-out when the display explicitly publishes "".
265
- label: settled === ACCOUNT_SIGNED_OUT ? "" : previous.label,
266
- probed: true
267
- };
268
- notifyAccount();
269
- }
270
-
271
- /** Publish a display name; a stale result from an older probe is dropped. */
272
- function publishAccountLabel(label) {
273
- if (accountSnapshot.label === label) return;
274
- accountSnapshot = { ...accountSnapshot, label };
275
- notifyAccount();
276
- }
277
-
278
- /**
279
- * Read one generated `ctx.remote.account` method. Generated Remotes answer
280
- * with an `{ ok, value, error }` envelope, so a bare payload is never
281
- * assumed; an absent namespace or a refused call degrades to undefined.
282
- * @param ctx - client plugin context.
283
- * @param method - generated account method name.
284
- * @param args - method arguments.
285
- * @returns the envelope's `value`, or undefined when unavailable.
286
- */
287
- async function callAccount(ctx, method, args = []) {
288
- let remote;
289
- try {
290
- remote = typeof ctx.get === "function" ? ctx.get("remote") : ctx.remote;
291
- } catch {
292
- remote = void 0;
293
- }
294
- const account = remote?.account;
295
- const call = account?.[method];
296
- if (typeof call !== "function") return void 0;
297
- const result = await call.apply(account, args);
298
- if (result === null || typeof result !== "object" || result.ok !== true) return void 0;
299
- return result.value;
300
- }
301
-
302
- /** Platform client identity headers, read at call time as the UI does. */
303
- function accountClient(ctx) {
304
- return {
305
- version: globalThis.__DSH_BOOT__?.release?.version || HOST_VERSION_FALLBACK,
306
- locale: typeof navigator !== "undefined" ? navigator.language || "en-US" : "en-US",
307
- timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60
308
- };
309
- }
310
-
311
- /**
312
- * Read the live DSH account state and display name. This is awareness
313
- * ONLY: no value here may ever feed the guest lease or rebuild the
314
- * webview, or a transient Remote failure would drop the chat session.
315
- * @param ctx - client plugin context.
316
- * @returns `{ status, label }`.
317
- */
318
- async function probeAccount(ctx) {
319
- let state;
320
- try {
321
- state = await callAccount(ctx, "getState");
322
- } catch {
323
- state = void 0;
324
- }
325
- if (state?.status !== "credential-stored") {
326
- return {
327
- status: state?.status === "signed-out" ? ACCOUNT_SIGNED_OUT : ACCOUNT_UNKNOWN,
328
- label: ""
329
- };
330
- }
331
- const probe = { status: ACCOUNT_SIGNED_IN, label: "" };
332
- try {
333
- const outcome = await callAccount(ctx, "getProfile", [accountClient(ctx)]);
334
- const value = outcome?.status === "ready" ? outcome.value : void 0;
335
- probe.label = [value?.name, value?.contact, value?.id]
336
- .find((item) => typeof item === "string" && item.length > 0) ?? "";
337
- } catch {
338
- /* profile is display-only: failure keeps the bare state */
339
- }
340
- return probe;
341
- }
342
-
343
- /**
344
- * Start the account watcher once per plugin activation. A 5-second poll
345
- * plus `connection/reset` gives self-heal without depending on the exact
346
- * Client shape of the account `watch` stream, and every resource is
347
- * released through the plugin's own ctx effect.
348
- * @param ctx - client plugin context.
349
- */
350
- let accountSyncStarted = false;
351
- function startAccountSync(ctx) {
352
- if (accountSyncStarted) return;
353
- accountSyncStarted = true;
354
- let stopped = false;
355
- let timer;
356
-
357
- const refresh = async () => {
358
- if (stopped) return;
359
- let probe = { status: ACCOUNT_UNKNOWN, label: "" };
360
- try {
361
- probe = await probeAccount(ctx);
362
- } catch {
363
- /* Remote unavailable: publishAccount ignores a transient unknown */
364
- probe = { status: ACCOUNT_UNKNOWN, label: "" };
365
- }
366
- if (stopped) return;
367
- publishAccount(probe.status);
368
- if (probe.label !== "") publishAccountLabel(probe.label);
369
- };
370
-
371
- const schedule = () => {
372
- if (stopped) return;
373
- timer = setTimeout(() => {
374
- void refresh().finally(schedule);
375
- }, 5000);
376
- // A long-lived page keeps the poll; a minimal context (or a Node
377
- // self-check) must not be held open by its timer alone.
378
- try {
379
- if (typeof timer.unref === "function") timer.unref();
380
- } catch {
381
- /* not a Node timer */
382
- }
383
- };
384
-
385
- void refresh().finally(schedule);
386
-
387
- let offReset;
388
- try {
389
- if (typeof ctx.on === "function") offReset = ctx.on("connection/reset", () => {
390
- void refresh();
391
- });
392
- } catch {
393
- /* optional: the poll still self-heals */
394
- }
395
-
396
- let hasDisposal = false;
397
- try {
398
- if (typeof ctx.effect === "function") {
399
- ctx.effect(() => () => {
400
- stopped = true;
401
- if (timer !== void 0) clearTimeout(timer);
402
- try {
403
- if (typeof offReset === "function") offReset();
404
- } catch {
405
- /* already detached */
406
- }
407
- accountListeners.clear();
408
- accountSyncStarted = false;
409
- }, "deepseek-chat: account sync");
410
- hasDisposal = true;
411
- }
412
- } catch {
413
- /* fall through to the no-disposal path */
414
- }
415
- if (!hasDisposal) {
416
- stopped = true;
417
- if (timer !== void 0) clearTimeout(timer);
418
- try {
419
- if (typeof offReset === "function") offReset();
420
- } catch {
421
- /* already detached */
422
- }
423
- // Without a real ctx effect there is no teardown to stop the poll;
424
- // keep the first probe but never leave a repeating timer behind.
425
- accountSyncStarted = false;
426
- }
427
- }
428
-
429
- // ── guest helpers ─────────────────────────────────────────────────────
430
- /**
431
- * Whether the guest element is still in the document. Electron's webview
432
- * methods throw once it is detached, so this gates every guest call.
433
- * @param element - webview element.
434
- * @returns true while the element can still host a guest.
435
- */
436
- function attached(element) {
437
- try {
438
- return element.isConnected === true;
439
- } catch {
440
- return false;
441
- }
442
- }
443
-
444
- /** Guest URL, or undefined when the element cannot report one. */
445
- function readUrl(element) {
446
- if (!attached(element)) return void 0;
447
- try {
448
- const url = element.getURL();
449
- return typeof url === "string" && url.length > 0 ? url : void 0;
450
- } catch {
451
- return void 0;
452
- }
453
- }
454
-
455
- /**
456
- * Navigate the guest without ever throwing.
457
- * @returns false when the guest is gone, so the caller can report it.
458
- */
459
- function navigate(element, url) {
460
- if (!attached(element)) return false;
461
- try {
462
- const pending = element.loadURL(url);
463
- if (pending !== void 0 && pending !== null && typeof pending.catch === "function") pending.catch(() => {});
464
- return true;
465
- } catch {
466
- return false;
467
- }
468
- }
469
-
470
- /**
471
- * Panel-list glyph, drawn on currentColor so the sidebar's active state owns
472
- * the ink. Matches the sidebar's 16/18px icon seat.
473
- */
474
- function ChatGlyph({ size = 18 }) {
475
- return react.createElement("svg", {
476
- width: size,
477
- height: size,
478
- viewBox: "0 0 24 24",
479
- fill: "none",
480
- "aria-hidden": "true",
481
- focusable: "false"
482
- }, react.createElement("path", {
483
- d: "M4.5 5.75C4.5 4.784 5.284 4 6.25 4h11.5c.966 0 1.75.784 1.75 1.75v8.5c0 .966-.784 1.75-1.75 1.75H9.56l-3.34 2.784c-.497.414-1.22.06-1.22-.586V5.75Z",
484
- stroke: "currentColor",
485
- strokeWidth: 1.3,
486
- strokeLinejoin: "round"
487
- }), react.createElement("path", {
488
- d: "M8.5 9.25h7M8.5 12.25h4.5",
489
- stroke: "currentColor",
490
- strokeWidth: 1.3,
491
- strokeLinecap: "round"
492
- }));
493
- }
494
-
495
- const styles = {
496
- root: {
497
- display: "flex",
498
- flexDirection: "column",
499
- height: "100%",
500
- minHeight: "0",
501
- background: "var(--dsw-alias-bg-base)",
502
- color: "var(--dsw-alias-label-primary)"
503
- },
504
- accountBar: {
505
- flex: "none",
506
- display: "flex",
507
- alignItems: "center",
508
- gap: "6px",
509
- minHeight: "24px",
510
- padding: "3px 10px",
511
- borderBottom: ".5px solid var(--dsw-alias-border-l3)",
512
- color: "var(--dsw-alias-label-tertiary)",
513
- font: "var(--dsw-font-xxxs-11)"
514
- },
515
- accountDot: {
516
- width: "6px",
517
- height: "6px",
518
- borderRadius: "50%",
519
- background: "currentColor",
520
- flex: "none"
521
- },
522
- host: {
523
- position: "relative",
524
- flex: "1",
525
- minHeight: "0",
526
- display: "flex"
527
- },
528
- guest: {
529
- width: "100%",
530
- height: "100%",
531
- border: "0",
532
- background: "transparent",
533
- display: "flex"
534
- },
535
- notice: {
536
- position: "absolute",
537
- inset: "0",
538
- display: "flex",
539
- flexDirection: "column",
540
- alignItems: "center",
541
- justifyContent: "center",
542
- gap: "10px",
543
- padding: "24px",
544
- textAlign: "center",
545
- color: "var(--dsw-alias-label-tertiary)",
546
- font: "var(--dsw-font-xxs-12)",
547
- background: "var(--dsw-alias-bg-base)"
548
- },
549
- button: {
550
- color: "var(--dsw-alias-label-primary)",
551
- background: "var(--dsw-alias-bg-layer-1)",
552
- border: ".5px solid var(--dsw-alias-border-l3)",
553
- borderRadius: "var(--dsw-radius-sm)",
554
- padding: "4px 12px",
555
- cursor: "pointer",
556
- font: "var(--dsw-font-xxs-12)"
557
- }
558
- };
559
-
560
- /** Copy for the account strip; the dot's opacity encodes the state. */
561
- function accountCopy(account) {
562
- if (account.status === ACCOUNT_SIGNED_IN) return {
563
- text: account.label ? COPY.syncedAs(account.label) : COPY.synced,
564
- opacity: "1"
565
- };
566
- if (account.status === ACCOUNT_SIGNED_OUT) return { text: COPY.unsigned, opacity: ".45" };
567
- if (!account.probed) return { text: COPY.probing, opacity: ".3" };
568
- return { text: COPY.unknown, opacity: ".3" };
569
- }
570
-
571
- /**
572
- * Keeps a rendering failure inside this panel. DSH has no boundary of its own
573
- * around slot entries: an escaping error unmounts the root slot, which is the
574
- * whole application UI.
575
- */
576
- class PanelBoundary extends react.Component {
577
- constructor(props) {
578
- super(props);
579
- this.state = { failure: void 0 };
580
- }
581
-
582
- static getDerivedStateFromError(error) {
583
- return { failure: String((error && error.message) || error) };
584
- }
585
-
586
- componentDidCatch(error) {
587
- try {
588
- console.error("dsh-plugin-deepseek-chat: panel render failed", error);
589
- } catch {
590
- /* console unavailable */
591
- }
592
- }
593
-
594
- render() {
595
- if (this.state.failure !== void 0) {
596
- return react.createElement("div", { style: styles.root },
597
- react.createElement("div", { style: styles.notice },
598
- react.createElement("div", null, `${COPY.failed}: ${this.state.failure}`)));
599
- }
600
- return this.props.children ?? null;
601
- }
602
- }
603
-
604
- /**
605
- * The global chat panel: an account strip and a guest webview filling the
606
- * main column.
607
- *
608
- * The guest is created only after the lease resolves, and it is mounted with
609
- * `name`/`partition`/`src` already set, because the main process inspects
610
- * exactly that bootstrap src when it approves the attachment. The identity
611
- * and the conversation memory are both scoped to the current DSH account;
612
- * an account transition bumps `epoch`, which tears down the old guest and
613
- * re-acquires a lease for the new identity.
614
- */
615
- function ChatPanel() {
616
- const hostRef = react.useRef(null);
617
- const lastUrlRef = react.useRef(void 0);
618
- const [account, setAccount] = react.useState(accountSnapshot);
619
- const [failure, setFailure] = react.useState(void 0);
620
- const [loading, setLoading] = react.useState(true);
621
- const [attempt, setAttempt] = react.useState(0);
622
-
623
- react.useEffect(() => subscribeAccount((next) => {
624
- try {
625
- setAccount(next);
626
- } catch {
627
- /* the panel is gone */
628
- }
629
- }), []);
630
-
631
- const retry = react.useCallback(() => {
632
- try {
633
- setFailure(void 0);
634
- setLoading(true);
635
- setAttempt((value) => value + 1);
636
- } catch {
637
- /* the panel is gone */
638
- }
639
- }, []);
640
-
641
- react.useEffect(() => {
642
- const host = hostRef.current;
643
- const bridge = globalThis.dshDesktop?.browser;
644
- if (host === null) return;
645
- if (bridge === void 0) {
646
- try {
647
- setFailure(COPY.unsupported);
648
- setLoading(false);
649
- } catch {
650
- /* the panel is gone */
651
- }
652
- return;
653
- }
654
-
655
- // Both identities are constants. The desktop shell caches one
656
- // session per workspace string; keeping them fixed is what makes
657
- // switching panels/sessions preserve the chat login.
658
- const storageKey = storageKeyFor();
659
- const partitionKey = partitionKeyFor();
660
- let disposed = false;
661
- let failed = false;
662
- let unsubscribeOpen;
663
- let lease;
664
- let watchdog;
665
- const element = document.createElement("webview");
666
- const initialTarget = rememberedTarget(storageKey);
667
- let restoring = initialTarget !== CHAT_URL;
668
-
669
- Object.assign(element.style, styles.guest);
670
- element.setAttribute("allowpopups", "");
671
-
672
- /**
673
- * Report a guest failure inside the panel; never throw. The dead
674
- * guest element is removed immediately so a Retry cannot stack a
675
- * second webview, and later events are suppressed. The lease is
676
- * still released by the effect cleanup, exactly once.
677
- */
678
- const fail = (error) => {
679
- if (disposed || failed) return;
680
- failed = true;
681
- try {
682
- element.remove();
683
- } catch {
684
- /* already detached */
685
- }
686
- if (watchdog !== void 0) {
687
- clearTimeout(watchdog);
688
- watchdog = void 0;
689
- }
690
- try {
691
- setLoading(false);
692
- setFailure(String((error && error.message) || error));
693
- } catch {
694
- /* the panel is gone */
695
- }
696
- };
697
-
698
- /** Remember the last document the guest reported. */
699
- const remember = (raw) => {
700
- if (raw === void 0) return;
701
- lastUrlRef.current = raw;
702
- rememberTarget(raw, storageKey);
703
- };
704
-
705
- const onDomReady = () => {
706
- if (disposed || failed) return;
707
- if (watchdog !== void 0) {
708
- clearTimeout(watchdog);
709
- watchdog = void 0;
710
- }
711
- if (!navigate(element, initialTarget)) fail(new Error(COPY.crashed));
712
- else remember(initialTarget);
713
- };
714
- const onStopLoading = () => {
715
- if (disposed || failed) return;
716
- const raw = readUrl(element);
717
- if (raw === void 0) return;
718
- remember(raw);
719
- restoring = false;
720
- try {
721
- setLoading(false);
722
- } catch {
723
- /* the panel is gone */
724
- }
725
- };
726
- const onNavigate = () => {
727
- if (disposed || failed) return;
728
- remember(readUrl(element));
729
- };
730
- const onNavigateInPage = (event) => {
731
- if (disposed || failed || event?.isMainFrame !== true) return;
732
- onNavigate();
733
- };
734
- const onFailLoad = (event) => {
735
- if (disposed || failed || event?.isMainFrame !== true) return;
736
- // -3 is ERR_ABORTED: a superseded navigation, not a failure.
737
- if (event.errorCode === -3) return;
738
- if (restoring) {
739
- // The remembered conversation is gone or unreachable; start clean.
740
- restoring = false;
741
- if (!navigate(element, CHAT_URL)) fail(new Error(COPY.crashed));
742
- else remember(CHAT_URL);
743
- return;
744
- }
745
- fail(`${event.errorDescription ?? event.errorCode}`);
746
- };
747
- /** The guest process exited or the guest was torn down underneath us. */
748
- const onGuestGone = () => {
749
- if (disposed || failed) return;
750
- fail(new Error(COPY.crashed));
751
- };
752
-
753
- const listeners = [
754
- ["dom-ready", onDomReady],
755
- ["did-stop-loading", onStopLoading],
756
- ["did-navigate", onNavigate],
757
- ["did-navigate-in-page", onNavigateInPage],
758
- ["did-fail-load", onFailLoad],
759
- ["render-process-gone", onGuestGone],
760
- ["destroyed", onGuestGone]
761
- ];
762
-
763
- // A listener that cannot be attached leaves a guest nobody can
764
- // drive; abort the start instead of mounting a silent webview.
765
- let listenersReady = false;
766
- try {
767
- for (const [name, handler] of listeners) {
768
- element.addEventListener(name, handler, name === "dom-ready" ? { once: true } : void 0);
769
- }
770
- listenersReady = true;
771
- } catch (error) {
772
- fail(error);
773
- }
774
-
775
- const start = async () => {
776
- try {
777
- const reservation = await bridge.acquire(partitionKey);
778
- if (disposed || failed) {
779
- try {
780
- Promise.resolve(bridge.release(reservation.lease)).catch(() => {});
781
- } catch {
782
- /* lease already gone */
783
- }
784
- return;
785
- }
786
- lease = reservation.lease;
787
- element.setAttribute("name", reservation.lease);
788
- element.setAttribute("partition", reservation.partition);
789
- element.setAttribute("src", `about:blank#${reservation.lease}`);
790
- try {
791
- unsubscribeOpen = bridge.onOpenRequested(reservation.lease, (raw) => {
792
- if (disposed || failed || typeof raw !== "string") return;
793
- const target = parsePopupUrl(raw);
794
- if (target !== void 0) navigate(element, target);
795
- });
796
- } catch {
797
- /* popup bridge unavailable: the guest still works */
798
- }
799
- host.append(element);
800
- watchdog = setTimeout(() => {
801
- watchdog = void 0;
802
- if (disposed || failed) return;
803
- if (readUrl(element) === void 0) fail(new Error(COPY.timeout));
804
- }, 15000);
805
- } catch (error) {
806
- fail(error);
807
- }
808
- };
809
- if (listenersReady) void start();
810
-
811
- return () => {
812
- disposed = true;
813
- // Read before removal: React cleans up after taking the DOM away, and
814
- // Electron's getURL() throws on a detached webview.
815
- remember(readUrl(element) ?? lastUrlRef.current);
816
- if (watchdog !== void 0) {
817
- clearTimeout(watchdog);
818
- watchdog = void 0;
819
- }
820
- try {
821
- if (typeof unsubscribeOpen === "function") unsubscribeOpen();
822
- } catch {
823
- /* already detached */
824
- }
825
- for (const [name, handler] of listeners) {
826
- try {
827
- element.removeEventListener(name, handler);
828
- } catch {
829
- /* already detached */
830
- }
831
- }
832
- try {
833
- element.remove();
834
- } catch {
835
- /* already detached */
836
- }
837
- const released = lease;
838
- lease = void 0;
839
- if (released !== void 0) {
840
- try {
841
- Promise.resolve(bridge.release(released)).catch(() => {});
842
- } catch {
843
- /* lease already gone */
844
- }
845
- }
846
- };
847
- }, [attempt]);
848
-
849
- const copy = accountCopy(account);
850
-
851
- return react.createElement("div", { style: styles.root },
852
- react.createElement("div", { style: styles.accountBar },
853
- react.createElement("span", { style: { ...styles.accountDot, opacity: copy.opacity }, "aria-hidden": "true" }),
854
- react.createElement("span", null, copy.text)),
855
- react.createElement("div", { style: styles.host, ref: hostRef },
856
- failure === void 0 && loading
857
- ? react.createElement("div", { style: styles.notice }, COPY.loading)
858
- : null,
859
- failure === void 0
860
- ? null
861
- : react.createElement("div", { style: styles.notice },
862
- react.createElement("div", null, `${COPY.failed}: ${failure}`),
863
- react.createElement("button", { type: "button", style: styles.button, onClick: retry }, COPY.retry))));
864
- }
865
-
866
- /** The registered panel: the boundary owns every rendering failure below it. */
867
- function ChatPanelShell() {
868
- return react.createElement(PanelBoundary, null, react.createElement(ChatPanel));
869
- }
870
-
871
- /** Client service this plugin reads; the loader waits for the slot registry. */
872
- const inject = ["slots"];
873
-
874
- /**
875
- * Register the left-sidebar entry and its main panel.
876
- * @param ctx - client plugin context.
877
- */
878
- function apply(ctx) {
879
- try {
880
- // The panel needs the desktop guest bridge; on the Web surface an iframe
881
- // cannot carry the site, so the plugin stays inert there.
882
- if (globalThis.dshDesktop?.browser === void 0) return;
883
- } catch {
884
- return;
885
- }
886
-
887
- try {
888
- startAccountSync(ctx);
889
- } catch (error) {
890
- try {
891
- console.error("dsh-plugin-deepseek-chat: account sync failed", error);
892
- } catch {
893
- /* console unavailable */
894
- }
895
- }
896
-
897
- try {
898
- ctx.slots.inject("main", () => ctx.slots.register({
899
- name: "main",
900
- key: PANEL_ID
901
- }, ChatPanelShell));
902
-
903
- ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
904
- name: "sidebar.panellist",
905
- id: PANEL_ID,
906
- order: 5,
907
- label: () => COPY.label
908
- }, ChatGlyph));
909
- } catch (error) {
910
- try {
911
- console.error("dsh-plugin-deepseek-chat: registration failed", error);
912
- } catch {
913
- /* console unavailable */
914
- }
915
- }
916
- }
917
-
918
- exports.apply = apply;
919
- exports.inject = inject;
920
- exports.__test__ = {
921
- ACCOUNT_UNKNOWN,
922
- ACCOUNT_SIGNED_IN,
923
- ACCOUNT_SIGNED_OUT,
924
- CHAT_URL,
925
- STORAGE_BASE,
926
- accountCopy,
927
- getSnapshot: () => accountSnapshot,
928
- partitionKeyFor,
929
- storageKeyFor,
930
- publishAccount,
931
- publishAccountLabel,
932
- probeAccount,
933
- callAccount,
934
- resetAccountSync() {
935
- accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
936
- accountListeners.clear();
937
- accountSyncStarted = false;
938
- }
939
- };
940
- return module.exports;
941
- }
942
- });
1
+ /**
2
+ * Browser half of dsh-plugin-deepseek-chat (v1.1.1).
3
+ *
4
+ * Registers one global left-sidebar panel — the same mechanism the Plugins and
5
+ * Scheduled tasks entries use:
6
+ * `sidebar.panellist` (list) contributes the icon row, and the root `main`
7
+ * keyed slot with the same key renders the panel page.
8
+ *
9
+ * The page hosts chat.deepseek.com in an Electron `<webview>`. The site sends
10
+ * `content-security-policy: frame-ancestors 'none'`, so an iframe cannot carry
11
+ * it; the desktop shell exposes `globalThis.dshDesktop.browser`, the same guest
12
+ * lease bridge the built-in sidebar browser uses: acquire a lease → create a
13
+ * webview named by the lease with its approved partition → load the URL.
14
+ *
15
+ * Stable login session (v1.1.1)
16
+ * -----------------------------
17
+ * The desktop shell caches ONE process-lifetime session per `workspace` string
18
+ * passed to acquire(), and release() destroys only the guest, never the
19
+ * partition's cookies. So the workspace MUST be an absolute constant:
20
+ * `panel:deepseek-chat`. v1.1.0 keyed it to an async account probe, which
21
+ * transiently fails during reconnects — every change handed the panel a fresh
22
+ * cookie jar and forced another QR scan. That regression is fixed here.
23
+ *
24
+ * Account awareness (display only)
25
+ * --------------------------------
26
+ * DSH's own sign-in is a platform.deepseek.com OAuth grant; the chat site uses
27
+ * a different, site-owned Token. There is no official exchange, and the
28
+ * renderer bridge exposes no token/cookie API, so a pure plugin cannot reuse
29
+ * the DSH token for the web chat (see docs/login-persistence.md §6). All this
30
+ * file does with the account is DISPLAY it:
31
+ *
32
+ * - a soft probe reads the Client `remote.account` namespace
33
+ * (`getState`/`getProfile`, generated `{ ok, value }` envelopes) and shows
34
+ * the state/name in a small strip;
35
+ * - the account state NEVER feeds the lease identity, the storage key, an
36
+ * epoch, or the guest lifecycle — no rebuild, no partition change, ever;
37
+ * - a transient `unknown` is ignored; only a confirmed `signed-out` updates
38
+ * the strip; a missing/late Remote never blocks the guest from starting.
39
+ *
40
+ * Failure containment is still the hard rule of this file. DSH treats any
41
+ * uncaught error thrown by a plugin as a crash of its slot entry, and a crash
42
+ * inside the `root` slot unmounts the entire application UI. Electron throws
43
+ * from webview methods such as `getURL()` / `loadURL()` as soon as the element
44
+ * leaves the document, and React runs effect cleanups after the DOM removal —
45
+ * so every guest call is attached-checked and wrapped, every event handler
46
+ * catches, a React error boundary keeps a render failure inside this panel, and
47
+ * a failure removes the dead guest before showing the in-panel Retry card.
48
+ */
49
+ window.__ModuleLoader__.load({
50
+ // MUST equal package.json "name" (the scoped npm name). The Host keys every
51
+ // client graph row by the package name and, after executing a bundle script,
52
+ // checks `factories.has(<row id>)`. A mismatched id makes arrive() think the
53
+ // bundle registered nothing, re-execute the same script, and throw
54
+ // "duplicate factory registration ... (bundle executed twice without
55
+ // invalidate?)" during web boot. See docs/RELEASE_NOTES_v1.1.1.md.
56
+ id: "@tianyulinfeng1/dsh-plugin-deepseek-chat",
57
+ factory: (require) => {
58
+ var module = { exports: {} };
59
+ var exports = module.exports;
60
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
61
+ const react = require("react");
62
+
63
+ const PANEL_ID = "deepseek-chat";
64
+ /**
65
+ * DSH runtime version sent as `x-client-version` on Platform account
66
+ * reads. The boot manifest carries the exact connected release; the
67
+ * minimum supported release is the fallback for a page without it.
68
+ */
69
+ const HOST_VERSION_FALLBACK = "0.2.2-rc.1";
70
+ const CHAT_URL = "https://chat.deepseek.com/";
71
+ /**
72
+ * Constant storage identities for the guest partition and the
73
+ * last-conversation memory. The main process keeps one partition per
74
+ * `workspace` string for the process lifetime; both values MUST NOT
75
+ * depend on account state, or a transient probe failure would swap the
76
+ * cookie jar and force a fresh QR scan. v1.1.1 regression fix.
77
+ */
78
+ const PARTITION_BASE = "panel:deepseek-chat";
79
+ const STORAGE_BASE = "dsh-plugin-deepseek-chat:last-url";
80
+ /** Paths that are not a conversation: a brand-new chat, or an auth page. */
81
+ const NON_CONVERSATION_PATHS = new Set(["/", "/sign_in", "/login", "/signup", "/auth"]);
82
+
83
+ const english = typeof navigator !== "undefined" && /^en\b/i.test(navigator.language ?? "");
84
+ const COPY = english ? {
85
+ label: "DeepSeek Chat",
86
+ loading: "Loading DeepSeek Chat…",
87
+ unsupported: "The embedded chat needs the desktop app.",
88
+ failed: "The chat page could not be opened",
89
+ crashed: "The embedded chat stopped responding",
90
+ timeout: "The embedded chat did not start",
91
+ retry: "Retry",
92
+ probing: "Syncing the DSH account…",
93
+ synced: "Synced with the DSH account",
94
+ syncedAs: (name) => `Synced with the DSH account · ${name}`,
95
+ unsigned: "No DSH account signed in — the chat panel is not synced",
96
+ unknown: "Account state unavailable — conversation kept locally"
97
+ } : {
98
+ label: "DeepSeek 聊天",
99
+ loading: "正在加载 DeepSeek 聊天…",
100
+ unsupported: "内嵌聊天窗口需要桌面版 App。",
101
+ failed: "聊天页面打开失败",
102
+ crashed: "内嵌聊天进程已退出",
103
+ timeout: "内嵌聊天页面没有启动",
104
+ retry: "重试",
105
+ probing: "正在同步 DSH 登录账号…",
106
+ synced: "已与 DSH 登录账号同步",
107
+ syncedAs: (name) => `已与 DSH 登录账号同步 · ${name}`,
108
+ unsigned: "未登录 DSH 账号,聊天面板暂未同步",
109
+ unknown: "账号状态不可用,会话按本地模式保存"
110
+ };
111
+
112
+ /** Normalized account states the panel can distinguish. */
113
+ const ACCOUNT_UNKNOWN = "unknown";
114
+ const ACCOUNT_SIGNED_IN = "signed-in";
115
+ const ACCOUNT_SIGNED_OUT = "signed-out";
116
+
117
+ /**
118
+ * ONE stable guest partition for the whole process.
119
+ *
120
+ * The desktop shell keys a process-lifetime partition Map by the
121
+ * `workspace` string passed to acquire() and reuses the same session for
122
+ * every acquire() with that key. release() destroys only the guest, never
123
+ * the partition's cookies. Consequently, changing this value — even once
124
+ * — hands the panel a fresh cookie jar and forces the user to scan the
125
+ * chat login QR code again. It MUST NOT depend on the DSH account probe:
126
+ * that probe is asynchronous, runs every 5 seconds, and can transiently
127
+ * return "unknown" during a reconnect or session switch.
128
+ * @returns the fixed workspace identity for guest leases.
129
+ */
130
+ function partitionKeyFor() {
131
+ return PARTITION_BASE;
132
+ }
133
+
134
+ /**
135
+ * ONE stable conversation-memory key. Account status changes must not
136
+ * move it either, or a transient probe failure would silently lose the
137
+ * remembered conversation.
138
+ * @returns the fixed localStorage key.
139
+ */
140
+ function storageKeyFor() {
141
+ return STORAGE_BASE;
142
+ }
143
+
144
+ /**
145
+ * Parse a chat.deepseek.com page URL.
146
+ * @param value - candidate URL.
147
+ * @returns the parsed URL for the site origin, or undefined.
148
+ */
149
+ function parseChatUrl(value) {
150
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) return void 0;
151
+ try {
152
+ const url = new URL(value);
153
+ if (url.protocol !== "https:" || url.hostname !== "chat.deepseek.com") return void 0;
154
+ if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
155
+ return url;
156
+ } catch {
157
+ return void 0;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Destinations a guest popup may open. The chat site and the Platform
163
+ * login pages are the only hosts this panel deliberately navigates to;
164
+ * an arbitrary URL from the open-request bridge is never loaded.
165
+ * @param value - candidate URL.
166
+ * @returns the normalized URL, or undefined.
167
+ */
168
+ function parsePopupUrl(value) {
169
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) return void 0;
170
+ try {
171
+ const url = new URL(value);
172
+ if (url.protocol !== "https:") return void 0;
173
+ if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
174
+ if (url.hostname !== "chat.deepseek.com" && url.hostname !== "platform.deepseek.com") return void 0;
175
+ return url.toString();
176
+ } catch {
177
+ return void 0;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Whether a URL deep enough to be a conversation page.
183
+ * @param url - parsed chat URL, possibly undefined.
184
+ * @returns true for a conversation, false for the new-chat root or an auth page.
185
+ */
186
+ function isConversation(url) {
187
+ if (url === void 0) return false;
188
+ const path = url.pathname.replace(/\/+$/u, "") || "/";
189
+ return !NON_CONVERSATION_PATHS.has(path);
190
+ }
191
+
192
+ /**
193
+ * Read the conversation remembered from the last visit for one account.
194
+ * @param storageKey - account-scoped storage key.
195
+ * @returns the URL to open; the site root when nothing usable is remembered.
196
+ */
197
+ function rememberedTarget(storageKey) {
198
+ try {
199
+ const saved = parseChatUrl(window.localStorage.getItem(storageKey));
200
+ return isConversation(saved) ? saved.toString() : CHAT_URL;
201
+ } catch {
202
+ return CHAT_URL;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Remember where the guest currently is.
208
+ *
209
+ * A brand-new chat clears the memory so the next visit starts clean. An auth
210
+ * page never overwrites it, so a login detour does not lose the conversation.
211
+ * @param raw - current page URL reported by the guest.
212
+ * @param storageKey - account-scoped storage key.
213
+ */
214
+ function rememberTarget(raw, storageKey) {
215
+ const url = parseChatUrl(raw);
216
+ if (url === void 0) return;
217
+ try {
218
+ const path = url.pathname.replace(/\/+$/u, "") || "/";
219
+ if (isConversation(url)) window.localStorage.setItem(storageKey, url.toString());
220
+ else if (path === "/") window.localStorage.removeItem(storageKey);
221
+ } catch {
222
+ /* storage unavailable: the panel still works, it just forgets */
223
+ }
224
+ }
225
+
226
+ // ── account sync store ────────────────────────────────────────────────
227
+ /**
228
+ * One module-level snapshot shared by every mounted panel. The account
229
+ * Remote is display/awareness only; it never feeds the lease identity or
230
+ * the guest lifecycle, so a flaky probe can never recreate the webview.
231
+ */
232
+ let accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
233
+ const accountListeners = new Set();
234
+
235
+ function notifyAccount() {
236
+ for (const listener of [...accountListeners]) {
237
+ try {
238
+ listener(accountSnapshot);
239
+ } catch {
240
+ /* a dead panel must not break another one */
241
+ }
242
+ }
243
+ }
244
+
245
+ /** Subscribe a mounted panel; returns its disposer. */
246
+ function subscribeAccount(listener) {
247
+ accountListeners.add(listener);
248
+ return () => {
249
+ accountListeners.delete(listener);
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Publish a probed account state. This ONLY updates the account strip:
255
+ * it never bumps an epoch and never rebuilds the guest. A transient
256
+ * "unknown" during reconnect is deliberately ignored so the displayed
257
+ * state does not flicker; a confirmed sign-out is shown immediately.
258
+ * @param status - normalized account state.
259
+ */
260
+ function publishAccount(status) {
261
+ const previous = accountSnapshot;
262
+ // Ignore a transient unknown once we know a real state: it is usually
263
+ // a reconnect, not a sign-out, and must not disturb the panel.
264
+ const settled = status === ACCOUNT_UNKNOWN && previous.probed && previous.status !== ACCOUNT_UNKNOWN
265
+ ? previous.status
266
+ : status;
267
+ accountSnapshot = {
268
+ status: settled,
269
+ // Preserve the name through a transient unknown; clear it only on a
270
+ // confirmed sign-out when the display explicitly publishes "".
271
+ label: settled === ACCOUNT_SIGNED_OUT ? "" : previous.label,
272
+ probed: true
273
+ };
274
+ notifyAccount();
275
+ }
276
+
277
+ /** Publish a display name; a stale result from an older probe is dropped. */
278
+ function publishAccountLabel(label) {
279
+ if (accountSnapshot.label === label) return;
280
+ accountSnapshot = { ...accountSnapshot, label };
281
+ notifyAccount();
282
+ }
283
+
284
+ /**
285
+ * Read one generated `ctx.remote.account` method. Generated Remotes answer
286
+ * with an `{ ok, value, error }` envelope, so a bare payload is never
287
+ * assumed; an absent namespace or a refused call degrades to undefined.
288
+ * @param ctx - client plugin context.
289
+ * @param method - generated account method name.
290
+ * @param args - method arguments.
291
+ * @returns the envelope's `value`, or undefined when unavailable.
292
+ */
293
+ async function callAccount(ctx, method, args = []) {
294
+ let remote;
295
+ try {
296
+ remote = typeof ctx.get === "function" ? ctx.get("remote") : ctx.remote;
297
+ } catch {
298
+ remote = void 0;
299
+ }
300
+ const account = remote?.account;
301
+ const call = account?.[method];
302
+ if (typeof call !== "function") return void 0;
303
+ const result = await call.apply(account, args);
304
+ if (result === null || typeof result !== "object" || result.ok !== true) return void 0;
305
+ return result.value;
306
+ }
307
+
308
+ /** Platform client identity headers, read at call time as the UI does. */
309
+ function accountClient(ctx) {
310
+ return {
311
+ version: globalThis.__DSH_BOOT__?.release?.version || HOST_VERSION_FALLBACK,
312
+ locale: typeof navigator !== "undefined" ? navigator.language || "en-US" : "en-US",
313
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Read the live DSH account state and display name. This is awareness
319
+ * ONLY: no value here may ever feed the guest lease or rebuild the
320
+ * webview, or a transient Remote failure would drop the chat session.
321
+ * @param ctx - client plugin context.
322
+ * @returns `{ status, label }`.
323
+ */
324
+ async function probeAccount(ctx) {
325
+ let state;
326
+ try {
327
+ state = await callAccount(ctx, "getState");
328
+ } catch {
329
+ state = void 0;
330
+ }
331
+ if (state?.status !== "credential-stored") {
332
+ return {
333
+ status: state?.status === "signed-out" ? ACCOUNT_SIGNED_OUT : ACCOUNT_UNKNOWN,
334
+ label: ""
335
+ };
336
+ }
337
+ const probe = { status: ACCOUNT_SIGNED_IN, label: "" };
338
+ try {
339
+ const outcome = await callAccount(ctx, "getProfile", [accountClient(ctx)]);
340
+ const value = outcome?.status === "ready" ? outcome.value : void 0;
341
+ probe.label = [value?.name, value?.contact, value?.id]
342
+ .find((item) => typeof item === "string" && item.length > 0) ?? "";
343
+ } catch {
344
+ /* profile is display-only: failure keeps the bare state */
345
+ }
346
+ return probe;
347
+ }
348
+
349
+ /**
350
+ * Start the account watcher once per plugin activation. A 5-second poll
351
+ * plus `connection/reset` gives self-heal without depending on the exact
352
+ * Client shape of the account `watch` stream, and every resource is
353
+ * released through the plugin's own ctx effect.
354
+ * @param ctx - client plugin context.
355
+ */
356
+ let accountSyncStarted = false;
357
+ function startAccountSync(ctx) {
358
+ if (accountSyncStarted) return;
359
+ accountSyncStarted = true;
360
+ let stopped = false;
361
+ let timer;
362
+
363
+ const refresh = async () => {
364
+ if (stopped) return;
365
+ let probe = { status: ACCOUNT_UNKNOWN, label: "" };
366
+ try {
367
+ probe = await probeAccount(ctx);
368
+ } catch {
369
+ /* Remote unavailable: publishAccount ignores a transient unknown */
370
+ probe = { status: ACCOUNT_UNKNOWN, label: "" };
371
+ }
372
+ if (stopped) return;
373
+ publishAccount(probe.status);
374
+ if (probe.label !== "") publishAccountLabel(probe.label);
375
+ };
376
+
377
+ const schedule = () => {
378
+ if (stopped) return;
379
+ timer = setTimeout(() => {
380
+ void refresh().finally(schedule);
381
+ }, 5000);
382
+ // A long-lived page keeps the poll; a minimal context (or a Node
383
+ // self-check) must not be held open by its timer alone.
384
+ try {
385
+ if (typeof timer.unref === "function") timer.unref();
386
+ } catch {
387
+ /* not a Node timer */
388
+ }
389
+ };
390
+
391
+ void refresh().finally(schedule);
392
+
393
+ let offReset;
394
+ try {
395
+ if (typeof ctx.on === "function") offReset = ctx.on("connection/reset", () => {
396
+ void refresh();
397
+ });
398
+ } catch {
399
+ /* optional: the poll still self-heals */
400
+ }
401
+
402
+ let hasDisposal = false;
403
+ try {
404
+ if (typeof ctx.effect === "function") {
405
+ ctx.effect(() => () => {
406
+ stopped = true;
407
+ if (timer !== void 0) clearTimeout(timer);
408
+ try {
409
+ if (typeof offReset === "function") offReset();
410
+ } catch {
411
+ /* already detached */
412
+ }
413
+ accountListeners.clear();
414
+ accountSyncStarted = false;
415
+ }, "deepseek-chat: account sync");
416
+ hasDisposal = true;
417
+ }
418
+ } catch {
419
+ /* fall through to the no-disposal path */
420
+ }
421
+ if (!hasDisposal) {
422
+ stopped = true;
423
+ if (timer !== void 0) clearTimeout(timer);
424
+ try {
425
+ if (typeof offReset === "function") offReset();
426
+ } catch {
427
+ /* already detached */
428
+ }
429
+ // Without a real ctx effect there is no teardown to stop the poll;
430
+ // keep the first probe but never leave a repeating timer behind.
431
+ accountSyncStarted = false;
432
+ }
433
+ }
434
+
435
+ // ── guest helpers ─────────────────────────────────────────────────────
436
+ /**
437
+ * Whether the guest element is still in the document. Electron's webview
438
+ * methods throw once it is detached, so this gates every guest call.
439
+ * @param element - webview element.
440
+ * @returns true while the element can still host a guest.
441
+ */
442
+ function attached(element) {
443
+ try {
444
+ return element.isConnected === true;
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+
450
+ /** Guest URL, or undefined when the element cannot report one. */
451
+ function readUrl(element) {
452
+ if (!attached(element)) return void 0;
453
+ try {
454
+ const url = element.getURL();
455
+ return typeof url === "string" && url.length > 0 ? url : void 0;
456
+ } catch {
457
+ return void 0;
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Navigate the guest without ever throwing.
463
+ * @returns false when the guest is gone, so the caller can report it.
464
+ */
465
+ function navigate(element, url) {
466
+ if (!attached(element)) return false;
467
+ try {
468
+ const pending = element.loadURL(url);
469
+ if (pending !== void 0 && pending !== null && typeof pending.catch === "function") pending.catch(() => {});
470
+ return true;
471
+ } catch {
472
+ return false;
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Panel-list glyph, drawn on currentColor so the sidebar's active state owns
478
+ * the ink. Matches the sidebar's 16/18px icon seat.
479
+ */
480
+ function ChatGlyph({ size = 18 }) {
481
+ return react.createElement("svg", {
482
+ width: size,
483
+ height: size,
484
+ viewBox: "0 0 24 24",
485
+ fill: "none",
486
+ "aria-hidden": "true",
487
+ focusable: "false"
488
+ }, react.createElement("path", {
489
+ d: "M4.5 5.75C4.5 4.784 5.284 4 6.25 4h11.5c.966 0 1.75.784 1.75 1.75v8.5c0 .966-.784 1.75-1.75 1.75H9.56l-3.34 2.784c-.497.414-1.22.06-1.22-.586V5.75Z",
490
+ stroke: "currentColor",
491
+ strokeWidth: 1.3,
492
+ strokeLinejoin: "round"
493
+ }), react.createElement("path", {
494
+ d: "M8.5 9.25h7M8.5 12.25h4.5",
495
+ stroke: "currentColor",
496
+ strokeWidth: 1.3,
497
+ strokeLinecap: "round"
498
+ }));
499
+ }
500
+
501
+ const styles = {
502
+ root: {
503
+ display: "flex",
504
+ flexDirection: "column",
505
+ height: "100%",
506
+ minHeight: "0",
507
+ background: "var(--dsw-alias-bg-base)",
508
+ color: "var(--dsw-alias-label-primary)"
509
+ },
510
+ accountBar: {
511
+ flex: "none",
512
+ display: "flex",
513
+ alignItems: "center",
514
+ gap: "6px",
515
+ minHeight: "24px",
516
+ padding: "3px 10px",
517
+ borderBottom: ".5px solid var(--dsw-alias-border-l3)",
518
+ color: "var(--dsw-alias-label-tertiary)",
519
+ font: "var(--dsw-font-xxxs-11)"
520
+ },
521
+ accountDot: {
522
+ width: "6px",
523
+ height: "6px",
524
+ borderRadius: "50%",
525
+ background: "currentColor",
526
+ flex: "none"
527
+ },
528
+ host: {
529
+ position: "relative",
530
+ flex: "1",
531
+ minHeight: "0",
532
+ display: "flex"
533
+ },
534
+ guest: {
535
+ width: "100%",
536
+ height: "100%",
537
+ border: "0",
538
+ background: "transparent",
539
+ display: "flex"
540
+ },
541
+ notice: {
542
+ position: "absolute",
543
+ inset: "0",
544
+ display: "flex",
545
+ flexDirection: "column",
546
+ alignItems: "center",
547
+ justifyContent: "center",
548
+ gap: "10px",
549
+ padding: "24px",
550
+ textAlign: "center",
551
+ color: "var(--dsw-alias-label-tertiary)",
552
+ font: "var(--dsw-font-xxs-12)",
553
+ background: "var(--dsw-alias-bg-base)"
554
+ },
555
+ button: {
556
+ color: "var(--dsw-alias-label-primary)",
557
+ background: "var(--dsw-alias-bg-layer-1)",
558
+ border: ".5px solid var(--dsw-alias-border-l3)",
559
+ borderRadius: "var(--dsw-radius-sm)",
560
+ padding: "4px 12px",
561
+ cursor: "pointer",
562
+ font: "var(--dsw-font-xxs-12)"
563
+ }
564
+ };
565
+
566
+ /** Copy for the account strip; the dot's opacity encodes the state. */
567
+ function accountCopy(account) {
568
+ if (account.status === ACCOUNT_SIGNED_IN) return {
569
+ text: account.label ? COPY.syncedAs(account.label) : COPY.synced,
570
+ opacity: "1"
571
+ };
572
+ if (account.status === ACCOUNT_SIGNED_OUT) return { text: COPY.unsigned, opacity: ".45" };
573
+ if (!account.probed) return { text: COPY.probing, opacity: ".3" };
574
+ return { text: COPY.unknown, opacity: ".3" };
575
+ }
576
+
577
+ /**
578
+ * Keeps a rendering failure inside this panel. DSH has no boundary of its own
579
+ * around slot entries: an escaping error unmounts the root slot, which is the
580
+ * whole application UI.
581
+ */
582
+ class PanelBoundary extends react.Component {
583
+ constructor(props) {
584
+ super(props);
585
+ this.state = { failure: void 0 };
586
+ }
587
+
588
+ static getDerivedStateFromError(error) {
589
+ return { failure: String((error && error.message) || error) };
590
+ }
591
+
592
+ componentDidCatch(error) {
593
+ try {
594
+ console.error("dsh-plugin-deepseek-chat: panel render failed", error);
595
+ } catch {
596
+ /* console unavailable */
597
+ }
598
+ }
599
+
600
+ render() {
601
+ if (this.state.failure !== void 0) {
602
+ return react.createElement("div", { style: styles.root },
603
+ react.createElement("div", { style: styles.notice },
604
+ react.createElement("div", null, `${COPY.failed}: ${this.state.failure}`)));
605
+ }
606
+ return this.props.children ?? null;
607
+ }
608
+ }
609
+
610
+ /**
611
+ * The global chat panel: an account strip and a guest webview filling the
612
+ * main column.
613
+ *
614
+ * The guest is created only after the lease resolves, and it is mounted with
615
+ * `name`/`partition`/`src` already set, because the main process inspects
616
+ * exactly that bootstrap src when it approves the attachment. The identity
617
+ * and the conversation memory are both scoped to the current DSH account;
618
+ * an account transition bumps `epoch`, which tears down the old guest and
619
+ * re-acquires a lease for the new identity.
620
+ */
621
+ function ChatPanel() {
622
+ const hostRef = react.useRef(null);
623
+ const lastUrlRef = react.useRef(void 0);
624
+ const [account, setAccount] = react.useState(accountSnapshot);
625
+ const [failure, setFailure] = react.useState(void 0);
626
+ const [loading, setLoading] = react.useState(true);
627
+ const [attempt, setAttempt] = react.useState(0);
628
+
629
+ react.useEffect(() => subscribeAccount((next) => {
630
+ try {
631
+ setAccount(next);
632
+ } catch {
633
+ /* the panel is gone */
634
+ }
635
+ }), []);
636
+
637
+ const retry = react.useCallback(() => {
638
+ try {
639
+ setFailure(void 0);
640
+ setLoading(true);
641
+ setAttempt((value) => value + 1);
642
+ } catch {
643
+ /* the panel is gone */
644
+ }
645
+ }, []);
646
+
647
+ react.useEffect(() => {
648
+ const host = hostRef.current;
649
+ const bridge = globalThis.dshDesktop?.browser;
650
+ if (host === null) return;
651
+ if (bridge === void 0) {
652
+ try {
653
+ setFailure(COPY.unsupported);
654
+ setLoading(false);
655
+ } catch {
656
+ /* the panel is gone */
657
+ }
658
+ return;
659
+ }
660
+
661
+ // Both identities are constants. The desktop shell caches one
662
+ // session per workspace string; keeping them fixed is what makes
663
+ // switching panels/sessions preserve the chat login.
664
+ const storageKey = storageKeyFor();
665
+ const partitionKey = partitionKeyFor();
666
+ let disposed = false;
667
+ let failed = false;
668
+ let unsubscribeOpen;
669
+ let lease;
670
+ let watchdog;
671
+ const element = document.createElement("webview");
672
+ const initialTarget = rememberedTarget(storageKey);
673
+ let restoring = initialTarget !== CHAT_URL;
674
+
675
+ Object.assign(element.style, styles.guest);
676
+ element.setAttribute("allowpopups", "");
677
+
678
+ /**
679
+ * Report a guest failure inside the panel; never throw. The dead
680
+ * guest element is removed immediately so a Retry cannot stack a
681
+ * second webview, and later events are suppressed. The lease is
682
+ * still released by the effect cleanup, exactly once.
683
+ */
684
+ const fail = (error) => {
685
+ if (disposed || failed) return;
686
+ failed = true;
687
+ try {
688
+ element.remove();
689
+ } catch {
690
+ /* already detached */
691
+ }
692
+ if (watchdog !== void 0) {
693
+ clearTimeout(watchdog);
694
+ watchdog = void 0;
695
+ }
696
+ try {
697
+ setLoading(false);
698
+ setFailure(String((error && error.message) || error));
699
+ } catch {
700
+ /* the panel is gone */
701
+ }
702
+ };
703
+
704
+ /** Remember the last document the guest reported. */
705
+ const remember = (raw) => {
706
+ if (raw === void 0) return;
707
+ lastUrlRef.current = raw;
708
+ rememberTarget(raw, storageKey);
709
+ };
710
+
711
+ const onDomReady = () => {
712
+ if (disposed || failed) return;
713
+ if (watchdog !== void 0) {
714
+ clearTimeout(watchdog);
715
+ watchdog = void 0;
716
+ }
717
+ if (!navigate(element, initialTarget)) fail(new Error(COPY.crashed));
718
+ else remember(initialTarget);
719
+ };
720
+ const onStopLoading = () => {
721
+ if (disposed || failed) return;
722
+ const raw = readUrl(element);
723
+ if (raw === void 0) return;
724
+ remember(raw);
725
+ restoring = false;
726
+ try {
727
+ setLoading(false);
728
+ } catch {
729
+ /* the panel is gone */
730
+ }
731
+ };
732
+ const onNavigate = () => {
733
+ if (disposed || failed) return;
734
+ remember(readUrl(element));
735
+ };
736
+ const onNavigateInPage = (event) => {
737
+ if (disposed || failed || event?.isMainFrame !== true) return;
738
+ onNavigate();
739
+ };
740
+ const onFailLoad = (event) => {
741
+ if (disposed || failed || event?.isMainFrame !== true) return;
742
+ // -3 is ERR_ABORTED: a superseded navigation, not a failure.
743
+ if (event.errorCode === -3) return;
744
+ if (restoring) {
745
+ // The remembered conversation is gone or unreachable; start clean.
746
+ restoring = false;
747
+ if (!navigate(element, CHAT_URL)) fail(new Error(COPY.crashed));
748
+ else remember(CHAT_URL);
749
+ return;
750
+ }
751
+ fail(`${event.errorDescription ?? event.errorCode}`);
752
+ };
753
+ /** The guest process exited or the guest was torn down underneath us. */
754
+ const onGuestGone = () => {
755
+ if (disposed || failed) return;
756
+ fail(new Error(COPY.crashed));
757
+ };
758
+
759
+ const listeners = [
760
+ ["dom-ready", onDomReady],
761
+ ["did-stop-loading", onStopLoading],
762
+ ["did-navigate", onNavigate],
763
+ ["did-navigate-in-page", onNavigateInPage],
764
+ ["did-fail-load", onFailLoad],
765
+ ["render-process-gone", onGuestGone],
766
+ ["destroyed", onGuestGone]
767
+ ];
768
+
769
+ // A listener that cannot be attached leaves a guest nobody can
770
+ // drive; abort the start instead of mounting a silent webview.
771
+ let listenersReady = false;
772
+ try {
773
+ for (const [name, handler] of listeners) {
774
+ element.addEventListener(name, handler, name === "dom-ready" ? { once: true } : void 0);
775
+ }
776
+ listenersReady = true;
777
+ } catch (error) {
778
+ fail(error);
779
+ }
780
+
781
+ const start = async () => {
782
+ try {
783
+ const reservation = await bridge.acquire(partitionKey);
784
+ if (disposed || failed) {
785
+ try {
786
+ Promise.resolve(bridge.release(reservation.lease)).catch(() => {});
787
+ } catch {
788
+ /* lease already gone */
789
+ }
790
+ return;
791
+ }
792
+ lease = reservation.lease;
793
+ element.setAttribute("name", reservation.lease);
794
+ element.setAttribute("partition", reservation.partition);
795
+ element.setAttribute("src", `about:blank#${reservation.lease}`);
796
+ try {
797
+ unsubscribeOpen = bridge.onOpenRequested(reservation.lease, (raw) => {
798
+ if (disposed || failed || typeof raw !== "string") return;
799
+ const target = parsePopupUrl(raw);
800
+ if (target !== void 0) navigate(element, target);
801
+ });
802
+ } catch {
803
+ /* popup bridge unavailable: the guest still works */
804
+ }
805
+ host.append(element);
806
+ watchdog = setTimeout(() => {
807
+ watchdog = void 0;
808
+ if (disposed || failed) return;
809
+ if (readUrl(element) === void 0) fail(new Error(COPY.timeout));
810
+ }, 15000);
811
+ } catch (error) {
812
+ fail(error);
813
+ }
814
+ };
815
+ if (listenersReady) void start();
816
+
817
+ return () => {
818
+ disposed = true;
819
+ // Read before removal: React cleans up after taking the DOM away, and
820
+ // Electron's getURL() throws on a detached webview.
821
+ remember(readUrl(element) ?? lastUrlRef.current);
822
+ if (watchdog !== void 0) {
823
+ clearTimeout(watchdog);
824
+ watchdog = void 0;
825
+ }
826
+ try {
827
+ if (typeof unsubscribeOpen === "function") unsubscribeOpen();
828
+ } catch {
829
+ /* already detached */
830
+ }
831
+ for (const [name, handler] of listeners) {
832
+ try {
833
+ element.removeEventListener(name, handler);
834
+ } catch {
835
+ /* already detached */
836
+ }
837
+ }
838
+ try {
839
+ element.remove();
840
+ } catch {
841
+ /* already detached */
842
+ }
843
+ const released = lease;
844
+ lease = void 0;
845
+ if (released !== void 0) {
846
+ try {
847
+ Promise.resolve(bridge.release(released)).catch(() => {});
848
+ } catch {
849
+ /* lease already gone */
850
+ }
851
+ }
852
+ };
853
+ }, [attempt]);
854
+
855
+ const copy = accountCopy(account);
856
+
857
+ return react.createElement("div", { style: styles.root },
858
+ react.createElement("div", { style: styles.accountBar },
859
+ react.createElement("span", { style: { ...styles.accountDot, opacity: copy.opacity }, "aria-hidden": "true" }),
860
+ react.createElement("span", null, copy.text)),
861
+ react.createElement("div", { style: styles.host, ref: hostRef },
862
+ failure === void 0 && loading
863
+ ? react.createElement("div", { style: styles.notice }, COPY.loading)
864
+ : null,
865
+ failure === void 0
866
+ ? null
867
+ : react.createElement("div", { style: styles.notice },
868
+ react.createElement("div", null, `${COPY.failed}: ${failure}`),
869
+ react.createElement("button", { type: "button", style: styles.button, onClick: retry }, COPY.retry))));
870
+ }
871
+
872
+ /** The registered panel: the boundary owns every rendering failure below it. */
873
+ function ChatPanelShell() {
874
+ return react.createElement(PanelBoundary, null, react.createElement(ChatPanel));
875
+ }
876
+
877
+ /** Client service this plugin reads; the loader waits for the slot registry. */
878
+ const inject = ["slots"];
879
+
880
+ /**
881
+ * Register the left-sidebar entry and its main panel.
882
+ * @param ctx - client plugin context.
883
+ */
884
+ function apply(ctx) {
885
+ try {
886
+ // The panel needs the desktop guest bridge; on the Web surface an iframe
887
+ // cannot carry the site, so the plugin stays inert there.
888
+ if (globalThis.dshDesktop?.browser === void 0) return;
889
+ } catch {
890
+ return;
891
+ }
892
+
893
+ try {
894
+ startAccountSync(ctx);
895
+ } catch (error) {
896
+ try {
897
+ console.error("dsh-plugin-deepseek-chat: account sync failed", error);
898
+ } catch {
899
+ /* console unavailable */
900
+ }
901
+ }
902
+
903
+ try {
904
+ ctx.slots.inject("main", () => ctx.slots.register({
905
+ name: "main",
906
+ key: PANEL_ID
907
+ }, ChatPanelShell));
908
+
909
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
910
+ name: "sidebar.panellist",
911
+ id: PANEL_ID,
912
+ order: 5,
913
+ label: () => COPY.label
914
+ }, ChatGlyph));
915
+ } catch (error) {
916
+ try {
917
+ console.error("dsh-plugin-deepseek-chat: registration failed", error);
918
+ } catch {
919
+ /* console unavailable */
920
+ }
921
+ }
922
+ }
923
+
924
+ exports.apply = apply;
925
+ exports.inject = inject;
926
+ exports.__test__ = {
927
+ ACCOUNT_UNKNOWN,
928
+ ACCOUNT_SIGNED_IN,
929
+ ACCOUNT_SIGNED_OUT,
930
+ CHAT_URL,
931
+ STORAGE_BASE,
932
+ accountCopy,
933
+ getSnapshot: () => accountSnapshot,
934
+ partitionKeyFor,
935
+ storageKeyFor,
936
+ publishAccount,
937
+ publishAccountLabel,
938
+ probeAccount,
939
+ callAccount,
940
+ resetAccountSync() {
941
+ accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
942
+ accountListeners.clear();
943
+ accountSyncStarted = false;
944
+ }
945
+ };
946
+ return module.exports;
947
+ }
948
+ });