dsh-clean-desktop-shell 0.1.9 → 0.1.10

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 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 扩展属性。
@@ -2,19 +2,27 @@
2
2
  * Shell config persistence (userData/config.json).
3
3
  *
4
4
  * Fields:
5
- * - targetUrl: default 'http://127.0.0.1:3080' — set to any remote
6
- * DSH address to run the shell as a pure window.
7
- * - closeToTray: default true.
8
- * - windowMode: 'advanced' (mica/vibrancy) | 'compatibility'.
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: 'http://127.0.0.1:3080',
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 raw = readFileSync(configPath(), 'utf8')
34
- cached = { ...DEFAULTS, ...JSON.parse(raw) }
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
- writeFileSync(file, JSON.stringify(cached, null, 2), 'utf8')
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 || 'http://127.0.0.1:3080'
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
- if (!app.isQuitting && config.closeToTray !== false) {
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 || 'http://127.0.0.1:3080'
99
+ const target = config.targetUrl || DEFAULT_TARGET_URL
94
100
  // Remote targets are the user's own business — never probe local.
95
- if (target !== 'http://127.0.0.1:3080') return
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 || 'http://127.0.0.1:3080')
145
+ reloadWindow(mainWindow, loadConfig().targetUrl || DEFAULT_TARGET_URL)
138
146
  }
139
147
  },
140
148
  onQuit: () => {
@@ -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
- const DEFAULT_PORT = 3080
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: `http://127.0.0.1:${DEFAULT_PORT}`,
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
- const controller = new AbortController()
61
- const timer = setTimeout(() => controller.abort(), timeoutMs)
62
- const res = await fetch(url, { signal: controller.signal, redirect: 'follow' })
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 url = `http://127.0.0.1:${DEFAULT_PORT}`
77
- const up = await probe(url)
88
+ const up = await probe(LOCAL_URL)
78
89
  if (up) {
79
90
  setStatus('running')
80
- return url
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
- const url = `http://127.0.0.1:${DEFAULT_PORT}`
158
- if (await probe(url, 1500)) {
168
+ if (await probe(LOCAL_URL, 1500)) {
159
169
  setStatus('running')
160
- r.resolve(url)
170
+ r.resolve(LOCAL_URL)
161
171
  return
162
172
  }
163
- child?.kill()
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 += d.toString()
191
+ stdout = cappedAppend(stdout, d.toString())
182
192
  onData()
183
193
  })
184
194
  child.stderr.on('data', (d) => {
185
- stderr += d.toString()
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, kill our child (and force-free the
195
- * port — a shell-spawned .cmd can leave orphan node processes behind).
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 new Promise((resolve) => {
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
- * PATHcommon Windows install locations. Returns the folder path or null.
380
+ * configDSH_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) `dsh` on PATH resolve where it actually lives.
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) `dsh` on PATH resolve to the real file so .cmd/.bat gets the
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
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Desktop shortcut management (Windows .lnk via WScript.Shell).
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 { spawn } from 'node:child_process'
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
- function ps(str) {
49
- return "'" + String(str).replace(/'/g, "''") + "'"
50
- }
51
-
52
- function runPs(script) {
53
- return new Promise((resolve) => {
54
- const p = spawn('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
55
- windowsHide: true,
56
- })
57
- let out = ''
58
- p.stdout.on('data', (d) => {
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 async function hasDesktopShortcut() {
68
- if (!isWin) return false
69
- const script =
70
- `$ws = New-Object -ComObject WScript.Shell; ` +
71
- `Get-ChildItem ${ps(join(homedir(), 'Desktop', '*.lnk'))} | ` +
72
- `ForEach-Object { $ws.CreateShortcut($_.FullName).TargetPath }`
73
- const out = await runPs(script)
74
- if (out === null) return false
75
- const target = process.execPath.toLowerCase()
76
- return out
77
- .split(/\r?\n/)
78
- .map((s) => s.trim().toLowerCase())
79
- .some((line) => line === target)
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 async function createDesktopShortcut() {
86
+ export function createDesktopShortcut() {
84
87
  if (!shortcutSupported()) return false
85
- const target = process.execPath
86
88
  const args = pluginArgs()
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`
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(homedir(), 'AppData', 'Roaming'),
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)