@jaychang1989/dsh-webchat 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +20 -8
- package/README.md +20 -8
- package/lib/client.js +407 -382
- package/lib/index.js +299 -29
- package/package.json +6 -2
package/lib/client.js
CHANGED
|
@@ -1,92 +1,96 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-webchat — browser half.
|
|
3
3
|
*
|
|
4
|
-
* One sidebar entry that
|
|
5
|
-
* the DSH window
|
|
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
|
-
*
|
|
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
|
-
*
|
|
17
|
-
* the
|
|
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
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
/** The page
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
"[
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
"
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
116
|
-
* stored per guest partition —
|
|
117
|
-
*
|
|
118
|
-
* `navigator.userAgent.toLowerCase().includes("electron")
|
|
119
|
-
*
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (platform === undefined || chrome === undefined) return
|
|
128
|
-
return platform +
|
|
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
|
-
/**
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
box.
|
|
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
|
-
|
|
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
|
-
/**
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
/**
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
/**
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
*
|
|
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
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
|
315
|
-
if (reservation === null || typeof reservation !==
|
|
316
|
-
|| typeof reservation.lease !==
|
|
317
|
-
throw new Error(
|
|
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
|
-
|
|
320
|
-
const
|
|
321
|
-
element
|
|
322
|
-
element.setAttribute(
|
|
323
|
-
element.setAttribute(
|
|
324
|
-
element.setAttribute(
|
|
325
|
-
element.setAttribute(
|
|
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 !==
|
|
328
|
-
element.addEventListener(
|
|
329
|
-
// The guest exists by now
|
|
330
|
-
//
|
|
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(
|
|
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
|
-
|
|
317
|
+
failure = messageOf(error)
|
|
340
318
|
})
|
|
341
319
|
}, { once: true })
|
|
342
|
-
|
|
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
|
-
|
|
346
|
-
|
|
366
|
+
failure = messageOf(error)
|
|
367
|
+
} finally {
|
|
368
|
+
acquiring = null
|
|
347
369
|
}
|
|
348
|
-
}
|
|
370
|
+
})()
|
|
371
|
+
return acquiring
|
|
372
|
+
}
|
|
349
373
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
466
|
-
exports.inject = []
|
|
467
|
-
return module.exports
|
|
492
|
+
return { inject: ["slots"], apply }
|
|
468
493
|
},
|
|
469
494
|
})
|