dsh-clean-desktop-shell 0.1.1 → 0.1.3
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/README.en.md +181 -91
- package/README.md +169 -90
- package/electron/config.js +3 -4
- package/electron/main.js +50 -20
- package/electron/shortcut.js +72 -0
- package/electron/tray.js +31 -11
- package/electron/update.js +133 -6
- package/lib/index.js +239 -5
- package/package.json +115 -104
- package/scripts/build.mjs +12 -3
- package/src/host/index.js +239 -5
- package/version.txt +1 -1
package/electron/main.js
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The main window is a pure shell — all backend controls live in the tray.
|
|
10
10
|
*/
|
|
11
|
-
import { app, BrowserWindow } from 'electron'
|
|
11
|
+
import { app, BrowserWindow, dialog } from 'electron'
|
|
12
12
|
import { createMainWindow, reloadWindow } from './window.js'
|
|
13
13
|
import { createTray, refreshTrayMenu } from './tray.js'
|
|
14
14
|
import { loadConfig, saveConfig } from './config.js'
|
|
15
|
-
import { detect
|
|
15
|
+
import { detect } from './service.js'
|
|
16
|
+
import { setupAutoUpdater } from './update.js'
|
|
17
|
+
import { shortcutSupported, hasDesktopShortcut, createDesktopShortcut } from './shortcut.js'
|
|
16
18
|
|
|
17
19
|
const isMac = process.platform === 'darwin'
|
|
18
20
|
|
|
@@ -22,6 +24,11 @@ if (process.platform === 'win32') {
|
|
|
22
24
|
app.setAppUserModelId('com.icather.dsh-clean-desktop-shell')
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
// Uniform userData across both distribution branches (installer vs
|
|
28
|
+
// plugin-market), so config (target URL, backend path, ...) is shared
|
|
29
|
+
// no matter how the shell was launched.
|
|
30
|
+
app.setName('DSH Clean Desktop Shell')
|
|
31
|
+
|
|
25
32
|
/** Single instance: a second launch just focuses the existing window. */
|
|
26
33
|
const gotLock = app.requestSingleInstanceLock()
|
|
27
34
|
if (!gotLock) {
|
|
@@ -56,26 +63,48 @@ if (!gotLock) {
|
|
|
56
63
|
return mainWindow
|
|
57
64
|
}
|
|
58
65
|
|
|
59
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* Launch-time backend detection. Detects whether a local dsh web is
|
|
68
|
+
* already running so the tray and window reflect the real state, but
|
|
69
|
+
* never auto-starts it — starting is a manual action (tray menu or the
|
|
70
|
+
* offline screen button) so it cannot fight an explicit "stop".
|
|
71
|
+
*/
|
|
60
72
|
async function bootstrapBackend() {
|
|
61
73
|
const config = loadConfig()
|
|
62
74
|
const target = config.targetUrl || 'http://127.0.0.1:3080'
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
|
|
66
|
-
const url = await detect()
|
|
67
|
-
if (!url) {
|
|
68
|
-
// Not running — try to start it, but never block window opening.
|
|
69
|
-
try {
|
|
70
|
-
await start({ backendPath: config.backendPath })
|
|
71
|
-
} catch {
|
|
72
|
-
// Backend unavailable; window still opens, tray shows the error.
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
+
// Remote targets are the user's own business — never probe local.
|
|
76
|
+
if (target !== 'http://127.0.0.1:3080') return
|
|
77
|
+
await detect()
|
|
75
78
|
// Reflect the post-detect state in the tray menu.
|
|
76
79
|
refreshTrayMenu()
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
/**
|
|
83
|
+
* First-run desktop shortcut prompt (Windows packaged apps only).
|
|
84
|
+
* Asks once; the tray "create desktop shortcut" item stays available
|
|
85
|
+
* forever, so saying no is never a dead end.
|
|
86
|
+
*/
|
|
87
|
+
async function ensureShortcut() {
|
|
88
|
+
if (!shortcutSupported() || loadConfig().shortcutAsked) return
|
|
89
|
+
saveConfig({ ...loadConfig(), shortcutAsked: true })
|
|
90
|
+
if (await hasDesktopShortcut()) return
|
|
91
|
+
const choice = dialog.showMessageBoxSync({
|
|
92
|
+
type: 'question',
|
|
93
|
+
title: '创建桌面快捷方式?',
|
|
94
|
+
message: '是否在桌面创建「DSH Clean Desktop Shell」快捷方式?',
|
|
95
|
+
detail: '选择「跳过」也不影响使用——之后可随时在托盘右键菜单中一键添加。',
|
|
96
|
+
buttons: ['创建', '跳过'],
|
|
97
|
+
defaultId: 0,
|
|
98
|
+
cancelId: 1,
|
|
99
|
+
})
|
|
100
|
+
if (choice === 0) {
|
|
101
|
+
const ok = await createDesktopShortcut()
|
|
102
|
+
if (!ok) {
|
|
103
|
+
dialog.showErrorBox('创建快捷方式失败', '无法在桌面创建快捷方式。可稍后在托盘右键菜单中重试。')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
app.whenReady().then(async () => {
|
|
80
109
|
await createWindow()
|
|
81
110
|
tray = createTray({
|
|
@@ -84,11 +113,6 @@ if (!gotLock) {
|
|
|
84
113
|
mainWindow.show()
|
|
85
114
|
mainWindow.focus()
|
|
86
115
|
},
|
|
87
|
-
onToggleAutoStart: (enabled) => {
|
|
88
|
-
const config = loadConfig()
|
|
89
|
-
saveConfig({ ...config, autoLaunch: enabled })
|
|
90
|
-
app.setLoginItemSettings({ openAtLogin: enabled })
|
|
91
|
-
},
|
|
92
116
|
onReload: () => {
|
|
93
117
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
94
118
|
reloadWindow(mainWindow, loadConfig().targetUrl || 'http://127.0.0.1:3080')
|
|
@@ -103,6 +127,12 @@ if (!gotLock) {
|
|
|
103
127
|
// Backend bootstrap in background (never delays the window).
|
|
104
128
|
bootstrapBackend().catch(() => {})
|
|
105
129
|
|
|
130
|
+
// Windows: wire the auto-updater (downloads new installers silently).
|
|
131
|
+
setupAutoUpdater()
|
|
132
|
+
|
|
133
|
+
// First run: offer a desktop shortcut (never nags twice).
|
|
134
|
+
ensureShortcut().catch(() => {})
|
|
135
|
+
|
|
106
136
|
app.on('activate', () => {
|
|
107
137
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
|
108
138
|
else mainWindow?.show()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop shortcut management (Windows .lnk via WScript.Shell).
|
|
3
|
+
*
|
|
4
|
+
* Used by:
|
|
5
|
+
* - first-run prompt in main.js ("create a desktop shortcut?")
|
|
6
|
+
* - the tray "create desktop shortcut" item (always available)
|
|
7
|
+
*
|
|
8
|
+
* Only meaningful for packaged apps — in dev mode there is no stable
|
|
9
|
+
* executable to point the shortcut at, so the feature is disabled there.
|
|
10
|
+
*/
|
|
11
|
+
import { app } from 'electron'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { homedir } from 'node:os'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
|
+
|
|
16
|
+
const isWin = process.platform === 'win32'
|
|
17
|
+
const SHORTCUT_NAME = 'DSH Clean Desktop Shell.lnk'
|
|
18
|
+
|
|
19
|
+
/** Shortcuts are a Windows packaged-app feature. */
|
|
20
|
+
export function shortcutSupported() {
|
|
21
|
+
return isWin && app.isPackaged
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ps(str) {
|
|
25
|
+
return "'" + String(str).replace(/'/g, "''") + "'"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function runPs(script) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
const p = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
31
|
+
windowsHide: true,
|
|
32
|
+
})
|
|
33
|
+
let out = ''
|
|
34
|
+
p.stdout.on('data', (d) => {
|
|
35
|
+
out += d.toString()
|
|
36
|
+
})
|
|
37
|
+
p.on('error', () => resolve(null))
|
|
38
|
+
p.on('exit', () => resolve(out))
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when a desktop shortcut already points at this app's exe. */
|
|
43
|
+
export async function hasDesktopShortcut() {
|
|
44
|
+
if (!isWin) return false
|
|
45
|
+
const script =
|
|
46
|
+
`$ws = New-Object -ComObject WScript.Shell; ` +
|
|
47
|
+
`Get-ChildItem ${ps(join(homedir(), 'Desktop', '*.lnk'))} | ` +
|
|
48
|
+
`ForEach-Object { $ws.CreateShortcut($_.FullName).TargetPath }`
|
|
49
|
+
const out = await runPs(script)
|
|
50
|
+
if (out === null) return false
|
|
51
|
+
const target = process.execPath.toLowerCase()
|
|
52
|
+
return out
|
|
53
|
+
.split(/\r?\n/)
|
|
54
|
+
.map((s) => s.trim().toLowerCase())
|
|
55
|
+
.some((line) => line === target)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Create (or overwrite) the desktop shortcut for this app. Returns bool. */
|
|
59
|
+
export async function createDesktopShortcut() {
|
|
60
|
+
if (!shortcutSupported()) return false
|
|
61
|
+
const target = process.execPath
|
|
62
|
+
const lnk = join(homedir(), 'Desktop', SHORTCUT_NAME)
|
|
63
|
+
const script =
|
|
64
|
+
`$ws = New-Object -ComObject WScript.Shell; ` +
|
|
65
|
+
`$s = $ws.CreateShortcut(${ps(lnk)}); ` +
|
|
66
|
+
`$s.TargetPath = ${ps(target)}; ` +
|
|
67
|
+
`$s.WorkingDirectory = ${ps(dirname(target))}; ` +
|
|
68
|
+
`$s.IconLocation = ${ps(`${target},0`)}; ` +
|
|
69
|
+
`$s.Save()`
|
|
70
|
+
const out = await runPs(script)
|
|
71
|
+
return out !== null
|
|
72
|
+
}
|
package/electron/tray.js
CHANGED
|
@@ -19,7 +19,8 @@ import {
|
|
|
19
19
|
onStatusChange,
|
|
20
20
|
} from './service.js'
|
|
21
21
|
import { showProgress, setProgress, closeProgress } from './progress.js'
|
|
22
|
-
import { checkForUpdate, openRepo, openUrl } from './update.js'
|
|
22
|
+
import { checkForUpdate, checkForUpdatesAuto, isAutoUpdateSupported, openRepo, openUrl } from './update.js'
|
|
23
|
+
import { shortcutSupported, createDesktopShortcut } from './shortcut.js'
|
|
23
24
|
|
|
24
25
|
const trayIconPath = join(
|
|
25
26
|
fileURLToPath(new URL('.', import.meta.url)),
|
|
@@ -30,10 +31,10 @@ const trayIconPath = join(
|
|
|
30
31
|
let trayInstance = null
|
|
31
32
|
let handlers = null
|
|
32
33
|
|
|
33
|
-
export function createTray({ onShow,
|
|
34
|
+
export function createTray({ onShow, onReload, onQuit }) {
|
|
34
35
|
const icon = nativeImage.createFromPath(trayIconPath)
|
|
35
36
|
|
|
36
|
-
handlers = { onShow,
|
|
37
|
+
handlers = { onShow, onReload, onQuit }
|
|
37
38
|
|
|
38
39
|
trayInstance = new Tray(icon)
|
|
39
40
|
trayInstance.setToolTip('DSH Clean Desktop Shell')
|
|
@@ -50,7 +51,7 @@ export function createTray({ onShow, onToggleAutoStart, onReload, onQuit }) {
|
|
|
50
51
|
/** Rebuild the context menu (call after backend state changes). */
|
|
51
52
|
export function refreshTrayMenu() {
|
|
52
53
|
if (!trayInstance || !handlers) return
|
|
53
|
-
const { onShow,
|
|
54
|
+
const { onShow, onReload, onQuit } = handlers
|
|
54
55
|
const st = getStatus()
|
|
55
56
|
const label = statusLabel(st)
|
|
56
57
|
|
|
@@ -60,6 +61,26 @@ export function refreshTrayMenu() {
|
|
|
60
61
|
label: '刷新窗口',
|
|
61
62
|
click: onReload,
|
|
62
63
|
},
|
|
64
|
+
{
|
|
65
|
+
label: '创建桌面快捷方式',
|
|
66
|
+
enabled: shortcutSupported(),
|
|
67
|
+
click: async () => {
|
|
68
|
+
const ok = await createDesktopShortcut()
|
|
69
|
+
if (ok) {
|
|
70
|
+
dialog.showMessageBoxSync({
|
|
71
|
+
type: 'info',
|
|
72
|
+
title: '已创建',
|
|
73
|
+
message: '桌面快捷方式已创建。',
|
|
74
|
+
})
|
|
75
|
+
} else {
|
|
76
|
+
dialog.showMessageBoxSync({
|
|
77
|
+
type: 'warning',
|
|
78
|
+
title: '创建失败',
|
|
79
|
+
message: '无法创建桌面快捷方式,请稍后重试。',
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
63
84
|
{ type: 'separator' },
|
|
64
85
|
{ label: `后端:${label}`, enabled: false },
|
|
65
86
|
{
|
|
@@ -115,6 +136,12 @@ export function refreshTrayMenu() {
|
|
|
115
136
|
{
|
|
116
137
|
label: '检查更新…',
|
|
117
138
|
click: async () => {
|
|
139
|
+
// Windows (packaged): auto-download + install on restart.
|
|
140
|
+
// macOS / dev mode: manual page link (macOS needs a signature).
|
|
141
|
+
if (isAutoUpdateSupported()) {
|
|
142
|
+
await checkForUpdatesAuto()
|
|
143
|
+
return
|
|
144
|
+
}
|
|
118
145
|
const r = await checkForUpdate()
|
|
119
146
|
if (r.hasUpdate) {
|
|
120
147
|
const choice = dialog.showMessageBoxSync({
|
|
@@ -147,13 +174,6 @@ export function refreshTrayMenu() {
|
|
|
147
174
|
click: () => openRepo(),
|
|
148
175
|
},
|
|
149
176
|
{ type: 'separator' },
|
|
150
|
-
{
|
|
151
|
-
label: '开机自启',
|
|
152
|
-
type: 'checkbox',
|
|
153
|
-
checked: !!loadConfig().autoLaunch,
|
|
154
|
-
click: (item) => onToggleAutoStart(item.checked),
|
|
155
|
-
},
|
|
156
|
-
{ type: 'separator' },
|
|
157
177
|
{ label: '退出', click: onQuit },
|
|
158
178
|
])
|
|
159
179
|
trayInstance.setContextMenu(menu)
|
package/electron/update.js
CHANGED
|
@@ -1,15 +1,142 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Update handling — Windows auto-update, macOS manual download.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Windows (packaged app): uses electron-updater to download the new
|
|
5
|
+
* installer from GitHub Releases in the background and install it on
|
|
6
|
+
* restart. Progress is shown in the small progress window.
|
|
7
|
+
*
|
|
8
|
+
* macOS / dev mode: falls back to a manual check that opens the GitHub
|
|
9
|
+
* Releases page (macOS auto-update needs a Developer ID signature which
|
|
10
|
+
* this project does not have yet).
|
|
7
11
|
*/
|
|
8
|
-
import { app, shell } from 'electron'
|
|
12
|
+
import { app, shell, dialog } from 'electron'
|
|
13
|
+
import { showProgress, setProgress, closeProgress } from './progress.js'
|
|
9
14
|
|
|
10
15
|
const REPO_URL = 'https://github.com/Icather/dsh-clean-desktop-shell'
|
|
11
16
|
const RELEASES_API = 'https://api.github.com/repos/Icather/dsh-clean-desktop-shell/releases/latest'
|
|
12
17
|
|
|
18
|
+
let autoUpdater = null
|
|
19
|
+
let updaterPromise = null
|
|
20
|
+
|
|
21
|
+
/** Lazy-load electron-updater (CJS → ESM interop via dynamic import). */
|
|
22
|
+
function getAutoUpdater() {
|
|
23
|
+
if (!updaterPromise) {
|
|
24
|
+
updaterPromise = import('electron-updater').then((m) => m.autoUpdater)
|
|
25
|
+
}
|
|
26
|
+
return updaterPromise
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Windows packaged apps can auto-update; everything else uses manual. */
|
|
30
|
+
export function isAutoUpdateSupported() {
|
|
31
|
+
return process.platform === 'win32' && app.isPackaged
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wire the auto-updater events once (no-op on mac / dev mode). */
|
|
35
|
+
export function setupAutoUpdater() {
|
|
36
|
+
if (!isAutoUpdateSupported()) return
|
|
37
|
+
getAutoUpdater()
|
|
38
|
+
.then((au) => {
|
|
39
|
+
if (autoUpdater) return
|
|
40
|
+
autoUpdater = au
|
|
41
|
+
autoUpdater.autoDownload = true
|
|
42
|
+
autoUpdater.autoInstallOnAppQuit = true
|
|
43
|
+
|
|
44
|
+
autoUpdater.on('update-available', () => {
|
|
45
|
+
showProgress({ title: '检查更新', message: '发现新版本,正在后台下载…' })
|
|
46
|
+
})
|
|
47
|
+
autoUpdater.on('download-progress', (p) => {
|
|
48
|
+
const pct = Math.round(p.percent)
|
|
49
|
+
setProgress({
|
|
50
|
+
title: '检查更新',
|
|
51
|
+
message: `正在下载更新:${pct}%`,
|
|
52
|
+
state: pct >= 100 ? 'ok' : 'busy',
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
autoUpdater.on('update-downloaded', () => {
|
|
56
|
+
setProgress({ title: '检查更新', message: '更新已下载', state: 'ok' })
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
closeProgress()
|
|
59
|
+
const choice = dialog.showMessageBoxSync({
|
|
60
|
+
type: 'info',
|
|
61
|
+
title: '更新已就绪',
|
|
62
|
+
message: '新版本已下载完成,重启应用即可安装。',
|
|
63
|
+
detail: '是否立即重启安装?',
|
|
64
|
+
buttons: ['立即重启', '稍后'],
|
|
65
|
+
defaultId: 0,
|
|
66
|
+
cancelId: 1,
|
|
67
|
+
})
|
|
68
|
+
if (choice === 0) autoUpdater.quitAndInstall()
|
|
69
|
+
}, 800)
|
|
70
|
+
})
|
|
71
|
+
autoUpdater.on('update-not-available', () => {
|
|
72
|
+
closeProgress()
|
|
73
|
+
dialog.showMessageBoxSync({
|
|
74
|
+
type: 'info',
|
|
75
|
+
title: '已是最新版本',
|
|
76
|
+
message: '当前已是最新版本。',
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
autoUpdater.on('error', (err) => {
|
|
80
|
+
closeProgress()
|
|
81
|
+
dialog.showMessageBoxSync({
|
|
82
|
+
type: 'warning',
|
|
83
|
+
title: '检查更新失败',
|
|
84
|
+
message: `自动更新失败:${err?.message || '未知错误'}`,
|
|
85
|
+
detail: '可前往 GitHub Releases 手动下载。',
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
.catch(() => {
|
|
90
|
+
// electron-updater failed to load — auto-update silently disabled.
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Tray "check for update": auto flow on Windows, manual fallback elsewhere. */
|
|
95
|
+
export async function checkForUpdatesAuto() {
|
|
96
|
+
if (isAutoUpdateSupported()) {
|
|
97
|
+
try {
|
|
98
|
+
await getAutoUpdater()
|
|
99
|
+
await autoUpdater?.checkForUpdates()
|
|
100
|
+
} catch (err) {
|
|
101
|
+
closeProgress()
|
|
102
|
+
dialog.showMessageBoxSync({
|
|
103
|
+
type: 'warning',
|
|
104
|
+
title: '检查更新失败',
|
|
105
|
+
message: `自动更新失败:${err?.message || '未知错误'}`,
|
|
106
|
+
detail: '可前往 GitHub Releases 手动下载。',
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Manual path (macOS / dev mode): compare versions, offer GitHub page.
|
|
113
|
+
const r = await checkForUpdate()
|
|
114
|
+
if (r.hasUpdate) {
|
|
115
|
+
const choice = dialog.showMessageBoxSync({
|
|
116
|
+
type: 'info',
|
|
117
|
+
title: '发现新版本',
|
|
118
|
+
message: `当前版本 ${r.current},最新版本 ${r.latest}。`,
|
|
119
|
+
detail: 'macOS 自动更新需要代码签名,当前请前往 GitHub Releases 手动下载。',
|
|
120
|
+
buttons: ['前往下载', '取消'],
|
|
121
|
+
defaultId: 0,
|
|
122
|
+
cancelId: 1,
|
|
123
|
+
})
|
|
124
|
+
if (choice === 0) openUrl(r.url)
|
|
125
|
+
} else if (r.latest) {
|
|
126
|
+
dialog.showMessageBoxSync({
|
|
127
|
+
type: 'info',
|
|
128
|
+
title: '已是最新版本',
|
|
129
|
+
message: `当前版本 ${r.current} 已是最新(${r.latest})。`,
|
|
130
|
+
})
|
|
131
|
+
} else {
|
|
132
|
+
dialog.showMessageBoxSync({
|
|
133
|
+
type: 'warning',
|
|
134
|
+
title: '检查更新失败',
|
|
135
|
+
message: '无法连接 GitHub 检查更新,请检查网络后重试。',
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
13
140
|
/** Parse "v1.2.3" / "1.2.3" → [1,2,3]; null when malformed. */
|
|
14
141
|
function parseVersion(v) {
|
|
15
142
|
if (!v) return null
|
|
@@ -27,7 +154,7 @@ function isNewer(a, b) {
|
|
|
27
154
|
}
|
|
28
155
|
|
|
29
156
|
/**
|
|
30
|
-
*
|
|
157
|
+
* Manual version check against the latest GitHub release (macOS path).
|
|
31
158
|
* @returns {{ hasUpdate: boolean, latest?: string, current: string, url: string }}
|
|
32
159
|
*/
|
|
33
160
|
export async function checkForUpdate() {
|
package/lib/index.js
CHANGED
|
@@ -1,13 +1,247 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-clean-desktop-shell — host half (plugin loader entry).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Branch 2 (plugin-market distribution): when installed through the DSH
|
|
5
|
+
* plugin market, this host half brings up the Electron shell itself.
|
|
6
|
+
*
|
|
7
|
+
* The electron runtime is NOT an npm dependency (electron-builder forbids
|
|
8
|
+
* electron in "dependencies", and pnpm's allowBuilds would block its
|
|
9
|
+
* postinstall anyway). Instead the host half manages the runtime on its
|
|
10
|
+
* own, under $DSH_HOME/desktop-shell-runtime/:
|
|
11
|
+
*
|
|
12
|
+
* 1. resolve the version to run (package.json → desktopShell.electronVersion)
|
|
13
|
+
* 2. if that version dir exists → reuse it
|
|
14
|
+
* 3. otherwise download the electron zip from the best source for the
|
|
15
|
+
* network (official GitHub releases vs npmmirror mirror), extract it,
|
|
16
|
+
* and drop any older version dirs (no unbounded disk growth)
|
|
17
|
+
* 4. spawn the shell (runtime electron + electron/main.js) — the same
|
|
18
|
+
* code branch 1 (installer) ships
|
|
19
|
+
*
|
|
20
|
+
* Window, tray, backend management etc. are identical to branch 1; only
|
|
21
|
+
* the runtime provisioning differs.
|
|
8
22
|
*/
|
|
23
|
+
import { spawn } from 'node:child_process'
|
|
24
|
+
import {
|
|
25
|
+
cpSync,
|
|
26
|
+
existsSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
renameSync,
|
|
31
|
+
rmSync,
|
|
32
|
+
symlinkSync,
|
|
33
|
+
} from 'node:fs'
|
|
34
|
+
import { homedir } from 'node:os'
|
|
35
|
+
import { dirname, join } from 'node:path'
|
|
36
|
+
import { fileURLToPath } from 'node:url'
|
|
37
|
+
|
|
38
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
39
|
+
const MAIN_JS = join(PKG_ROOT, 'electron', 'main.js')
|
|
40
|
+
const isWin = process.platform === 'win32'
|
|
41
|
+
const EXE_NAME = isWin ? 'electron.exe' : 'electron'
|
|
42
|
+
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
|
|
43
|
+
const PLATFORM = isWin ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'
|
|
44
|
+
|
|
45
|
+
// Disable auto-launch with DSH_SHELL_AUTO_LAUNCH=0.
|
|
46
|
+
const AUTO_LAUNCH = process.env.DSH_SHELL_AUTO_LAUNCH !== '0'
|
|
47
|
+
|
|
48
|
+
let launched = false
|
|
49
|
+
|
|
9
50
|
export function apply(ctx) {
|
|
10
|
-
ctx.on('ready', () => {
|
|
51
|
+
ctx.on('ready', async () => {
|
|
11
52
|
ctx.logger.info('[clean-desktop-shell] mounted (host half)')
|
|
53
|
+
if (!AUTO_LAUNCH) return
|
|
54
|
+
try {
|
|
55
|
+
const exe = await ensureRuntime(ctx)
|
|
56
|
+
launchShell(exe, ctx)
|
|
57
|
+
} catch (err) {
|
|
58
|
+
ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${err?.message ?? err}`)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------- electron runtime provisioning ----------
|
|
64
|
+
|
|
65
|
+
function electronVersion() {
|
|
66
|
+
try {
|
|
67
|
+
const meta = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
68
|
+
return meta.desktopShell?.electronVersion || null
|
|
69
|
+
} catch {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function runtimeRoot() {
|
|
75
|
+
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'desktop-shell-runtime')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function versionDir(root, version) {
|
|
79
|
+
return join(root, `electron-v${version}`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function zipName(version) {
|
|
83
|
+
return `electron-v${version}-${PLATFORM}-${ARCH}.zip`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function ensureRuntime(ctx) {
|
|
87
|
+
const version = electronVersion()
|
|
88
|
+
if (!version) throw new Error('desktopShell.electronVersion missing in package.json')
|
|
89
|
+
const root = runtimeRoot()
|
|
90
|
+
const dir = versionDir(root, version)
|
|
91
|
+
const exe = join(dir, EXE_NAME)
|
|
92
|
+
|
|
93
|
+
// 1) Already provisioned for this version?
|
|
94
|
+
if (existsSync(exe)) {
|
|
95
|
+
cleanupOldVersions(root, dir)
|
|
96
|
+
return exe
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 2) Local reuse: DSH_SHELL_ELECTRON_DIR → link/copy its dist/ (fast).
|
|
100
|
+
const localSrc = process.env.DSH_SHELL_ELECTRON_DIR
|
|
101
|
+
if (localSrc) {
|
|
102
|
+
const srcExe = join(localSrc, 'dist', EXE_NAME)
|
|
103
|
+
if (existsSync(srcExe) && provisionLocalDist(localSrc, dir)) {
|
|
104
|
+
ctx.logger.info(`[clean-desktop-shell] reused electron runtime from ${localSrc}`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 3) Download + extract the official zip from a network-appropriate source.
|
|
109
|
+
if (!existsSync(exe)) {
|
|
110
|
+
mkdirSync(root, { recursive: true })
|
|
111
|
+
await downloadRuntime(ctx, version, root, dir)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!existsSync(exe)) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
'electron runtime provisioning failed — check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
cleanupOldVersions(root, dir)
|
|
120
|
+
ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
|
|
121
|
+
return exe
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function downloadRuntime(ctx, version, root, dir) {
|
|
125
|
+
const tmpZip = join(root, `.electron-${version}.zip.tmp`)
|
|
126
|
+
rmSync(tmpZip, { force: true })
|
|
127
|
+
const urls = await runtimeUrls(version)
|
|
128
|
+
for (const url of urls) {
|
|
129
|
+
ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${url}`)
|
|
130
|
+
if (await fetchFile(url, tmpZip)) {
|
|
131
|
+
// Zip extracts to an inner dir named like the zip basename.
|
|
132
|
+
const inner = join(root, zipName(version).replace(/\.zip$/, ''))
|
|
133
|
+
try {
|
|
134
|
+
await extractZip(tmpZip, root)
|
|
135
|
+
if (existsSync(join(inner, EXE_NAME)) && inner !== dir) {
|
|
136
|
+
rmSync(dir, { recursive: true, force: true })
|
|
137
|
+
renameSync(inner, dir)
|
|
138
|
+
}
|
|
139
|
+
rmSync(tmpZip, { force: true })
|
|
140
|
+
return
|
|
141
|
+
} catch (err) {
|
|
142
|
+
ctx.logger.warn(`[clean-desktop-shell] extract failed: ${err?.message ?? err}`)
|
|
143
|
+
rmSync(inner, { recursive: true, force: true })
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
throw new Error('electron download failed from all sources')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Probe GitHub; reachable → official releases, else the npmmirror mirror. */
|
|
151
|
+
async function runtimeUrls(version) {
|
|
152
|
+
const official = `https://github.com/electron/electron/releases/download/v${version}/${zipName(version)}`
|
|
153
|
+
const mirror = `https://npmmirror.com/mirrors/electron/${version}/${zipName(version)}`
|
|
154
|
+
try {
|
|
155
|
+
const ctrl = new AbortController()
|
|
156
|
+
const timer = setTimeout(() => ctrl.abort(), 3000)
|
|
157
|
+
const res = await fetch('https://github.com', { signal: ctrl.signal, method: 'HEAD' })
|
|
158
|
+
clearTimeout(timer)
|
|
159
|
+
if (res.status < 500) return [official, mirror]
|
|
160
|
+
} catch {
|
|
161
|
+
// unreachable — mirror first
|
|
162
|
+
}
|
|
163
|
+
return [mirror, official]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fetchFile(url, dest) {
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
// curl is available on Windows 10+; streams to disk, honors proxy env.
|
|
169
|
+
const child = spawn('curl', ['-L', '--fail', '--silent', '--show-error', '-o', dest, url], {
|
|
170
|
+
windowsHide: true,
|
|
171
|
+
stdio: 'ignore',
|
|
172
|
+
})
|
|
173
|
+
child.on('error', () => resolve(false))
|
|
174
|
+
child.on('exit', (code) => resolve(code === 0))
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function extractZip(zipPath, dest) {
|
|
179
|
+
// Windows ships bsdtar (tar.exe) which reads zip; fall back to
|
|
180
|
+
// PowerShell Expand-Archive if needed.
|
|
181
|
+
const child = spawn('tar', ['-xf', zipPath, '-C', dest], { windowsHide: true, stdio: 'ignore' })
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
child.on('error', reject)
|
|
184
|
+
child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exit ${code}`))))
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Remove version dirs older than the current one (dead weight). */
|
|
189
|
+
function cleanupOldVersions(root, currentDir) {
|
|
190
|
+
try {
|
|
191
|
+
for (const entry of readdirSync(root)) {
|
|
192
|
+
if (!entry.startsWith('electron-v')) continue
|
|
193
|
+
const full = join(root, entry)
|
|
194
|
+
if (full === currentDir) continue
|
|
195
|
+
rmSync(full, { recursive: true, force: true })
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// best-effort
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Provision a local electron package's dist/ as the version dir itself,
|
|
204
|
+
* so the layout matches a downloaded runtime: <dir>/electron(.exe) at the
|
|
205
|
+
* version-dir root. Windows: junction (zero-copy, instant) — a 269MB
|
|
206
|
+
* recursive cpSync can be killed by sandbox/AV on large trees, so only
|
|
207
|
+
* fall back to a copy.
|
|
208
|
+
*/
|
|
209
|
+
function provisionLocalDist(srcPkg, destDir) {
|
|
210
|
+
if (isWin) {
|
|
211
|
+
try {
|
|
212
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
213
|
+
symlinkSync(join(srcPkg, 'dist'), destDir, 'junction')
|
|
214
|
+
return true
|
|
215
|
+
} catch {
|
|
216
|
+
// fall through to a real copy
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
221
|
+
cpSync(join(srcPkg, 'dist'), destDir, { recursive: true })
|
|
222
|
+
return true
|
|
223
|
+
} catch {
|
|
224
|
+
return false
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---------- shell launch ----------
|
|
229
|
+
|
|
230
|
+
function launchShell(exe, ctx) {
|
|
231
|
+
if (launched) return
|
|
232
|
+
const child = spawn(exe, [MAIN_JS], {
|
|
233
|
+
cwd: PKG_ROOT,
|
|
234
|
+
env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined },
|
|
235
|
+
stdio: 'ignore',
|
|
236
|
+
windowsHide: false,
|
|
237
|
+
})
|
|
238
|
+
launched = true
|
|
239
|
+
child.on('error', (err) => {
|
|
240
|
+
launched = false
|
|
241
|
+
ctx.logger.warn(`[clean-desktop-shell] shell spawn error: ${err.message}`)
|
|
242
|
+
})
|
|
243
|
+
child.on('exit', (code) => {
|
|
244
|
+
launched = false
|
|
245
|
+
ctx.logger.info(`[clean-desktop-shell] shell exited (${code})`)
|
|
12
246
|
})
|
|
13
247
|
}
|