@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/client.js CHANGED
@@ -1,92 +1,96 @@
1
1
  /**
2
2
  * dsh-webchat — browser half.
3
3
  *
4
- * One sidebar entry that opens the official chat.deepseek.com web app **inside
5
- * the DSH window**, in the center column, exactly like the other full-page
6
- * views (Automation Tasks and friends).
4
+ * One sidebar entry that renders the official chat.deepseek.com web app inside
5
+ * the DSH window, filling the center column like any other full-page view.
7
6
  *
8
- * It can do that because the desktop shell lends out native browser guests: the
9
- * renderer asks `globalThis.dshDesktop.browser` for a reservation and then
10
- * attaches a `<webview>` whose `src` is `about:blank#<lease>` and whose
11
- * `partition` matches — the shell approves exactly that element and assigns the
12
- * real web preferences, so the page is not subject to `frame-ancestors` the way
13
- * an iframe would be. This is the same mechanism the built-in side-card browser
14
- * uses; see MAINTAINING.md.
7
+ * Both halves of that are **slots**, so the shell owns them:
15
8
  *
16
- * Where that bridge is absent (a plain `dsh web` profile, not the desktop app)
17
- * the entry falls back to asking the host half to open the page in a window.
9
+ * - `sidebar.panellist` gets an icon and a label; the sidebar owns the button
10
+ * and selects the main panel whose id matches, which is why this plugin no
11
+ * longer injects a row into someone else's DOM;
12
+ * - `main` (keyed by the same id) gets the page. The layout renders only the
13
+ * selected key, so switching panels is the shell's business and this panel
14
+ * never has to hide another page or ask to be closed.
18
15
  *
19
- * Everything here is plain DOM — no React, no build step.
16
+ * The page itself is the host's native browser guest, not an iframe: the
17
+ * renderer asks `globalThis.dshDesktop.browser` for a reservation and attaches a
18
+ * `<webview>` whose `src` is `about:blank#<lease>` and whose `partition`
19
+ * matches, which the shell's `will-attach-webview` approves and rewrites with
20
+ * the real web preferences. That is the same channel the built-in side-card
21
+ * browser uses, and it is why `frame-ancestors 'none'` does not apply.
22
+ *
23
+ * The guest element deliberately lives outside the React tree, in a container
24
+ * this plugin owns at the document root. A `<webview>` is destroyed when it
25
+ * leaves the document, so a guest owned by the slot would be torn down and
26
+ * reloaded every time the user looked at another panel. Instead the slot hands
27
+ * this plugin a measured area, and the persistent guest is positioned onto it
28
+ * while this panel is the selected one, and hidden when it is not.
29
+ *
30
+ * React comes from the browser module table; everything else is plain DOM.
20
31
  */
21
32
  window.__ModuleLoader__.load({
22
33
  id: "@jaychang1989/dsh-webchat",
23
- factory: () => {
24
- var module = { exports: {} }
25
- var exports = module.exports
26
-
27
- /** Host route used only by the window fallback. */
28
- var OPEN_ROUTE = '/api/dsh-webchat/open'
29
- /** The page the panel shows. */
30
- var PAGE_URL = 'https://chat.deepseek.com/'
31
- /**
32
- * Storage identity for the guest partition. The shell keys one
33
- * process-lifetime partition per identity, so this string is what makes
34
- * the DeepSeek login survive panel close/reopen within one run.
35
- */
36
- var STORAGE_IDENTITY = 'dsh-webchat'
37
-
38
- var ENTRY_ATTR = 'data-dsh-webchat-entry'
39
- var VIEW_ATTR = 'data-dsh-webchat-view'
40
- var ACTIVE_ATTR = 'data-dsh-webchat-active'
41
- var OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
42
- var ACTIVATE_EVENT = 'dsh-panel-activate'
43
- var PANEL_NAME = 'webchat'
44
- var STYLE_ID = 'dsh-webchat-style'
45
- var TOAST_ATTR = 'data-dsh-webchat-toast'
46
- var FRAME_CLASS = 'dsh-wc-frame'
47
-
48
- var ZH = typeof navigator === 'object' && typeof navigator.language === 'string'
49
- && navigator.language.toLowerCase().indexOf('zh') === 0
34
+ factory: (require) => {
35
+ const React = require("react")
36
+ const h = React.createElement
37
+
38
+ /** This plugin's panel id: the sidebar row and the main cell share it. */
39
+ const PANEL_ID = "webchat"
40
+ /** The page this plugin exists to show. */
41
+ const PAGE_URL = "https://chat.deepseek.com/"
42
+ /** Storage identity: one guest partition per identity, per app run. */
43
+ const STORAGE_IDENTITY = "dsh-webchat"
44
+ /** Host route used by the window fallback. */
45
+ const OPEN_ROUTE = "/api/dsh-webchat/open"
46
+ /** Host routes that keep the login alive across restarts. */
47
+ const RESTORE_ROUTE = "/api/dsh-webchat/session/restore"
48
+ const SAVE_ROUTE = "/api/dsh-webchat/session/save"
49
+ /** How often the guest's own storage is snapshotted while it is open. */
50
+ const SAVE_INTERVAL_MS = 30000
51
+
52
+ const STYLE_ID = "dsh-webchat-style"
53
+ const OVERLAY_ATTR = "data-dsh-webchat-overlay"
54
+
55
+ /** Locale at read time, so the label thunk follows the app's language. */
56
+ function isChinese() {
57
+ const tag = document.documentElement.getAttribute("lang")
58
+ || (typeof navigator === "object" ? navigator.language : "")
59
+ || ""
60
+ return String(tag).toLowerCase().indexOf("zh") === 0
61
+ }
50
62
 
51
- /** Surface copy; the plugin ships both languages rather than a locale service. */
52
- var T = ZH
53
- ? {
54
- label: 'DeepSeek 网页',
55
- tooltip: '在 Harness 内打开 chat.deepseek.com',
56
- failed: '载入失败',
57
- fallbackOpened: '已打开 DeepSeek 网页',
58
- fallbackHanded: '已交给系统默认浏览器打开',
59
- fallbackFailed: '打开失败',
60
- }
61
- : {
62
- label: 'DeepSeek Web',
63
- tooltip: 'Open chat.deepseek.com inside Harness',
64
- failed: 'Could not load it',
65
- fallbackOpened: 'DeepSeek Web is open',
66
- fallbackHanded: 'Handed to the system default browser',
67
- fallbackFailed: 'Could not open',
68
- }
63
+ /** Every visible string this plugin owns. */
64
+ function copy() {
65
+ return isChinese()
66
+ ? {
67
+ label: "DeepSeek 网页",
68
+ failed: "载入失败",
69
+ fallbackNote: "这个 Harness 没有提供原生浏览器访客(桌面端才有),无法在窗口内显示官方页面。",
70
+ fallbackAction: "在窗口中打开",
71
+ fallbackOpened: "已打开 DeepSeek 网页",
72
+ fallbackHanded: "已交给系统默认浏览器打开",
73
+ fallbackFailed: "打开失败",
74
+ }
75
+ : {
76
+ label: "DeepSeek Web",
77
+ failed: "Could not load it",
78
+ fallbackNote: "This Harness provides no native browser guest (only the desktop app does), so the official page cannot render in the window.",
79
+ fallbackAction: "Open it in a window",
80
+ fallbackOpened: "DeepSeek Web is open",
81
+ fallbackHanded: "Handed to the system default browser",
82
+ fallbackFailed: "Could not open",
83
+ }
84
+ }
69
85
 
70
- /** Entry, center-column takeover and guest styling. */
71
- var CSS = [
72
- "[data-pane='conversation'],[class*='centerCol']{position:relative}",
73
- '[' + VIEW_ATTR + ']{position:absolute;inset:0;display:none;z-index:60;background:var(--dsw-alias-bg-base)}',
74
- 'html[' + ACTIVE_ATTR + ']:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [' + VIEW_ATTR + ']{display:flex}',
75
- "html[" + ACTIVE_ATTR + "]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([" + VIEW_ATTR + ']),',
76
- 'html[' + ACTIVE_ATTR + "]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [class*='centerCol'] > :not([" + VIEW_ATTR + ']){display:none !important}',
77
- '.' + FRAME_CLASS + '{-webkit-app-region:no-drag;border:0;flex:auto;width:100%;min-width:0;height:100%;min-height:0;display:flex}',
78
- '.dsh-wc-message{margin:auto;max-width:460px;padding:22px;text-align:center;font-size:13px;line-height:1.65;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}',
79
- '[' + ENTRY_ATTR + ']{display:flex;align-items:center;gap:8px;width:100%;height:32px;padding:0 12px;background:transparent;border:none;border-radius:8px;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:13px;white-space:nowrap}',
80
- '[' + ENTRY_ATTR + ']:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}',
81
- '[' + ENTRY_ATTR + '][data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}',
82
- '[' + ENTRY_ATTR + '][disabled]{opacity:.55;cursor:progress}',
83
- '[' + ENTRY_ATTR + '] [data-part=icon]{display:inline-flex;align-items:center;justify-content:center;flex:none}',
84
- '[' + ENTRY_ATTR + '] [data-part=label]{overflow:hidden;text-overflow:ellipsis}',
85
- '[data-dsh-frame][data-sidebar-collapsed] [' + ENTRY_ATTR + ']{justify-content:center;padding:0}',
86
- '[data-dsh-frame][data-sidebar-collapsed] [' + ENTRY_ATTR + '] [data-part=label]{display:none}',
87
- '[' + TOAST_ATTR + ']{position:fixed;right:20px;bottom:20px;z-index:200;max-width:420px;padding:10px 14px;border-radius:10px;background:var(--dsw-alias-bg-elevated);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 8px 24px rgba(0,0,0,.18);font-size:13px;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}',
88
- '[' + TOAST_ATTR + '][data-state=bad]{border-color:var(--dsw-alias-state-danger,#ef4444);color:var(--dsw-alias-state-danger,#ef4444)}',
89
- ].join('\n')
86
+ const CSS = [
87
+ ".dsh-wc-host{width:100%;height:100%;min-width:0;min-height:0;display:flex;align-items:center;justify-content:center}",
88
+ "[" + OVERLAY_ATTR + "]{position:fixed;display:none;z-index:40}",
89
+ "[" + OVERLAY_ATTR + "] webview{-webkit-app-region:no-drag;border:0;flex:auto;width:100%;min-width:0;height:100%;min-height:0;display:flex}",
90
+ ".dsh-wc-message{max-width:460px;padding:22px;text-align:center;font-size:13px;line-height:1.65;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}",
91
+ ".dsh-wc-action{margin-top:14px;padding:6px 14px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);color:var(--dsw-alias-label-primary);cursor:pointer;font-size:13px;font-family:var(--dsw-font-family)}",
92
+ ".dsh-wc-action[disabled]{opacity:.55;cursor:progress}",
93
+ ].join("\n")
90
94
 
91
95
  /**
92
96
  * The desktop shell's browser bridge, when this renderer is the desktop
@@ -94,376 +98,397 @@ window.__ModuleLoader__.load({
94
98
  * version, so the window fallback takes over.
95
99
  */
96
100
  const carrier = globalThis.dshDesktop
97
- const guests = carrier !== null && typeof carrier === 'object' && carrier.protocolVersion === 1
101
+ const bridge = carrier !== null && typeof carrier === "object" && carrier.protocolVersion === 1
98
102
  ? carrier.browser
99
103
  : undefined
100
104
 
105
+ /**
106
+ * The only state that outlives a panel mount. `container` and `element`
107
+ * stay in the document for the whole app run so the guest is never torn
108
+ * down; the slot only decides where they are shown.
109
+ */
110
+ let guest = null
111
+ /** In-flight acquisition, so two mounts cannot reserve two guests. */
112
+ let acquiring = null
113
+ /** Last acquisition failure, if any. */
114
+ let failure = ""
115
+
116
+ /** Render any thrown value as a message. */
117
+ function messageOf(error) {
118
+ return error instanceof Error ? error.message : String(error)
119
+ }
120
+
101
121
  /** Inject the stylesheet once. */
102
122
  function ensureStyle() {
103
123
  if (document.getElementById(STYLE_ID) !== null) return
104
- var tag = document.createElement('style')
124
+ const tag = document.createElement("style")
105
125
  tag.id = STYLE_ID
106
126
  tag.textContent = CSS
107
127
  document.head.appendChild(tag)
108
128
  }
109
129
 
130
+ /** A centred message, optionally with one action button. */
131
+ function messageBox(text, action) {
132
+ const box = document.createElement("div")
133
+ box.className = "dsh-wc-message"
134
+ box.textContent = text
135
+ if (action !== undefined) {
136
+ const button = document.createElement("button")
137
+ button.type = "button"
138
+ button.className = "dsh-wc-action"
139
+ button.textContent = action.label
140
+ button.addEventListener("click", async function () {
141
+ if (button.disabled) return
142
+ button.disabled = true
143
+ try {
144
+ await action.run(button)
145
+ } finally {
146
+ button.disabled = false
147
+ }
148
+ })
149
+ box.appendChild(button)
150
+ }
151
+ return box
152
+ }
153
+
154
+ /** Ask the host half to open the page in a window. */
155
+ async function openInWindow(button) {
156
+ const text = copy()
157
+ const note = document.createElement("div")
158
+ note.className = "dsh-wc-message"
159
+ const settle = (message) => {
160
+ note.textContent = message
161
+ const parent = button.parentElement
162
+ if (note.parentElement === null && parent !== null) parent.appendChild(note)
163
+ }
164
+ try {
165
+ const response = await fetch(OPEN_ROUTE, { method: "POST", headers: { accept: "application/json" } })
166
+ let payload = {}
167
+ try { payload = await response.json() } catch (error) { payload = {} }
168
+ if (payload.ok === true) {
169
+ settle(String(payload.via) === "system-browser" ? text.fallbackHanded : text.fallbackOpened)
170
+ } else {
171
+ settle(text.fallbackFailed + ":" + String(payload.error || payload.via || "HTTP " + response.status))
172
+ }
173
+ } catch (error) {
174
+ settle(text.fallbackFailed + ":" + messageOf(error))
175
+ }
176
+ }
177
+
110
178
  /**
111
179
  * A plain Chrome user agent for the guest, derived from this renderer's
112
180
  * own so the Chrome major version stays truthful.
113
181
  *
114
182
  * chat.deepseek.com greets an `electron` token in the user agent with a
115
- * "使用环境异常 / Abnormal usage environment" dialog, and its dismissal is
116
- * stored per guest partition — which is process-lifetime here, so it
117
- * would come back on every restart. Its check is literally
118
- * `navigator.userAgent.toLowerCase().includes("electron")` (the other
119
- * arm, `window.process.type`, cannot fire in a sandboxed, isolated
120
- * guest), so not advertising Electron is what keeps the page clean.
183
+ * "使用环境异常 / Abnormal usage environment" dialog, and dismissing it is
184
+ * stored per guest partition — process-lifetime here, so it would come
185
+ * back on every restart. Its check is literally
186
+ * `navigator.userAgent.toLowerCase().includes("electron")`, so not
187
+ * advertising Electron is what keeps the page clean.
121
188
  * @returns a Chrome user agent, or '' when this renderer reports none.
122
189
  */
123
190
  function cleanUserAgent() {
124
- var ua = typeof navigator === 'object' && typeof navigator.userAgent === 'string' ? navigator.userAgent : ''
125
- var platform = (ua.match(/^Mozilla\/5\.0 \([^)]*\)/) || [])[0]
126
- var chrome = (ua.match(/Chrome\/([0-9]+)/) || [])[1]
127
- if (platform === undefined || chrome === undefined) return ''
128
- return platform + ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/' + chrome + '.0.0.0 Safari/537.36'
191
+ const ua = typeof navigator === "object" && typeof navigator.userAgent === "string" ? navigator.userAgent : ""
192
+ const platform = (ua.match(/^Mozilla\/5\.0 \([^)]*\)/) || [])[0]
193
+ const chrome = (ua.match(/Chrome\/([0-9]+)/) || [])[1]
194
+ if (platform === undefined || chrome === undefined) return ""
195
+ return platform + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + chrome + ".0.0.0 Safari/537.36"
129
196
  }
130
197
 
131
- /** Show one transient message in the corner. */
132
- function toast(text, state) {
133
- var previous = document.querySelector('[' + TOAST_ATTR + ']')
134
- if (previous !== null) previous.remove()
135
- var box = document.createElement('div')
136
- box.setAttribute(TOAST_ATTR, '')
137
- if (state !== undefined) box.setAttribute('data-state', state)
138
- box.textContent = text
198
+ /**
199
+ * The document-root container that keeps the guest alive.
200
+ */
201
+ function overlayContainer() {
202
+ const existing = document.querySelector("[" + OVERLAY_ATTR + "]")
203
+ if (existing !== null && existing !== undefined) return existing
204
+ const box = document.createElement("div")
205
+ box.setAttribute(OVERLAY_ATTR, "")
139
206
  document.body.appendChild(box)
140
- setTimeout(function () {
141
- if (box.isConnected) box.remove()
142
- }, state === 'bad' ? 8000 : 3000)
143
- }
144
-
145
- /** Ask the host half to open the page in a window (the non-desktop path). */
146
- async function openInWindow() {
147
- const response = await fetch(OPEN_ROUTE, { method: 'POST', headers: { accept: 'application/json' } })
148
- let payload = {}
149
- try { payload = await response.json() } catch (error) { payload = {} }
150
- if (!response.ok && payload.error === undefined) payload.error = 'HTTP ' + response.status
151
- return payload
152
- }
153
-
154
- /** Tiny observable for the panel's open state. */
155
- function createController() {
156
- var open = false
157
- var listeners = new Set()
158
- var notify = function () {
159
- listeners.forEach(function (listener) {
160
- try {
161
- listener()
162
- } catch (error) {
163
- console.warn('[dsh-webchat] listener failed:', error)
164
- }
165
- })
166
- }
167
- return {
168
- isOpen: function () { return open },
169
- subscribe: function (listener) {
170
- listeners.add(listener)
171
- return function () { listeners.delete(listener) }
172
- },
173
- toggle: function () { open = !open; notify() },
174
- close: function () { if (!open) return; open = false; notify() },
175
- }
207
+ return box
176
208
  }
177
209
 
178
- /** The sidebar shell root, or undefined while it is not mounted. */
179
- function sidebarRoot() {
180
- var column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
181
- if (column === null) return undefined
182
- var logoRow = column.querySelector('[class*="logoRow"]')
183
- var owner = logoRow === null ? null : logoRow.parentElement
184
- return owner !== null && owner !== undefined ? owner : (column.firstElementChild || undefined)
210
+ /** Read the guest page's own storage as `[[key, value], …]`. */
211
+ function readStorage(element) {
212
+ return element.executeJavaScript(
213
+ "JSON.stringify(Object.keys(localStorage).map(function (key) { return [key, localStorage.getItem(key)] }))",
214
+ )
185
215
  }
186
216
 
187
- /** The New Session button the entry is ordered against. */
188
- function newSessionButton(root) {
189
- var nested = root.querySelector('button[class*="newSession"]')
190
- if (nested !== null) return nested
191
- for (var i = 0; i < root.children.length; i++) {
192
- if (root.children[i].tagName === 'BUTTON') return root.children[i]
193
- }
194
- return undefined
195
- }
196
-
197
- /** Build the entry row. */
198
- function createEntry(onClick) {
199
- var entry = document.createElement('button')
200
- entry.type = 'button'
201
- entry.setAttribute(ENTRY_ATTR, '')
202
- entry.setAttribute('data-dsh-plugin', 'webchat')
203
- entry.setAttribute('data-dsh-part', 'sidebar-entry')
204
- entry.setAttribute('aria-label', T.label)
205
- entry.setAttribute('title', T.tooltip)
206
- var icon = document.createElement('span')
207
- icon.setAttribute('data-part', 'icon')
208
- icon.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.5a5.5 5.5 0 0 0-4.7 8.3L2.5 13.5l2.8-.8A5.5 5.5 0 1 0 8 2.5z"/><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none"/></svg>'
209
- var label = document.createElement('span')
210
- label.setAttribute('data-part', 'label')
211
- label.textContent = T.label
212
- entry.appendChild(icon)
213
- entry.appendChild(label)
214
- entry.addEventListener('click', onClick)
215
- return entry
217
+ /** Write `[[key, value], …]` back into the guest page's storage. */
218
+ function writeStorage(element, entries) {
219
+ return element.executeJavaScript(
220
+ "(function () { var entries = " + JSON.stringify(entries) + ";"
221
+ + " for (var i = 0; i < entries.length; i++) { try { localStorage.setItem(entries[i][0], entries[i][1]) } catch (error) {} }"
222
+ + " return entries.length })()",
223
+ )
216
224
  }
217
225
 
218
- /** Re-insert the entry after the New Session row when React displaces it. */
219
- function placeEntry(root, entry) {
220
- var button = newSessionButton(root)
221
- if (button === undefined) return false
222
- if (entry.parentElement === root) return true
223
- var row = button.closest('[class*="logoRow"]')
224
- var base = row !== null && row.parentElement === root ? row : button
225
- var family = Array.prototype.filter.call(root.children, function (el) {
226
- return el.matches && el.matches('[' + ENTRY_ATTR + '], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]')
227
- })
228
- var anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
229
- root.insertBefore(entry, anchor)
230
- return true
231
- }
232
-
233
- /** Mount the sidebar entry with its self-healing observers. */
234
- function mountSidebarEntry(controller, entry) {
235
- var root
236
- var placed = false
237
-
238
- var tryPlace = function () {
239
- if (root !== undefined && !root.isConnected) {
240
- rootObserver.disconnect()
241
- root = undefined
242
- placed = false
243
- }
244
- if (placed) {
245
- if (document.body.contains(entry)) return
246
- rootObserver.disconnect()
247
- root = undefined
248
- placed = false
226
+ /**
227
+ * Ask the host to copy the saved cookies onto this run's partition. This
228
+ * runs before the first navigation, so the page's first request already
229
+ * carries them.
230
+ * @param partition - the partition the shell issued for the lease.
231
+ * @returns the saved site storage, or null when there is none usable.
232
+ */
233
+ async function restoreGuestSession(partition) {
234
+ try {
235
+ const response = await fetch(RESTORE_ROUTE, {
236
+ method: "POST",
237
+ headers: { "content-type": "application/json" },
238
+ body: JSON.stringify({ partition }),
239
+ })
240
+ const payload = await response.json()
241
+ if (payload === null || typeof payload !== "object" || payload.ok !== true) {
242
+ console.warn("[dsh-webchat] session restore:", payload === null ? "no answer" : payload.error || "failed")
243
+ return null
249
244
  }
250
- if (root === undefined) root = sidebarRoot()
251
- if (root === undefined) return
252
- placed = placeEntry(root, entry)
253
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
245
+ return Array.isArray(payload.storage) && payload.storage.length > 0 ? payload.storage : null
246
+ } catch (error) {
247
+ console.warn("[dsh-webchat] session restore failed:", error)
248
+ return null
254
249
  }
250
+ }
255
251
 
256
- var waitObserver = new MutationObserver(function () { tryPlace() })
257
- waitObserver.observe(document.body, { childList: true, subtree: true })
258
- var rootObserver = new MutationObserver(function () {
259
- if (root === undefined || !root.isConnected) {
260
- placed = false
261
- tryPlace()
262
- return
252
+ /**
253
+ * Ask the host to refresh the snapshot. Cookies are read host-side;
254
+ * `withStorage` additionally ships the page's storage, which the unload
255
+ * beacon deliberately skips (the host then keeps the previous one).
256
+ * @param withStorage - whether to include the guest's storage.
257
+ * @param target - the guest record; defaults to the live one, which
258
+ * disposal clears before its last save.
259
+ */
260
+ async function saveGuestSession(withStorage, target) {
261
+ const record = target === undefined ? guest : target
262
+ if (record === null || record === undefined) return
263
+ const body = { partition: record.partition }
264
+ if (withStorage) {
265
+ try {
266
+ const parsed = JSON.parse(await readStorage(record.element))
267
+ if (Array.isArray(parsed)) body.storage = parsed
268
+ } catch (error) {
269
+ // Not ready, or nothing stored: cookies still get saved.
263
270
  }
264
- if (!root.contains(entry)) placed = placeEntry(root, entry)
265
- })
266
-
267
- var syncActive = function () {
268
- if (controller.isOpen()) entry.dataset.active = 'true'
269
- else delete entry.dataset.active
270
271
  }
271
- var unsubscribe = controller.subscribe(syncActive)
272
- syncActive()
273
- tryPlace()
274
-
275
- return function () {
276
- waitObserver.disconnect()
277
- rootObserver.disconnect()
278
- unsubscribe()
279
- entry.remove()
272
+ try {
273
+ await fetch(SAVE_ROUTE, {
274
+ method: "POST",
275
+ headers: { "content-type": "application/json" },
276
+ body: JSON.stringify(body),
277
+ })
278
+ } catch (error) {
279
+ // Best effort: a snapshot that fails costs a login, not the page.
280
280
  }
281
281
  }
282
282
 
283
- /** The center column that hosts full-page views. */
284
- function conversationColumn() {
285
- return document.querySelector('[data-pane="conversation"], [class*="centerCol"]') || undefined
286
- }
287
-
288
283
  /**
289
- * Mount the panel that carries the guest.
290
- *
291
- * The guest is created lazily on first open and then kept mounted while
292
- * the panel is closed, so re-opening does not reload the page and the
293
- * DeepSeek session stays where it was.
294
- * @param controller - panel open state.
295
- * @returns disposer removing the panel and releasing the guest.
284
+ * Reserve a guest and attach its webview, once per app run.
285
+ * @returns a promise resolving once the attempt settled.
296
286
  */
297
- function mountPanel(controller) {
298
- var container
299
- var frame
300
- var lease
301
- var failed = false
302
-
303
- var showMessage = function (text) {
304
- var box = document.createElement('div')
305
- box.className = 'dsh-wc-message'
306
- box.textContent = text
307
- container.appendChild(box)
308
- }
309
-
310
- /** Reserve a guest and attach its webview. */
311
- var ensureGuest = async function () {
312
- if (frame !== undefined || failed) return
287
+ function ensureGuest() {
288
+ if (guest !== null || bridge === undefined) return Promise.resolve()
289
+ if (acquiring !== null) return acquiring
290
+ acquiring = (async () => {
313
291
  try {
314
- const reservation = await guests.acquire(STORAGE_IDENTITY)
315
- if (reservation === null || typeof reservation !== 'object'
316
- || typeof reservation.lease !== 'string' || typeof reservation.partition !== 'string') {
317
- throw new Error('the desktop browser bridge returned no reservation')
292
+ const reservation = await bridge.acquire(STORAGE_IDENTITY)
293
+ if (reservation === null || typeof reservation !== "object"
294
+ || typeof reservation.lease !== "string" || typeof reservation.partition !== "string") {
295
+ throw new Error("the desktop browser bridge returned no reservation")
318
296
  }
319
- lease = reservation.lease
320
- const element = document.createElement('webview')
321
- element.className = FRAME_CLASS
322
- element.setAttribute('name', reservation.lease)
323
- element.setAttribute('partition', reservation.partition)
324
- element.setAttribute('allowpopups', '')
325
- element.setAttribute('src', 'about:blank#' + reservation.lease)
297
+ // Cookies land on the partition before anything is requested.
298
+ const storage = await restoreGuestSession(reservation.partition)
299
+ const element = document.createElement("webview")
300
+ element.setAttribute("name", reservation.lease)
301
+ element.setAttribute("partition", reservation.partition)
302
+ element.setAttribute("allowpopups", "")
303
+ element.setAttribute("src", "about:blank#" + reservation.lease)
326
304
  const userAgent = cleanUserAgent()
327
- if (userAgent !== '') element.setAttribute('useragent', userAgent)
328
- element.addEventListener('dom-ready', function () {
329
- // The guest exists by now, and nothing has been navigated
330
- // yet, so this UA is the one the page actually loads with.
331
- if (userAgent !== '') {
305
+ if (userAgent !== "") element.setAttribute("useragent", userAgent)
306
+ element.addEventListener("dom-ready", function () {
307
+ // The guest exists by now and nothing has been navigated yet,
308
+ // so this is the user agent the page actually loads with.
309
+ if (userAgent !== "") {
332
310
  try {
333
311
  element.setUserAgent(userAgent)
334
312
  } catch (error) {
335
- console.warn('[dsh-webchat] could not set the guest user agent:', error)
313
+ console.warn("[dsh-webchat] could not set the guest user agent:", error)
336
314
  }
337
315
  }
338
316
  element.loadURL(PAGE_URL).catch(function (error) {
339
- toast(T.failed + ':' + (error instanceof Error ? error.message : String(error)), 'bad')
317
+ failure = messageOf(error)
340
318
  })
341
319
  }, { once: true })
342
- frame = element
320
+ // Site storage can only be written while the page is on its own
321
+ // origin, so it is restored after the first load and then the
322
+ // page is asked to load once more — this time already signed in.
323
+ // Until that settles, snapshots are skipped: the freshly loaded
324
+ // page still has empty storage, and saving that would erase the
325
+ // very snapshot being restored.
326
+ let storageState = storage === null ? "none" : "pending"
327
+ element.addEventListener("did-finish-load", function () {
328
+ if (storageState !== "pending") return
329
+ storageState = "restoring"
330
+ Promise.resolve(writeStorage(element, storage))
331
+ .then(function () {
332
+ storageState = "done"
333
+ element.reload()
334
+ })
335
+ .catch(function (error) {
336
+ storageState = "done"
337
+ console.warn("[dsh-webchat] could not restore site storage:", error)
338
+ })
339
+ })
340
+ element.addEventListener("did-finish-load", function () {
341
+ if (storageState === "pending" || storageState === "restoring") return
342
+ void saveGuestSession(true)
343
+ })
344
+ const onUnload = function () {
345
+ // Cookies are the part that matters and the host reads them
346
+ // itself, so the beacon carries no payload.
347
+ try {
348
+ navigator.sendBeacon(SAVE_ROUTE, JSON.stringify({ partition: reservation.partition }))
349
+ } catch (error) {
350
+ // The page is going away; nothing left to do.
351
+ }
352
+ }
353
+ window.addEventListener("beforeunload", onUnload)
354
+ const container = overlayContainer()
343
355
  container.appendChild(element)
356
+ guest = {
357
+ lease: reservation.lease,
358
+ partition: reservation.partition,
359
+ element,
360
+ container,
361
+ onUnload,
362
+ timer: setInterval(function () { void saveGuestSession(true) }, SAVE_INTERVAL_MS),
363
+ }
364
+ failure = ""
344
365
  } catch (error) {
345
- failed = true
346
- showMessage(T.failed + ':' + (error instanceof Error ? error.message : String(error)))
366
+ failure = messageOf(error)
367
+ } finally {
368
+ acquiring = null
347
369
  }
348
- }
370
+ })()
371
+ return acquiring
372
+ }
349
373
 
350
- var ensure = function () {
351
- if (container !== undefined) {
352
- if (container.isConnected) return
353
- container.remove()
354
- container = undefined
355
- frame = undefined
356
- }
357
- var column = conversationColumn()
358
- if (column === undefined) return
359
- container = document.createElement('div')
360
- container.setAttribute(VIEW_ATTR, '')
361
- container.setAttribute('data-dsh-plugin', 'webchat')
362
- column.appendChild(container)
374
+ /** Release the guest, for plugin disposal. */
375
+ function disposeGuest() {
376
+ if (guest === null) return
377
+ const releasing = guest
378
+ guest = null
379
+ if (releasing.timer !== undefined) clearInterval(releasing.timer)
380
+ if (releasing.onUnload !== undefined) window.removeEventListener("beforeunload", releasing.onUnload)
381
+ // Last chance to keep the login: the page is about to be torn down.
382
+ void saveGuestSession(true, releasing)
383
+ releasing.container.remove()
384
+ if (bridge !== undefined) {
385
+ try { void bridge.release(releasing.lease) } catch (error) { /* already gone */ }
363
386
  }
387
+ }
364
388
 
365
- var waitObserver = new MutationObserver(function () { ensure() })
389
+ /** Position the persistent guest over the area the slot gave this panel. */
390
+ function syncBounds(host) {
391
+ if (guest === null) return
392
+ const rect = host.getBoundingClientRect()
393
+ const style = guest.container.style
394
+ style.left = rect.left + "px"
395
+ style.top = rect.top + "px"
396
+ style.width = rect.width + "px"
397
+ style.height = rect.height + "px"
398
+ }
366
399
 
367
- var applyActive = function () {
368
- if (controller.isOpen()) {
369
- OTHER_ACTIVE_ATTRS.forEach(function (attr) { document.documentElement.removeAttribute(attr) })
370
- document.documentElement.setAttribute(ACTIVE_ATTR, '')
371
- // Mount before announcing: the arbitration event dispatches into
372
- // other plugins, and a throwing listener must not be able to leave
373
- // this panel half-open.
374
- ensure()
375
- void ensureGuest()
376
- document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
377
- } else {
378
- document.documentElement.removeAttribute(ACTIVE_ATTR)
400
+ /** The center-column panel the layout mounts while this id is selected. */
401
+ function WebchatPanel() {
402
+ const hostRef = React.useRef(null)
403
+
404
+ React.useEffect(function () {
405
+ const host = hostRef.current
406
+ if (host === null || host === undefined) return undefined
407
+ let alive = true
408
+ let observer
409
+ let reposition = null
410
+
411
+ const showFallback = function (text) {
412
+ host.replaceChildren()
413
+ host.appendChild(messageBox(text, {
414
+ label: copy().fallbackAction,
415
+ run: (button) => openInWindow(button),
416
+ }))
379
417
  }
380
- }
381
- var onOtherActivate = function (event) {
382
- var detail = event.detail
383
- if ((detail === 'taskboard' || detail === 'ssh') && controller.isOpen()) controller.close()
384
- }
385
- /**
386
- * The shell's own panel list owns the center column. Its rows —
387
- * Plugins, Automation Tasks, the task board, sessions, workspaces —
388
- * do not take part in the third-party `data-*-active` handshake, so
389
- * this panel has to yield on any navigation from the sidebar, or its
390
- * overlay would keep hiding the page the shell just selected.
391
- */
392
- var SIDEBAR_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"]'
393
- var onClickSidebarRow = function (event) {
394
- if (!controller.isOpen()) return
395
- var target = event.target
396
- if (target === null || target.closest === undefined) return
397
- // This plugin's own row toggles the panel; it is not navigation.
398
- if (target.closest('[' + ENTRY_ATTR + ']') !== null) return
399
- if (target.closest(SIDEBAR_SELECTOR) !== null) controller.close()
400
- }
401
418
 
402
- document.addEventListener('click', onClickSidebarRow, true)
403
- document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
404
- var unsubscribe = controller.subscribe(applyActive)
405
- applyActive()
406
- ensure()
407
- waitObserver.observe(document.body, { childList: true, subtree: true })
408
-
409
- return function () {
410
- document.removeEventListener('click', onClickSidebarRow, true)
411
- document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
412
- waitObserver.disconnect()
413
- unsubscribe()
414
- document.documentElement.removeAttribute(ACTIVE_ATTR)
415
- if (frame !== undefined) frame.remove()
416
- if (container !== undefined) container.remove()
417
- frame = undefined
418
- container = undefined
419
- if (lease !== undefined) {
420
- const releasing = lease
421
- lease = undefined
422
- try { void guests.release(releasing) } catch (error) { /* already gone */ }
419
+ if (bridge === undefined) {
420
+ showFallback(copy().fallbackNote)
421
+ return undefined
423
422
  }
424
- }
425
- }
426
-
427
- /** Mount the entry, plus the panel when the desktop bridge is present. */
428
- function apply(ctx) {
429
- ensureStyle()
430
- var controller = createController()
431
- var disposers = []
432
423
 
433
- if (guests !== undefined) {
434
- // Desktop: the entry toggles the in-app panel.
435
- var entry = createEntry(function () { controller.toggle() })
436
- disposers.push(mountSidebarEntry(controller, entry))
437
- disposers.push(mountPanel(controller))
438
- } else {
439
- // Web: no native guest to lend, so the entry asks the host for a window.
440
- var fallbackEntry = createEntry(async function () {
441
- if (fallbackEntry.disabled) return
442
- fallbackEntry.disabled = true
443
- try {
444
- var outcome = await openInWindow()
445
- if (outcome.ok === true) {
446
- toast(String(outcome.via) === 'system-browser' ? T.fallbackHanded : T.fallbackOpened)
447
- } else {
448
- toast(T.fallbackFailed + ':' + String(outcome.error || outcome.via || ''), 'bad')
449
- }
450
- } catch (error) {
451
- toast(T.fallbackFailed + ':' + (error instanceof Error ? error.message : String(error)), 'bad')
424
+ void ensureGuest().then(function () {
425
+ if (!alive) return
426
+ if (guest === null) {
427
+ showFallback(copy().failed + ":" + (failure || "unknown"))
428
+ return
429
+ }
430
+ host.replaceChildren()
431
+ syncBounds(host)
432
+ guest.container.style.display = "block"
433
+ reposition = function () {
434
+ if (alive) syncBounds(host)
452
435
  }
453
- fallbackEntry.disabled = false
436
+ if (typeof ResizeObserver === "function") {
437
+ observer = new ResizeObserver(reposition)
438
+ observer.observe(host)
439
+ }
440
+ window.addEventListener("resize", reposition)
454
441
  })
455
- disposers.push(mountSidebarEntry(controller, fallbackEntry))
456
- }
457
442
 
458
- ctx.effect(function () {
459
443
  return function () {
460
- disposers.splice(0).forEach(function (dispose) { dispose() })
444
+ alive = false
445
+ if (observer !== undefined) observer.disconnect()
446
+ if (reposition !== null) window.removeEventListener("resize", reposition)
447
+ // Hidden, never detached: the guest keeps its page and its login.
448
+ if (guest !== null) guest.container.style.display = "none"
461
449
  }
462
- }, 'dsh-webchat: ui mounts')
450
+ }, [])
451
+
452
+ return h("div", { ref: hostRef, className: "dsh-wc-host" })
453
+ }
454
+
455
+ /** The sidebar row's icon. The sidebar owns the button around it. */
456
+ function WebchatIcon(props) {
457
+ const size = props !== null && props !== undefined && typeof props.size === "number" ? props.size : 16
458
+ return h("svg", {
459
+ viewBox: "0 0 16 16",
460
+ width: size,
461
+ height: size,
462
+ fill: "none",
463
+ stroke: "currentColor",
464
+ strokeWidth: 1.3,
465
+ strokeLinecap: "round",
466
+ strokeLinejoin: "round",
467
+ "aria-hidden": true,
468
+ },
469
+ h("path", { d: "M8 2.5a5.5 5.5 0 0 0-4.7 8.3L2.5 13.5l2.8-.8A5.5 5.5 0 1 0 8 2.5z" }),
470
+ h("circle", { cx: 8, cy: 8, r: 1.1, fill: "currentColor", stroke: "none" }),
471
+ )
472
+ }
473
+
474
+ /** Register the nav row and the page, then own the guest's lifetime. */
475
+ function apply(ctx) {
476
+ ensureStyle()
477
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
478
+ name: "sidebar.panellist",
479
+ id: PANEL_ID,
480
+ order: -1,
481
+ label: () => copy().label,
482
+ }, WebchatIcon))
483
+ ctx.slots.inject("main", () => ctx.slots.register({
484
+ name: "main",
485
+ key: PANEL_ID,
486
+ }, WebchatPanel))
487
+ ctx.effect(() => () => {
488
+ disposeGuest()
489
+ }, "dsh-webchat: native guest")
463
490
  }
464
491
 
465
- exports.apply = apply
466
- exports.inject = []
467
- return module.exports
492
+ return { inject: ["slots"], apply }
468
493
  },
469
494
  })