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/main.js
CHANGED
|
@@ -138,9 +138,18 @@ if (!gotLock) {
|
|
|
138
138
|
cancelId: 1,
|
|
139
139
|
})
|
|
140
140
|
if (choice === 0) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
}
|
package/electron/preload.js
CHANGED
|
@@ -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
|
-
|
|
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
|
})
|
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/shortcut.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
},
|