dsh-clean-desktop-shell 0.1.11 → 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 +25 -19
- package/README.en.md +422 -333
- package/README.md +363 -278
- package/electron/aumid.js +17 -17
- package/electron/crashGuard.js +60 -60
- package/electron/error.html +113 -113
- package/electron/main.js +203 -187
- package/electron/outage.js +60 -0
- package/electron/preload.js +216 -49
- package/electron/progress-preload.js +11 -11
- package/electron/progress.html +79 -79
- package/electron/progress.js +56 -56
- package/electron/service.js +559 -458
- package/electron/shortcut.js +122 -122
- package/electron/tray.js +232 -232
- package/electron/window.js +603 -264
- package/lib/client.js +41 -6
- package/lib/common.js +74 -74
- package/lib/icon.js +85 -51
- package/lib/index.js +90 -15
- package/lib/runtime.js +423 -423
- package/package.json +140 -127
- package/scripts/check-syntax.mjs +54 -54
- package/scripts/selftest-recovery.mjs +140 -0
- package/scripts/selftest-runtime.mjs +90 -90
- package/src/client/client.js +41 -6
- package/src/host/common.js +74 -74
- package/src/host/icon.js +85 -51
- package/src/host/index.js +90 -15
- package/src/host/runtime.js +423 -423
- package/version.txt +1 -1
package/electron/main.js
CHANGED
|
@@ -1,187 +1,203 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-clean-desktop-shell — Electron main process entry.
|
|
3
|
-
*
|
|
4
|
-
* Shell/core decoupling:
|
|
5
|
-
* - default target: http://127.0.0.1:3080 (existing web profile, zero migration)
|
|
6
|
-
* - configurable remote target (settings file)
|
|
7
|
-
* - local backend auto-start on launch; full manual control from the tray
|
|
8
|
-
*
|
|
9
|
-
* The main window is a pure shell — all backend controls live in the tray.
|
|
10
|
-
*/
|
|
11
|
-
import { app, BrowserWindow, dialog, nativeImage } from 'electron'
|
|
12
|
-
import { fileURLToPath } from 'node:url'
|
|
13
|
-
import { createMainWindow, reloadWindow } from './window.js'
|
|
14
|
-
import { createTray, refreshTrayMenu } from './tray.js'
|
|
15
|
-
import { loadConfig, saveConfig, DEFAULT_TARGET_URL } from './config.js'
|
|
16
|
-
import { detect } from './service.js'
|
|
17
|
-
import { setupAutoUpdater } from './update.js'
|
|
18
|
-
import { shortcutSupported, hasDesktopShortcut, createDesktopShortcut, ensureStartMenuShortcut } from './shortcut.js'
|
|
19
|
-
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
20
|
-
import { setupCrashGuard } from './crashGuard.js'
|
|
21
|
-
|
|
22
|
-
const isMac = process.platform === 'darwin'
|
|
23
|
-
|
|
24
|
-
// Last-resort error capture (userData/shell-crash.log) — install before
|
|
25
|
-
// anything else so even early startup failures leave a trace.
|
|
26
|
-
setupCrashGuard()
|
|
27
|
-
|
|
28
|
-
// Windows: pin the AppUserModelId so the taskbar shows our whale icon
|
|
29
|
-
// instead of the generic Electron icon. Plugin mode uses a distinct ID
|
|
30
|
-
// (see aumid.js) so Windows re-reads the icon instead of serving a
|
|
31
|
-
// stale per-AUMID cached one.
|
|
32
|
-
if (process.platform === 'win32') {
|
|
33
|
-
app.setAppUserModelId(APP_USER_MODEL_ID)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// macOS: a bare runtime has no .app bundle (no icon resource), so set the
|
|
37
|
-
// Dock icon at runtime. Unlike Windows there is no per-AUMID taskbar cache
|
|
38
|
-
// here — app.dock.setIcon applies directly. Packaged builds already carry
|
|
39
|
-
// the icon in their bundle, so skip those.
|
|
40
|
-
if (isMac && !app.isPackaged) {
|
|
41
|
-
try {
|
|
42
|
-
const dockIcon = nativeImage.createFromPath(
|
|
43
|
-
fileURLToPath(new URL('../build/icon.png', import.meta.url)),
|
|
44
|
-
)
|
|
45
|
-
if (!dockIcon.isEmpty()) app.dock.setIcon(dockIcon)
|
|
46
|
-
} catch {
|
|
47
|
-
// non-fatal: keep the default icon
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Uniform userData across both distribution branches (installer vs
|
|
52
|
-
// plugin-market), so config (target URL, backend path, ...) is shared
|
|
53
|
-
// no matter how the shell was launched.
|
|
54
|
-
app.setName('DSH Clean Desktop Shell')
|
|
55
|
-
|
|
56
|
-
/** Single instance: a second launch just focuses the existing window. */
|
|
57
|
-
const gotLock = app.requestSingleInstanceLock()
|
|
58
|
-
if (!gotLock) {
|
|
59
|
-
app.quit()
|
|
60
|
-
} else {
|
|
61
|
-
let mainWindow = null
|
|
62
|
-
let tray = null
|
|
63
|
-
|
|
64
|
-
app.on('second-instance', () => {
|
|
65
|
-
if (mainWindow) {
|
|
66
|
-
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
67
|
-
mainWindow.focus()
|
|
68
|
-
}
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
async function createWindow() {
|
|
72
|
-
const config = loadConfig()
|
|
73
|
-
const target = config.targetUrl || DEFAULT_TARGET_URL
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
1
|
+
/**
|
|
2
|
+
* dsh-clean-desktop-shell — Electron main process entry.
|
|
3
|
+
*
|
|
4
|
+
* Shell/core decoupling:
|
|
5
|
+
* - default target: http://127.0.0.1:3080 (existing web profile, zero migration)
|
|
6
|
+
* - configurable remote target (settings file)
|
|
7
|
+
* - local backend auto-start on launch; full manual control from the tray
|
|
8
|
+
*
|
|
9
|
+
* The main window is a pure shell — all backend controls live in the tray.
|
|
10
|
+
*/
|
|
11
|
+
import { app, BrowserWindow, dialog, nativeImage } from 'electron'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { createMainWindow, reloadWindow } from './window.js'
|
|
14
|
+
import { createTray, refreshTrayMenu } from './tray.js'
|
|
15
|
+
import { loadConfig, saveConfig, DEFAULT_TARGET_URL } from './config.js'
|
|
16
|
+
import { detect, getAuthenticatedUrl, getStatus } from './service.js'
|
|
17
|
+
import { setupAutoUpdater } from './update.js'
|
|
18
|
+
import { shortcutSupported, hasDesktopShortcut, createDesktopShortcut, ensureStartMenuShortcut } from './shortcut.js'
|
|
19
|
+
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
20
|
+
import { setupCrashGuard } from './crashGuard.js'
|
|
21
|
+
|
|
22
|
+
const isMac = process.platform === 'darwin'
|
|
23
|
+
|
|
24
|
+
// Last-resort error capture (userData/shell-crash.log) — install before
|
|
25
|
+
// anything else so even early startup failures leave a trace.
|
|
26
|
+
setupCrashGuard()
|
|
27
|
+
|
|
28
|
+
// Windows: pin the AppUserModelId so the taskbar shows our whale icon
|
|
29
|
+
// instead of the generic Electron icon. Plugin mode uses a distinct ID
|
|
30
|
+
// (see aumid.js) so Windows re-reads the icon instead of serving a
|
|
31
|
+
// stale per-AUMID cached one.
|
|
32
|
+
if (process.platform === 'win32') {
|
|
33
|
+
app.setAppUserModelId(APP_USER_MODEL_ID)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// macOS: a bare runtime has no .app bundle (no icon resource), so set the
|
|
37
|
+
// Dock icon at runtime. Unlike Windows there is no per-AUMID taskbar cache
|
|
38
|
+
// here — app.dock.setIcon applies directly. Packaged builds already carry
|
|
39
|
+
// the icon in their bundle, so skip those.
|
|
40
|
+
if (isMac && !app.isPackaged) {
|
|
41
|
+
try {
|
|
42
|
+
const dockIcon = nativeImage.createFromPath(
|
|
43
|
+
fileURLToPath(new URL('../build/icon.png', import.meta.url)),
|
|
44
|
+
)
|
|
45
|
+
if (!dockIcon.isEmpty()) app.dock.setIcon(dockIcon)
|
|
46
|
+
} catch {
|
|
47
|
+
// non-fatal: keep the default icon
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Uniform userData across both distribution branches (installer vs
|
|
52
|
+
// plugin-market), so config (target URL, backend path, ...) is shared
|
|
53
|
+
// no matter how the shell was launched.
|
|
54
|
+
app.setName('DSH Clean Desktop Shell')
|
|
55
|
+
|
|
56
|
+
/** Single instance: a second launch just focuses the existing window. */
|
|
57
|
+
const gotLock = app.requestSingleInstanceLock()
|
|
58
|
+
if (!gotLock) {
|
|
59
|
+
app.quit()
|
|
60
|
+
} else {
|
|
61
|
+
let mainWindow = null
|
|
62
|
+
let tray = null
|
|
63
|
+
|
|
64
|
+
app.on('second-instance', () => {
|
|
65
|
+
if (mainWindow) {
|
|
66
|
+
if (mainWindow.isMinimized()) mainWindow.restore()
|
|
67
|
+
mainWindow.focus()
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
async function createWindow() {
|
|
72
|
+
const config = loadConfig()
|
|
73
|
+
const target = config.targetUrl || DEFAULT_TARGET_URL
|
|
74
|
+
// In plugin mode the host half (same dsh web process) supplies the real
|
|
75
|
+
// per-process launch URL through DSH_WEB_LAUNCH_URL. In tray-start mode
|
|
76
|
+
// service.start() will surface a fresh one through status changes.
|
|
77
|
+
// Only apply BrowserAuth bootstrap to the local default; remote targets
|
|
78
|
+
// remain the user's own externally-managed surface.
|
|
79
|
+
let isLocalDefault = false
|
|
80
|
+
try {
|
|
81
|
+
isLocalDefault = new URL(target).origin === new URL(DEFAULT_TARGET_URL).origin
|
|
82
|
+
} catch {
|
|
83
|
+
// A malformed configured target must not break window creation, and it
|
|
84
|
+
// must never receive the local launch credential — stay non-local.
|
|
85
|
+
}
|
|
86
|
+
const launchUrl = isLocalDefault ? (getStatus().launchUrl || null) : null
|
|
87
|
+
|
|
88
|
+
mainWindow = createMainWindow({ target, launchUrl })
|
|
89
|
+
mainWindow.on('closed', () => {
|
|
90
|
+
mainWindow = null
|
|
91
|
+
})
|
|
92
|
+
mainWindow.on('close', (event) => {
|
|
93
|
+
// Close hides to tray unless we are actually quitting. Read the
|
|
94
|
+
// config fresh so a runtime change to closeToTray takes effect.
|
|
95
|
+
if (!app.isQuitting && loadConfig().closeToTray !== false) {
|
|
96
|
+
event.preventDefault()
|
|
97
|
+
mainWindow?.hide()
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return mainWindow
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Launch-time backend detection. Detects whether a local dsh web is
|
|
106
|
+
* already running so the tray and window reflect the real state, but
|
|
107
|
+
* never auto-starts it — starting is a manual action (tray menu or the
|
|
108
|
+
* offline screen button) so it cannot fight an explicit "stop".
|
|
109
|
+
*/
|
|
110
|
+
async function bootstrapBackend() {
|
|
111
|
+
const config = loadConfig()
|
|
112
|
+
const target = config.targetUrl || DEFAULT_TARGET_URL
|
|
113
|
+
// Remote targets are the user's own business — never probe local.
|
|
114
|
+
// Origin comparison (not string equality) tolerates trailing slashes
|
|
115
|
+
// and an explicitly written default.
|
|
116
|
+
if (new URL(target).origin !== new URL(DEFAULT_TARGET_URL).origin) return
|
|
117
|
+
await detect()
|
|
118
|
+
// Reflect the post-detect state in the tray menu.
|
|
119
|
+
refreshTrayMenu()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* First-run desktop shortcut prompt (Windows packaged apps only).
|
|
124
|
+
* Asks once; the tray "create desktop shortcut" item stays available
|
|
125
|
+
* forever, so saying no is never a dead end.
|
|
126
|
+
*/
|
|
127
|
+
async function ensureShortcut() {
|
|
128
|
+
if (!shortcutSupported() || loadConfig().shortcutAsked) return
|
|
129
|
+
saveConfig({ ...loadConfig(), shortcutAsked: true })
|
|
130
|
+
if (await hasDesktopShortcut()) return
|
|
131
|
+
const choice = dialog.showMessageBoxSync({
|
|
132
|
+
type: 'question',
|
|
133
|
+
title: '创建桌面快捷方式?',
|
|
134
|
+
message: '是否在桌面创建「DSH Clean Desktop Shell」快捷方式?',
|
|
135
|
+
detail: '选择「跳过」也不影响使用——之后可随时在托盘右键菜单中一键添加。',
|
|
136
|
+
buttons: ['创建', '跳过'],
|
|
137
|
+
defaultId: 0,
|
|
138
|
+
cancelId: 1,
|
|
139
|
+
})
|
|
140
|
+
if (choice === 0) {
|
|
141
|
+
const ok = await createDesktopShortcut()
|
|
142
|
+
if (!ok) {
|
|
143
|
+
dialog.showErrorBox('创建快捷方式失败', '无法在桌面创建快捷方式。可稍后在托盘右键菜单中重试。')
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
app.whenReady().then(async () => {
|
|
149
|
+
await createWindow()
|
|
150
|
+
tray = createTray({
|
|
151
|
+
onShow: () => {
|
|
152
|
+
if (!mainWindow) return createWindow()
|
|
153
|
+
mainWindow.show()
|
|
154
|
+
mainWindow.focus()
|
|
155
|
+
},
|
|
156
|
+
onReload: () => {
|
|
157
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
158
|
+
// Prefer the live launch-token URL from the last shell-started
|
|
159
|
+
// backend; a manual refresh is exactly when a stale config target
|
|
160
|
+
// (or a bare pre-0.1.2 loopback URL) would 401.
|
|
161
|
+
reloadWindow(mainWindow, getAuthenticatedUrl() || loadConfig().targetUrl || DEFAULT_TARGET_URL)
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
onQuit: () => {
|
|
165
|
+
app.isQuitting = true
|
|
166
|
+
app.quit()
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
// Backend bootstrap in background (never delays the window).
|
|
171
|
+
bootstrapBackend().catch(() => {})
|
|
172
|
+
|
|
173
|
+
// Windows: wire the auto-updater (downloads new installers silently).
|
|
174
|
+
setupAutoUpdater()
|
|
175
|
+
|
|
176
|
+
// Windows: ensure the AUMID-carrying Start-menu shortcut exists so the
|
|
177
|
+
// taskbar button shows our icon (see shortcut.js). Best-effort.
|
|
178
|
+
try {
|
|
179
|
+
ensureStartMenuShortcut()
|
|
180
|
+
} catch {
|
|
181
|
+
// non-fatal
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// First run: offer a desktop shortcut (never nags twice).
|
|
185
|
+
ensureShortcut().catch(() => {})
|
|
186
|
+
|
|
187
|
+
app.on('activate', () => {
|
|
188
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
189
|
+
else mainWindow?.show()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
app.on('window-all-closed', () => {
|
|
194
|
+
// Keep the app alive in the tray (like a normal desktop utility).
|
|
195
|
+
if (!isMac && !app.isQuitting) {
|
|
196
|
+
// do not quit — tray keeps running
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
app.on('before-quit', () => {
|
|
201
|
+
app.isQuitting = true
|
|
202
|
+
})
|
|
203
|
+
}
|
|
@@ -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
|
+
}
|