dsh-clean-desktop-shell 0.1.12 → 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.
@@ -6,6 +6,16 @@
6
6
  * - start() / stop() / restart()
7
7
  * - status: 'running' | 'stopped' | 'starting' | 'error'
8
8
  *
9
+ * Lifecycle vs responsiveness: every status carries a `cause` so consumers
10
+ * can tell a CONFIRMED lifecycle event from an OBSERVED state:
11
+ * - cause 'exit' — the managed child process really exited (or died while
12
+ * starting);
13
+ * - cause 'stop' — an explicit tray stop terminated the backend;
14
+ * - cause 'probe' — detect() found nothing answering on the port (an
15
+ * observation; the backend may merely be slow or external).
16
+ * Only 'exit'/'stop' mean the backend that owns a loaded page is gone; a
17
+ * 'probe'-derived 'stopped' must never be treated as a process exit.
18
+ *
9
19
  * Shell/core decoupling: when a remote target URL is configured, no local
10
20
  * backend is ever touched — this module only manages the local dsh CLI.
11
21
  */
@@ -32,6 +42,7 @@ function cappedAppend(prev, chunk) {
32
42
  let child = null
33
43
  let currentStatus = 'stopped'
34
44
  let lastError = null
45
+ let currentCause = null
35
46
  let startResolver = null
36
47
 
37
48
  // dsh 0.1.2+ gates the web index behind a per-process launch token: the
@@ -64,10 +75,14 @@ export function onStatusChange(cb) {
64
75
  return () => listeners.delete(cb)
65
76
  }
66
77
 
67
- function setStatus(next, error = null) {
68
- if (currentStatus !== next || lastError !== error) {
78
+ function setStatus(next, error = null, cause = null) {
79
+ // A same-status re-emission with a different cause (e.g. probe-derived
80
+ // 'stopped' followed by the real child exit) must still notify — the
81
+ // cause is what upgrades an observation to a confirmed lifecycle event.
82
+ if (currentStatus !== next || lastError !== error || currentCause !== cause) {
69
83
  currentStatus = next
70
84
  lastError = error
85
+ currentCause = cause
71
86
  for (const cb of listeners) {
72
87
  try {
73
88
  cb(getStatus())
@@ -81,6 +96,7 @@ function setStatus(next, error = null) {
81
96
  export function getStatus() {
82
97
  return {
83
98
  status: currentStatus,
99
+ cause: currentCause,
84
100
  port: DEFAULT_PORT,
85
101
  url: LOCAL_URL,
86
102
  launchUrl: currentLaunchUrl,
@@ -122,17 +138,30 @@ export async function probe(url, timeoutMs = 1500) {
122
138
  * Detect a reachable local dsh web service.
123
139
  * Returns the URL when something already listens on the port,
124
140
  * or null when the backend is down.
141
+ *
142
+ * Never demotes a shell-managed child: while one is alive, reachability is
143
+ * the liveness watch's job (window.js probes), so a transient probe failure
144
+ * here cannot corrupt lifecycle state. The status is likewise never
145
+ * promoted here — during 'starting' the launch URL is not minted yet, and
146
+ * only the ready line (or the start-timeout probe) may flip 'starting' →
147
+ * 'running'. Unmanaged results are observations and are reported with
148
+ * cause 'probe' — not confirmed exits.
125
149
  */
126
150
  export async function detect() {
151
+ if (child && child.exitCode === null) {
152
+ // Managed child alive — lifecycle state owns itself; no probe, no
153
+ // status change (in particular: no promotion out of 'starting').
154
+ return LOCAL_URL
155
+ }
127
156
  const state = await probeState(LOCAL_URL)
128
157
  if (state.alive) {
129
- setStatus('running')
158
+ setStatus('running', null, 'probe')
130
159
  return LOCAL_URL
131
160
  }
132
161
  // The old process token (if any) belongs to a backend that is no longer
133
162
  // listening. Keep it out of the next backend's bootstrap.
134
163
  currentLaunchUrl = null
135
- setStatus('stopped')
164
+ setStatus('stopped', null, 'probe')
136
165
  return null
137
166
  }
138
167
 
@@ -196,10 +225,14 @@ export async function start({ backendPath } = {}) {
196
225
  if (currentStatus === 'starting' && startResolver) {
197
226
  const r = startResolver
198
227
  startResolver = null
199
- setStatus('error', `dsh 后端异常退出 (code ${code})`)
228
+ setStatus('error', `dsh 后端异常退出 (code ${code})`, 'exit')
200
229
  r.reject(new Error(lastError))
201
- } else if (currentStatus !== 'stopped') {
202
- setStatus('stopped')
230
+ } else {
231
+ // Confirmed lifecycle exit. Always (re)emit with cause 'exit' — when a
232
+ // probe-derived 'stopped' preceded the real exit, the cause upgrade is
233
+ // what tells listeners this was a genuine process death, not an
234
+ // observation.
235
+ setStatus('stopped', null, 'exit')
203
236
  }
204
237
  })
205
238
 
@@ -274,7 +307,9 @@ export async function stop() {
274
307
  if (child) {
275
308
  const proc = child
276
309
  child = null
277
- setStatus('stopped')
310
+ // Intentional lifecycle stop of the managed child — a confirmed exit
311
+ // (cause 'stop'), unlike an observed unreachability.
312
+ setStatus('stopped', null, 'stop')
278
313
  await terminate(proc)
279
314
  await ensurePortFree(DEFAULT_PORT)
280
315
  return
@@ -287,9 +322,12 @@ export async function stop() {
287
322
  if (name && /node/i.test(name)) {
288
323
  await killProcess(pid)
289
324
  await ensurePortFree(DEFAULT_PORT)
325
+ setStatus('stopped', null, 'stop')
290
326
  }
291
327
  }
292
- setStatus('stopped')
328
+ // Nothing was terminated: no status change at all. Emitting 'stopped'
329
+ // here would fabricate a confirmed exit (cause 'stop') where none
330
+ // happened — a probe-derived 'stopped' must stay an observation.
293
331
  }
294
332
 
295
333
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -5,21 +5,30 @@
5
5
  * window-controls overlay (Win) / hiddenInset (mac), and nothing else.
6
6
  * No Mica, no vibrancy — keep it clean.
7
7
  *
8
- * Window reliability (Edge-style instant refresh):
8
+ * Window reliability (non-destructive recovery):
9
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.
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.
17
25
  */
18
26
  import { app, BrowserWindow, ipcMain } from 'electron'
19
27
  import { existsSync } from 'node:fs'
20
28
  import { dirname, join } from 'node:path'
21
29
  import { fileURLToPath, pathToFileURL } from 'node:url'
22
30
  import { probe, onStatusChange, detect, getStatus } from './service.js'
31
+ import { OutageRun } from './outage.js'
23
32
  import { startBackendWithProgress, chooseBackendFolder } from './tray.js'
24
33
  import { APP_USER_MODEL_ID } from './aumid.js'
25
34
 
@@ -59,10 +68,11 @@ const RECONNECT_INTERVAL_MS = 2500
59
68
  // How often we check the backend is still alive while the page is shown.
60
69
  const WATCH_INTERVAL_MS = 4000
61
70
 
62
- // Top drag-strip geometry. Must mirror the strip the preload injects
63
- // (preload.js: dragHeight 32 on Windows, 28 elsewhere) the strip swallows
64
- // pointer events over the top band, so dockable panels must be told how many
65
- // pixels to yield. better-sidebar reads this through its documented shell
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
66
76
  // contract: `dsh-desktop-titlebar-inset` on the render URL.
67
77
  function isWin() {
68
78
  return process.platform === 'win32'
@@ -97,6 +107,82 @@ const reconnectTimers = new Map()
97
107
  const watchTimers = new Map()
98
108
  const windowTargets = new Map()
99
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
+ }
100
186
  // Pending launch URL for this window. It exists only until DSH exchanges the
101
187
  // ?token= for the HttpOnly cookie and 303s back to clean "/", then it is
102
188
  // cleared so normal reloads/reconnects use the bare canonical target.
@@ -119,6 +205,27 @@ function clearLaunchUrl(win) {
119
205
  if (windowLaunchUrls.has(win.id)) windowLaunchUrls.set(win.id, null)
120
206
  }
121
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
+
122
229
  // Manual reload requests come from the tray button and from the offline
123
230
  // screen's retry button (via preload -> ipcRenderer). Route them to the
124
231
  // window that sent the message.
@@ -129,6 +236,37 @@ ipcMain.on('shell:reload', (event) => {
129
236
  }
130
237
  })
131
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
+
132
270
  // Offline-screen quick actions: start / detect backend, pick install
133
271
  // folder. The resulting state changes propagate via onStatusChange
134
272
  // (window flip + tray refresh), so no extra wiring is needed here.
@@ -136,6 +274,69 @@ ipcMain.on('shell:start-backend', () => startBackendWithProgress())
136
274
  ipcMain.on('shell:detect-backend', () => detect())
137
275
  ipcMain.on('shell:choose-backend-folder', () => chooseBackendFolder())
138
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
+
139
340
  // ---------- offline mode ----------
140
341
 
141
342
  function stopReconnect(win) {
@@ -159,9 +360,13 @@ function startReconnect(win, target) {
159
360
  // and lose the ?token= bootstrap. Wait for service.start() to deliver
160
361
  // running + launchUrl instead.
161
362
  if ((await getStatus()).status === 'starting') return
162
- const up = await probe(target)
363
+ const up = await guardedProbe(win, target)
364
+ if (up === null) return
163
365
  if (up) {
164
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)
165
370
  win.webContents.loadURL(urlToLoad(win)).catch(() => startReconnect(win, target))
166
371
  }
167
372
  }, RECONNECT_INTERVAL_MS)
@@ -181,17 +386,12 @@ function stopWatch(win) {
181
386
  /** While the real page is shown, watch that the backend stays alive. */
182
387
  function startWatch(win, target) {
183
388
  if (watchTimers.has(win.id)) return
184
- const timer = setInterval(async () => {
389
+ const timer = setInterval(() => {
185
390
  if (win.isDestroyed()) {
186
391
  stopWatch(win)
187
392
  return
188
393
  }
189
- const up = await probe(target, 1500)
190
- if (!up) {
191
- // Backend vanished — flip to the offline screen immediately so the
192
- // stale page cannot fool the user into thinking the app is alive.
193
- showOffline(win)
194
- }
394
+ checkBackend(win, target)
195
395
  }, WATCH_INTERVAL_MS)
196
396
  watchTimers.set(win.id, timer)
197
397
  }
@@ -203,6 +403,7 @@ function showOffline(win) {
203
403
  if (win.isDestroyed()) return
204
404
  const target = windowTargets.get(win.id)
205
405
  stopWatch(win)
406
+ bumpNav(win)
206
407
  win.loadFile(ERROR_PAGE).catch(() => {})
207
408
  if (target) startReconnect(win, target)
208
409
  }
@@ -213,6 +414,7 @@ function showOnline(win) {
213
414
  const target = windowTargets.get(win.id)
214
415
  if (!target) return
215
416
  stopReconnect(win)
417
+ bumpNav(win)
216
418
  win.webContents.loadURL(urlToLoad(win)).catch(() => startReconnect(win, target))
217
419
  }
218
420
 
@@ -220,6 +422,7 @@ function showOnline(win) {
220
422
  export function reloadWindow(win, target) {
221
423
  if (!win || win.isDestroyed()) return
222
424
  stopReconnect(win)
425
+ bumpNav(win)
223
426
  win.webContents.loadURL(urlToLoad(win)).catch(() => startReconnect(win, target))
224
427
  }
225
428
 
@@ -290,7 +493,18 @@ export function createMainWindow({ target, launchUrl }) {
290
493
  const unsub = onStatusChange((st) => {
291
494
  if (win.isDestroyed()) return
292
495
  const isOffline = win.webContents.getURL().startsWith(ERROR_PAGE_URL)
293
- if (st.status === 'stopped' || st.status === 'error') {
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) {
294
508
  // The old process token died with the backend. Drop it so a later
295
509
  // reconnect cannot replay a stale launch URL.
296
510
  windowLaunchUrls.set(win.id, null)
@@ -327,7 +541,10 @@ export function createMainWindow({ target, launchUrl }) {
327
541
  startReconnect(win, windowTargets.get(win.id) || target)
328
542
  return
329
543
  }
330
- // Backend unreachable: show the offline screen and start re-probing.
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.
331
548
  showOffline(win)
332
549
  })
333
550
 
@@ -350,8 +567,11 @@ export function createMainWindow({ target, launchUrl }) {
350
567
  }
351
568
  if (clean) clearLaunchUrl(win)
352
569
  }
353
- // Real backend page reached — stop re-probing and watch it.
570
+ // Real backend page reached — stop re-probing, start a fresh outage
571
+ // run + merged connection state and watch it.
354
572
  stopReconnect(win)
573
+ runFor(win).reset()
574
+ resetConn(win)
355
575
  startWatch(win, active)
356
576
  // The token exchange 303s to clean "/", which drops the shell contract
357
577
  // params. Re-load the stamped target once so panels relying on the
@@ -374,6 +594,9 @@ export function createMainWindow({ target, launchUrl }) {
374
594
  windowTargets.delete(win.id)
375
595
  windowLaunchUrls.delete(win.id)
376
596
  restampedWindows.delete(win.id)
597
+ outageRuns.delete(win.id)
598
+ pollInFlight.delete(win.id)
599
+ connStates.delete(win.id)
377
600
  })
378
601
 
379
602
  return win
package/lib/client.js CHANGED
@@ -1,11 +1,21 @@
1
1
  /**
2
2
  * dsh-clean-desktop-shell — client half (web browser bundle).
3
3
  *
4
- * The shell is a standalone Electron window; it injects nothing into the
5
- * dsh web UI. This client module exists only to satisfy the client-modules
6
- * loader contract a bundle's client entry must register itself via
7
- * `window.__ModuleLoader__.load({ id, factory })`, otherwise dsh reports
8
- * "loaded without registering … via __ModuleLoader__.load".
4
+ * Bridges the page's DSH client-runtime connection lifecycle into the
5
+ * Electron shell so HTTP health alone can never hide a terminal disconnect
6
+ * (e.g. the backend restarted under the page: HTTP answers again while the
7
+ * page's WebSocket generation is dead).
8
+ *
9
+ * - ctx.connection.state ('connected' | 'connecting' | 'disconnected') is
10
+ * forwarded to the main process (shell:client-connection → preload
11
+ * shellAPI.connectionReport);
12
+ * - main-process reconnect requests (shell:client-reconnect →
13
+ * shellAPI.onReconnectRequest) call ctx.connection.reconnect() —
14
+ * recovery through the app's own reconnect loop, never a reload.
15
+ *
16
+ * The supported baseline provides ctx.connection.state / reconnect();
17
+ * missing APIs fail activation loudly instead of degrading. A plain browser
18
+ * (no shellAPI — no Electron shell) is the only silent case.
9
19
  */
10
20
  window.__ModuleLoader__.load({
11
21
  id: 'dsh-clean-desktop-shell',
@@ -13,7 +23,32 @@ window.__ModuleLoader__.load({
13
23
  var module = { exports: {} };
14
24
  var exports = module.exports;
15
25
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
16
- exports.apply = () => {};
26
+ exports.inject = ['connection'];
27
+ exports.apply = function (ctx) {
28
+ var api = window.shellAPI;
29
+ if (!api) return;
30
+ var connection = ctx.connection;
31
+ var latest = null;
32
+ var notify = function () {
33
+ var state = connection.state.getSnapshot();
34
+ if (state === latest) return;
35
+ latest = state;
36
+ if (state === 'connected' || state === 'connecting' || state === 'disconnected') {
37
+ api.connectionReport(state);
38
+ }
39
+ };
40
+ var unsubscribe = connection.state.subscribe(notify);
41
+ notify(); // report whatever the runtime already is — a lost initial disconnect must not be hidden
42
+ var unregisterRequest = api.onReconnectRequest(function () {
43
+ connection.reconnect();
44
+ });
45
+ ctx.effect(function () {
46
+ return function () {
47
+ unsubscribe();
48
+ unregisterRequest();
49
+ };
50
+ });
51
+ };
17
52
  return module.exports;
18
53
  },
19
54
  });
package/lib/icon.js CHANGED
@@ -16,6 +16,48 @@ import { existsSync, writeFileSync } from 'node:fs'
16
16
  import { join } from 'node:path'
17
17
  import { PKG_ROOT, isWin, runtimeRoot, fetchFile } from './common.js'
18
18
 
19
+ const RCEDIT_NAME = 'rcedit-x64.exe'
20
+ const RCEDIT_URL = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
21
+
22
+ /**
23
+ * rcedit is a ~1.3 MB self-contained exe, and its download sits on the launch
24
+ * path (Windows locks a running image, so the patch has to happen before the
25
+ * exe is spawned). fetchFile's default budget — 600 s — is sized for the
26
+ * ~100 MB Electron runtime, not for this: behind a stalled proxy it could keep
27
+ * the window off screen for ten minutes. Cap it well below that; a miss only
28
+ * costs the default icon.
29
+ */
30
+ const RCEDIT_TIMEOUT_SEC = 20
31
+
32
+ let rceditPromise = null
33
+
34
+ /**
35
+ * Fetch (once) the cached rcedit binary next to the runtimes. Memoised so the
36
+ * host half can start this alongside the Electron runtime download and have
37
+ * the icon step reuse the same fetch instead of serialising behind it.
38
+ *
39
+ * Never rejects: every failure mode just means "no rcedit, default icon".
40
+ */
41
+ export function ensureRcedit(ctx) {
42
+ if (!rceditPromise) {
43
+ rceditPromise = (async () => {
44
+ try {
45
+ const rcedit = join(runtimeRoot(), RCEDIT_NAME)
46
+ if (existsSync(rcedit)) return rcedit
47
+ ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
48
+ if (await fetchFile(RCEDIT_URL, rcedit, RCEDIT_TIMEOUT_SEC)) return rcedit
49
+ ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
50
+ } catch (err) {
51
+ ctx.logger.warn(`[clean-desktop-shell] rcedit unavailable (${err?.message ?? err}) — taskbar icon stays default`)
52
+ }
53
+ // Not memoised as a failure: a later launch deserves a fresh attempt.
54
+ rceditPromise = null
55
+ return null
56
+ })()
57
+ }
58
+ return rceditPromise
59
+ }
60
+
19
61
  export async function patchExeIcon(ctx, exe) {
20
62
  if (!isWin) return
21
63
  const ico = join(PKG_ROOT, 'build', 'icon.ico')
@@ -23,16 +65,8 @@ export async function patchExeIcon(ctx, exe) {
23
65
  const marker = `${exe}.whale-icon`
24
66
  if (existsSync(marker)) return
25
67
 
26
- // rcedit is a single self-contained exe, cached next to the runtimes.
27
- const rcedit = join(runtimeRoot(), 'rcedit-x64.exe')
28
- if (!existsSync(rcedit)) {
29
- const url = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
30
- ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
31
- if (!(await fetchFile(url, rcedit))) {
32
- ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
33
- return
34
- }
35
- }
68
+ const rcedit = await ensureRcedit(ctx)
69
+ if (!rcedit) return
36
70
 
37
71
  const child = spawn(rcedit, [exe, '--set-icon', ico], {
38
72
  windowsHide: true,