dsh-clean-desktop-shell 0.1.11 → 0.1.13
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/CONTRIBUTORS.md +25 -19
- package/README.en.md +422 -333
- package/README.md +363 -278
- package/electron/aumid.js +17 -17
- package/electron/crashGuard.js +60 -60
- package/electron/error.html +113 -113
- package/electron/main.js +203 -187
- package/electron/outage.js +60 -0
- package/electron/preload.js +216 -49
- package/electron/progress-preload.js +11 -11
- package/electron/progress.html +79 -79
- package/electron/progress.js +56 -56
- package/electron/service.js +559 -458
- package/electron/shortcut.js +122 -122
- package/electron/tray.js +232 -232
- package/electron/window.js +603 -264
- package/lib/client.js +41 -6
- package/lib/common.js +74 -74
- package/lib/icon.js +85 -51
- package/lib/index.js +90 -15
- package/lib/runtime.js +423 -423
- package/package.json +140 -127
- package/scripts/check-syntax.mjs +54 -54
- package/scripts/selftest-recovery.mjs +140 -0
- package/scripts/selftest-runtime.mjs +90 -90
- package/src/client/client.js +41 -6
- package/src/host/common.js +74 -74
- package/src/host/icon.js +85 -51
- package/src/host/index.js +90 -15
- package/src/host/runtime.js +423 -423
- package/version.txt +1 -1
package/electron/window.js
CHANGED
|
@@ -1,264 +1,603 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Clean window creation — no frosted-glass materials.
|
|
3
|
-
*
|
|
4
|
-
* Pure shell philosophy: the window is a normal native frame with
|
|
5
|
-
* window-controls overlay (Win) / hiddenInset (mac), and nothing else.
|
|
6
|
-
* No Mica, no vibrancy — keep it clean.
|
|
7
|
-
*
|
|
8
|
-
* Window reliability (
|
|
9
|
-
* - the window shows immediately on launch (never waits for the backend);
|
|
10
|
-
* -
|
|
11
|
-
* local "backend offline" screen
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
//
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
win.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Clean window creation — no frosted-glass materials.
|
|
3
|
+
*
|
|
4
|
+
* Pure shell philosophy: the window is a normal native frame with
|
|
5
|
+
* window-controls overlay (Win) / hiddenInset (mac), and nothing else.
|
|
6
|
+
* No Mica, no vibrancy — keep it clean.
|
|
7
|
+
*
|
|
8
|
+
* Window reliability (non-destructive recovery):
|
|
9
|
+
* - the window shows immediately on launch (never waits for the backend);
|
|
10
|
+
* - without a loaded session (boot, or a page load that failed) an
|
|
11
|
+
* unreachable backend swaps in the local "backend offline" screen, which
|
|
12
|
+
* re-probes and loads the real page automatically when it answers;
|
|
13
|
+
* - once a real page is loaded, a failed liveness probe NEVER navigates
|
|
14
|
+
* away: the page stays up (draft, scroll, selection intact) and an
|
|
15
|
+
* in-page notice appears with retry/reload actions. The notice clears by
|
|
16
|
+
* itself once BOTH health signals are clear — the HTTP probe answers AND
|
|
17
|
+
* the page's own DSH client runtime reports 'connected' (bridged from
|
|
18
|
+
* src/client/client.js) — so recovery syncs through the app's own
|
|
19
|
+
* reconnect loop, never an artificial full reload;
|
|
20
|
+
* - a confirmed backend exit (service status cause 'exit'/'stop': tray
|
|
21
|
+
* stop, managed child crash) is the one case that still flips a loaded
|
|
22
|
+
* page to the offline screen — visible recovery instead of a stale
|
|
23
|
+
* "online" page. Probe-observed states (watch or detect() timeouts,
|
|
24
|
+
* cause 'probe') are never treated as a process exit.
|
|
25
|
+
*/
|
|
26
|
+
import { app, BrowserWindow, ipcMain } from 'electron'
|
|
27
|
+
import { existsSync } from 'node:fs'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
30
|
+
import { probe, onStatusChange, detect, getStatus } from './service.js'
|
|
31
|
+
import { OutageRun } from './outage.js'
|
|
32
|
+
import { startBackendWithProgress, chooseBackendFolder } from './tray.js'
|
|
33
|
+
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
34
|
+
|
|
35
|
+
export const WINDOWS_TITLEBAR_HEIGHT = 32
|
|
36
|
+
|
|
37
|
+
// Compare by origin (scheme + host + port), ignoring path/query. With dsh
|
|
38
|
+
// 0.1.2+ the target may carry a one-time launch token (`.../?token=…`); the
|
|
39
|
+
// backend exchanges it for a session cookie and 303-redirects to a clean
|
|
40
|
+
// `/`, so the window's settled URL no longer contains the token and an exact
|
|
41
|
+
// prefix match would wrongly reject a successful load.
|
|
42
|
+
function sameOrigin(a, b) {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(a).origin === new URL(b).origin
|
|
45
|
+
} catch {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
51
|
+
const PRELOAD_PATH = fileURLToPath(new URL('./preload.js', import.meta.url))
|
|
52
|
+
// Windows taskbar follows the window icon only when it is an .ico; a png
|
|
53
|
+
// covers the title bar / alt-tab but not the taskbar button. In plugin
|
|
54
|
+
// mode there is no exe icon resource, so prefer the bundled .ico.
|
|
55
|
+
const TASKBAR_ICO = join(PKG_ROOT, 'build', 'icon.ico')
|
|
56
|
+
// Black-whale app icon (matches the DSH web favicon).
|
|
57
|
+
const ICON_PATH = existsSync(TASKBAR_ICO)
|
|
58
|
+
? TASKBAR_ICO
|
|
59
|
+
: fileURLToPath(new URL('../build/icon.png', import.meta.url))
|
|
60
|
+
// Local fallback page shown while the backend is down. The file URL is
|
|
61
|
+
// precomputed so "is the offline page showing?" is an exact comparison,
|
|
62
|
+
// not a substring sniff over arbitrary web content.
|
|
63
|
+
const ERROR_PAGE = fileURLToPath(new URL('./error.html', import.meta.url))
|
|
64
|
+
const ERROR_PAGE_URL = pathToFileURL(ERROR_PAGE).href
|
|
65
|
+
|
|
66
|
+
// How often we re-probe the backend while the window is in "offline" mode.
|
|
67
|
+
const RECONNECT_INTERVAL_MS = 2500
|
|
68
|
+
// How often we check the backend is still alive while the page is shown.
|
|
69
|
+
const WATCH_INTERVAL_MS = 4000
|
|
70
|
+
|
|
71
|
+
// Top drag-strip geometry. The preload strip is 32px on Windows, 12px on
|
|
72
|
+
// macOS (kept BELOW the web UI's own ~16px toolbar padding so no clickable
|
|
73
|
+
// control is swallowed). The inset stamped here is deliberately 28px on
|
|
74
|
+
// macOS — larger than the strip — so dockable panels (better-sidebar) both
|
|
75
|
+
// clear the swallow band and keep clear of the traffic lights. better-sidebar reads this through its documented shell
|
|
76
|
+
// contract: `dsh-desktop-titlebar-inset` on the render URL.
|
|
77
|
+
function isWin() {
|
|
78
|
+
return process.platform === 'win32'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const TITLEBAR_INSET_PX = isWin() ? 32 : 28
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Stamp the shell contract onto a page URL: declares this shell frameless
|
|
85
|
+
* (advanced), its platform, and the exact top pixels the drag strip
|
|
86
|
+
* reserves. Dockable panels (better-sidebar) move their top chrome below
|
|
87
|
+
* the strip; plain browsers never see these params.
|
|
88
|
+
*/
|
|
89
|
+
function stampTarget(target) {
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(target)
|
|
92
|
+
url.searchParams.set('dsh-desktop-mode', 'advanced')
|
|
93
|
+
url.searchParams.set('dsh-desktop-platform', process.platform)
|
|
94
|
+
url.searchParams.set('dsh-desktop-titlebar-inset', String(TITLEBAR_INSET_PX))
|
|
95
|
+
return url.href
|
|
96
|
+
} catch {
|
|
97
|
+
// Not a parseable URL (custom scheme etc.) — load it untouched.
|
|
98
|
+
return target
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ERR_ABORTED — navigation was cancelled, not a real failure. Ignore it.
|
|
103
|
+
const ERR_ABORTED = -3
|
|
104
|
+
|
|
105
|
+
// Per-window state, keyed by webContents id.
|
|
106
|
+
const reconnectTimers = new Map()
|
|
107
|
+
const watchTimers = new Map()
|
|
108
|
+
const windowTargets = new Map()
|
|
109
|
+
const statusUnsubs = new Map()
|
|
110
|
+
// Per-window backend-outage run (see outage.js): consecutive-failure
|
|
111
|
+
// escalation for the notice + the navigation token that discards stale
|
|
112
|
+
// probe results after a flip/navigation/disposal.
|
|
113
|
+
const outageRuns = new Map()
|
|
114
|
+
// Single-flight guard: at most one backend probe per window at a time,
|
|
115
|
+
// shared by the watch tick, the reconnect tick and manual retries, so two
|
|
116
|
+
// overlapping polls can never double-act on the same result.
|
|
117
|
+
const pollInFlight = new Map()
|
|
118
|
+
// Merged per-window connection state: HTTP liveness outcome (from the
|
|
119
|
+
// outage run) + DSH client-runtime reports from the loaded page (client
|
|
120
|
+
// half, src/client/client.js). The notice reflects BOTH — an HTTP success
|
|
121
|
+
// must never hide a known terminal disconnect of the page's own connection.
|
|
122
|
+
// http: last committed probe payload ({state:'ok'|'degraded',…}) or null
|
|
123
|
+
// client: 'connected'|'connecting'|'disconnected', or null until the
|
|
124
|
+
// page runtime's first report (bridge forward of the initial
|
|
125
|
+
// snapshot covers the disconnect-before-first-probe case)
|
|
126
|
+
// reconnectPending: a shell-requested client reconnect is outstanding
|
|
127
|
+
// (cleared when the runtime reports connecting/connected)
|
|
128
|
+
const connStates = new Map()
|
|
129
|
+
|
|
130
|
+
/** Fresh merged state for a window that (re)loaded a page. */
|
|
131
|
+
function resetConn(win) {
|
|
132
|
+
connStates.set(win.id, { http: null, client: null, reconnectPending: false })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function connStateOf(win) {
|
|
136
|
+
let st = connStates.get(win.id)
|
|
137
|
+
if (!st) {
|
|
138
|
+
st = { http: null, client: null, reconnectPending: false }
|
|
139
|
+
connStates.set(win.id, st)
|
|
140
|
+
}
|
|
141
|
+
return st
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Recompute what the in-page notice should show from the merged state and
|
|
146
|
+
* push it to the renderer. The notice is visible whenever the page's own
|
|
147
|
+
* connection is known bad (client 'connecting'/'disconnected') OR the last
|
|
148
|
+
* HTTP probe failed; it hides only when both are clear. The persistent copy
|
|
149
|
+
* (stronger copy + reload action) is offered for anything terminal — a
|
|
150
|
+
* long HTTP outage OR the client runtime sitting in 'disconnected' — but
|
|
151
|
+
* not for 'connecting', where an automatic retry attempt is already in
|
|
152
|
+
* progress and must not be interrupted or escalated.
|
|
153
|
+
*/
|
|
154
|
+
function refreshNotice(win) {
|
|
155
|
+
if (win.isDestroyed()) return
|
|
156
|
+
const st = connStateOf(win)
|
|
157
|
+
const httpOk = !st.http || st.http.state === 'ok'
|
|
158
|
+
const clientBad = st.client === 'connecting' || st.client === 'disconnected'
|
|
159
|
+
let payload
|
|
160
|
+
if (!clientBad && httpOk) {
|
|
161
|
+
payload = { state: 'ok' }
|
|
162
|
+
} else {
|
|
163
|
+
payload = {
|
|
164
|
+
state: 'degraded',
|
|
165
|
+
persistent:
|
|
166
|
+
st.client === 'disconnected'
|
|
167
|
+
|| !!(st.http && st.http.state !== 'ok' && st.http.persistent),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
win.webContents.send('shell:connection-state', payload)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Ask the loaded page's DSH client runtime to reconnect through its own
|
|
175
|
+
* reconnect loop (shell:client-reconnect → preload → client.js →
|
|
176
|
+
* ctx.connection.reconnect()). Never sent while the runtime already
|
|
177
|
+
* reports 'connecting' (an attempt is in progress) and never repeated per
|
|
178
|
+
* watch tick while an earlier request is still outstanding.
|
|
179
|
+
*/
|
|
180
|
+
function requestClientReconnect(win) {
|
|
181
|
+
const st = connStateOf(win)
|
|
182
|
+
if (st.reconnectPending || st.client === 'connecting') return
|
|
183
|
+
st.reconnectPending = true
|
|
184
|
+
win.webContents.send('shell:client-reconnect')
|
|
185
|
+
}
|
|
186
|
+
// Pending launch URL for this window. It exists only until DSH exchanges the
|
|
187
|
+
// ?token= for the HttpOnly cookie and 303s back to clean "/", then it is
|
|
188
|
+
// cleared so normal reloads/reconnects use the bare canonical target.
|
|
189
|
+
const windowLaunchUrls = new Map()
|
|
190
|
+
// One-shot re-stamp guard per window: after the token exchange DSH redirects
|
|
191
|
+
// to clean "/", dropping the shell contract params, so the stamped target is
|
|
192
|
+
// loaded once more.
|
|
193
|
+
const restampedWindows = new Set()
|
|
194
|
+
|
|
195
|
+
/** The URL this window should load right now (launch bootstrap first, then clean target). */
|
|
196
|
+
function urlToLoad(win) {
|
|
197
|
+
const pending = windowLaunchUrls.get(win.id)
|
|
198
|
+
if (pending) return pending
|
|
199
|
+
const target = windowTargets.get(win.id)
|
|
200
|
+
return target ? stampTarget(target) : target
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Mark a launch bootstrap as consumed once we land on a clean target URL. */
|
|
204
|
+
function clearLaunchUrl(win) {
|
|
205
|
+
if (windowLaunchUrls.has(win.id)) windowLaunchUrls.set(win.id, null)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The outage run of this window (created lazily on first use). */
|
|
209
|
+
function runFor(win) {
|
|
210
|
+
let run = outageRuns.get(win.id)
|
|
211
|
+
if (!run) {
|
|
212
|
+
run = new OutageRun()
|
|
213
|
+
outageRuns.set(win.id, run)
|
|
214
|
+
}
|
|
215
|
+
return run
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The window is about to navigate (flip, reload, reconnect success):
|
|
220
|
+
* invalidate every in-flight probe of this window and start fresh outage +
|
|
221
|
+
* merged-connection state, so stale results can never act on the newer
|
|
222
|
+
* navigation.
|
|
223
|
+
*/
|
|
224
|
+
function bumpNav(win) {
|
|
225
|
+
runFor(win).reset()
|
|
226
|
+
resetConn(win)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Manual reload requests come from the tray button and from the offline
|
|
230
|
+
// screen's retry button (via preload -> ipcRenderer). Route them to the
|
|
231
|
+
// window that sent the message.
|
|
232
|
+
ipcMain.on('shell:reload', (event) => {
|
|
233
|
+
const win = BrowserWindow.fromWebContents(event.sender)
|
|
234
|
+
if (win && !win.isDestroyed()) {
|
|
235
|
+
reloadWindow(win, windowTargets.get(win.id))
|
|
236
|
+
}
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
// Manual retries share the same probe and reconnect guards as the watch.
|
|
240
|
+
// A reachable backend may need a fresh client generation even if the page
|
|
241
|
+
// has not yet reported its old connection as disconnected.
|
|
242
|
+
ipcMain.on('shell:retry-connection', (event) => {
|
|
243
|
+
const win = BrowserWindow.fromWebContents(event.sender)
|
|
244
|
+
if (win && !win.isDestroyed()) {
|
|
245
|
+
const target = windowTargets.get(win.id)
|
|
246
|
+
checkBackend(win, target, true)
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// The loaded page's DSH client runtime reports its own connection lifecycle
|
|
251
|
+
// (client.js → shellAPI.connectionReport). Terminal disconnect of the page
|
|
252
|
+
// connection is invisible to HTTP probes — merge it into the notice state.
|
|
253
|
+
ipcMain.on('shell:client-connection', (event, state) => {
|
|
254
|
+
if (state !== 'connected' && state !== 'connecting' && state !== 'disconnected') return
|
|
255
|
+
const win = BrowserWindow.fromWebContents(event.sender)
|
|
256
|
+
if (!win || win.isDestroyed()) return
|
|
257
|
+
// Only reports from the loaded real page count; the offline screen has no
|
|
258
|
+
// client runtime and stale frames after navigation must not act.
|
|
259
|
+
const target = windowTargets.get(win.id)
|
|
260
|
+
if (!target || !win.webContents.getURL().startsWith(target)) return
|
|
261
|
+
const st = connStateOf(win)
|
|
262
|
+
st.client = state
|
|
263
|
+
// A request is outstanding only while the runtime stays 'disconnected':
|
|
264
|
+
// 'connecting' means an attempt is in progress, 'connected' means done.
|
|
265
|
+
if (state !== 'disconnected') st.reconnectPending = false
|
|
266
|
+
refreshNotice(win)
|
|
267
|
+
// Automatic retries are paced by checkBackend, not by failure feedback.
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
// Offline-screen quick actions: start / detect backend, pick install
|
|
271
|
+
// folder. The resulting state changes propagate via onStatusChange
|
|
272
|
+
// (window flip + tray refresh), so no extra wiring is needed here.
|
|
273
|
+
ipcMain.on('shell:start-backend', () => startBackendWithProgress())
|
|
274
|
+
ipcMain.on('shell:detect-backend', () => detect())
|
|
275
|
+
ipcMain.on('shell:choose-backend-folder', () => chooseBackendFolder())
|
|
276
|
+
|
|
277
|
+
// ---------- backend probing (shared by reconnect + watch + manual retry) ----------
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Run one guarded backend probe for this window.
|
|
281
|
+
*
|
|
282
|
+
* `commit` true (liveness watch / manual retry): the result advances the
|
|
283
|
+
* window's outage run and returns the renderer notice payload
|
|
284
|
+
* ({state:'ok'} / {state:'degraded', persistent}) — or null when skipped
|
|
285
|
+
* (another poll already in flight) or stale (run reset because the window
|
|
286
|
+
* navigated or was disposed while the probe was in flight).
|
|
287
|
+
*
|
|
288
|
+
* `commit` false (offline reconnect): returns the bare probe result
|
|
289
|
+
* (true/false) or null for the same skip/stale cases, without touching the
|
|
290
|
+
* outage run.
|
|
291
|
+
*
|
|
292
|
+
* Timeout stays at 1500ms — the same value as the service.js probe default.
|
|
293
|
+
*/
|
|
294
|
+
async function guardedProbe(win, target, commit = false) {
|
|
295
|
+
if (win.isDestroyed() || pollInFlight.get(win.id)) return null
|
|
296
|
+
const run = runFor(win)
|
|
297
|
+
const token = run.checkpoint()
|
|
298
|
+
pollInFlight.set(win.id, true)
|
|
299
|
+
let up = false
|
|
300
|
+
try {
|
|
301
|
+
up = await probe(target, 1500)
|
|
302
|
+
} finally {
|
|
303
|
+
pollInFlight.delete(win.id)
|
|
304
|
+
}
|
|
305
|
+
if (win.isDestroyed()) return null
|
|
306
|
+
return commit ? run.apply(token, up) : run.isCurrent(token) ? up : null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Probe the backend while the real page is shown and record the outcome
|
|
311
|
+
* into the merged per-window connection state (refreshNotice then decides
|
|
312
|
+
* the notice — HTTP and client-runtime signals combined). Never navigates:
|
|
313
|
+
* a failed probe keeps the loaded page (draft, scroll, selection) and only
|
|
314
|
+
* escalates the notice copy after consecutive failures (OutageRun). The
|
|
315
|
+
* offline screen is reached exclusively via confirmed lifecycle exits
|
|
316
|
+
* (service status cause 'exit'/'stop') or boot without a loaded page.
|
|
317
|
+
*/
|
|
318
|
+
async function checkBackend(win, target, forceReconnect = false) {
|
|
319
|
+
if (win.isDestroyed() || !target) return
|
|
320
|
+
const outcome = await guardedProbe(win, target, true)
|
|
321
|
+
if (!outcome) return
|
|
322
|
+
// Only the loaded real page carries the notice overlay; the offline screen
|
|
323
|
+
// has its own copy and mid-navigation pages must not get a stray message.
|
|
324
|
+
if (!win.webContents.getURL().startsWith(target)) {
|
|
325
|
+
// Page left the target (navigation in progress / other origin) — when it
|
|
326
|
+
// comes back, outage + connection state start fresh.
|
|
327
|
+
bumpNav(win)
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
const st = connStateOf(win)
|
|
331
|
+
st.http = outcome
|
|
332
|
+
refreshNotice(win)
|
|
333
|
+
// Retry only after a fresh successful probe. An immediately failing
|
|
334
|
+
// client generation must not feed back into another immediate attempt.
|
|
335
|
+
if (outcome.state === 'ok' && (forceReconnect || st.client === 'disconnected')) {
|
|
336
|
+
requestClientReconnect(win)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------- offline mode ----------
|
|
341
|
+
|
|
342
|
+
function stopReconnect(win) {
|
|
343
|
+
const timer = reconnectTimers.get(win.id)
|
|
344
|
+
if (timer) {
|
|
345
|
+
clearInterval(timer)
|
|
346
|
+
reconnectTimers.delete(win.id)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function startReconnect(win, target) {
|
|
351
|
+
if (reconnectTimers.has(win.id)) return
|
|
352
|
+
const timer = setInterval(async () => {
|
|
353
|
+
if (win.isDestroyed()) {
|
|
354
|
+
stopReconnect(win)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
// While the shell itself is starting the backend, the status machine
|
|
358
|
+
// owns the flip: the port can answer (4xx) before the CLI prints its
|
|
359
|
+
// launch-URL ready line, and probing early would load the bare target
|
|
360
|
+
// and lose the ?token= bootstrap. Wait for service.start() to deliver
|
|
361
|
+
// running + launchUrl instead.
|
|
362
|
+
if ((await getStatus()).status === 'starting') return
|
|
363
|
+
const up = await guardedProbe(win, target)
|
|
364
|
+
if (up === null) return
|
|
365
|
+
if (up) {
|
|
366
|
+
stopReconnect(win)
|
|
367
|
+
// Navigating away from the offline screen — invalidate any probe that
|
|
368
|
+
// started before this moment so it cannot race the navigation.
|
|
369
|
+
bumpNav(win)
|
|
370
|
+
win.webContents.loadURL(urlToLoad(win)).catch(() => startReconnect(win, target))
|
|
371
|
+
}
|
|
372
|
+
}, RECONNECT_INTERVAL_MS)
|
|
373
|
+
reconnectTimers.set(win.id, timer)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ---------- online mode (backend liveness watch) ----------
|
|
377
|
+
|
|
378
|
+
function stopWatch(win) {
|
|
379
|
+
const timer = watchTimers.get(win.id)
|
|
380
|
+
if (timer) {
|
|
381
|
+
clearInterval(timer)
|
|
382
|
+
watchTimers.delete(win.id)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** While the real page is shown, watch that the backend stays alive. */
|
|
387
|
+
function startWatch(win, target) {
|
|
388
|
+
if (watchTimers.has(win.id)) return
|
|
389
|
+
const timer = setInterval(() => {
|
|
390
|
+
if (win.isDestroyed()) {
|
|
391
|
+
stopWatch(win)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
checkBackend(win, target)
|
|
395
|
+
}, WATCH_INTERVAL_MS)
|
|
396
|
+
watchTimers.set(win.id, timer)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------- state flips ----------
|
|
400
|
+
|
|
401
|
+
/** Switch to the offline screen and start re-probing. */
|
|
402
|
+
function showOffline(win) {
|
|
403
|
+
if (win.isDestroyed()) return
|
|
404
|
+
const target = windowTargets.get(win.id)
|
|
405
|
+
stopWatch(win)
|
|
406
|
+
bumpNav(win)
|
|
407
|
+
win.loadFile(ERROR_PAGE).catch(() => {})
|
|
408
|
+
if (target) startReconnect(win, target)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Load the real backend page and start watching it. */
|
|
412
|
+
function showOnline(win) {
|
|
413
|
+
if (win.isDestroyed()) return
|
|
414
|
+
const target = windowTargets.get(win.id)
|
|
415
|
+
if (!target) return
|
|
416
|
+
stopReconnect(win)
|
|
417
|
+
bumpNav(win)
|
|
418
|
+
win.webContents.loadURL(urlToLoad(win)).catch(() => startReconnect(win, target))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Load the real target URL in a window (used by tray + offline retry). */
|
|
422
|
+
export function reloadWindow(win, target) {
|
|
423
|
+
if (!win || win.isDestroyed()) return
|
|
424
|
+
stopReconnect(win)
|
|
425
|
+
bumpNav(win)
|
|
426
|
+
win.webContents.loadURL(urlToLoad(win)).catch(() => startReconnect(win, target))
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------- window creation ----------
|
|
430
|
+
|
|
431
|
+
export function createMainWindow({ target, launchUrl }) {
|
|
432
|
+
const platform = process.platform
|
|
433
|
+
const isWin = platform === 'win32'
|
|
434
|
+
const isMac = platform === 'darwin'
|
|
435
|
+
|
|
436
|
+
const base = {
|
|
437
|
+
width: 1280,
|
|
438
|
+
height: 800,
|
|
439
|
+
minWidth: 760,
|
|
440
|
+
minHeight: 520,
|
|
441
|
+
// Show immediately — the backend may be starting, the window must not
|
|
442
|
+
// wait for `ready-to-show` (which lags when the page fails to load).
|
|
443
|
+
show: true,
|
|
444
|
+
title: 'DeepSeek Harness',
|
|
445
|
+
backgroundColor: '#10131A',
|
|
446
|
+
icon: isWin ? ICON_PATH : undefined,
|
|
447
|
+
webPreferences: {
|
|
448
|
+
preload: PRELOAD_PATH,
|
|
449
|
+
contextIsolation: true,
|
|
450
|
+
nodeIntegration: false,
|
|
451
|
+
sandbox: true,
|
|
452
|
+
webSecurity: true,
|
|
453
|
+
},
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
let options = { ...base }
|
|
457
|
+
|
|
458
|
+
if (isMac) {
|
|
459
|
+
options = {
|
|
460
|
+
...base,
|
|
461
|
+
titleBarStyle: 'hiddenInset',
|
|
462
|
+
trafficLightPosition: { x: 16, y: 16 },
|
|
463
|
+
}
|
|
464
|
+
} else if (isWin) {
|
|
465
|
+
options = {
|
|
466
|
+
...base,
|
|
467
|
+
autoHideMenuBar: true,
|
|
468
|
+
titleBarStyle: 'hidden',
|
|
469
|
+
titleBarOverlay: {
|
|
470
|
+
color: '#00000000',
|
|
471
|
+
symbolColor: '#7f858f',
|
|
472
|
+
height: WINDOWS_TITLEBAR_HEIGHT,
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// Linux / other: keep the native frame.
|
|
477
|
+
|
|
478
|
+
const win = new BrowserWindow(options)
|
|
479
|
+
// Windows taskbar button: bare runtime electron.exe has no custom icon,
|
|
480
|
+
// so pin the button to our .ico via setAppDetails (appId must match the
|
|
481
|
+
// app-level AppUserModelId set in main.js, else the options are ignored).
|
|
482
|
+
if (process.platform === 'win32' && existsSync(TASKBAR_ICO)) {
|
|
483
|
+
win.setAppDetails({
|
|
484
|
+
appId: APP_USER_MODEL_ID,
|
|
485
|
+
appIconPath: TASKBAR_ICO,
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
windowTargets.set(win.id, target)
|
|
489
|
+
windowLaunchUrls.set(win.id, launchUrl || null)
|
|
490
|
+
win.loadURL(urlToLoad(win)).catch(() => startReconnect(win, target))
|
|
491
|
+
|
|
492
|
+
// Instant flip when the backend state machine changes (tray stop/start).
|
|
493
|
+
const unsub = onStatusChange((st) => {
|
|
494
|
+
if (win.isDestroyed()) return
|
|
495
|
+
const isOffline = win.webContents.getURL().startsWith(ERROR_PAGE_URL)
|
|
496
|
+
// Confirmed lifecycle exit only: the status machine witnessed the
|
|
497
|
+
// backend leave — the managed child really exited (cause 'exit',
|
|
498
|
+
// whether it died while running → 'stopped' or while starting →
|
|
499
|
+
// 'error') or an explicit tray stop actually terminated it (cause
|
|
500
|
+
// 'stop' → 'stopped'). Probe-derived states (detect() timeouts →
|
|
501
|
+
// cause 'probe') and start failures are observations, not exits — they
|
|
502
|
+
// must never navigate a loaded page away; the liveness watch reports
|
|
503
|
+
// them through the in-page notice.
|
|
504
|
+
const confirmedExit =
|
|
505
|
+
(st.status === 'stopped' || st.status === 'error')
|
|
506
|
+
&& (st.cause === 'exit' || st.cause === 'stop')
|
|
507
|
+
if (confirmedExit) {
|
|
508
|
+
// The old process token died with the backend. Drop it so a later
|
|
509
|
+
// reconnect cannot replay a stale launch URL.
|
|
510
|
+
windowLaunchUrls.set(win.id, null)
|
|
511
|
+
if (!isOffline) showOffline(win)
|
|
512
|
+
} else if (st.status === 'running') {
|
|
513
|
+
// A freshly started dsh web brings a fresh per-process launch URL —
|
|
514
|
+
// printed on its banner, or handed over by the DSH host half in plugin
|
|
515
|
+
// mode. Adopt it so the window re-bootstraps when the previous HttpOnly
|
|
516
|
+
// cookie is gone or the previous process token is stale.
|
|
517
|
+
const fresh = st.launchUrl || null
|
|
518
|
+
const changed = fresh !== null && fresh !== windowLaunchUrls.get(win.id)
|
|
519
|
+
if (fresh !== null) {
|
|
520
|
+
windowLaunchUrls.set(win.id, fresh)
|
|
521
|
+
// A fresh bootstrap means a fresh 303, which will strip the shell
|
|
522
|
+
// contract params again — allow one more re-stamp for this window.
|
|
523
|
+
if (changed) restampedWindows.delete(win.id)
|
|
524
|
+
}
|
|
525
|
+
if (isOffline) {
|
|
526
|
+
// Backend came up while we are on the offline screen — load it.
|
|
527
|
+
showOnline(win)
|
|
528
|
+
} else if (changed) {
|
|
529
|
+
// The backend was replaced without the window ever going offline.
|
|
530
|
+
reloadWindow(win, target)
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
})
|
|
534
|
+
statusUnsubs.set(win.id, unsub)
|
|
535
|
+
|
|
536
|
+
win.webContents.on('did-fail-load', (_e, code, _desc, url, isMainFrame) => {
|
|
537
|
+
if (!isMainFrame || code === ERR_ABORTED) return
|
|
538
|
+
// Offline screen already showing — just keep re-probing, do not
|
|
539
|
+
// reload the offline page again (avoids a reload loop if it fails).
|
|
540
|
+
if (win.webContents.getURL().startsWith(ERROR_PAGE_URL)) {
|
|
541
|
+
startReconnect(win, windowTargets.get(win.id) || target)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
// The navigation itself failed, so no loaded session is on screen
|
|
545
|
+
// (a user reload or initial boot reached a dead backend). The offline
|
|
546
|
+
// screen is the honest fallback here — unlike a liveness-probe timeout,
|
|
547
|
+
// this is not navigating away from a live page.
|
|
548
|
+
showOffline(win)
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
win.webContents.on('did-finish-load', () => {
|
|
552
|
+
const current = win.webContents.getURL()
|
|
553
|
+
const active = windowTargets.get(win.id) || target
|
|
554
|
+
// Compare by origin (scheme + host + port), ignoring path/query: the launch
|
|
555
|
+
// token in the target is dropped by the 303 redirect, so a plain prefix
|
|
556
|
+
// match would reject a load that actually succeeded and authenticated.
|
|
557
|
+
if (sameOrigin(current, active)) {
|
|
558
|
+
// Once DSH redirects the launch URL back to clean "/", the token has done
|
|
559
|
+
// its job. Future loads use the canonical target instead of replaying a
|
|
560
|
+
// one-time credential.
|
|
561
|
+
if (windowLaunchUrls.get(win.id)) {
|
|
562
|
+
let clean = false
|
|
563
|
+
try {
|
|
564
|
+
clean = !new URL(current).searchParams.has('token')
|
|
565
|
+
} catch {
|
|
566
|
+
clean = !current.includes('?token=')
|
|
567
|
+
}
|
|
568
|
+
if (clean) clearLaunchUrl(win)
|
|
569
|
+
}
|
|
570
|
+
// Real backend page reached — stop re-probing, start a fresh outage
|
|
571
|
+
// run + merged connection state and watch it.
|
|
572
|
+
stopReconnect(win)
|
|
573
|
+
runFor(win).reset()
|
|
574
|
+
resetConn(win)
|
|
575
|
+
startWatch(win, active)
|
|
576
|
+
// The token exchange 303s to clean "/", which drops the shell contract
|
|
577
|
+
// params. Re-load the stamped target once so panels relying on the
|
|
578
|
+
// contract (better-sidebar titlebar inset) see it from the start.
|
|
579
|
+
if (!current.includes('dsh-desktop-mode=') && !restampedWindows.has(win.id)) {
|
|
580
|
+
restampedWindows.add(win.id)
|
|
581
|
+
win.webContents.loadURL(stampTarget(target)).catch(() => {})
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
win.on('closed', () => {
|
|
587
|
+
stopReconnect(win)
|
|
588
|
+
stopWatch(win)
|
|
589
|
+
const unsub = statusUnsubs.get(win.id)
|
|
590
|
+
if (unsub) {
|
|
591
|
+
unsub()
|
|
592
|
+
statusUnsubs.delete(win.id)
|
|
593
|
+
}
|
|
594
|
+
windowTargets.delete(win.id)
|
|
595
|
+
windowLaunchUrls.delete(win.id)
|
|
596
|
+
restampedWindows.delete(win.id)
|
|
597
|
+
outageRuns.delete(win.id)
|
|
598
|
+
pollInFlight.delete(win.id)
|
|
599
|
+
connStates.delete(win.id)
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
return win
|
|
603
|
+
}
|