dsh-clean-desktop-shell 0.1.3 → 0.1.4
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 +16 -5
- package/README.md +11 -5
- package/build/icon.ico +0 -0
- package/electron/aumid.js +17 -0
- package/electron/main.js +31 -4
- package/electron/shortcut.js +64 -6
- package/electron/window.js +21 -2
- package/lib/client.js +15 -6
- package/lib/index.js +103 -20
- package/package.json +2 -1
- package/src/client/client.js +15 -6
- package/src/host/index.js +103 -20
- package/version.txt +1 -1
package/README.en.md
CHANGED
|
@@ -31,22 +31,22 @@ Key differences from other desktop clients in the ecosystem:
|
|
|
31
31
|
|
|
32
32
|
## Install
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
**Option 1: download the installer from Releases (recommended for end users — the only full desktop-app install)**
|
|
34
|
+
**Option 1: download the installer from Releases (for a standalone desktop app)**
|
|
37
35
|
|
|
38
36
|
- Windows: `DSH-Clean-Desktop-Shell-Setup-<version>.exe`
|
|
39
37
|
- macOS: `DSH-Clean-Desktop-Shell-<version>.dmg` (Intel) or `-arm64.dmg` (Apple Silicon)
|
|
40
38
|
|
|
41
39
|
The installer **creates a desktop shortcut automatically** and provides the full desktop experience (tray). The first time you run the Windows installer you may see a SmartScreen warning — **this is normal for unsigned programs, not a virus**, see "Windows SmartScreen warning" below.
|
|
42
40
|
|
|
43
|
-
**Option 2:
|
|
41
|
+
**Option 2: install as a DSH plugin (DSH ecosystem users)**
|
|
44
42
|
|
|
45
43
|
```sh
|
|
46
44
|
dsh plugin --profile web add dsh-clean-desktop-shell
|
|
47
45
|
```
|
|
48
46
|
|
|
49
|
-
|
|
47
|
+
Restart `dsh web` and the desktop shell window **opens automatically** (the first run prepares the Electron runtime over the network, ~1-2 minutes).
|
|
48
|
+
|
|
49
|
+
> Option 2 gives you a shell that launches alongside DSH: the window is spawned by the plugin when `dsh web` starts, with **no standalone installer / desktop icon**. For a double-clickable app with a desktop shortcut and auto-update, use Option 1. The core window experience is identical either way.
|
|
50
50
|
|
|
51
51
|
> The shell needs a reachable `dsh web` service (local or configured remote address). See Usage.
|
|
52
52
|
|
|
@@ -145,6 +145,17 @@ npm run pack # package NSIS (Win) / DMG (mac)
|
|
|
145
145
|
|
|
146
146
|
## Changelog
|
|
147
147
|
|
|
148
|
+
### 0.1.4
|
|
149
|
+
- Branch 2 (plugin-market distribution) is now live: `dsh plugin add` →
|
|
150
|
+
restart `dsh web` → the desktop shell opens automatically. The Electron
|
|
151
|
+
runtime is self-provisioned by the plugin (local reuse / network-aware
|
|
152
|
+
source selection).
|
|
153
|
+
- Desktop shortcut: first-run prompt + one-click "create desktop shortcut"
|
|
154
|
+
in the tray (both the installer and plugin forms).
|
|
155
|
+
- Icons: Windows taskbar and macOS Dock show the whale icon in bare-runtime
|
|
156
|
+
(plugin) mode.
|
|
157
|
+
- Auto-launch (login item) removed — both forms are now fully manual.
|
|
158
|
+
|
|
148
159
|
### 0.1.2
|
|
149
160
|
- Windows auto-update: tray "check for updates" now downloads in the
|
|
150
161
|
background with progress and installs on restart (electron-updater);
|
package/README.md
CHANGED
|
@@ -31,22 +31,22 @@
|
|
|
31
31
|
|
|
32
32
|
## 安装
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
**方式一:从 Release 下载安装包(推荐普通用户,唯一完整的桌面应用安装方式)**
|
|
34
|
+
**方式一:从 Release 下载安装包(想要独立桌面应用的用户)**
|
|
37
35
|
|
|
38
36
|
- Windows:下载 `DSH-Clean-Desktop-Shell-Setup-<版本>.exe`
|
|
39
37
|
- macOS:下载 `DSH-Clean-Desktop-Shell-<版本>.dmg`(Intel)或 `-arm64.dmg`(Apple Silicon)
|
|
40
38
|
|
|
41
39
|
安装包会**自动创建桌面快捷方式**,并提供系统托盘等完整桌面体验。首次运行 Windows 安装包可能触发 SmartScreen 警告——**这是未签名程序的正常现象,不是病毒**,见下方「Windows SmartScreen 警告说明」。
|
|
42
40
|
|
|
43
|
-
**方式二:作为 DSH
|
|
41
|
+
**方式二:作为 DSH 插件安装(DSH 生态用户)**
|
|
44
42
|
|
|
45
43
|
```sh
|
|
46
44
|
dsh plugin --profile web add dsh-clean-desktop-shell
|
|
47
45
|
```
|
|
48
46
|
|
|
49
|
-
|
|
47
|
+
重启 `dsh web` 后,桌面壳窗口会**自动弹出**(首次运行需联网准备 Electron 运行时,约 1-2 分钟)。
|
|
48
|
+
|
|
49
|
+
> 方式二得到的是"随 DSH 启动的桌面壳":窗口由插件在 `dsh web` 启动时自动拉起,**不产生独立安装包 / 桌面图标**。想要可双击启动、带桌面快捷方式和自动更新的独立应用,请用方式一。两种方式的核心窗口体验一致。
|
|
50
50
|
|
|
51
51
|
> 桌面壳需要本机有可用的 `dsh web` 服务(或配置的远程地址)。见下方「使用」。
|
|
52
52
|
|
|
@@ -144,6 +144,12 @@ npm run pack # 打包 NSIS (Win) / DMG (mac)
|
|
|
144
144
|
|
|
145
145
|
## 更新历史
|
|
146
146
|
|
|
147
|
+
### 0.1.4
|
|
148
|
+
- 分支二(插件市场分发)正式可用:`dsh plugin add` 装插件 → 重启 `dsh web` → 桌面壳自动弹出;Electron 运行时由插件自管理(本地复用 / 按网络环境自动选源下载)。
|
|
149
|
+
- 桌面快捷方式:首次启动询问创建 + 托盘「创建桌面快捷方式」一键添加(安装包与插件两种形态均支持)。
|
|
150
|
+
- 图标:Windows 任务栏 / macOS Dock 均显示鲸鱼图标(裸运行时场景)。
|
|
151
|
+
- 移除开机自启(两种形态统一为纯手动启动)。
|
|
152
|
+
|
|
147
153
|
### 0.1.2
|
|
148
154
|
- Windows 自动更新:托盘「检查更新」改为后台下载 + 进度显示 + 重启安装(electron-updater);macOS 仍为手动下载。
|
|
149
155
|
- 移除启动时自动拉起后端(改纯手动,与「关闭后端」不冲突)。
|
package/build/icon.ico
ADDED
|
Binary file
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { app } from 'electron'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AppUserModelID, shared by main (process-level), window (taskbar button)
|
|
5
|
+
* and shortcut (Start-menu .lnk) so the three always agree.
|
|
6
|
+
*
|
|
7
|
+
* Why plugin mode gets its own ID: Windows caches the taskbar button icon
|
|
8
|
+
* per AUMID. If the plugin shell reuses the installer's ID that ever ran
|
|
9
|
+
* with Electron's default icon (an early dev launch / an old build), the
|
|
10
|
+
* taskbar keeps showing that stale icon — no window `icon`, `setAppDetails`
|
|
11
|
+
* or exe patch can override the cache. A distinct ID makes Windows treat it
|
|
12
|
+
* as a brand-new app and re-read the icon. It also keeps the plugin shell
|
|
13
|
+
* from merging with an installed copy on the same machine.
|
|
14
|
+
*/
|
|
15
|
+
export const APP_USER_MODEL_ID = app.isPackaged
|
|
16
|
+
? 'com.icather.dsh-clean-desktop-shell'
|
|
17
|
+
: 'com.icather.dsh-clean-desktop-shell.plugin'
|
package/electron/main.js
CHANGED
|
@@ -8,20 +8,39 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The main window is a pure shell — all backend controls live in the tray.
|
|
10
10
|
*/
|
|
11
|
-
import { app, BrowserWindow, dialog } from 'electron'
|
|
11
|
+
import { app, BrowserWindow, dialog, nativeImage } from 'electron'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
12
13
|
import { createMainWindow, reloadWindow } from './window.js'
|
|
13
14
|
import { createTray, refreshTrayMenu } from './tray.js'
|
|
14
15
|
import { loadConfig, saveConfig } from './config.js'
|
|
15
16
|
import { detect } from './service.js'
|
|
16
17
|
import { setupAutoUpdater } from './update.js'
|
|
17
|
-
import { shortcutSupported, hasDesktopShortcut, createDesktopShortcut } from './shortcut.js'
|
|
18
|
+
import { shortcutSupported, hasDesktopShortcut, createDesktopShortcut, ensureStartMenuShortcut } from './shortcut.js'
|
|
19
|
+
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
18
20
|
|
|
19
21
|
const isMac = process.platform === 'darwin'
|
|
20
22
|
|
|
21
23
|
// Windows: pin the AppUserModelId so the taskbar shows our whale icon
|
|
22
|
-
// instead of the generic Electron icon.
|
|
24
|
+
// instead of the generic Electron icon. Plugin mode uses a distinct ID
|
|
25
|
+
// (see aumid.js) so Windows re-reads the icon instead of serving a
|
|
26
|
+
// stale per-AUMID cached one.
|
|
23
27
|
if (process.platform === 'win32') {
|
|
24
|
-
app.setAppUserModelId(
|
|
28
|
+
app.setAppUserModelId(APP_USER_MODEL_ID)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// macOS: a bare runtime has no .app bundle (no icon resource), so set the
|
|
32
|
+
// Dock icon at runtime. Unlike Windows there is no per-AUMID taskbar cache
|
|
33
|
+
// here — app.dock.setIcon applies directly. Packaged builds already carry
|
|
34
|
+
// the icon in their bundle, so skip those.
|
|
35
|
+
if (isMac && !app.isPackaged) {
|
|
36
|
+
try {
|
|
37
|
+
const dockIcon = nativeImage.createFromPath(
|
|
38
|
+
fileURLToPath(new URL('../build/icon.png', import.meta.url)),
|
|
39
|
+
)
|
|
40
|
+
if (!dockIcon.isEmpty()) app.dock.setIcon(dockIcon)
|
|
41
|
+
} catch {
|
|
42
|
+
// non-fatal: keep the default icon
|
|
43
|
+
}
|
|
25
44
|
}
|
|
26
45
|
|
|
27
46
|
// Uniform userData across both distribution branches (installer vs
|
|
@@ -130,6 +149,14 @@ if (!gotLock) {
|
|
|
130
149
|
// Windows: wire the auto-updater (downloads new installers silently).
|
|
131
150
|
setupAutoUpdater()
|
|
132
151
|
|
|
152
|
+
// Windows: ensure the AUMID-carrying Start-menu shortcut exists so the
|
|
153
|
+
// taskbar button shows our icon (see shortcut.js). Best-effort.
|
|
154
|
+
try {
|
|
155
|
+
ensureStartMenuShortcut()
|
|
156
|
+
} catch {
|
|
157
|
+
// non-fatal
|
|
158
|
+
}
|
|
159
|
+
|
|
133
160
|
// First run: offer a desktop shortcut (never nags twice).
|
|
134
161
|
ensureShortcut().catch(() => {})
|
|
135
162
|
|
package/electron/shortcut.js
CHANGED
|
@@ -5,20 +5,44 @@
|
|
|
5
5
|
* - first-run prompt in main.js ("create a desktop shortcut?")
|
|
6
6
|
* - the tray "create desktop shortcut" item (always available)
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Works in both distribution branches:
|
|
9
|
+
* - installer (packaged): targets the installed exe, no arguments
|
|
10
|
+
* - plugin-market (bare runtime): targets the provisioned electron.exe
|
|
11
|
+
* with the plugin's electron/main.js as its argument
|
|
12
|
+
*
|
|
13
|
+
* Pure dev mode (npm run dev from a checkout) has no stable executable,
|
|
14
|
+
* so the feature stays disabled there.
|
|
10
15
|
*/
|
|
11
|
-
import { app } from 'electron'
|
|
16
|
+
import { app, shell } from 'electron'
|
|
12
17
|
import { spawn } from 'node:child_process'
|
|
18
|
+
import { existsSync } from 'node:fs'
|
|
13
19
|
import { homedir } from 'node:os'
|
|
14
20
|
import { dirname, join } from 'node:path'
|
|
21
|
+
import { fileURLToPath } from 'node:url'
|
|
22
|
+
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
15
23
|
|
|
16
24
|
const isWin = process.platform === 'win32'
|
|
17
25
|
const SHORTCUT_NAME = 'DSH Clean Desktop Shell.lnk'
|
|
26
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
27
|
+
const ICON_ICO = join(PKG_ROOT, 'build', 'icon.ico')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Plugin-market mode: the host half spawns a bare electron runtime with the
|
|
31
|
+
* plugin's electron/main.js as its entry. process.execPath is the runtime
|
|
32
|
+
* electron.exe; process.argv[1] is the entry script (packaged installers
|
|
33
|
+
* have no such argument).
|
|
34
|
+
*/
|
|
35
|
+
function pluginArgs() {
|
|
36
|
+
if (app.isPackaged) return []
|
|
37
|
+
const entry = process.argv[1]
|
|
38
|
+
if (entry && /\.js$/i.test(entry)) return [entry]
|
|
39
|
+
return []
|
|
40
|
+
}
|
|
18
41
|
|
|
19
|
-
/** Shortcuts
|
|
42
|
+
/** Shortcuts need a stable executable — supported when packaged or in
|
|
43
|
+
* plugin mode (where the runtime path is fixed under DSH_HOME). */
|
|
20
44
|
export function shortcutSupported() {
|
|
21
|
-
return isWin && app.isPackaged
|
|
45
|
+
return isWin && (app.isPackaged || pluginArgs().length > 0)
|
|
22
46
|
}
|
|
23
47
|
|
|
24
48
|
function ps(str) {
|
|
@@ -59,14 +83,48 @@ export async function hasDesktopShortcut() {
|
|
|
59
83
|
export async function createDesktopShortcut() {
|
|
60
84
|
if (!shortcutSupported()) return false
|
|
61
85
|
const target = process.execPath
|
|
86
|
+
const args = pluginArgs()
|
|
62
87
|
const lnk = join(homedir(), 'Desktop', SHORTCUT_NAME)
|
|
88
|
+
// Plugin mode: point the shortcut at the runtime exe + main.js argument.
|
|
89
|
+
// WScript needs the path double-quoted inside the Arguments string.
|
|
90
|
+
const argPart = args.length
|
|
91
|
+
? `; $s.Arguments = ${ps(`"${args[0]}"`)}; `
|
|
92
|
+
: ''
|
|
93
|
+
// Plugin-mode exe has no custom icon resource — point the shortcut at the
|
|
94
|
+
// bundled .ico when present (packaged installers use the exe itself).
|
|
95
|
+
const iconLoc = existsSync(ICON_ICO) ? ICON_ICO : `${target},0`
|
|
63
96
|
const script =
|
|
64
97
|
`$ws = New-Object -ComObject WScript.Shell; ` +
|
|
65
98
|
`$s = $ws.CreateShortcut(${ps(lnk)}); ` +
|
|
66
99
|
`$s.TargetPath = ${ps(target)}; ` +
|
|
100
|
+
argPart +
|
|
67
101
|
`$s.WorkingDirectory = ${ps(dirname(target))}; ` +
|
|
68
|
-
`$s.IconLocation = ${ps(
|
|
102
|
+
`$s.IconLocation = ${ps(iconLoc)}; ` +
|
|
69
103
|
`$s.Save()`
|
|
70
104
|
const out = await runPs(script)
|
|
71
105
|
return out !== null
|
|
72
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Ensure a Start-menu shortcut carrying the AppUserModelID. This is what
|
|
110
|
+
* makes the taskbar button show our icon: Windows matches a running
|
|
111
|
+
* window's AUMID to a shortcut's icon (bare runtime electron.exe has no
|
|
112
|
+
* icon resource of its own). Idempotent — the shortcut is only created
|
|
113
|
+
* when missing.
|
|
114
|
+
*/
|
|
115
|
+
export function ensureStartMenuShortcut() {
|
|
116
|
+
if (!isWin || !shortcutSupported()) return false
|
|
117
|
+
const lnkDir = join(
|
|
118
|
+
process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'),
|
|
119
|
+
'Microsoft', 'Windows', 'Start Menu', 'Programs',
|
|
120
|
+
)
|
|
121
|
+
const lnk = join(lnkDir, SHORTCUT_NAME)
|
|
122
|
+
const args = pluginArgs()
|
|
123
|
+
return shell.writeShortcutLink(lnk, 'create', {
|
|
124
|
+
target: process.execPath,
|
|
125
|
+
args: args.length ? args.join(' ') : undefined,
|
|
126
|
+
icon: existsSync(ICON_ICO) ? ICON_ICO : undefined,
|
|
127
|
+
iconIndex: 0,
|
|
128
|
+
appUserModelId: APP_USER_MODEL_ID,
|
|
129
|
+
})
|
|
130
|
+
}
|
package/electron/window.js
CHANGED
|
@@ -15,16 +15,26 @@
|
|
|
15
15
|
* window flips back to the offline screen instead of showing a stale page
|
|
16
16
|
* that suggests the app is still alive.
|
|
17
17
|
*/
|
|
18
|
-
import { BrowserWindow, ipcMain } from 'electron'
|
|
18
|
+
import { app, BrowserWindow, ipcMain } from 'electron'
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
|
+
import { dirname, join } from 'node:path'
|
|
19
21
|
import { fileURLToPath } from 'node:url'
|
|
20
22
|
import { probe, onStatusChange, detect } from './service.js'
|
|
21
23
|
import { startBackendWithProgress, chooseBackendFolder } from './tray.js'
|
|
24
|
+
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
22
25
|
|
|
23
26
|
export const WINDOWS_TITLEBAR_HEIGHT = 32
|
|
24
27
|
|
|
28
|
+
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
|
|
25
29
|
const PRELOAD_PATH = fileURLToPath(new URL('./preload.js', import.meta.url))
|
|
30
|
+
// Windows taskbar follows the window icon only when it is an .ico; a png
|
|
31
|
+
// covers the title bar / alt-tab but not the taskbar button. In plugin
|
|
32
|
+
// mode there is no exe icon resource, so prefer the bundled .ico.
|
|
33
|
+
const TASKBAR_ICO = join(PKG_ROOT, 'build', 'icon.ico')
|
|
26
34
|
// Black-whale app icon (matches the DSH web favicon).
|
|
27
|
-
const ICON_PATH =
|
|
35
|
+
const ICON_PATH = existsSync(TASKBAR_ICO)
|
|
36
|
+
? TASKBAR_ICO
|
|
37
|
+
: fileURLToPath(new URL('../build/icon.png', import.meta.url))
|
|
28
38
|
// Local fallback page shown while the backend is down.
|
|
29
39
|
const ERROR_PAGE = fileURLToPath(new URL('./error.html', import.meta.url))
|
|
30
40
|
|
|
@@ -189,6 +199,15 @@ export function createMainWindow({ target }) {
|
|
|
189
199
|
// Linux / other: keep the native frame.
|
|
190
200
|
|
|
191
201
|
const win = new BrowserWindow(options)
|
|
202
|
+
// Windows taskbar button: bare runtime electron.exe has no custom icon,
|
|
203
|
+
// so pin the button to our .ico via setAppDetails (appId must match the
|
|
204
|
+
// app-level AppUserModelId set in main.js, else the options are ignored).
|
|
205
|
+
if (process.platform === 'win32' && existsSync(TASKBAR_ICO)) {
|
|
206
|
+
win.setAppDetails({
|
|
207
|
+
appId: APP_USER_MODEL_ID,
|
|
208
|
+
appIconPath: TASKBAR_ICO,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
192
211
|
windowTargets.set(win.id, target)
|
|
193
212
|
win.loadURL(target).catch(() => startReconnect(win, target))
|
|
194
213
|
|
package/lib/client.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-clean-desktop-shell — client half (web browser bundle).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The shell is a standalone Electron window; it injects nothing into the
|
|
5
|
+
* dsh web UI. This client module exists only to satisfy the client-modules
|
|
6
|
+
* loader contract — a bundle's client entry must register itself via
|
|
7
|
+
* `window.__ModuleLoader__.load({ id, factory })`, otherwise dsh reports
|
|
8
|
+
* "loaded without registering … via __ModuleLoader__.load".
|
|
7
9
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: 'dsh-clean-desktop-shell',
|
|
12
|
+
factory: () => {
|
|
13
|
+
var module = { exports: {} };
|
|
14
|
+
var exports = module.exports;
|
|
15
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
16
|
+
exports.apply = () => {};
|
|
17
|
+
return module.exports;
|
|
18
|
+
},
|
|
19
|
+
});
|
package/lib/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
renameSync,
|
|
31
31
|
rmSync,
|
|
32
32
|
symlinkSync,
|
|
33
|
+
writeFileSync,
|
|
33
34
|
} from 'node:fs'
|
|
34
35
|
import { homedir } from 'node:os'
|
|
35
36
|
import { dirname, join } from 'node:path'
|
|
@@ -42,22 +43,31 @@ const EXE_NAME = isWin ? 'electron.exe' : 'electron'
|
|
|
42
43
|
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
|
|
43
44
|
const PLATFORM = isWin ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'
|
|
44
45
|
|
|
46
|
+
// cordis registers the plugin by this name — bundles without an explicit
|
|
47
|
+
// `name` export are silently skipped by the dsh loader.
|
|
48
|
+
export const name = 'dsh-clean-desktop-shell'
|
|
49
|
+
|
|
45
50
|
// Disable auto-launch with DSH_SHELL_AUTO_LAUNCH=0.
|
|
46
51
|
const AUTO_LAUNCH = process.env.DSH_SHELL_AUTO_LAUNCH !== '0'
|
|
47
52
|
|
|
48
53
|
let launched = false
|
|
49
54
|
|
|
50
55
|
export function apply(ctx) {
|
|
51
|
-
ctx.
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
ctx.logger.info('[clean-desktop-shell] mounted (host half)')
|
|
57
|
+
if (!AUTO_LAUNCH) return
|
|
58
|
+
// The dsh bundle loader calls apply() during early boot, but the cordis
|
|
59
|
+
// 'ready' event never fires for bundle plugins (dshmarket / agent-teams
|
|
60
|
+
// use ctx.inject or run inline instead). Launch directly: the shell is a
|
|
61
|
+
// separate process with its own offline screen + auto-reconnect, so an
|
|
62
|
+
// early launch is safe — it shows the offline page until 3080 answers.
|
|
63
|
+
;(async () => {
|
|
54
64
|
try {
|
|
55
65
|
const exe = await ensureRuntime(ctx)
|
|
56
66
|
launchShell(exe, ctx)
|
|
57
67
|
} catch (err) {
|
|
58
68
|
ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${err?.message ?? err}`)
|
|
59
69
|
}
|
|
60
|
-
})
|
|
70
|
+
})()
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
// ---------- electron runtime provisioning ----------
|
|
@@ -93,6 +103,7 @@ async function ensureRuntime(ctx) {
|
|
|
93
103
|
// 1) Already provisioned for this version?
|
|
94
104
|
if (existsSync(exe)) {
|
|
95
105
|
cleanupOldVersions(root, dir)
|
|
106
|
+
await patchExeIcon(ctx, exe, root).catch(() => {})
|
|
96
107
|
return exe
|
|
97
108
|
}
|
|
98
109
|
|
|
@@ -118,9 +129,55 @@ async function ensureRuntime(ctx) {
|
|
|
118
129
|
}
|
|
119
130
|
cleanupOldVersions(root, dir)
|
|
120
131
|
ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
|
|
132
|
+
await patchExeIcon(ctx, exe, root).catch(() => {})
|
|
121
133
|
return exe
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Patch the runtime electron.exe's icon resource so the Windows taskbar
|
|
138
|
+
* shows our whale icon. A bare runtime exe ships Electron's default icon
|
|
139
|
+
* and — as documented — no runtime API (BrowserWindow icon, setAppDetails,
|
|
140
|
+
* AUMID shortcuts) can change the taskbar button: it reads the exe's icon
|
|
141
|
+
* resource. rcedit (electron team's official tool) rewrites it in place.
|
|
142
|
+
*
|
|
143
|
+
* Best-effort: icon patching must never block the shell from launching.
|
|
144
|
+
* Idempotent: a marker file next to the exe records success; a re-provisioned
|
|
145
|
+
* (new version) exe has no marker and gets patched again.
|
|
146
|
+
*/
|
|
147
|
+
async function patchExeIcon(ctx, exe, root) {
|
|
148
|
+
if (!isWin) return
|
|
149
|
+
const ico = join(PKG_ROOT, 'build', 'icon.ico')
|
|
150
|
+
if (!existsSync(ico)) return
|
|
151
|
+
const marker = `${exe}.whale-icon`
|
|
152
|
+
if (existsSync(marker)) return
|
|
153
|
+
|
|
154
|
+
// rcedit is a single self-contained exe, cached next to the runtimes.
|
|
155
|
+
const rcedit = join(root, 'rcedit-x64.exe')
|
|
156
|
+
if (!existsSync(rcedit)) {
|
|
157
|
+
const url = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
|
|
158
|
+
ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
|
|
159
|
+
if (!(await fetchFile(url, rcedit))) {
|
|
160
|
+
ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const child = spawn(rcedit, [exe, '--set-icon', ico], {
|
|
166
|
+
windowsHide: true,
|
|
167
|
+
stdio: 'ignore',
|
|
168
|
+
})
|
|
169
|
+
const ok = await new Promise((resolve) => {
|
|
170
|
+
child.on('error', () => resolve(false))
|
|
171
|
+
child.on('exit', (code) => resolve(code === 0))
|
|
172
|
+
})
|
|
173
|
+
if (ok) {
|
|
174
|
+
writeFileSync(marker, String(Date.now()), 'utf8')
|
|
175
|
+
ctx.logger.info('[clean-desktop-shell] taskbar icon patched (rcedit)')
|
|
176
|
+
} else {
|
|
177
|
+
ctx.logger.warn('[clean-desktop-shell] rcedit patch failed — taskbar icon stays default')
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
124
181
|
async function downloadRuntime(ctx, version, root, dir) {
|
|
125
182
|
const tmpZip = join(root, `.electron-${version}.zip.tmp`)
|
|
126
183
|
rmSync(tmpZip, { force: true })
|
|
@@ -143,33 +200,59 @@ async function downloadRuntime(ctx, version, root, dir) {
|
|
|
143
200
|
rmSync(inner, { recursive: true, force: true })
|
|
144
201
|
}
|
|
145
202
|
}
|
|
203
|
+
// Failed download — drop the partial file so a later run starts clean.
|
|
204
|
+
rmSync(tmpZip, { force: true })
|
|
146
205
|
}
|
|
147
206
|
throw new Error('electron download failed from all sources')
|
|
148
207
|
}
|
|
149
208
|
|
|
150
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Pick the download source by racing a HEAD probe against each candidate
|
|
211
|
+
* (direct connection, 3s each). The fastest reachable source goes first —
|
|
212
|
+
* this naturally prefers the domestic npmmirror mirror on CN networks,
|
|
213
|
+
* the official GitHub source on international/well-proxied networks, and
|
|
214
|
+
* never wastes a full download on a dead source.
|
|
215
|
+
*/
|
|
151
216
|
async function runtimeUrls(version) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
217
|
+
const candidates = [
|
|
218
|
+
{ name: 'github', url: `https://github.com/electron/electron/releases/download/v${version}/${zipName(version)}` },
|
|
219
|
+
{ name: 'npmmirror', url: `https://npmmirror.com/mirrors/electron/${version}/${zipName(version)}` },
|
|
220
|
+
]
|
|
221
|
+
const results = await Promise.all(
|
|
222
|
+
candidates.map(async (c) => {
|
|
223
|
+
const t0 = Date.now()
|
|
224
|
+
try {
|
|
225
|
+
const ctrl = new AbortController()
|
|
226
|
+
const timer = setTimeout(() => ctrl.abort(), 3000)
|
|
227
|
+
const res = await fetch(c.url, { signal: ctrl.signal, method: 'HEAD' })
|
|
228
|
+
clearTimeout(timer)
|
|
229
|
+
if (res.status < 500) return { ...c, ms: Date.now() - t0 }
|
|
230
|
+
} catch {
|
|
231
|
+
// unreachable — drop
|
|
232
|
+
}
|
|
233
|
+
return null
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
const ok = results.filter(Boolean).sort((a, b) => a.ms - b.ms)
|
|
237
|
+
if (ok.length === 0) {
|
|
238
|
+
// Probes all failed (offline?) — still try both, mirror first (cheap).
|
|
239
|
+
return [candidates[1].url, candidates[0].url]
|
|
162
240
|
}
|
|
163
|
-
|
|
241
|
+
const rest = candidates.map((c) => c.url).filter((u) => u !== ok[0].url)
|
|
242
|
+
return [ok[0].url, ...rest]
|
|
164
243
|
}
|
|
165
244
|
|
|
166
245
|
function fetchFile(url, dest) {
|
|
167
246
|
return new Promise((resolve) => {
|
|
168
247
|
// curl is available on Windows 10+; streams to disk, honors proxy env.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
248
|
+
// --max-time keeps a stalled download from hanging forever (a proxy
|
|
249
|
+
// stall previously left a half-written .zip.tmp and blocked the shell
|
|
250
|
+
// launch); --retry 2 rides out transient failures.
|
|
251
|
+
const child = spawn(
|
|
252
|
+
'curl',
|
|
253
|
+
['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', '600', '-o', dest, url],
|
|
254
|
+
{ windowsHide: true, stdio: 'ignore' },
|
|
255
|
+
)
|
|
173
256
|
child.on('error', () => resolve(false))
|
|
174
257
|
child.on('exit', (code) => resolve(code === 0))
|
|
175
258
|
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-clean-desktop-shell",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Clean desktop shell for DeepSeek Harness (DSH) as a DSH plugin — reuses your web profile, tray/single-instance/auto-launch, zero visual changes. DSH 插件形态的纯净桌面壳:复用现有 web profile,托盘管理后端,零视觉改造。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"src/",
|
|
11
11
|
"scripts/",
|
|
12
12
|
"build/icon.png",
|
|
13
|
+
"build/icon.ico",
|
|
13
14
|
"cordis.patch.yml",
|
|
14
15
|
"README.md",
|
|
15
16
|
"README.en.md",
|
package/src/client/client.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-clean-desktop-shell — client half (web browser bundle).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The shell is a standalone Electron window; it injects nothing into the
|
|
5
|
+
* dsh web UI. This client module exists only to satisfy the client-modules
|
|
6
|
+
* loader contract — a bundle's client entry must register itself via
|
|
7
|
+
* `window.__ModuleLoader__.load({ id, factory })`, otherwise dsh reports
|
|
8
|
+
* "loaded without registering … via __ModuleLoader__.load".
|
|
7
9
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
window.__ModuleLoader__.load({
|
|
11
|
+
id: 'dsh-clean-desktop-shell',
|
|
12
|
+
factory: () => {
|
|
13
|
+
var module = { exports: {} };
|
|
14
|
+
var exports = module.exports;
|
|
15
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
16
|
+
exports.apply = () => {};
|
|
17
|
+
return module.exports;
|
|
18
|
+
},
|
|
19
|
+
});
|
package/src/host/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
renameSync,
|
|
31
31
|
rmSync,
|
|
32
32
|
symlinkSync,
|
|
33
|
+
writeFileSync,
|
|
33
34
|
} from 'node:fs'
|
|
34
35
|
import { homedir } from 'node:os'
|
|
35
36
|
import { dirname, join } from 'node:path'
|
|
@@ -42,22 +43,31 @@ const EXE_NAME = isWin ? 'electron.exe' : 'electron'
|
|
|
42
43
|
const ARCH = process.arch === 'arm64' ? 'arm64' : 'x64'
|
|
43
44
|
const PLATFORM = isWin ? 'win32' : process.platform === 'darwin' ? 'darwin' : 'linux'
|
|
44
45
|
|
|
46
|
+
// cordis registers the plugin by this name — bundles without an explicit
|
|
47
|
+
// `name` export are silently skipped by the dsh loader.
|
|
48
|
+
export const name = 'dsh-clean-desktop-shell'
|
|
49
|
+
|
|
45
50
|
// Disable auto-launch with DSH_SHELL_AUTO_LAUNCH=0.
|
|
46
51
|
const AUTO_LAUNCH = process.env.DSH_SHELL_AUTO_LAUNCH !== '0'
|
|
47
52
|
|
|
48
53
|
let launched = false
|
|
49
54
|
|
|
50
55
|
export function apply(ctx) {
|
|
51
|
-
ctx.
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
ctx.logger.info('[clean-desktop-shell] mounted (host half)')
|
|
57
|
+
if (!AUTO_LAUNCH) return
|
|
58
|
+
// The dsh bundle loader calls apply() during early boot, but the cordis
|
|
59
|
+
// 'ready' event never fires for bundle plugins (dshmarket / agent-teams
|
|
60
|
+
// use ctx.inject or run inline instead). Launch directly: the shell is a
|
|
61
|
+
// separate process with its own offline screen + auto-reconnect, so an
|
|
62
|
+
// early launch is safe — it shows the offline page until 3080 answers.
|
|
63
|
+
;(async () => {
|
|
54
64
|
try {
|
|
55
65
|
const exe = await ensureRuntime(ctx)
|
|
56
66
|
launchShell(exe, ctx)
|
|
57
67
|
} catch (err) {
|
|
58
68
|
ctx.logger.warn(`[clean-desktop-shell] shell launch failed: ${err?.message ?? err}`)
|
|
59
69
|
}
|
|
60
|
-
})
|
|
70
|
+
})()
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
// ---------- electron runtime provisioning ----------
|
|
@@ -93,6 +103,7 @@ async function ensureRuntime(ctx) {
|
|
|
93
103
|
// 1) Already provisioned for this version?
|
|
94
104
|
if (existsSync(exe)) {
|
|
95
105
|
cleanupOldVersions(root, dir)
|
|
106
|
+
await patchExeIcon(ctx, exe, root).catch(() => {})
|
|
96
107
|
return exe
|
|
97
108
|
}
|
|
98
109
|
|
|
@@ -118,9 +129,55 @@ async function ensureRuntime(ctx) {
|
|
|
118
129
|
}
|
|
119
130
|
cleanupOldVersions(root, dir)
|
|
120
131
|
ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
|
|
132
|
+
await patchExeIcon(ctx, exe, root).catch(() => {})
|
|
121
133
|
return exe
|
|
122
134
|
}
|
|
123
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Patch the runtime electron.exe's icon resource so the Windows taskbar
|
|
138
|
+
* shows our whale icon. A bare runtime exe ships Electron's default icon
|
|
139
|
+
* and — as documented — no runtime API (BrowserWindow icon, setAppDetails,
|
|
140
|
+
* AUMID shortcuts) can change the taskbar button: it reads the exe's icon
|
|
141
|
+
* resource. rcedit (electron team's official tool) rewrites it in place.
|
|
142
|
+
*
|
|
143
|
+
* Best-effort: icon patching must never block the shell from launching.
|
|
144
|
+
* Idempotent: a marker file next to the exe records success; a re-provisioned
|
|
145
|
+
* (new version) exe has no marker and gets patched again.
|
|
146
|
+
*/
|
|
147
|
+
async function patchExeIcon(ctx, exe, root) {
|
|
148
|
+
if (!isWin) return
|
|
149
|
+
const ico = join(PKG_ROOT, 'build', 'icon.ico')
|
|
150
|
+
if (!existsSync(ico)) return
|
|
151
|
+
const marker = `${exe}.whale-icon`
|
|
152
|
+
if (existsSync(marker)) return
|
|
153
|
+
|
|
154
|
+
// rcedit is a single self-contained exe, cached next to the runtimes.
|
|
155
|
+
const rcedit = join(root, 'rcedit-x64.exe')
|
|
156
|
+
if (!existsSync(rcedit)) {
|
|
157
|
+
const url = 'https://github.com/electron/rcedit/releases/download/v2.0.0/rcedit-x64.exe'
|
|
158
|
+
ctx.logger.info('[clean-desktop-shell] downloading rcedit for icon patching')
|
|
159
|
+
if (!(await fetchFile(url, rcedit))) {
|
|
160
|
+
ctx.logger.warn('[clean-desktop-shell] rcedit download failed — taskbar icon stays default')
|
|
161
|
+
return
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const child = spawn(rcedit, [exe, '--set-icon', ico], {
|
|
166
|
+
windowsHide: true,
|
|
167
|
+
stdio: 'ignore',
|
|
168
|
+
})
|
|
169
|
+
const ok = await new Promise((resolve) => {
|
|
170
|
+
child.on('error', () => resolve(false))
|
|
171
|
+
child.on('exit', (code) => resolve(code === 0))
|
|
172
|
+
})
|
|
173
|
+
if (ok) {
|
|
174
|
+
writeFileSync(marker, String(Date.now()), 'utf8')
|
|
175
|
+
ctx.logger.info('[clean-desktop-shell] taskbar icon patched (rcedit)')
|
|
176
|
+
} else {
|
|
177
|
+
ctx.logger.warn('[clean-desktop-shell] rcedit patch failed — taskbar icon stays default')
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
124
181
|
async function downloadRuntime(ctx, version, root, dir) {
|
|
125
182
|
const tmpZip = join(root, `.electron-${version}.zip.tmp`)
|
|
126
183
|
rmSync(tmpZip, { force: true })
|
|
@@ -143,33 +200,59 @@ async function downloadRuntime(ctx, version, root, dir) {
|
|
|
143
200
|
rmSync(inner, { recursive: true, force: true })
|
|
144
201
|
}
|
|
145
202
|
}
|
|
203
|
+
// Failed download — drop the partial file so a later run starts clean.
|
|
204
|
+
rmSync(tmpZip, { force: true })
|
|
146
205
|
}
|
|
147
206
|
throw new Error('electron download failed from all sources')
|
|
148
207
|
}
|
|
149
208
|
|
|
150
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* Pick the download source by racing a HEAD probe against each candidate
|
|
211
|
+
* (direct connection, 3s each). The fastest reachable source goes first —
|
|
212
|
+
* this naturally prefers the domestic npmmirror mirror on CN networks,
|
|
213
|
+
* the official GitHub source on international/well-proxied networks, and
|
|
214
|
+
* never wastes a full download on a dead source.
|
|
215
|
+
*/
|
|
151
216
|
async function runtimeUrls(version) {
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
217
|
+
const candidates = [
|
|
218
|
+
{ name: 'github', url: `https://github.com/electron/electron/releases/download/v${version}/${zipName(version)}` },
|
|
219
|
+
{ name: 'npmmirror', url: `https://npmmirror.com/mirrors/electron/${version}/${zipName(version)}` },
|
|
220
|
+
]
|
|
221
|
+
const results = await Promise.all(
|
|
222
|
+
candidates.map(async (c) => {
|
|
223
|
+
const t0 = Date.now()
|
|
224
|
+
try {
|
|
225
|
+
const ctrl = new AbortController()
|
|
226
|
+
const timer = setTimeout(() => ctrl.abort(), 3000)
|
|
227
|
+
const res = await fetch(c.url, { signal: ctrl.signal, method: 'HEAD' })
|
|
228
|
+
clearTimeout(timer)
|
|
229
|
+
if (res.status < 500) return { ...c, ms: Date.now() - t0 }
|
|
230
|
+
} catch {
|
|
231
|
+
// unreachable — drop
|
|
232
|
+
}
|
|
233
|
+
return null
|
|
234
|
+
}),
|
|
235
|
+
)
|
|
236
|
+
const ok = results.filter(Boolean).sort((a, b) => a.ms - b.ms)
|
|
237
|
+
if (ok.length === 0) {
|
|
238
|
+
// Probes all failed (offline?) — still try both, mirror first (cheap).
|
|
239
|
+
return [candidates[1].url, candidates[0].url]
|
|
162
240
|
}
|
|
163
|
-
|
|
241
|
+
const rest = candidates.map((c) => c.url).filter((u) => u !== ok[0].url)
|
|
242
|
+
return [ok[0].url, ...rest]
|
|
164
243
|
}
|
|
165
244
|
|
|
166
245
|
function fetchFile(url, dest) {
|
|
167
246
|
return new Promise((resolve) => {
|
|
168
247
|
// curl is available on Windows 10+; streams to disk, honors proxy env.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
248
|
+
// --max-time keeps a stalled download from hanging forever (a proxy
|
|
249
|
+
// stall previously left a half-written .zip.tmp and blocked the shell
|
|
250
|
+
// launch); --retry 2 rides out transient failures.
|
|
251
|
+
const child = spawn(
|
|
252
|
+
'curl',
|
|
253
|
+
['-L', '--fail', '--silent', '--show-error', '--retry', '2', '--max-time', '600', '-o', dest, url],
|
|
254
|
+
{ windowsHide: true, stdio: 'ignore' },
|
|
255
|
+
)
|
|
173
256
|
child.on('error', () => resolve(false))
|
|
174
257
|
child.on('exit', (code) => resolve(code === 0))
|
|
175
258
|
})
|
package/version.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.1.
|
|
1
|
+
0.1.4
|