dsh-clean-desktop-shell 0.1.9 → 0.1.11
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 +32 -0
- package/README.md +20 -0
- package/electron/config.js +40 -10
- package/electron/crashGuard.js +60 -0
- package/electron/main.js +15 -7
- package/electron/service.js +91 -48
- package/electron/shortcut.js +47 -55
- package/electron/tray.js +6 -32
- package/electron/update.js +44 -25
- package/electron/window.js +7 -5
- package/lib/common.js +21 -0
- package/lib/icon.js +1 -13
- package/lib/runtime.js +82 -33
- package/package.json +4 -3
- package/scripts/check-syntax.mjs +54 -0
- package/src/host/common.js +21 -0
- package/src/host/icon.js +1 -13
- package/src/host/runtime.js +82 -33
- package/version.txt +1 -1
package/README.en.md
CHANGED
|
@@ -231,6 +231,38 @@ npm run pack # package NSIS (Win) / DMG (mac)
|
|
|
231
231
|
|
|
232
232
|
## Changelog
|
|
233
233
|
|
|
234
|
+
### 0.1.10
|
|
235
|
+
- Version comparison now uses semver (`semver.coerce` + `semver.gt`) — the industry standard — replacing the hand-rolled tuple parser.
|
|
236
|
+
- All HTTP timeouts migrated to `AbortSignal.timeout` (standard self-cleaning API, no manual controller leaks).
|
|
237
|
+
- Config persistence switched to atomic writes (tmp + rename); a crash mid-write can no longer truncate config.json.
|
|
238
|
+
- Removed the developer-machine-specific hardcoded path (`D:\deepseek-harness\prod\...`); replaced with config `backendPath` + `DSH_BACKEND_DIR` env var + npm global dir candidates.
|
|
239
|
+
- Windows backend stop now uses `taskkill /T /F` tree kill (the old `proc.kill()` left orphan node children holding the port when the command was a .cmd shim); POSIX falls back through SIGTERM → SIGKILL gracefully.
|
|
240
|
+
- Shortcut management rewritten to Electron-native `shell.writeShortcutLink` / `readShortcutLink`, dropping the PowerShell + WScript COM dependency (also fixes OneDrive Desktop redirection via `app.getPath('desktop')`).
|
|
241
|
+
- New process-level crash guard: `uncaughtException` / `unhandledRejection` append to `userData/shell-crash.log` (128 KB cap with auto-truncation) — attachable in bug reports.
|
|
242
|
+
- Electron runtime zip now verified against SHASUMS256.txt post-download (SHA-256 streaming check); corrupted files are discarded and the next mirror source is tried.
|
|
243
|
+
- Resilience hardening: backend stdout/stderr capped at 64 KB ring buffer; config load type-validates known keys and silently drops unknown ones; offline page detection upgraded from `includes('error.html')` to precise file:// URL comparison.
|
|
244
|
+
- `npm run check` syntax gate expanded from 1 file (lib/index.js) to all 17 shipped JS files.
|
|
245
|
+
|
|
246
|
+
### 0.1.9
|
|
247
|
+
- One-click "update now" for plugin mode when a new version is found: the
|
|
248
|
+
installing package manager (pnpm/npm/yarn/bun) is inferred from the lockfile
|
|
249
|
+
next to the install, and a chain of command variants (with a corepack
|
|
250
|
+
fallback) absorbs PATH and pnpm version differences. The on-disk version is
|
|
251
|
+
verified afterwards, with success / unchanged / failure dialogs — failures
|
|
252
|
+
list the attempted commands and output (copyable).
|
|
253
|
+
- The update dialog also shows a copyable manual command and keeps the DSH
|
|
254
|
+
market entry; commands run via `shell:true` + `windowsHide` on Windows for
|
|
255
|
+
`.cmd` shim and PowerShell / cmd compatibility.
|
|
256
|
+
|
|
257
|
+
### 0.1.8
|
|
258
|
+
- Plugin (npm-installed) mode now checks the npm registry `dist-tags.latest`
|
|
259
|
+
and compares it against the local version from the same source, instead of
|
|
260
|
+
wrongly querying GitHub `/releases/latest` (when the GitHub Latest tag
|
|
261
|
+
lagged behind, this produced the contradictory "current 0.1.7 is up to date
|
|
262
|
+
(v0.1.6)" dialog).
|
|
263
|
+
- The packaged desktop app still uses GitHub Releases; dropped the redundant
|
|
264
|
+
version repetition in the "up to date" dialog.
|
|
265
|
+
|
|
234
266
|
### 0.1.7
|
|
235
267
|
- **Fixed a silent failure that made the window never open on macOS**: the
|
|
236
268
|
Electron macOS archive is an `Electron.app` bundle with the binary at
|
package/README.md
CHANGED
|
@@ -212,6 +212,26 @@ npm run pack # 打包 NSIS (Win) / DMG (mac)
|
|
|
212
212
|
|
|
213
213
|
## 更新历史
|
|
214
214
|
|
|
215
|
+
### 0.1.10
|
|
216
|
+
- 版本比较改用 semver(industry-standard `semver.coerce` + `semver.gt`),替换手写元组比较。
|
|
217
|
+
- 所有 HTTP 超时改为 `AbortSignal.timeout`(标准自清理 API,无泄漏风险)。
|
|
218
|
+
- 配置持久化改为原子写入(tmp + rename),崩溃/断电不会留下截断的 config.json。
|
|
219
|
+
- 移除代码中硬编码的 `D:\deepseek-harness\prod\...` 开发机路径:改用 config backendPath + `DSH_BACKEND_DIR` 环境变量 + npm 全局目录候选。
|
|
220
|
+
- Windows 后端进程停止改用 `taskkill /T /F` 树杀(.cmd shim 留下的孤儿 node 进程不再占端口);POSIX 先 SIGTERM 再 SIGKILL 优雅降级。
|
|
221
|
+
- 快捷方式管理整体替换为 Electron 原生 `shell.writeShortcutLink` / `readShortcutLink`,移除 PowerShell + COM 依赖(顺带修复 OneDrive 桌面重定向问题)。
|
|
222
|
+
- 新增进程级 crash guard:`uncaughtException` / `unhandledRejection` 写入 `userData/shell-crash.log`(128KB 上限自动截断),便于附在 bug 报告中。
|
|
223
|
+
- Electron runtime 下载后新增 SHA-256 完整性校验(对比源站点 SHASUMS256.txt),损坏文件自动跳到下一源重试。
|
|
224
|
+
- 冗余加固:后端 stdout/stderr 缓冲 64KB 环形截断;配置字段类型校验并自动丢弃未知键;窗口离线页检测从 `includes('error.html')` 改为精确 URL 比对。
|
|
225
|
+
- 自检脚本 `npm run check` 扩展到覆盖全部 17 个发布 JS 文件的语法门禁(此前只检查 lib/index.js)。
|
|
226
|
+
|
|
227
|
+
### 0.1.9
|
|
228
|
+
- 插件形态发现新版本时新增「立即更新」一键更新:从安装位置旁的 lockfile 推断实际使用的包管理器(pnpm/npm/yarn/bun),按命令变体链逐条尝试(含 corepack 兜底),吸收 PATH 与 pnpm 版本差异;更新后校验磁盘版本,成功 / 未变化 / 失败三态弹窗,失败时附已试命令与输出(可复制)。
|
|
229
|
+
- 更新弹窗同时内嵌可复制的手动更新命令,并保留 DSH 插件市场入口;Windows 下经 `shell:true` + `windowsHide` 执行,兼容 `.cmd` shim 与 PowerShell / cmd 环境。
|
|
230
|
+
|
|
231
|
+
### 0.1.8
|
|
232
|
+
- 插件形态(npm 安装)的更新检查改查 npm registry 的 `dist-tags.latest`,与本地版本同源对比;不再错爬 GitHub `/releases/latest`(此前 GitHub Latest 标记未及时挪动时会弹出「当前 0.1.7 已是最新(v0.1.6)」的自相矛盾提示)。
|
|
233
|
+
- 打包桌面应用仍走 GitHub Releases;修复「已是最新」弹窗括号内重复显示版本号的文案。
|
|
234
|
+
|
|
215
235
|
### 0.1.7
|
|
216
236
|
- **修复 macOS 上窗口完全打不开的静默失败**:Electron 的 macOS 包是 `Electron.app` 应用包,可执行文件位于 `Electron.app/Contents/MacOS/Electron`;此前按 Linux 布局去找顶层 `electron`,导致 Mac 上必然启动失败且无任何提示。
|
|
217
237
|
- 解压改为多策略回退(ditto / unzip / tar)并校验产物;解压后补齐可执行位,清除 macOS quarantine 扩展属性。
|
package/electron/config.js
CHANGED
|
@@ -2,19 +2,27 @@
|
|
|
2
2
|
* Shell config persistence (userData/config.json).
|
|
3
3
|
*
|
|
4
4
|
* Fields:
|
|
5
|
-
* - targetUrl:
|
|
6
|
-
*
|
|
7
|
-
* - closeToTray:
|
|
8
|
-
* -
|
|
5
|
+
* - targetUrl: DEFAULT_TARGET_URL — set to any remote DSH address to
|
|
6
|
+
* run the shell as a pure window.
|
|
7
|
+
* - closeToTray: default true.
|
|
8
|
+
* - backendPath: folder containing the dsh CLI (tray "set backend folder").
|
|
9
|
+
* - shortcutAsked: true once the first-run shortcut prompt was shown.
|
|
10
|
+
*
|
|
11
|
+
* Persistence is atomic (tmp file + rename) and load-time type-checked, so
|
|
12
|
+
* a crash mid-write or a hand-edited file can never poison the shell.
|
|
9
13
|
*/
|
|
10
14
|
import { app } from 'electron'
|
|
11
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
15
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs'
|
|
12
16
|
import { dirname, join } from 'node:path'
|
|
13
17
|
|
|
18
|
+
// Single source of truth for the local dsh web endpoint. Every module that
|
|
19
|
+
// needs the default URL or its port imports it from here — no duplicated
|
|
20
|
+
// literals.
|
|
21
|
+
export const DEFAULT_TARGET_URL = 'http://127.0.0.1:3080'
|
|
22
|
+
|
|
14
23
|
const DEFAULTS = {
|
|
15
|
-
targetUrl:
|
|
24
|
+
targetUrl: DEFAULT_TARGET_URL,
|
|
16
25
|
closeToTray: true,
|
|
17
|
-
windowMode: 'advanced',
|
|
18
26
|
backendPath: null,
|
|
19
27
|
// True once the first-run "create a desktop shortcut?" prompt was shown
|
|
20
28
|
// (so it never nags again). The tray item stays available regardless.
|
|
@@ -30,8 +38,14 @@ function configPath() {
|
|
|
30
38
|
export function loadConfig() {
|
|
31
39
|
if (cached) return cached
|
|
32
40
|
try {
|
|
33
|
-
const
|
|
34
|
-
|
|
41
|
+
const parsed = JSON.parse(readFileSync(configPath(), 'utf8'))
|
|
42
|
+
// Keep only known fields with expected types — unknown/legacy keys
|
|
43
|
+
// (e.g. the never-wired `windowMode`) are dropped on the next save.
|
|
44
|
+
cached = { ...DEFAULTS }
|
|
45
|
+
if (typeof parsed.targetUrl === 'string' && parsed.targetUrl) cached.targetUrl = parsed.targetUrl
|
|
46
|
+
if (typeof parsed.closeToTray === 'boolean') cached.closeToTray = parsed.closeToTray
|
|
47
|
+
if (typeof parsed.backendPath === 'string' || parsed.backendPath === null) cached.backendPath = parsed.backendPath
|
|
48
|
+
if (typeof parsed.shortcutAsked === 'boolean') cached.shortcutAsked = parsed.shortcutAsked
|
|
35
49
|
} catch {
|
|
36
50
|
cached = { ...DEFAULTS }
|
|
37
51
|
}
|
|
@@ -42,6 +56,22 @@ export function saveConfig(next) {
|
|
|
42
56
|
cached = { ...DEFAULTS, ...next }
|
|
43
57
|
const file = configPath()
|
|
44
58
|
mkdirSync(dirname(file), { recursive: true })
|
|
45
|
-
|
|
59
|
+
// Atomic persistence: write a sibling tmp file, then rename over the real
|
|
60
|
+
// one (rename is atomic within a volume). A crash mid-write leaves the
|
|
61
|
+
// old config intact instead of a truncated file.
|
|
62
|
+
const tmp = `${file}.tmp`
|
|
63
|
+
writeFileSync(tmp, JSON.stringify(cached, null, 2), 'utf8')
|
|
64
|
+
try {
|
|
65
|
+
renameSync(tmp, file)
|
|
66
|
+
} catch {
|
|
67
|
+
// Windows rename can briefly fail with EPERM/EBUSY (AV scan) — drop the
|
|
68
|
+
// target and retry; fall back to a non-atomic write as a last resort.
|
|
69
|
+
try {
|
|
70
|
+
rmSync(file, { force: true })
|
|
71
|
+
renameSync(tmp, file)
|
|
72
|
+
} catch {
|
|
73
|
+
writeFileSync(file, JSON.stringify(cached, null, 2), 'utf8')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
46
76
|
return cached
|
|
47
77
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash guard — last-resort observability for the Electron main process.
|
|
3
|
+
*
|
|
4
|
+
* Unhandled errors used to vanish (a GUI process has no console), leaving
|
|
5
|
+
* only "the window didn't come up". Mirror the host half's diagnostics
|
|
6
|
+
* philosophy (reportLaunchFailure): append one entry per event to
|
|
7
|
+
* userData/shell-crash.log so a user can attach something actionable to a
|
|
8
|
+
* bug report.
|
|
9
|
+
*
|
|
10
|
+
* The log is capped: past 128 KB the oldest content is dropped, so a
|
|
11
|
+
* repeating failure can never grow the file without bound.
|
|
12
|
+
*/
|
|
13
|
+
import { app } from 'electron'
|
|
14
|
+
import { appendFileSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
const MAX_BYTES = 128 * 1024
|
|
18
|
+
const KEEP_BYTES = 32 * 1024
|
|
19
|
+
|
|
20
|
+
function crashLogFile() {
|
|
21
|
+
return join(app.getPath('userData'), 'shell-crash.log')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Where the crash log lives (named in docs / bug-report guidance). */
|
|
25
|
+
export function crashLogPath() {
|
|
26
|
+
return crashLogFile()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function append(entry) {
|
|
30
|
+
try {
|
|
31
|
+
const file = crashLogFile()
|
|
32
|
+
try {
|
|
33
|
+
if (statSync(file).size > MAX_BYTES) {
|
|
34
|
+
// Cap: keep only the newest KEEP_BYTES when the log outgrows itself.
|
|
35
|
+
const tail = readFileSync(file, 'utf8').slice(-KEEP_BYTES)
|
|
36
|
+
writeFileSync(file, tail, 'utf8')
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// First write (file missing) — nothing to cap.
|
|
40
|
+
}
|
|
41
|
+
appendFileSync(file, entry, 'utf8')
|
|
42
|
+
} catch {
|
|
43
|
+
// Nowhere left to report to — stay silent rather than crash the guard.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Install process-level handlers. Errors are logged and swallowed: for a
|
|
49
|
+
* tray-resident shell, surviving one stray async rejection beats dying on
|
|
50
|
+
* it — the process-level state after a handled uncaughtException is the
|
|
51
|
+
* same "best effort" the shell already applies everywhere else.
|
|
52
|
+
*/
|
|
53
|
+
export function setupCrashGuard() {
|
|
54
|
+
process.on('uncaughtException', (err) => {
|
|
55
|
+
append(`[${new Date().toISOString()}] uncaughtException: ${err?.stack || err}\n`)
|
|
56
|
+
})
|
|
57
|
+
process.on('unhandledRejection', (reason) => {
|
|
58
|
+
append(`[${new Date().toISOString()}] unhandledRejection: ${reason?.stack || reason}\n`)
|
|
59
|
+
})
|
|
60
|
+
}
|
package/electron/main.js
CHANGED
|
@@ -12,14 +12,19 @@ import { app, BrowserWindow, dialog, nativeImage } from 'electron'
|
|
|
12
12
|
import { fileURLToPath } from 'node:url'
|
|
13
13
|
import { createMainWindow, reloadWindow } from './window.js'
|
|
14
14
|
import { createTray, refreshTrayMenu } from './tray.js'
|
|
15
|
-
import { loadConfig, saveConfig } from './config.js'
|
|
15
|
+
import { loadConfig, saveConfig, DEFAULT_TARGET_URL } from './config.js'
|
|
16
16
|
import { detect } from './service.js'
|
|
17
17
|
import { setupAutoUpdater } from './update.js'
|
|
18
18
|
import { shortcutSupported, hasDesktopShortcut, createDesktopShortcut, ensureStartMenuShortcut } from './shortcut.js'
|
|
19
19
|
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
20
|
+
import { setupCrashGuard } from './crashGuard.js'
|
|
20
21
|
|
|
21
22
|
const isMac = process.platform === 'darwin'
|
|
22
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
|
+
|
|
23
28
|
// Windows: pin the AppUserModelId so the taskbar shows our whale icon
|
|
24
29
|
// instead of the generic Electron icon. Plugin mode uses a distinct ID
|
|
25
30
|
// (see aumid.js) so Windows re-reads the icon instead of serving a
|
|
@@ -65,15 +70,16 @@ if (!gotLock) {
|
|
|
65
70
|
|
|
66
71
|
async function createWindow() {
|
|
67
72
|
const config = loadConfig()
|
|
68
|
-
const target = config.targetUrl ||
|
|
73
|
+
const target = config.targetUrl || DEFAULT_TARGET_URL
|
|
69
74
|
|
|
70
75
|
mainWindow = createMainWindow({ target })
|
|
71
76
|
mainWindow.on('closed', () => {
|
|
72
77
|
mainWindow = null
|
|
73
78
|
})
|
|
74
79
|
mainWindow.on('close', (event) => {
|
|
75
|
-
// Close hides to tray unless we are actually quitting.
|
|
76
|
-
|
|
80
|
+
// Close hides to tray unless we are actually quitting. Read the
|
|
81
|
+
// config fresh so a runtime change to closeToTray takes effect.
|
|
82
|
+
if (!app.isQuitting && loadConfig().closeToTray !== false) {
|
|
77
83
|
event.preventDefault()
|
|
78
84
|
mainWindow?.hide()
|
|
79
85
|
}
|
|
@@ -90,9 +96,11 @@ if (!gotLock) {
|
|
|
90
96
|
*/
|
|
91
97
|
async function bootstrapBackend() {
|
|
92
98
|
const config = loadConfig()
|
|
93
|
-
const target = config.targetUrl ||
|
|
99
|
+
const target = config.targetUrl || DEFAULT_TARGET_URL
|
|
94
100
|
// Remote targets are the user's own business — never probe local.
|
|
95
|
-
|
|
101
|
+
// Origin comparison (not string equality) tolerates trailing slashes
|
|
102
|
+
// and an explicitly written default.
|
|
103
|
+
if (new URL(target).origin !== new URL(DEFAULT_TARGET_URL).origin) return
|
|
96
104
|
await detect()
|
|
97
105
|
// Reflect the post-detect state in the tray menu.
|
|
98
106
|
refreshTrayMenu()
|
|
@@ -134,7 +142,7 @@ if (!gotLock) {
|
|
|
134
142
|
},
|
|
135
143
|
onReload: () => {
|
|
136
144
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
137
|
-
reloadWindow(mainWindow, loadConfig().targetUrl ||
|
|
145
|
+
reloadWindow(mainWindow, loadConfig().targetUrl || DEFAULT_TARGET_URL)
|
|
138
146
|
}
|
|
139
147
|
},
|
|
140
148
|
onQuit: () => {
|
package/electron/service.js
CHANGED
|
@@ -11,10 +11,23 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from 'node:child_process'
|
|
13
13
|
import { access } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
14
15
|
import { dirname, join } from 'node:path'
|
|
15
|
-
import { loadConfig } from './config.js'
|
|
16
|
+
import { loadConfig, DEFAULT_TARGET_URL } from './config.js'
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
// The local backend endpoint — derived from the shared default, never a
|
|
19
|
+
// duplicated literal.
|
|
20
|
+
const LOCAL_URL = DEFAULT_TARGET_URL
|
|
21
|
+
const DEFAULT_PORT = Number(new URL(DEFAULT_TARGET_URL).port) || 80
|
|
22
|
+
|
|
23
|
+
// Long-lived backend output must not accumulate without bound — keep the
|
|
24
|
+
// tail only (enough for ready-line matching and diagnostics).
|
|
25
|
+
const OUTPUT_CAP = 64 * 1024
|
|
26
|
+
|
|
27
|
+
function cappedAppend(prev, chunk) {
|
|
28
|
+
const s = prev + chunk
|
|
29
|
+
return s.length > OUTPUT_CAP ? s.slice(-OUTPUT_CAP) : s
|
|
30
|
+
}
|
|
18
31
|
|
|
19
32
|
let child = null
|
|
20
33
|
let currentStatus = 'stopped'
|
|
@@ -48,7 +61,7 @@ export function getStatus() {
|
|
|
48
61
|
return {
|
|
49
62
|
status: currentStatus,
|
|
50
63
|
port: DEFAULT_PORT,
|
|
51
|
-
url:
|
|
64
|
+
url: LOCAL_URL,
|
|
52
65
|
error: lastError,
|
|
53
66
|
pid: child?.pid ?? null,
|
|
54
67
|
}
|
|
@@ -57,10 +70,9 @@ export function getStatus() {
|
|
|
57
70
|
/** Simple HTTP probe — true when something responds on the port. */
|
|
58
71
|
export async function probe(url, timeoutMs = 1500) {
|
|
59
72
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const res = await fetch(url, { signal:
|
|
63
|
-
clearTimeout(timer)
|
|
73
|
+
// AbortSignal.timeout is the standard self-cleaning timeout — no manual
|
|
74
|
+
// controller/timer pair to leak when fetch rejects first.
|
|
75
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'follow' })
|
|
64
76
|
return res.status < 500
|
|
65
77
|
} catch {
|
|
66
78
|
return false
|
|
@@ -73,11 +85,10 @@ export async function probe(url, timeoutMs = 1500) {
|
|
|
73
85
|
* or null when the backend is down.
|
|
74
86
|
*/
|
|
75
87
|
export async function detect() {
|
|
76
|
-
const
|
|
77
|
-
const up = await probe(url)
|
|
88
|
+
const up = await probe(LOCAL_URL)
|
|
78
89
|
if (up) {
|
|
79
90
|
setStatus('running')
|
|
80
|
-
return
|
|
91
|
+
return LOCAL_URL
|
|
81
92
|
}
|
|
82
93
|
setStatus('stopped')
|
|
83
94
|
return null
|
|
@@ -154,13 +165,12 @@ export async function start({ backendPath } = {}) {
|
|
|
154
165
|
startResolver = null
|
|
155
166
|
// Timeout does not mean failure — the port may already be up while
|
|
156
167
|
// the CLI never printed a parseable URL. Probe before giving up.
|
|
157
|
-
|
|
158
|
-
if (await probe(url, 1500)) {
|
|
168
|
+
if (await probe(LOCAL_URL, 1500)) {
|
|
159
169
|
setStatus('running')
|
|
160
|
-
r.resolve(
|
|
170
|
+
r.resolve(LOCAL_URL)
|
|
161
171
|
return
|
|
162
172
|
}
|
|
163
|
-
child
|
|
173
|
+
terminate(child)
|
|
164
174
|
setStatus('error', 'dsh 后端启动超时(45s)')
|
|
165
175
|
r.reject(new Error(lastError))
|
|
166
176
|
}, 45000)
|
|
@@ -178,11 +188,11 @@ export async function start({ backendPath } = {}) {
|
|
|
178
188
|
}
|
|
179
189
|
}
|
|
180
190
|
child.stdout.on('data', (d) => {
|
|
181
|
-
stdout
|
|
191
|
+
stdout = cappedAppend(stdout, d.toString())
|
|
182
192
|
onData()
|
|
183
193
|
})
|
|
184
194
|
child.stderr.on('data', (d) => {
|
|
185
|
-
stderr
|
|
195
|
+
stderr = cappedAppend(stderr, d.toString())
|
|
186
196
|
onData()
|
|
187
197
|
})
|
|
188
198
|
})
|
|
@@ -191,8 +201,8 @@ export async function start({ backendPath } = {}) {
|
|
|
191
201
|
/**
|
|
192
202
|
* Stop the backend.
|
|
193
203
|
*
|
|
194
|
-
* - If the backend was spawned by us,
|
|
195
|
-
*
|
|
204
|
+
* - If the backend was spawned by us, terminate our child (tree kill — a
|
|
205
|
+
* shell-spawned .cmd can leave orphan node processes behind).
|
|
196
206
|
* - Otherwise (an external instance, e.g. the user ran `dsh web` themselves)
|
|
197
207
|
* find the process listening on the port and terminate it, but only when
|
|
198
208
|
* it looks like a node-based backend, never an unrelated program.
|
|
@@ -202,11 +212,7 @@ export async function stop() {
|
|
|
202
212
|
const proc = child
|
|
203
213
|
child = null
|
|
204
214
|
setStatus('stopped')
|
|
205
|
-
await
|
|
206
|
-
proc.once('exit', resolve)
|
|
207
|
-
proc.kill()
|
|
208
|
-
setTimeout(resolve, 3000)
|
|
209
|
-
})
|
|
215
|
+
await terminate(proc)
|
|
210
216
|
await ensurePortFree(DEFAULT_PORT)
|
|
211
217
|
return
|
|
212
218
|
}
|
|
@@ -225,6 +231,37 @@ export async function stop() {
|
|
|
225
231
|
|
|
226
232
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
227
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Terminate a spawned backend and wait for it to exit.
|
|
236
|
+
*
|
|
237
|
+
* On Windows `proc.kill()` only reaches the direct child — when the command
|
|
238
|
+
* is a .cmd shim, the node process it launched survives and keeps holding
|
|
239
|
+
* the port. `taskkill /T` (tree kill) is the recognized fix. POSIX: SIGTERM
|
|
240
|
+
* first, SIGKILL after a short grace period.
|
|
241
|
+
*/
|
|
242
|
+
async function terminate(proc) {
|
|
243
|
+
if (!proc || proc.exitCode !== null || proc.signalCode !== null) return
|
|
244
|
+
const exited = new Promise((resolve) => proc.once('exit', resolve))
|
|
245
|
+
if (process.platform === 'win32' && proc.pid) {
|
|
246
|
+
await killProcess(proc.pid)
|
|
247
|
+
} else {
|
|
248
|
+
try {
|
|
249
|
+
proc.kill('SIGTERM')
|
|
250
|
+
} catch {
|
|
251
|
+
// already gone
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const done = await Promise.race([exited.then(() => true), sleep(3000).then(() => false)])
|
|
255
|
+
if (!done && process.platform !== 'win32') {
|
|
256
|
+
try {
|
|
257
|
+
proc.kill('SIGKILL')
|
|
258
|
+
} catch {
|
|
259
|
+
// already gone
|
|
260
|
+
}
|
|
261
|
+
await Promise.race([exited, sleep(1000)])
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
228
265
|
/** Wait until nothing answers on the port (or give up after ~6s). */
|
|
229
266
|
async function ensurePortFree(port, tries = 12) {
|
|
230
267
|
const url = `http://127.0.0.1:${port}`
|
|
@@ -319,10 +356,28 @@ export async function findDshInFolder(folder) {
|
|
|
319
356
|
return null
|
|
320
357
|
}
|
|
321
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Candidate dsh CLI locations beyond the explicit config path:
|
|
361
|
+
* the DSH_BACKEND_DIR env var (documented, machine-independent) and the
|
|
362
|
+
* npm global bin dir. The previous list carried a developer-specific
|
|
363
|
+
* absolute path — removed in favor of config (backendPath) and env.
|
|
364
|
+
*/
|
|
365
|
+
function fallbackCandidates() {
|
|
366
|
+
const list = []
|
|
367
|
+
const envDir = process.env.DSH_BACKEND_DIR
|
|
368
|
+
if (envDir) list.push(join(envDir, 'dsh.cmd'), join(envDir, 'dsh'))
|
|
369
|
+
if (process.platform === 'win32') {
|
|
370
|
+
list.push(join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'npm', 'dsh.cmd'))
|
|
371
|
+
} else {
|
|
372
|
+
list.push('/usr/local/bin/dsh', join(homedir(), '.local', 'bin', 'dsh'))
|
|
373
|
+
}
|
|
374
|
+
return list
|
|
375
|
+
}
|
|
376
|
+
|
|
322
377
|
/**
|
|
323
378
|
* Auto-detect the dsh install folder (the folder that contains the dsh
|
|
324
379
|
* CLI), following the same resolution order used to start the backend:
|
|
325
|
-
*
|
|
380
|
+
* config → DSH_BACKEND_DIR → PATH → npm global. Returns the folder or null.
|
|
326
381
|
*/
|
|
327
382
|
export async function detectInstallFolder() {
|
|
328
383
|
// 1) Explicit backend folder from config.
|
|
@@ -332,20 +387,14 @@ export async function detectInstallFolder() {
|
|
|
332
387
|
if (found) return dirOf(found)
|
|
333
388
|
}
|
|
334
389
|
|
|
335
|
-
// 2)
|
|
390
|
+
// 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
|
|
391
|
+
for (const c of fallbackCandidates()) {
|
|
392
|
+
if (await exists(c)) return dirOf(c)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 3) `dsh` on PATH → resolve where it actually lives.
|
|
336
396
|
const onPath = await commandPath('dsh')
|
|
337
397
|
if (onPath) return dirOf(onPath)
|
|
338
|
-
|
|
339
|
-
// 3) Common locations.
|
|
340
|
-
if (process.platform === 'win32') {
|
|
341
|
-
const candidates = [
|
|
342
|
-
'D:\\deepseek-harness\\prod\\node_modules\\.bin\\dsh.cmd',
|
|
343
|
-
`${process.env.USERPROFILE}\\AppData\\Roaming\\npm\\dsh.cmd`,
|
|
344
|
-
]
|
|
345
|
-
for (const c of candidates) {
|
|
346
|
-
if (await exists(c)) return dirOf(c)
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
398
|
return null
|
|
350
399
|
}
|
|
351
400
|
|
|
@@ -357,23 +406,17 @@ async function resolveDshCommand(backendPath) {
|
|
|
357
406
|
if (found) return { command: found, args: [] }
|
|
358
407
|
}
|
|
359
408
|
|
|
360
|
-
// 2)
|
|
409
|
+
// 2) Candidates: DSH_BACKEND_DIR env, then npm global bin.
|
|
410
|
+
for (const c of fallbackCandidates()) {
|
|
411
|
+
if (await exists(c)) return { command: c, args: [] }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 3) `dsh` on PATH — resolve to the real file so .cmd/.bat gets the
|
|
361
415
|
// shell treatment (a bare `spawn('dsh')` would ENOENT on Windows).
|
|
362
416
|
const onPath = await commandPath('dsh')
|
|
363
417
|
if (onPath) {
|
|
364
418
|
return { command: /\.(cmd|bat)$/i.test(onPath) ? onPath : 'dsh', args: [] }
|
|
365
419
|
}
|
|
366
|
-
|
|
367
|
-
// 3) Common locations (dev convenience).
|
|
368
|
-
if (process.platform === 'win32') {
|
|
369
|
-
const candidates = [
|
|
370
|
-
'D:\\deepseek-harness\\prod\\node_modules\\.bin\\dsh.cmd',
|
|
371
|
-
`${process.env.USERPROFILE}\\AppData\\Roaming\\npm\\dsh.cmd`,
|
|
372
|
-
]
|
|
373
|
-
for (const c of candidates) {
|
|
374
|
-
if (await exists(c)) return { command: c, args: [] }
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
420
|
return null
|
|
378
421
|
}
|
|
379
422
|
|
package/electron/shortcut.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Desktop shortcut management (Windows .lnk via
|
|
2
|
+
* Desktop shortcut management (Windows .lnk via Electron's shell API).
|
|
3
3
|
*
|
|
4
4
|
* Used by:
|
|
5
5
|
* - first-run prompt in main.js ("create a desktop shortcut?")
|
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Pure dev mode (npm run dev from a checkout) has no stable executable,
|
|
14
14
|
* so the feature stays disabled there.
|
|
15
|
+
*
|
|
16
|
+
* Electron's built-in shortcut API replaced the earlier hand-rolled
|
|
17
|
+
* PowerShell + WScript.Shell COM script: fewer moving parts (no spawned
|
|
18
|
+
* interpreter to hang or quote-escape), and it composes with the
|
|
19
|
+
* Start-menu shortcut path that already used the same API.
|
|
15
20
|
*/
|
|
16
21
|
import { app, shell } from 'electron'
|
|
17
|
-
import {
|
|
18
|
-
import { existsSync } from 'node:fs'
|
|
19
|
-
import { homedir } from 'node:os'
|
|
22
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
20
23
|
import { dirname, join } from 'node:path'
|
|
21
24
|
import { fileURLToPath } from 'node:url'
|
|
22
25
|
import { APP_USER_MODEL_ID } from './aumid.js'
|
|
@@ -45,64 +48,53 @@ export function shortcutSupported() {
|
|
|
45
48
|
return isWin && (app.isPackaged || pluginArgs().length > 0)
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
out += d.toString()
|
|
60
|
-
})
|
|
61
|
-
p.on('error', () => resolve(null))
|
|
62
|
-
p.on('exit', () => resolve(out))
|
|
63
|
-
})
|
|
51
|
+
/**
|
|
52
|
+
* The user's real Desktop. app.getPath('desktop') resolves OneDrive
|
|
53
|
+
* redirection and roaming profile policies — a hardcoded ~/Desktop misses
|
|
54
|
+
* those and writes the shortcut where the user will never see it.
|
|
55
|
+
*/
|
|
56
|
+
function desktopDir() {
|
|
57
|
+
try {
|
|
58
|
+
return app.getPath('desktop')
|
|
59
|
+
} catch {
|
|
60
|
+
return join(app.getPath('home'), 'Desktop')
|
|
61
|
+
}
|
|
64
62
|
}
|
|
65
63
|
|
|
66
64
|
/** True when a desktop shortcut already points at this app's exe. */
|
|
67
|
-
export
|
|
68
|
-
if (!isWin) return false
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
65
|
+
export function hasDesktopShortcut() {
|
|
66
|
+
if (!isWin || !shortcutSupported()) return false
|
|
67
|
+
try {
|
|
68
|
+
const target = process.execPath.toLowerCase()
|
|
69
|
+
for (const entry of readdirSync(desktopDir())) {
|
|
70
|
+
if (!entry.toLowerCase().endsWith('.lnk')) continue
|
|
71
|
+
try {
|
|
72
|
+
// readShortcutLink throws on invalid/non-shortcut files — skip those.
|
|
73
|
+
const link = shell.readShortcutLink(join(desktopDir(), entry))
|
|
74
|
+
if (String(link.target).toLowerCase() === target) return true
|
|
75
|
+
} catch {
|
|
76
|
+
// unreadable .lnk — not ours
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// unreadable desktop — treat as "not present"
|
|
81
|
+
}
|
|
82
|
+
return false
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
/** Create (or overwrite) the desktop shortcut for this app. Returns bool. */
|
|
83
|
-
export
|
|
86
|
+
export function createDesktopShortcut() {
|
|
84
87
|
if (!shortcutSupported()) return false
|
|
85
|
-
const target = process.execPath
|
|
86
88
|
const args = pluginArgs()
|
|
87
|
-
const
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const script =
|
|
97
|
-
`$ws = New-Object -ComObject WScript.Shell; ` +
|
|
98
|
-
`$s = $ws.CreateShortcut(${ps(lnk)}); ` +
|
|
99
|
-
`$s.TargetPath = ${ps(target)}; ` +
|
|
100
|
-
argPart +
|
|
101
|
-
`$s.WorkingDirectory = ${ps(dirname(target))}; ` +
|
|
102
|
-
`$s.IconLocation = ${ps(iconLoc)}; ` +
|
|
103
|
-
`$s.Save()`
|
|
104
|
-
const out = await runPs(script)
|
|
105
|
-
return out !== null
|
|
89
|
+
const ico = existsSync(ICON_ICO) ? ICON_ICO : undefined
|
|
90
|
+
// 'overwrite' makes the call idempotent (safe to re-run after updates).
|
|
91
|
+
return shell.writeShortcutLink(join(desktopDir(), SHORTCUT_NAME), 'overwrite', {
|
|
92
|
+
target: process.execPath,
|
|
93
|
+
args: args.length ? args.join(' ') : undefined,
|
|
94
|
+
icon: ico,
|
|
95
|
+
iconIndex: 0,
|
|
96
|
+
appUserModelId: APP_USER_MODEL_ID,
|
|
97
|
+
})
|
|
106
98
|
}
|
|
107
99
|
|
|
108
100
|
/**
|
|
@@ -115,7 +107,7 @@ export async function createDesktopShortcut() {
|
|
|
115
107
|
export function ensureStartMenuShortcut() {
|
|
116
108
|
if (!isWin || !shortcutSupported()) return false
|
|
117
109
|
const lnkDir = join(
|
|
118
|
-
process.env.APPDATA || join(
|
|
110
|
+
process.env.APPDATA || join(app.getPath('home'), 'AppData', 'Roaming'),
|
|
119
111
|
'Microsoft', 'Windows', 'Start Menu', 'Programs',
|
|
120
112
|
)
|
|
121
113
|
const lnk = join(lnkDir, SHORTCUT_NAME)
|