dsh-clean-desktop-shell 0.1.12 → 0.1.14
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 +6 -0
- package/README.en.md +428 -341
- package/README.md +369 -286
- package/electron/main.js +12 -3
- package/electron/outage.js +60 -0
- package/electron/preload.js +168 -1
- package/electron/service.js +47 -9
- package/electron/shortcut.js +4 -1
- package/electron/tray.js +26 -14
- package/electron/window.js +246 -23
- package/lib/client.js +41 -6
- package/lib/icon.js +44 -10
- package/lib/index.js +33 -2
- package/package.json +141 -127
- package/scripts/selftest-recovery.mjs +140 -0
- package/src/client/client.js +41 -6
- package/src/host/icon.js +44 -10
- package/src/host/index.js +33 -2
- package/version.txt +1 -1
package/electron/window.js
CHANGED
|
@@ -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 (
|
|
8
|
+
* Window reliability (non-destructive recovery):
|
|
9
9
|
* - the window shows immediately on launch (never waits for the backend);
|
|
10
|
-
* -
|
|
11
|
-
* local "backend offline" screen
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
63
|
-
// (
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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
|
|
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(
|
|
389
|
+
const timer = setInterval(() => {
|
|
185
390
|
if (win.isDestroyed()) {
|
|
186
391
|
stopWatch(win)
|
|
187
392
|
return
|
|
188
393
|
}
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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.
|
|
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
|
-
|
|
27
|
-
|
|
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,
|
package/lib/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { spawn } from 'node:child_process'
|
|
|
12
12
|
import { appendFileSync } from 'node:fs'
|
|
13
13
|
import { PKG_ROOT, MAIN_JS, dshHome, runtimeRoot, launchLogPath } from './common.js'
|
|
14
14
|
import { ensureRuntime } from './runtime.js'
|
|
15
|
-
import { patchExeIcon } from './icon.js'
|
|
15
|
+
import { ensureRcedit, patchExeIcon } from './icon.js'
|
|
16
16
|
|
|
17
17
|
// cordis registers the plugin by this name — bundles without an explicit
|
|
18
18
|
// `name` export are silently skipped by the dsh loader.
|
|
@@ -21,8 +21,36 @@ export const name = 'dsh-clean-desktop-shell'
|
|
|
21
21
|
// Disable auto-launch with DSH_SHELL_AUTO_LAUNCH=0.
|
|
22
22
|
const AUTO_LAUNCH = process.env.DSH_SHELL_AUTO_LAUNCH !== '0'
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Hard budget for the taskbar-icon step. It runs before the spawn (Windows
|
|
26
|
+
* locks a running image), so it is on the launch path — but the icon is
|
|
27
|
+
* cosmetic and must never decide when the window appears. Without a budget a
|
|
28
|
+
* stalled rcedit fetch held the first launch for up to the fetchFile default
|
|
29
|
+
* of 600 s; with it, the worst case is 25 s and the usual case is ~0 (the
|
|
30
|
+
* fetch has already finished behind the Electron runtime download).
|
|
31
|
+
*/
|
|
32
|
+
const ICON_PATCH_DEADLINE_MS = 25000
|
|
33
|
+
|
|
24
34
|
let launched = false
|
|
25
35
|
|
|
36
|
+
/**
|
|
37
|
+
* Let a best-effort step run, but never wait longer than `ms` for it. Whatever
|
|
38
|
+
* it was doing keeps running in the background.
|
|
39
|
+
*/
|
|
40
|
+
function withDeadline(ctx, promise, ms) {
|
|
41
|
+
let timer
|
|
42
|
+
const deadline = new Promise((resolve) => {
|
|
43
|
+
timer = setTimeout(() => {
|
|
44
|
+
ctx.logger.warn(`[clean-desktop-shell] icon step exceeded ${ms} ms — launching without it`)
|
|
45
|
+
resolve()
|
|
46
|
+
}, ms)
|
|
47
|
+
// Do not keep the host process alive just to fire a warning.
|
|
48
|
+
timer.unref?.()
|
|
49
|
+
})
|
|
50
|
+
const settled = promise.then(() => {}, () => {})
|
|
51
|
+
return Promise.race([settled, deadline]).finally(() => clearTimeout(timer))
|
|
52
|
+
}
|
|
53
|
+
|
|
26
54
|
export function apply(ctx) {
|
|
27
55
|
ctx.logger.info('[clean-desktop-shell] mounted (host half)')
|
|
28
56
|
if (!AUTO_LAUNCH) return
|
|
@@ -30,10 +58,13 @@ export function apply(ctx) {
|
|
|
30
58
|
// Runtime preparation is independent of authentication — start it now, but
|
|
31
59
|
// defer the Electron spawn until the launch URL is minted below.
|
|
32
60
|
let runtimeExe = null
|
|
61
|
+
// Kick the (tiny) rcedit fetch off alongside the (large) Electron runtime so
|
|
62
|
+
// the two never serialise; patchExeIcon reuses this same memoised promise.
|
|
63
|
+
void ensureRcedit(ctx)
|
|
33
64
|
const runtimeReady = ensureRuntime(ctx)
|
|
34
65
|
.then(async (exe) => {
|
|
35
66
|
runtimeExe = exe
|
|
36
|
-
await patchExeIcon(ctx, exe)
|
|
67
|
+
await withDeadline(ctx, patchExeIcon(ctx, exe), ICON_PATCH_DEADLINE_MS)
|
|
37
68
|
})
|
|
38
69
|
.catch((err) => {
|
|
39
70
|
reportLaunchFailure(ctx, err)
|