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.
@@ -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 (Edge-style instant refresh):
9
- * - the window shows immediately on launch (never waits for the backend);
10
- * - while the backend is unreachable the page load fails and we swap in a
11
- * local "backend offline" screen;
12
- * - a background poll keeps probing the target; as soon as the backend
13
- * answers, the real page is loaded automatically;
14
- * - the moment the backend goes down (tray stop, external kill, crash) the
15
- * window flips back to the offline screen instead of showing a stale page
16
- * that suggests the app is still alive.
17
- */
18
- import { app, BrowserWindow, ipcMain } from 'electron'
19
- import { existsSync } from 'node:fs'
20
- import { dirname, join } from 'node:path'
21
- import { fileURLToPath, pathToFileURL } from 'node:url'
22
- import { probe, onStatusChange, detect } from './service.js'
23
- import { startBackendWithProgress, chooseBackendFolder } from './tray.js'
24
- import { APP_USER_MODEL_ID } from './aumid.js'
25
-
26
- export const WINDOWS_TITLEBAR_HEIGHT = 32
27
-
28
- const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
29
- const PRELOAD_PATH = fileURLToPath(new URL('./preload.js', import.meta.url))
30
- // Windows taskbar follows the window icon only when it is an .ico; a png
31
- // covers the title bar / alt-tab but not the taskbar button. In plugin
32
- // mode there is no exe icon resource, so prefer the bundled .ico.
33
- const TASKBAR_ICO = join(PKG_ROOT, 'build', 'icon.ico')
34
- // Black-whale app icon (matches the DSH web favicon).
35
- const ICON_PATH = existsSync(TASKBAR_ICO)
36
- ? TASKBAR_ICO
37
- : fileURLToPath(new URL('../build/icon.png', import.meta.url))
38
- // Local fallback page shown while the backend is down. The file URL is
39
- // precomputed so "is the offline page showing?" is an exact comparison,
40
- // not a substring sniff over arbitrary web content.
41
- const ERROR_PAGE = fileURLToPath(new URL('./error.html', import.meta.url))
42
- const ERROR_PAGE_URL = pathToFileURL(ERROR_PAGE).href
43
-
44
- // How often we re-probe the backend while the window is in "offline" mode.
45
- const RECONNECT_INTERVAL_MS = 2500
46
- // How often we check the backend is still alive while the page is shown.
47
- const WATCH_INTERVAL_MS = 4000
48
- // ERR_ABORTED — navigation was cancelled, not a real failure. Ignore it.
49
- const ERR_ABORTED = -3
50
-
51
- // Per-window state, keyed by webContents id.
52
- const reconnectTimers = new Map()
53
- const watchTimers = new Map()
54
- const windowTargets = new Map()
55
- const statusUnsubs = new Map()
56
-
57
- // Manual reload requests come from the tray button and from the offline
58
- // screen's retry button (via preload -> ipcRenderer). Route them to the
59
- // window that sent the message.
60
- ipcMain.on('shell:reload', (event) => {
61
- const win = BrowserWindow.fromWebContents(event.sender)
62
- if (win && !win.isDestroyed()) {
63
- reloadWindow(win, windowTargets.get(win.id))
64
- }
65
- })
66
-
67
- // Offline-screen quick actions: start / detect backend, pick install
68
- // folder. The resulting state changes propagate via onStatusChange
69
- // (window flip + tray refresh), so no extra wiring is needed here.
70
- ipcMain.on('shell:start-backend', () => startBackendWithProgress())
71
- ipcMain.on('shell:detect-backend', () => detect())
72
- ipcMain.on('shell:choose-backend-folder', () => chooseBackendFolder())
73
-
74
- // ---------- offline mode ----------
75
-
76
- function stopReconnect(win) {
77
- const timer = reconnectTimers.get(win.id)
78
- if (timer) {
79
- clearInterval(timer)
80
- reconnectTimers.delete(win.id)
81
- }
82
- }
83
-
84
- function startReconnect(win, target) {
85
- if (reconnectTimers.has(win.id)) return
86
- const timer = setInterval(async () => {
87
- if (win.isDestroyed()) {
88
- stopReconnect(win)
89
- return
90
- }
91
- const up = await probe(target)
92
- if (up) {
93
- stopReconnect(win)
94
- win.webContents.loadURL(target).catch(() => startReconnect(win, target))
95
- }
96
- }, RECONNECT_INTERVAL_MS)
97
- reconnectTimers.set(win.id, timer)
98
- }
99
-
100
- // ---------- online mode (backend liveness watch) ----------
101
-
102
- function stopWatch(win) {
103
- const timer = watchTimers.get(win.id)
104
- if (timer) {
105
- clearInterval(timer)
106
- watchTimers.delete(win.id)
107
- }
108
- }
109
-
110
- /** While the real page is shown, watch that the backend stays alive. */
111
- function startWatch(win, target) {
112
- if (watchTimers.has(win.id)) return
113
- const timer = setInterval(async () => {
114
- if (win.isDestroyed()) {
115
- stopWatch(win)
116
- return
117
- }
118
- const up = await probe(target, 1500)
119
- if (!up) {
120
- // Backend vanished flip to the offline screen immediately so the
121
- // stale page cannot fool the user into thinking the app is alive.
122
- showOffline(win)
123
- }
124
- }, WATCH_INTERVAL_MS)
125
- watchTimers.set(win.id, timer)
126
- }
127
-
128
- // ---------- state flips ----------
129
-
130
- /** Switch to the offline screen and start re-probing. */
131
- function showOffline(win) {
132
- if (win.isDestroyed()) return
133
- const target = windowTargets.get(win.id)
134
- stopWatch(win)
135
- win.loadFile(ERROR_PAGE).catch(() => {})
136
- if (target) startReconnect(win, target)
137
- }
138
-
139
- /** Load the real backend page and start watching it. */
140
- function showOnline(win) {
141
- if (win.isDestroyed()) return
142
- const target = windowTargets.get(win.id)
143
- if (!target) return
144
- stopReconnect(win)
145
- win.webContents.loadURL(target).catch(() => startReconnect(win, target))
146
- }
147
-
148
- /** Load the real target URL in a window (used by tray + offline retry). */
149
- export function reloadWindow(win, target) {
150
- if (!win || win.isDestroyed()) return
151
- stopReconnect(win)
152
- win.webContents.loadURL(target).catch(() => startReconnect(win, target))
153
- }
154
-
155
- // ---------- window creation ----------
156
-
157
- export function createMainWindow({ target }) {
158
- const platform = process.platform
159
- const isWin = platform === 'win32'
160
- const isMac = platform === 'darwin'
161
-
162
- const base = {
163
- width: 1280,
164
- height: 800,
165
- minWidth: 760,
166
- minHeight: 520,
167
- // Show immediately the backend may be starting, the window must not
168
- // wait for `ready-to-show` (which lags when the page fails to load).
169
- show: true,
170
- title: 'DeepSeek Harness',
171
- backgroundColor: '#10131A',
172
- icon: isWin ? ICON_PATH : undefined,
173
- webPreferences: {
174
- preload: PRELOAD_PATH,
175
- contextIsolation: true,
176
- nodeIntegration: false,
177
- sandbox: true,
178
- webSecurity: true,
179
- },
180
- }
181
-
182
- let options = { ...base }
183
-
184
- if (isMac) {
185
- options = {
186
- ...base,
187
- titleBarStyle: 'hiddenInset',
188
- trafficLightPosition: { x: 16, y: 16 },
189
- }
190
- } else if (isWin) {
191
- options = {
192
- ...base,
193
- autoHideMenuBar: true,
194
- titleBarStyle: 'hidden',
195
- titleBarOverlay: {
196
- color: '#00000000',
197
- symbolColor: '#7f858f',
198
- height: WINDOWS_TITLEBAR_HEIGHT,
199
- },
200
- }
201
- }
202
- // Linux / other: keep the native frame.
203
-
204
- const win = new BrowserWindow(options)
205
- // Windows taskbar button: bare runtime electron.exe has no custom icon,
206
- // so pin the button to our .ico via setAppDetails (appId must match the
207
- // app-level AppUserModelId set in main.js, else the options are ignored).
208
- if (process.platform === 'win32' && existsSync(TASKBAR_ICO)) {
209
- win.setAppDetails({
210
- appId: APP_USER_MODEL_ID,
211
- appIconPath: TASKBAR_ICO,
212
- })
213
- }
214
- windowTargets.set(win.id, target)
215
- win.loadURL(target).catch(() => startReconnect(win, target))
216
-
217
- // Instant flip when the backend state machine changes (tray stop/start).
218
- const unsub = onStatusChange((st) => {
219
- if (win.isDestroyed()) return
220
- const isOffline = win.webContents.getURL().startsWith(ERROR_PAGE_URL)
221
- if ((st.status === 'stopped' || st.status === 'error') && !isOffline) {
222
- // Backend went down while a real page is showing — go dark at once.
223
- showOffline(win)
224
- } else if (st.status === 'running' && isOffline) {
225
- // Backend came up while we are on the offline screen — load it.
226
- showOnline(win)
227
- }
228
- })
229
- statusUnsubs.set(win.id, unsub)
230
-
231
- win.webContents.on('did-fail-load', (_e, code, _desc, url, isMainFrame) => {
232
- if (!isMainFrame || code === ERR_ABORTED) return
233
- // Offline screen already showing — just keep re-probing, do not
234
- // reload the offline page again (avoids a reload loop if it fails).
235
- if (win.webContents.getURL().startsWith(ERROR_PAGE_URL)) {
236
- startReconnect(win, target)
237
- return
238
- }
239
- // Backend unreachable: show the offline screen and start re-probing.
240
- showOffline(win)
241
- })
242
-
243
- win.webContents.on('did-finish-load', () => {
244
- const current = win.webContents.getURL()
245
- if (current.startsWith(target)) {
246
- // Real backend page reached — stop re-probing and watch it.
247
- stopReconnect(win)
248
- startWatch(win, target)
249
- }
250
- })
251
-
252
- win.on('closed', () => {
253
- stopReconnect(win)
254
- stopWatch(win)
255
- const unsub = statusUnsubs.get(win.id)
256
- if (unsub) {
257
- unsub()
258
- statusUnsubs.delete(win.id)
259
- }
260
- windowTargets.delete(win.id)
261
- })
262
-
263
- return win
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
+ }