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/electron/main.js CHANGED
@@ -138,9 +138,18 @@ if (!gotLock) {
138
138
  cancelId: 1,
139
139
  })
140
140
  if (choice === 0) {
141
- const ok = await createDesktopShortcut()
142
- if (!ok) {
143
- dialog.showErrorBox('创建快捷方式失败', '无法在桌面创建快捷方式。可稍后在托盘右键菜单中重试。')
141
+ try {
142
+ const ok = await createDesktopShortcut()
143
+ if (!ok) {
144
+ dialog.showErrorBox('创建快捷方式失败', '无法在桌面创建快捷方式。可稍后在托盘右键菜单中重试。')
145
+ }
146
+ } catch (err) {
147
+ // See tray.js: writeShortcutLink throws on some failures, and an
148
+ // unguarded rejection here would abort the rest of the ready chain.
149
+ dialog.showErrorBox(
150
+ '创建快捷方式失败',
151
+ `${err && err.message ? err.message : String(err)}\n\n可稍后在托盘右键菜单中重试。`,
152
+ )
144
153
  }
145
154
  }
146
155
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Per-window backend-outage run (liveness-watch state).
3
+ *
4
+ * Pure module — no Electron imports — so the recovery self-test
5
+ * (scripts/selftest-recovery.mjs) can exercise the real transitions under
6
+ * plain node: consecutive-failure escalation, reset-on-recovery, and the
7
+ * navigation token that discards probe results which finished after the
8
+ * window navigated away or was disposed.
9
+ */
10
+
11
+ // An outage escalates from transient to persistent/unavailable copy after
12
+ // this many consecutive failed probes (~3 × 4s watch interval).
13
+ export const PERSISTENT_AFTER_FAILURES = 3
14
+
15
+ export class OutageRun {
16
+ constructor() {
17
+ this.failures = 0
18
+ // Navigation epoch. reset() bumps it; every async probe captures a
19
+ // checkpoint() before awaiting and apply() discards the result if the
20
+ // epoch moved meanwhile — a stale probe can never act on a newer
21
+ // navigation (or on a disposed window's fresh lookalike state).
22
+ this.epoch = 0
23
+ }
24
+
25
+ /** Capture the current epoch before starting an async probe. */
26
+ checkpoint() {
27
+ return this.epoch
28
+ }
29
+
30
+ /** True while the run is still on the navigation `token` was taken under. */
31
+ isCurrent(token) {
32
+ return token === this.epoch
33
+ }
34
+
35
+ /** Navigation/flip (or fresh page load): invalidate in-flight probes and start a fresh run. */
36
+ reset() {
37
+ this.epoch += 1
38
+ this.failures = 0
39
+ }
40
+
41
+ /**
42
+ * Apply a probe result taken under `token`. Returns null when the run was
43
+ * reset (the window navigated away or was disposed) while the probe was in
44
+ * flight — the result is stale and must not act. Otherwise advances the
45
+ * consecutive-failure counter and returns the exact renderer payload for
46
+ * the in-page connection notice (window.js sends it verbatim).
47
+ */
48
+ apply(token, up) {
49
+ if (!this.isCurrent(token)) return null
50
+ if (up) {
51
+ this.failures = 0
52
+ return { state: 'ok' }
53
+ }
54
+ this.failures += 1
55
+ return {
56
+ state: 'degraded',
57
+ persistent: this.failures >= PERSISTENT_AFTER_FAILURES,
58
+ }
59
+ }
60
+ }
@@ -12,9 +12,28 @@
12
12
  * use a pragmatic height and rely on the page's own top padding for the
13
13
  * DSH top bar area. Overlap is acceptable: the strip is click-through for
14
14
  * everything except dragging (app-region drag areas swallow mouse events).
15
+ *
16
+ * The in-page connection notice is the non-destructive counterpart of the
17
+ * offline screen: while a real page is loaded, backend liveness failures
18
+ * never navigate away — the main process pushes watch results here and we
19
+ * show/clear a small fixed banner instead. Draft, scroll and selection in
20
+ * the loaded page are untouched.
15
21
  */
16
22
  const { contextBridge, ipcRenderer } = require('electron')
17
23
 
24
+ // Page-side handler invoked when the main process asks the DSH client
25
+ // runtime (injected page module, src/client/client.js) to reconnect. One
26
+ // handler per page context — navigation resets it with the page.
27
+ let reconnectHandler = null
28
+
29
+ ipcRenderer.on('shell:client-reconnect', () => {
30
+ try {
31
+ if (typeof reconnectHandler === 'function') reconnectHandler()
32
+ } catch {
33
+ // page handler errors must not break the shell
34
+ }
35
+ })
36
+
18
37
  contextBridge.exposeInMainWorld('shellAPI', {
19
38
  // Ask the main process to (re)load the real target URL. Used by the
20
39
  // offline screen's retry button.
@@ -23,12 +42,153 @@ contextBridge.exposeInMainWorld('shellAPI', {
23
42
  startBackend: () => ipcRenderer.send('shell:start-backend'),
24
43
  detectBackend: () => ipcRenderer.send('shell:detect-backend'),
25
44
  chooseBackendFolder: () => ipcRenderer.send('shell:choose-backend-folder'),
45
+ // DSH client-runtime connection bridge (see src/client/client.js):
46
+ // - connectionReport(state) forwards the page connection lifecycle
47
+ // ('connected' | 'connecting' | 'disconnected') to the main process so
48
+ // HTTP health alone can never hide a terminal disconnect;
49
+ // - onReconnectRequest(cb) registers the page-side reconnect action (the
50
+ // runtime's own reconnect(), never a page reload); returns an
51
+ // unregister function.
52
+ connectionReport: (state) => ipcRenderer.send('shell:client-connection', state),
53
+ onReconnectRequest: (cb) => {
54
+ if (typeof cb !== 'function') return () => {}
55
+ reconnectHandler = cb
56
+ return () => {
57
+ if (reconnectHandler === cb) reconnectHandler = null
58
+ }
59
+ },
60
+ })
61
+
62
+ // ---------- in-page connection notice ----------
63
+
64
+ // Latest push from window.js refreshNotice: { state: 'ok' } or
65
+ // { state: 'degraded', persistent: boolean }, computed from merged HTTP
66
+ // probe + client-runtime connection state. Kept until the DOM is ready
67
+ // (messages can arrive before DOMContentLoaded on very fast loads).
68
+ let connState = null
69
+ let connUi = null
70
+
71
+ ipcRenderer.on('shell:connection-state', (_event, state) => {
72
+ connState = state
73
+ applyConnState()
26
74
  })
27
75
 
76
+ function applyConnState() {
77
+ if (!connState || !connUi) return
78
+ if (!connState.state || connState.state === 'ok') {
79
+ connUi.el.style.display = 'none'
80
+ return
81
+ }
82
+ const persistent = connState.persistent === true
83
+ connUi.title.textContent = persistent
84
+ ? '后端暂时无法访问'
85
+ : '与后端的连接暂时中断'
86
+ connUi.sub.textContent = persistent
87
+ ? '当前页面已保留,连接恢复后本页会自动继续,也可手动重试。'
88
+ : '正在自动重连,当前页面已保留。'
89
+ connUi.retryBtn.hidden = false
90
+ // Full reload is only offered once the outage is persistent: it is
91
+ // destructive to unsaved page state, so it stays an explicit user choice.
92
+ connUi.reloadBtn.hidden = !persistent
93
+ connUi.el.style.display = 'flex'
94
+ }
95
+
96
+ /**
97
+ * Build the notice banner below the drag strip (top-center, fixed). Uses
98
+ * inline styles like the drag strip — no <style> injection, so the loaded
99
+ * page's Content-Security-Policy cannot block it. The element itself is
100
+ * inert while hidden and never steals focus while visible.
101
+ */
102
+ function buildConnUi() {
103
+ const base = {
104
+ position: 'fixed',
105
+ top: '0',
106
+ left: '50%',
107
+ transform: 'translateX(-50%)',
108
+ display: 'none',
109
+ alignItems: 'center',
110
+ gap: '14px',
111
+ maxWidth: '76vw',
112
+ padding: '8px 14px',
113
+ background: 'rgba(16, 19, 26, 0.94)',
114
+ border: '1px solid #33405a',
115
+ borderRadius: '10px',
116
+ color: '#e8eaf0',
117
+ font: '13px/1.4 system-ui, "Segoe UI", "Microsoft YaHei", sans-serif',
118
+ boxShadow: '0 8px 24px rgba(0, 0, 0, 0.45)',
119
+ zIndex: '2147483646',
120
+ pointerEvents: 'auto',
121
+ userSelect: 'none',
122
+ WebkitUserSelect: 'none',
123
+ }
124
+ const el = document.createElement('div')
125
+ el.id = 'dsh-clean-shell-conn'
126
+ el.setAttribute('role', 'status')
127
+ el.setAttribute('aria-live', 'polite')
128
+ Object.assign(el.style, base)
129
+
130
+ const copy = document.createElement('div')
131
+ copy.style.display = 'flex'
132
+ copy.style.flexDirection = 'column'
133
+ copy.style.gap = '2px'
134
+
135
+ const title = document.createElement('div')
136
+ title.id = 'dsh-clean-shell-conn-title'
137
+ title.style.fontWeight = '600'
138
+ title.style.color = '#e8eaf0'
139
+ const sub = document.createElement('div')
140
+ sub.id = 'dsh-clean-shell-conn-sub'
141
+ sub.style.fontSize = '12px'
142
+ sub.style.opacity = '0.75'
143
+ copy.append(title, sub)
144
+
145
+ const actions = document.createElement('div')
146
+ actions.style.display = 'flex'
147
+ actions.style.gap = '8px'
148
+
149
+ const btnStyle = {
150
+ padding: '5px 12px',
151
+ fontSize: '12px',
152
+ color: '#e8eaf0',
153
+ background: '#232d3d',
154
+ border: '1px solid #33405a',
155
+ borderRadius: '7px',
156
+ cursor: 'pointer',
157
+ }
158
+
159
+ const retryBtn = document.createElement('button')
160
+ retryBtn.id = 'dsh-clean-shell-conn-retry'
161
+ retryBtn.type = 'button'
162
+ retryBtn.textContent = '立即重试'
163
+ Object.assign(retryBtn.style, btnStyle)
164
+ retryBtn.addEventListener('mouseenter', () => { retryBtn.style.background = '#2b3750' })
165
+ retryBtn.addEventListener('mouseleave', () => { retryBtn.style.background = '#232d3d' })
166
+ retryBtn.addEventListener('click', () => ipcRenderer.send('shell:retry-connection'))
167
+
168
+ const reloadBtn = document.createElement('button')
169
+ reloadBtn.id = 'dsh-clean-shell-conn-reload'
170
+ reloadBtn.type = 'button'
171
+ reloadBtn.textContent = '重新加载页面'
172
+ Object.assign(reloadBtn.style, btnStyle)
173
+ reloadBtn.hidden = true
174
+ reloadBtn.addEventListener('mouseenter', () => { reloadBtn.style.background = '#2b3750' })
175
+ reloadBtn.addEventListener('mouseleave', () => { reloadBtn.style.background = '#232d3d' })
176
+ reloadBtn.addEventListener('click', () => ipcRenderer.send('shell:reload'))
177
+
178
+ actions.append(retryBtn, reloadBtn)
179
+ el.append(copy, actions)
180
+ return { el, title, sub, retryBtn, reloadBtn }
181
+ }
182
+
28
183
  window.addEventListener('DOMContentLoaded', () => {
29
184
  const platform = process.platform
30
185
  const isWin = platform === 'win32'
31
- const dragHeight = isWin ? 32 : 28
186
+ // macOS: 12px (was 28). The DSH web UI's own top toolbar starts ~16px
187
+ // from the window top and does NOT consume dsh-desktop-titlebar-inset,
188
+ // so a 28px strip swallowed the upper half of every toolbar control —
189
+ // clicks had to aim low. 12px stays above that padding (drag-only band)
190
+ // while better-sidebar keeps yielding 28px for the traffic lights.
191
+ const dragHeight = isWin ? 32 : 12
32
192
  // Width reserved for native window controls (Win caption buttons / mac
33
193
  // traffic lights live at the top-right / top-left).
34
194
  const rightReserve = isWin ? 138 : 80
@@ -46,4 +206,11 @@ window.addEventListener('DOMContentLoaded', () => {
46
206
  z-index: 2147483647;
47
207
  `
48
208
  document.body.appendChild(strip)
209
+
210
+ connUi = buildConnUi()
211
+ // Clear the drag band so the banner is clickable, then render any state
212
+ // pushed before the DOM was ready.
213
+ connUi.el.style.top = `${dragHeight + 10}px`
214
+ document.body.appendChild(connUi.el)
215
+ applyConnState()
49
216
  })
@@ -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))
@@ -52,8 +52,11 @@ export function shortcutSupported() {
52
52
  * The user's real Desktop. app.getPath('desktop') resolves OneDrive
53
53
  * redirection and roaming profile policies — a hardcoded ~/Desktop misses
54
54
  * those and writes the shortcut where the user will never see it.
55
+ * Exported so callers can tell the user which folder actually received the
56
+ * .lnk (a shortcut written to a redirected Desktop is a classic
57
+ * "nothing happened" report).
55
58
  */
56
- function desktopDir() {
59
+ export function desktopDir() {
57
60
  try {
58
61
  return app.getPath('desktop')
59
62
  } catch {
package/electron/tray.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  } from './service.js'
21
21
  import { showProgress, setProgress, closeProgress } from './progress.js'
22
22
  import { checkForUpdatesAuto, openRepo } from './update.js'
23
- import { shortcutSupported, createDesktopShortcut } from './shortcut.js'
23
+ import { shortcutSupported, createDesktopShortcut, desktopDir } from './shortcut.js'
24
24
 
25
25
  const trayIconPath = join(
26
26
  fileURLToPath(new URL('.', import.meta.url)),
@@ -71,19 +71,31 @@ export function refreshTrayMenu() {
71
71
  label: '创建桌面快捷方式',
72
72
  visible: shortcutSupported(),
73
73
  click: async () => {
74
- const ok = await createDesktopShortcut()
75
- if (ok) {
76
- dialog.showMessageBoxSync({
77
- type: 'info',
78
- title: '已创建',
79
- message: '桌面快捷方式已创建。',
80
- })
81
- } else {
82
- dialog.showMessageBoxSync({
83
- type: 'warning',
84
- title: '创建失败',
85
- message: '无法创建桌面快捷方式,请稍后重试。',
86
- })
74
+ try {
75
+ const ok = await createDesktopShortcut()
76
+ if (ok) {
77
+ dialog.showMessageBoxSync({
78
+ type: 'info',
79
+ title: '已创建',
80
+ message: '桌面快捷方式已创建。',
81
+ detail: `位置:${desktopDir()}`,
82
+ })
83
+ } else {
84
+ dialog.showMessageBoxSync({
85
+ type: 'warning',
86
+ title: '创建失败',
87
+ message: '无法创建桌面快捷方式,请稍后重试。',
88
+ })
89
+ }
90
+ } catch (err) {
91
+ // shell.writeShortcutLink throws (instead of returning false) for
92
+ // several failure modes. Without this guard the rejection is
93
+ // swallowed by the async click handler and the menu item looks like
94
+ // it did nothing at all.
95
+ dialog.showErrorBox(
96
+ '创建桌面快捷方式失败',
97
+ `${err && err.message ? err.message : String(err)}\n\n位置:${desktopDir()}`,
98
+ )
87
99
  }
88
100
  },
89
101
  },