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.
- package/CONTRIBUTORS.md +6 -0
- package/README.en.md +88 -7
- package/README.md +84 -7
- package/electron/outage.js +60 -0
- package/electron/preload.js +168 -1
- package/electron/service.js +47 -9
- 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 +140 -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/service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
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,
|