dsh-x-core 0.1.21 → 0.1.23

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.
Files changed (50) hide show
  1. package/assets/dsh-preview.png +0 -0
  2. package/assets/dsh.ico +0 -0
  3. package/assets/icon.ico +0 -0
  4. package/assets/tray.ico +0 -0
  5. package/assets/tray.png +0 -0
  6. package/core-npm.js +201 -183
  7. package/desktop-plugins.js +86 -0
  8. package/desktop.js +200 -0
  9. package/filemanager-base.js +7 -8
  10. package/filemanager-linux.js +124 -0
  11. package/filemanager.js +4 -7
  12. package/native-notify.js +74 -0
  13. package/node/node +2 -1
  14. package/package.json +1 -1
  15. package/packs/dsh-x-workbench/README.md +61 -0
  16. package/packs/dsh-x-workbench/dspack.json +4 -0
  17. package/packs/dsh-x-workbench/home/skills/agent-browser/LICENSE-APACHE-2.0.txt +202 -0
  18. package/packs/dsh-x-workbench/home/skills/agent-browser/NOTICE.md +6 -0
  19. package/packs/dsh-x-workbench/home/skills/agent-browser/SKILL.md +697 -0
  20. package/packs/dsh-x-workbench/home/skills/agent-browser/references/authentication.md +313 -0
  21. package/packs/dsh-x-workbench/home/skills/agent-browser/references/commands.md +300 -0
  22. package/packs/dsh-x-workbench/home/skills/agent-browser/references/profiling.md +120 -0
  23. package/packs/dsh-x-workbench/home/skills/agent-browser/references/proxy-support.md +194 -0
  24. package/packs/dsh-x-workbench/home/skills/agent-browser/references/session-management.md +201 -0
  25. package/packs/dsh-x-workbench/home/skills/agent-browser/references/snapshot-refs.md +229 -0
  26. package/packs/dsh-x-workbench/home/skills/agent-browser/references/video-recording.md +173 -0
  27. package/packs/dsh-x-workbench/home/skills/agent-browser/templates/authenticated-session.sh +105 -0
  28. package/packs/dsh-x-workbench/home/skills/agent-browser/templates/capture-workflow.sh +69 -0
  29. package/packs/dsh-x-workbench/home/skills/agent-browser/templates/form-automation.sh +62 -0
  30. package/packs/dsh-x-workbench/home/skills/electron/LICENSE-APACHE-2.0.txt +202 -0
  31. package/packs/dsh-x-workbench/home/skills/electron/NOTICE.md +6 -0
  32. package/packs/dsh-x-workbench/home/skills/electron/SKILL.md +243 -0
  33. package/packs/dsh-x-workbench/manifest.json +35 -0
  34. package/perf/patch-hooks.mjs +14 -1
  35. package/perf/plugin-profile.mjs +204 -0
  36. package/perf/register.mjs +4 -0
  37. package/platform.js +2 -1
  38. package/public/booting.html +475 -0
  39. package/public/favicon.ico +0 -0
  40. package/public/favicon.png +0 -0
  41. package/public/icon.png +0 -0
  42. package/public/index.html +9277 -8173
  43. package/public/internal-tabs.js +45 -41
  44. package/public/launcher.css +285 -98
  45. package/public/mascot.js +1 -1
  46. package/public/theme.css +33 -6
  47. package/public/water.js +1 -1
  48. package/registry.js +38 -3
  49. package/server.js +1060 -119
  50. package/settings.js +78 -1
package/server.js CHANGED
@@ -16,6 +16,8 @@ import pkg from './package.json' with { type: 'json' }
16
16
  import { VERSION_CHANNEL, VERSION_CHANNELS, effectiveChannel, versionChannel, versionInChannel } from './version.js'
17
17
  import { createLogWriter } from './log-writer.js'
18
18
  import { createStartupDiagnosis } from './startup-diagnosis.js'
19
+ import { applyNativeNotify } from './native-notify.js'
20
+ import { DESKTOP_PLUGINS_SKIP_PROFILES, desktopPluginsToInstall } from './desktop-plugins.js'
19
21
  import { bindDiagnosis, checkedRepairPlan, profileFingerprint } from './repair-plan.js'
20
22
  import { DiagnosticRepairs, verificationSummary } from './repair-actions.js'
21
23
  import { recordedChanges } from './repair-evidence.js'
@@ -117,17 +119,21 @@ import {
117
119
  safeProfile,
118
120
  safeProxyMode,
119
121
  safeProxyUrl,
122
+ safeNodeRuntime,
123
+ safeSystemNodePath,
120
124
  safeArgs,
121
125
  safeUpdateChannel,
122
126
  safeWebBind,
127
+ safeWorkdir,
123
128
  saveSettings,
124
129
  VERSION_RE,
125
130
  } from './settings.js'
126
131
  // 带代理的 fetch:Node 的全局 fetch 既不看系统代理也不看 HTTP_PROXY,外网请求都走 netFetch
127
132
  import { netFetch, proxyEnv, resetProxyCache, resolveProxy } from './proxy.js'
128
133
  import { APP_DIR, fileManager } from './filemanager.js'
134
+ import { DESKTOP_API_VERSION, DESKTOP_CAPABILITIES, parseDesktopRequest } from './desktop.js'
129
135
  import { checkCoreUpdate, downloadCoreTarball, extractCoreTarball, stagingExists, stagingStats } from './core-npm.js'
130
- import { IN_ELECTRON, IS_MAC, IS_WINDOWS, LAUNCHER_NAME, MAC_APP_NAME, NPM_MODE, PACKAGED, RUNTIME_NODE, RUNTIME_NODE_ENV } from './platform.js'
136
+ import { IN_ELECTRON, IS_LINUX, IS_MAC, IS_WINDOWS, LAUNCHER_NAME, MAC_APP_NAME, NPM_MODE, PACKAGED, RUNTIME_NODE, RUNTIME_NODE_ENV } from './platform.js'
131
137
 
132
138
  const execFileAsync = promisify(execFile)
133
139
 
@@ -206,6 +212,13 @@ let UPDATE_SOURCE = updateSourceForDownload(loadSettingsSync().downloadSource)
206
212
  let OPEN_MODE = safeOpenMode(loadSettingsSync().openMode)
207
213
  // 是否把 dsh 的 shim 目录写进用户 PATH(默认关,改了要新开终端才生效)
208
214
  let SYSTEM_PATH = loadSettingsSync().systemPath === true
215
+ // 内置浏览器(官方右侧栏浏览器)的总开关:启动参数里的 --patch 按它在启用 / 关闭之间切换。
216
+ // 外壳那侧还有一道 guest 放行检查(shell/main.mjs),两边读同一份 settings.json。
217
+ let BROWSER_CONTROL = loadSettingsSync().browserControl !== false
218
+ // 右键菜单(dsh-x-context-menu):关掉后启动补丁把插件行停用(不卸载),也不再自动补装。
219
+ let CONTEXT_MENU = loadSettingsSync().contextMenu !== false
220
+ const CONTEXT_MENU_PLUGIN = 'dsh-x-context-menu'
221
+ const CONTEXT_MENU_ROW_ID = 'x-context-menu'
209
222
 
210
223
  /** 安装目录里的 lang.txt(安装程序写的),只认 zh / en。 */
211
224
  function installLang() {
@@ -220,7 +233,67 @@ const LOG_FILE = join(LOG_DIR, 'manager.log')
220
233
  const LOG_MAX_BYTES = 5 * 1024 * 1024
221
234
  const NOISY_LOG_RE = /^(?:已安装 \d+\/\d+|已解析 \d+)/
222
235
  const logWriter = createLogWriter(LOG_FILE, { maxBytes: LOG_MAX_BYTES })
223
- process.on('exit', () => logWriter.flush())
236
+ /** 每个启动项一份日志:启动时行里的上下文带上 launchId,`pushLog` 顺手 tee 过来。 */
237
+ const LAUNCH_LOG_DIR = join(APP_DIR, 'launch-logs')
238
+ const LAUNCH_LOG_MAX_BYTES = 2 * 1024 * 1024
239
+ const LAUNCH_ID_RE = /^[A-Za-z0-9_-]{1,64}$/
240
+ const launchLogWriters = new Map()
241
+ function launchLogWriter(id) {
242
+ let writer = launchLogWriters.get(id)
243
+ if (!writer) {
244
+ writer = createLogWriter(join(LAUNCH_LOG_DIR, `launch-${id}.log`), { maxBytes: LAUNCH_LOG_MAX_BYTES })
245
+ launchLogWriters.set(id, writer)
246
+ }
247
+ return writer
248
+ }
249
+ /** 清空某个启动项的原始日志(耗时记录保留,账本和 RSS 还要用)。 */
250
+ function clearLaunchLog(id) {
251
+ const writer = launchLogWriters.get(id)
252
+ if (writer) {
253
+ writer.flush()
254
+ launchLogWriters.delete(id)
255
+ }
256
+ for (const name of [`launch-${id}.log`, `launch-${id}.log.1`]) {
257
+ try { unlinkSync(join(LAUNCH_LOG_DIR, name)) } catch { /* 没有就算了 */ }
258
+ }
259
+ }
260
+
261
+ /** 删除启动项时顺手删掉它自己的日志(分片也一起)。 */
262
+ function dropLaunchLog(id) {
263
+ const writer = launchLogWriters.get(id)
264
+ if (writer) {
265
+ writer.flush()
266
+ launchLogWriters.delete(id)
267
+ }
268
+ for (const name of [`launch-${id}.log`, `launch-${id}.log.1`, `launch-${id}.json`]) {
269
+ try { unlinkSync(join(LAUNCH_LOG_DIR, name)) } catch { /* 没有就算了 */ }
270
+ }
271
+ }
272
+
273
+ /** 记一次启动的阶段耗时(最近 20 次,新的在前),给日志弹窗画分段条。 */
274
+ function recordLaunchRun(id, run) {
275
+ if (!id || !LAUNCH_ID_RE.test(id)) return
276
+ const file = join(LAUNCH_LOG_DIR, `launch-${id}.json`)
277
+ let runs = []
278
+ try {
279
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
280
+ if (Array.isArray(parsed?.runs)) runs = parsed.runs
281
+ } catch {
282
+ // 第一次启动还没有记录
283
+ }
284
+ runs.unshift(run)
285
+ if (runs.length > 20) runs.length = 20
286
+ try {
287
+ mkdirSync(LAUNCH_LOG_DIR, { recursive: true })
288
+ writeFileSync(file, `${JSON.stringify({ runs }, null, 2)}\n`)
289
+ } catch {
290
+ // 记录不是启动的依赖,写不进去只影响可视化
291
+ }
292
+ }
293
+ process.on('exit', () => {
294
+ logWriter.flush()
295
+ for (const writer of launchLogWriters.values()) writer.flush()
296
+ })
224
297
  let pendingLogEvents = []
225
298
  let logEventTimer = null
226
299
 
@@ -238,6 +311,8 @@ let host = {
238
311
  openWindow: (url) => { process.stdout.write(`${OPEN_SIGNAL} ${url}\n`) },
239
312
  /** 收尾之后结束服务。外壳同进程时由它决定怎么退(先关窗口、托盘),默认直接退进程。 */
240
313
  exit: (code = 0) => process.exit(code),
314
+ /** 桌面通知(任务完成 / 失败)。浏览器外壳没有这条通道,默认什么都不做。 */
315
+ notify: () => {},
241
316
  }
242
317
  const logs = []
243
318
  /**
@@ -248,6 +323,8 @@ const logs = []
248
323
  * 插件树按 profile 各是各的。同一个组合再点一次启动只会拿回已经在跑的那个。
249
324
  */
250
325
  const instances = new Map()
326
+ /** 退出 / 换核心时的收尾阶段:这期间实例退出是预期内的,不用发通知。 */
327
+ let shuttingDown = false
251
328
  // 删除环境与启动不能穿插:启动准备期间还没 child,也要算作占用这份 profile。
252
329
  const startingProfiles = new Set()
253
330
  const maintainingProfiles = new Set()
@@ -551,7 +628,11 @@ async function saveConfig(config) {
551
628
  /** 把重要日志追加到 manager.log(进度类噪音行丢弃,超过 5MB 轮转一次)。 */
552
629
  function persistLog(text) {
553
630
  if (NOISY_LOG_RE.test(text)) return
554
- logWriter.append(`[${new Date().toISOString()}] ${text}\n`)
631
+ const line = `[${new Date().toISOString()}] ${text}\n`
632
+ logWriter.append(line)
633
+ // 启动项启动/停止期间的行同时落到它自己的日志里;手动启动(没有启动项)只有全局那份。
634
+ const launchId = launchScope.getStore()?.launchId
635
+ if (launchId && LAUNCH_ID_RE.test(launchId)) launchLogWriter(launchId).append(line)
555
636
  }
556
637
 
557
638
  function flushLogEvents() {
@@ -985,11 +1066,42 @@ function liveInstanceList() {
985
1066
  return instanceList().filter((proc) => proc.status === 'running' || proc.status === 'starting')
986
1067
  }
987
1068
 
988
- /** 整个环境的删除/卸载需要停干净;默认选择并不代表多开中只有这一份在跑。 */
989
- async function withIdleProfile(profile, operation) {
1069
+ /**
1070
+ * 读一个进程的常驻内存(RSS,MB)。跨平台各用系统自带的查询,不引原生依赖。
1071
+ *
1072
+ * 只给进程级数字:dsh 的插件都在同一个 Node 进程里,操作系统的 RSS 没有插件维度。
1073
+ *
1074
+ * @param {number} pid - 进程号。
1075
+ * @returns {number|null} MB,读不到时 null。
1076
+ */
1077
+ function readProcessRssMB(pid) {
1078
+ if (!Number.isInteger(pid) || pid <= 0) return null
1079
+ try {
1080
+ if (IS_WINDOWS) {
1081
+ // "DSH.exe","1234","Console","1","123,456 K"
1082
+ const out = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], { encoding: 'utf8', windowsHide: true, timeout: 4000 }).stdout || ''
1083
+ const match = /^"[^"]*","[^"]*","[^"]*","[^"]*","([\d.,]+) K"/m.exec(out)
1084
+ if (!match) return null
1085
+ const kb = Number(match[1].replace(/[.,]/g, ''))
1086
+ return Number.isFinite(kb) ? Math.round(kb / 1024) : null
1087
+ }
1088
+ const out = spawnSync('ps', ['-o', 'rss=', '-p', String(pid)], { encoding: 'utf8', windowsHide: true, timeout: 4000 }).stdout || ''
1089
+ const kb = Number(out.trim())
1090
+ return Number.isFinite(kb) && kb > 0 ? Math.round(kb / 1024) : null
1091
+ } catch {
1092
+ return null
1093
+ }
1094
+ }
1095
+
1096
+ /**
1097
+ * 指名某个用户目录里的 Profile 做「整包」操作:该目录里它有实例在跑、
1098
+ * 或者有人正在改它,都先拒绝。默认目录走 withIdleProfile;跨目录复制 Profile 时
1099
+ * 来源那一份要用它按自己的目录锁(见 createProfile)。
1100
+ */
1101
+ async function withIdleProfileIn(home, profile, operation) {
990
1102
  const name = safeProfile(profile)
991
- const key = profileKey(name)
992
- if (startingProfiles.has(key) || instanceList().some((proc) => proc.profile === name && proc.home === homeDir())) {
1103
+ const key = `${home}\0${name}`
1104
+ if (startingProfiles.has(key) || instanceList().some((proc) => proc.profile === name && fileManager.samePath(proc.home, home))) {
993
1105
  throw new Error(`profile「${name}」仍有实例在运行或启动,请先停止这个环境的全部实例`)
994
1106
  }
995
1107
  if (maintainingProfiles.has(key) || pluginBusy || syncRunning) {
@@ -998,7 +1110,7 @@ async function withIdleProfile(profile, operation) {
998
1110
  maintainingProfiles.add(key)
999
1111
  pluginBusy = true
1000
1112
  try {
1001
- const dir = profileDirOf(name)
1113
+ const dir = join(home, 'profiles', name)
1002
1114
  // 已被外部删掉的环境只清安装记录,不能为了拿锁又创建一份空目录。
1003
1115
  return await (existsSync(dir) ? withProfileLock(dir, operation) : operation())
1004
1116
  } finally {
@@ -1007,6 +1119,11 @@ async function withIdleProfile(profile, operation) {
1007
1119
  }
1008
1120
  }
1009
1121
 
1122
+ /** 默认目录里的那份:老调用点都走这里,行为不变。 */
1123
+ async function withIdleProfile(profile, operation) {
1124
+ return withIdleProfileIn(homeDir(), profile, operation)
1125
+ }
1126
+
1010
1127
  /** rm 会连 lock 一起删除;进程内的占用标记补上这段窗口,避免热加载写回已删目录。 */
1011
1128
  async function editProfile(profile, operation) {
1012
1129
  const name = safeProfile(profile)
@@ -1031,6 +1148,7 @@ function instanceInfo(proc) {
1031
1148
  status: proc.status,
1032
1149
  url: proc.url,
1033
1150
  startedAt: proc.startedAt,
1151
+ startupMs: proc.startupMs ?? null,
1034
1152
  startupPhase: proc.status === 'starting' ? (proc.startupPhase || 'launching') : '',
1035
1153
  diagnosticWarning: publicFailure(proc.diagnosticWarning),
1036
1154
  }
@@ -1231,6 +1349,16 @@ async function snapshotDefault() {
1231
1349
  return proc ? { version: proc.version, status: proc.status, url: proc.url } : null
1232
1350
  })(),
1233
1351
  autoFix: autoFixForHome(),
1352
+ // 外观设置:设置页可能开在**另一个标签页**里,改完只有那一页自己换了主题,
1353
+ // 管这个窗口的那一页(标签条、卡片、水面背景)要等整页刷新才跟上。
1354
+ // 把这一组放进状态里,页面收到推送就能自己跟,不用刷新。
1355
+ appearance: {
1356
+ theme: THEME,
1357
+ panelTransparency: PANEL_TRANSPARENCY,
1358
+ reduceMotion: REDUCE_MOTION,
1359
+ hideBackground: HIDE_BACKGROUND,
1360
+ hideBigFish: HIDE_BIG_FISH,
1361
+ },
1234
1362
  health: lastHealth,
1235
1363
  failure: publicFailure(lastFailure),
1236
1364
  diagnosticRepairs: publicFailure({ message: '', diagnosis: diagnosticRepairs.history().map(record => ({ ...record, recorded: recordedChanges(record) })) }).diagnosis,
@@ -1289,6 +1417,10 @@ async function publicSettings() {
1289
1417
  systemBinDir: systemBinDir(),
1290
1418
  systemPathMode: IS_WINDOWS ? 'automatic' : 'manual',
1291
1419
  systemPathCommand: manualPathCommand(systemBinDir()),
1420
+ // 跑 dsh 用哪个 node:设置值 + 现在生效的 + 系统里找到的那个(页面据此画提示行)
1421
+ nodeRuntime: safeNodeRuntime(stored.nodeRuntime),
1422
+ systemNodePath: safeSystemNodePath(stored.systemNodePath),
1423
+ nodeRuntimeInfo: nodeRuntimeView(stored),
1292
1424
  updateSource: updateSourceForDownload(stored.downloadSource),
1293
1425
  updateSources: [
1294
1426
  { id: 'mirror', label: '国内加速(先走镜像,连不上再直连)' },
@@ -1317,6 +1449,22 @@ async function publicSettings() {
1317
1449
  reduceMotion: REDUCE_MOTION,
1318
1450
  hideBackground: HIDE_BACKGROUND,
1319
1451
  hideBigFish: HIDE_BIG_FISH,
1452
+ // 桌面端系统开关的当前值。外壳直接读 settings.json(见 shell/prefs.mjs),这里只是给页面回显;
1453
+ // shellPrefs 表明当前外壳懂这组设置(旧外壳不认,页面据此提示更新)。
1454
+ hardwareAcceleration: stored.hardwareAcceleration !== false,
1455
+ autoUpdate: stored.autoUpdate === true,
1456
+ taskNotifications: stored.taskNotifications !== false,
1457
+ notificationSound: stored.notificationSound !== false,
1458
+ closeToTray: stored.closeToTray !== false,
1459
+ keepAwake: stored.keepAwake === true,
1460
+ // 内置浏览器(右侧栏浏览器):开关与证书策略。browserHost 表明当前外壳能否清理浏览数据
1461
+ // (只有 Electron 外壳拿得到 guest 分区;浏览器里打开管理页 / 旧外壳没有这层能力)。
1462
+ browserControl: stored.browserControl !== false,
1463
+ browserIgnoreCertificate: stored.browserIgnoreCertificate === true,
1464
+ contextMenu: stored.contextMenu !== false,
1465
+ browserHost: Boolean(host.browser),
1466
+ shellPrefs: process.env.DSH_SHELL_PREFS === '1',
1467
+ desktopShell: shellWindowHost(),
1320
1468
  // Web 绑定:设置值 + 插件开关是否压着它(页面要如实说明当前生效的是哪一个)
1321
1469
  webBind: WEB_BIND,
1322
1470
  webBindLan: LAN_TOGGLE,
@@ -1421,6 +1569,30 @@ function resolvePresetVersion(entry, home, config, versions = null) {
1421
1569
  return live?.version || plan.installed[0] || ''
1422
1570
  }
1423
1571
 
1572
+ /**
1573
+ * 托盘要的状态文本(外壳每 1.5s 取一次)。外壳和核心同进程时直接调用,
1574
+ * 不用再绕一圈本机 HTTP;`/api/tray` 仍然保留,浏览器外壳和旧外壳照旧读它。
1575
+ */
1576
+ export function trayStateText() {
1577
+ // 保留旧外壳的标量字段;新外壳按命名启动项操作,不再把单项停止变成停止全部。
1578
+ const running = primaryInstance()
1579
+ const launches = trayLaunches()
1580
+ return [
1581
+ `status=${running?.status || 'stopped'}`,
1582
+ `url=${running?.url || ''}`,
1583
+ `installed=${trayInstalledVersions().length ? 1 : 0}`,
1584
+ `lang=${LANG}`,
1585
+ // 托盘照着它决定「打开 DSH」是叫内嵌窗口还是丢给系统浏览器
1586
+ `openmode=${OPEN_MODE}`,
1587
+ // 本次是不是由原生外壳托管:外壳缺失时窗口模式不成立,托盘据此退回浏览器
1588
+ `shell=${shellWindowHost() ? 1 : 0}`,
1589
+ `theme=${THEME}`,
1590
+ // 原生标签页要跟随所有实例,不能用「最近启动的实例」去覆盖用户正在看的其他页面。
1591
+ `instances=${JSON.stringify(instanceList().map((proc) => internalTabTarget(proc, launches.find((row) => row.proc === proc)?.name)))}`,
1592
+ `launches=${JSON.stringify(launches.map(({ id, instanceId, name, version, profile, status, busy, installed, target }) => ({ id, instanceId, name, version, profile, status, busy, installed, target })))}`,
1593
+ ].join('\n')
1594
+ }
1595
+
1424
1596
  /** 删除入口不会停止实例;这类运行项仍要能从托盘找回和单独停止。 */
1425
1597
  function trayLaunches() {
1426
1598
  const versions = trayInstalledVersions()
@@ -1547,16 +1719,31 @@ async function saveLaunchPresetNow(body) {
1547
1719
  if (!name || name.length > 32) throw new Error('启动项名称请填 1-32 个字符')
1548
1720
  const version = safeVersion(body.version)
1549
1721
  const profile = safeProfile(body.profile)
1550
- const dshHome = safeDshHome(body.dshHome)
1722
+ // 项目环境(issue #72):数据目录不再另挑,固定是项目根里的 .dsh;这个派生只做一次,
1723
+ // 页面显示的路径和真正落盘的是同一个。两个字段打架时以项目目录为准 —— 用户填了项目,
1724
+ // 就是「这套环境属于这个项目」,容不下第二个数据目录的答案。
1725
+ const workdir = safeWorkdir(body.workdir)
1726
+ const dshHome = workdir ? join(workdir, '.dsh') : safeDshHome(body.dshHome)
1551
1727
  const home = dshHome || defaultHome()
1728
+ // 项目目录本身允许不存在(「新建项目工作目录」是这个功能的一半),但不校验就可能在启动时
1729
+ // 才炸:先建出来并试写一次,打错的盘符当场报错。
1730
+ if (workdir) await ensureWritableDir(workdir)
1552
1731
  if (dshHome && dshHome !== defaultHome()) await ensureWritableDir(dshHome)
1553
- // 「添加启动项」的隔离方式引导:Profile 隔离/完全隔离只让人填 Profile 名字,不存在就按名字建。
1554
- // 建在 home 对应的目录作用域里(和 /api/profiles/create 的 x-dsh-home 同一套语义),页面
1555
- // 不用先建 Profile 再存启动项——两步之间的失败会留下半截环境。
1556
- if (body.ensureProfile === true && !listProfiles(join(home, 'profiles')).includes(profile)) {
1732
+ // Profile 不存在时按名字建,建在 home 对应的目录作用域里(和 /api/profiles/create 的
1733
+ // x-dsh-home 同一套语义),页面不用先建 Profile 再存启动项——两步之间的失败会留下半截环境。
1734
+ // 指定了复制来源就先复制:同样一步完成目录、Profile、依赖和启动项,用户不必自己搬配置。
1735
+ // 目标里已经有同名 Profile 时不动它 —— 那份是用户后来自己改的,重试保存也不该被覆盖。
1736
+ const missing = () => !listProfiles(join(home, 'profiles')).includes(profile)
1737
+ const copyFrom = body.copyFrom && typeof body.copyFrom === 'object' ? body.copyFrom : null
1738
+ if (copyFrom && missing()) {
1739
+ const fromProfile = safeProfile(copyFrom.profile)
1740
+ // 空的来源目录 = 当前(默认)用户目录,跟页面上的「沿用全局目录」一个意思。
1741
+ const fromHome = safeDshHome(copyFrom.home) || ''
1742
+ await launchScope.run({ home }, () => createProfile(profile, fromProfile, fromHome))
1743
+ } else if (body.ensureProfile === true && missing()) {
1557
1744
  await launchScope.run({ home }, () => createProfile(profile))
1558
1745
  }
1559
- if (!listProfiles(join(home, 'profiles')).includes(profile)) throw new Error(`profile ${profile} 不存在`)
1746
+ if (missing()) throw new Error(`profile ${profile} 不存在`)
1560
1747
  const portText = String(body.port ?? '').trim()
1561
1748
  const port = portText && portText !== '0' ? safePort(portText) : 0
1562
1749
  // 没带这个字段就沿用原来的通道(同 autoLaunch):只改别的字段的调用方,比如「更新到新版」时
@@ -1599,6 +1786,7 @@ async function saveLaunchPresetNow(body) {
1599
1786
  // 用过的版本记录跟着启动项走,编辑参数不该抹掉回退历史。
1600
1787
  ...(existingEntry?.usedVersion ? { usedVersion: existingEntry.usedVersion } : {}),
1601
1788
  ...(existingEntry?.prevVersion ? { prevVersion: existingEntry.prevVersion } : {}),
1789
+ ...(workdir ? { workdir } : {}),
1602
1790
  ...(dshHome ? { dshHome } : {}), ...(icon !== 'terminal' ? { icon } : {}) }
1603
1791
  if (index >= 0) presets[index] = entry
1604
1792
  else presets.push(entry)
@@ -1617,6 +1805,7 @@ async function removeLaunchPresetNow(id) {
1617
1805
  if (next.length === presets.length) throw new Error('这个启动项已不存在,请刷新后重试')
1618
1806
  // 删除快捷入口不改实例端口,避免影响同一组合的手动启动设置。
1619
1807
  await saveSettings({ launchPresets: next })
1808
+ dropLaunchLog(id)
1620
1809
  await emitState()
1621
1810
  return { ok: true }
1622
1811
  }
@@ -1669,6 +1858,15 @@ async function saveManagerSettings(body) {
1669
1858
  throw new Error('正在确认修复或回退,结束后再修改启动设置。')
1670
1859
  }
1671
1860
  let systemPathResult
1861
+ // 系统 node 是路径/命令,保存前先确认真的找得到、而且跑得起来 —— 否则要等下次启动 dsh 才发现选了个空
1862
+ if ('systemNodePath' in body) {
1863
+ const path = safeSystemNodePath(body.systemNodePath)
1864
+ if (path) {
1865
+ const found = findSystemNode(path)
1866
+ if (!found) throw new Error(`找不到这个 node:${path}`)
1867
+ if (!nodeVersionOf(found)) throw new Error(`这个文件不是能用的 node:${found}`)
1868
+ }
1869
+ }
1672
1870
  if (body.dataDir) {
1673
1871
  const dir = safeDataDir(body.dataDir)
1674
1872
  if (dir !== DATA && (instances.size || startingProfiles.size)) throw new Error('请先停止再改版本目录')
@@ -1686,6 +1884,8 @@ async function saveManagerSettings(body) {
1686
1884
  ...('proxyUrl' in body ? { proxyUrl: safeProxyUrl(body.proxyUrl) } : {}),
1687
1885
  ...('openMode' in body ? { openMode: safeOpenMode(body.openMode) } : {}),
1688
1886
  ...('systemPath' in body ? { systemPath: body.systemPath === true } : {}),
1887
+ ...('nodeRuntime' in body ? { nodeRuntime: safeNodeRuntime(body.nodeRuntime) } : {}),
1888
+ ...('systemNodePath' in body ? { systemNodePath: safeSystemNodePath(body.systemNodePath) } : {}),
1689
1889
  ...('dshHome' in body ? { dshHome: safeDshHome(body.dshHome) } : {}),
1690
1890
  ...('webBind' in body ? { webBind: safeWebBind(body.webBind) } : {}),
1691
1891
  ...('lang' in body ? { lang: safeLang(body.lang) } : {}),
@@ -1695,6 +1895,15 @@ async function saveManagerSettings(body) {
1695
1895
  ...('reduceMotion' in body ? { reduceMotion: body.reduceMotion === true } : {}),
1696
1896
  ...('hideBackground' in body ? { hideBackground: body.hideBackground === true } : {}),
1697
1897
  ...('hideBigFish' in body ? { hideBigFish: body.hideBigFish === true } : {}),
1898
+ ...('hardwareAcceleration' in body ? { hardwareAcceleration: body.hardwareAcceleration !== false } : {}),
1899
+ ...('autoUpdate' in body ? { autoUpdate: body.autoUpdate === true } : {}),
1900
+ ...('taskNotifications' in body ? { taskNotifications: body.taskNotifications !== false } : {}),
1901
+ ...('notificationSound' in body ? { notificationSound: body.notificationSound !== false } : {}),
1902
+ ...('closeToTray' in body ? { closeToTray: body.closeToTray !== false } : {}),
1903
+ ...('keepAwake' in body ? { keepAwake: body.keepAwake === true } : {}),
1904
+ ...('browserControl' in body ? { browserControl: body.browserControl !== false } : {}),
1905
+ ...('browserIgnoreCertificate' in body ? { browserIgnoreCertificate: body.browserIgnoreCertificate === true } : {}),
1906
+ ...('contextMenu' in body ? { contextMenu: body.contextMenu !== false } : {}),
1698
1907
  ...('autoStart' in body ? { autoStart: Boolean(body.autoStart) } : {}),
1699
1908
  ...('pluginMarketSources' in body ? { pluginMarketSources: safePluginMarketSources(body.pluginMarketSources) } : {}),
1700
1909
  ...('autoDisablePlugins' in body ? { autoDisablePlugins: body.autoDisablePlugins !== false } : {}),
@@ -1758,11 +1967,59 @@ async function saveManagerSettings(body) {
1758
1967
  pushLog(`dsh 用户目录改为 ${home}${next ? '' : '(默认位置)'};重启 dsh 后生效`)
1759
1968
  }
1760
1969
  }
1970
+ if ('nodeRuntime' in body || 'systemNodePath' in body) {
1971
+ // 刚改过路径:版本缓存作废,页面下一次读设置就能看到新值
1972
+ nodeVersionCache.clear()
1973
+ const view = nodeRuntimeView(stored)
1974
+ pushLog(view.configured === 'system'
1975
+ ? `跑 dsh 的 node 改为系统 Node(${view.system?.path || '没找到'});重启启动器后生效`
1976
+ : '跑 dsh 的 node 改回启动器自带的 Node;重启启动器后生效')
1977
+ }
1761
1978
  if ('autoCleanVersions' in body) {
1762
1979
  pushLog(stored.autoCleanVersions
1763
1980
  ? '装新版本后自动清理旧版本'
1764
1981
  : '不再自动清理旧版本(已装版本全部保留)')
1765
1982
  }
1983
+ if ('taskNotifications' in body) {
1984
+ // 原生通知总开关:外壳按它拦发不发,这里再把原生通知的 loader 行一并停掉 / 恢复。
1985
+ // 处理当前目录与登记过的目录;没登记过的 DSH_HOME 管不到。
1986
+ const current = homeDir()
1987
+ const homes = [...new Set([current, ...safeDshHomes(stored.dshHomes)])]
1988
+ const changed = []
1989
+ for (const home of homes) {
1990
+ try {
1991
+ const result = applyNativeNotify(home, stored.taskNotifications !== false)
1992
+ for (const name of result.profiles) changed.push(home === current ? name : `${name}(${home})`)
1993
+ for (const failure of result.failed) pushLog(`原生通知开关:${failure.profile} 没改成功(${failure.error})`)
1994
+ } catch (error) {
1995
+ pushLog(`原生通知开关:${home} 处理失败(${error instanceof Error ? error.message : error})`)
1996
+ }
1997
+ }
1998
+ pushLog(stored.taskNotifications !== false
1999
+ ? `原生通知已开启${changed.length ? `:${changed.join('、')},重启 dsh 后生效` : '(没有环境装过原生通知)'}`
2000
+ : `原生通知已关闭${changed.length ? `:${changed.join('、')} 已停用,重启 dsh 后生效` : '(没有环境装过原生通知)'}`)
2001
+ }
2002
+ if ('hardwareAcceleration' in body) {
2003
+ pushLog(stored.hardwareAcceleration ? 'Chrome 硬件加速已开启,重启启动器后生效' : 'Chrome 硬件加速已关闭,重启启动器后生效')
2004
+ }
2005
+ if ('keepAwake' in body) pushLog(stored.keepAwake ? '已阻止系统空闲休眠' : '不再阻止系统空闲休眠')
2006
+ if ('browserControl' in body) {
2007
+ BROWSER_CONTROL = stored.browserControl !== false
2008
+ pushLog(BROWSER_CONTROL
2009
+ ? '内置浏览器已启用:启动新的 dsh 实例后,右侧栏浏览器可用'
2010
+ : '内置浏览器已关闭:启动新的 dsh 实例后不再提供右侧栏浏览器')
2011
+ }
2012
+ if ('browserIgnoreCertificate' in body) {
2013
+ pushLog(stored.browserIgnoreCertificate
2014
+ ? '内置浏览器不再校验 HTTPS 证书(只影响内置浏览器,重启启动器后生效)'
2015
+ : '内置浏览器恢复 HTTPS 证书校验(重启启动器后生效)')
2016
+ }
2017
+ if ('contextMenu' in body) {
2018
+ CONTEXT_MENU = stored.contextMenu !== false
2019
+ pushLog(CONTEXT_MENU
2020
+ ? '右键菜单已启用:启动新的 dsh 实例后生效'
2021
+ : '右键菜单已关闭:启动新的 dsh 实例后不再加载')
2022
+ }
1766
2023
  // profile 立即生效:插件页、启动参数、npmrc 都读这个变量(已经在跑的 dsh 不受影响)
1767
2024
  EXTRA_ARGS = composeExtraArgs(stored.args)
1768
2025
  WEB_BIND = safeWebBind(stored.webBind)
@@ -1806,6 +2063,11 @@ export function dshEnv(version, options = {}) {
1806
2063
  DSH_PROFILE: PROFILE_NAME,
1807
2064
  // 只给官方市场 schema 提供缺省值;用户保存过的 indexes(包括空数组)始终优先。
1808
2065
  DSH_X_PLUGIN_MARKET_SOURCES: JSON.stringify(safePluginMarketSources(loadSettingsSync().pluginMarketSources)),
2066
+ // 插件桌面 API 的发现信息:管理页端口每次启动可能顺延,插件不许硬编码 3780。
2067
+ // DSH_X_DESKTOP 只在真外壳接上 host.desktop 时给(浏览器模式 / 旧外壳没有),
2068
+ // 插件据此静默降级,不必先发一次注定 503 的请求。
2069
+ DSH_X_MANAGER_URL: `http://127.0.0.1:${PORT}`,
2070
+ ...(host.desktop ? { DSH_X_DESKTOP: '1' } : {}),
1809
2071
  // 只有「必须被 dsh 起的 worker 线程继承」的东西留在这里:worker 的 execArgv 是空的,
1810
2072
  // 命令行参数传不进去,只能靠 NODE_OPTIONS(路径写法见 nodeOptionsPath)。
1811
2073
  // dsh 自己要的那几个开关(--use-system-ca、--max-http-header-size、--import 钩子)走命令行,
@@ -2085,6 +2347,117 @@ export function withBundledRuntime(pathValue, preferredPnpm = '') {
2085
2347
  return orderRuntimePaths(parts, dir, preferredPnpm).join(delimiter)
2086
2348
  }
2087
2349
 
2350
+ /**
2351
+ * 在 PATH(或用户填的路径)里找系统 node。显式路径优先;只填命令名时按 PATH 找。
2352
+ * 返回路径或空串 —— 设置页据此说明「没找到系统 Node」。可注入 env/platform/exists 便于测试。
2353
+ */
2354
+ export function findSystemNode(explicit = '', options = {}) {
2355
+ const env = options.env || process.env
2356
+ const platform = options.platform || process.platform
2357
+ const exists = options.exists || existsSync
2358
+ const name = platform === 'win32' ? 'node.exe' : 'node'
2359
+ const pathDelimiter = platform === 'win32' ? ';' : ':'
2360
+ const dirs = String(env.PATH || '').split(pathDelimiter)
2361
+ .map((dir) => dir.trim().replace(/^"(.*)"$/, '$1'))
2362
+ .filter(Boolean)
2363
+ const given = String(explicit || '').trim().replace(/^"(.*)"$/, '$1').trim()
2364
+ if (given) {
2365
+ if (exists(given)) return given
2366
+ // 只填了命令名(没带路径分隔符):按 PATH 找。Windows 上还要补 PATHEXT 那几种后缀
2367
+ if (!/[\\/]/.test(given)) {
2368
+ const names = platform === 'win32' && !/\.[a-z0-9]+$/i.test(given)
2369
+ ? [given, `${given}.exe`, `${given}.cmd`, `${given}.bat`]
2370
+ : [given]
2371
+ for (const dir of dirs) {
2372
+ for (const name of names) {
2373
+ const file = join(dir, name)
2374
+ if (exists(file)) return file
2375
+ }
2376
+ }
2377
+ }
2378
+ return ''
2379
+ }
2380
+ for (const dir of dirs) {
2381
+ const file = join(dir, name)
2382
+ if (exists(file)) return file
2383
+ }
2384
+ return ''
2385
+ }
2386
+
2387
+ const nodeVersionCache = new Map()
2388
+
2389
+ /** 问一个 node 可执行文件的版本(`v24.9.0` → `24.9.0`);跑不起来返回空串(失败不缓存,换文件后还能重问)。 */
2390
+ export function nodeVersionOf(file) {
2391
+ if (!file) return ''
2392
+ if (nodeVersionCache.has(file)) return nodeVersionCache.get(file)
2393
+ let version = ''
2394
+ try {
2395
+ const out = spawnSync(file, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true })
2396
+ const match = /^v?(\d+\.\d+\.\d+)/.exec(String(out.stdout || '').trim())
2397
+ if (match) version = match[1]
2398
+ } catch { /* 不是 node / 跑不起来 */ }
2399
+ if (version) nodeVersionCache.set(file, version)
2400
+ return version
2401
+ }
2402
+
2403
+ /** dsh 的 engines.node 只认 `>=x.y.z`(官方包就是这么写的);别的范围返回空串,不拿它下结论。 */
2404
+ export function parseNodeRange(range) {
2405
+ const match = /^>=\s*(\d+\.\d+\.\d+)$/.exec(String(range || '').trim())
2406
+ return match ? match[1] : ''
2407
+ }
2408
+
2409
+ /**
2410
+ * 已装版本里最高的 Node 要求(dsh 的 package.json engines.node)。系统 node 低于它时设置页要
2411
+ * 提醒:dsh 自己不拦版本,报错会出现在别处,很难看出是 node 太老。
2412
+ */
2413
+ function nodeRequirementForInstalled() {
2414
+ let best = ''
2415
+ for (const version of scanInstalled()) {
2416
+ try {
2417
+ const file = join(dirname(binPath(version)), '..', 'package.json')
2418
+ const min = parseNodeRange(JSON.parse(readFileSync(file, 'utf8')).engines?.node)
2419
+ if (min && (!best || cmpVer(parseVer(min), parseVer(best)) > 0)) best = min
2420
+ } catch { /* 读不到就不算 */ }
2421
+ }
2422
+ return best
2423
+ }
2424
+
2425
+ /**
2426
+ * 设置页「运行 dsh 的 Node」要显示的一切:配置值、现在生效的那个、系统里找到的那个。
2427
+ *
2428
+ * 「生效」在 Electron 里看 RUNTIME_NODE_ENV:只有落到 Electron 本体时才带 ELECTRON_RUN_AS_NODE。
2429
+ * 源码运行(node start.js)时 dsh 就是用启动它的那个 node,这个设置没有意义(selectable=false)。
2430
+ */
2431
+ export function nodeRuntimeView(stored) {
2432
+ const configured = safeNodeRuntime(stored.nodeRuntime)
2433
+ const configuredPath = safeSystemNodePath(stored.systemNodePath)
2434
+ const systemPath = findSystemNode(configuredPath)
2435
+ const system = systemPath ? { path: systemPath, version: nodeVersionOf(systemPath) } : null
2436
+ const bundled = { path: process.execPath, version: process.versions.node }
2437
+ const bundledActive = IN_ELECTRON && Boolean(RUNTIME_NODE_ENV.ELECTRON_RUN_AS_NODE)
2438
+ const effective = !IN_ELECTRON
2439
+ ? { kind: 'system', path: process.execPath, version: process.versions.node }
2440
+ : bundledActive
2441
+ ? { kind: 'bundled', path: process.execPath, version: process.versions.node }
2442
+ : { kind: 'system', path: RUNTIME_NODE, version: nodeVersionOf(RUNTIME_NODE) }
2443
+ const requirement = nodeRequirementForInstalled()
2444
+ const pending = IN_ELECTRON && (configured !== effective.kind
2445
+ || (configured === 'system' && system && !fileManager.samePath(effective.path, system.path)))
2446
+ const below = (version) => Boolean(requirement && version && cmpVer(parseVer(version), parseVer(requirement)) < 0)
2447
+ return {
2448
+ selectable: IN_ELECTRON,
2449
+ configured,
2450
+ configuredPath,
2451
+ bundled,
2452
+ system,
2453
+ effective,
2454
+ pending,
2455
+ requirement,
2456
+ systemTooOld: below(system?.version),
2457
+ effectiveTooOld: below(effective.version),
2458
+ }
2459
+ }
2460
+
2088
2461
  function assertLauncherIdle() {
2089
2462
  if (instances.size || startingProfiles.size) throw new Error('请先停止正在运行的 DSH,再安装整合包')
2090
2463
  }
@@ -2112,6 +2485,116 @@ function profileDir() {
2112
2485
  return profileDirOf(PROFILE_NAME)
2113
2486
  }
2114
2487
 
2488
+ // ---- profile 配置层备份(每次启动前快照,出事了能回退) ----
2489
+ // 为什么不整目录复制:profile 里的 node_modules 动辄几百 MB,每次启动都复制不现实;
2490
+ // 真正会被改坏、回退时值得救的是配置层(补丁层、依赖清单、npmrc),加起来几十 KB。
2491
+ // 备份放在数据目录 backups/profiles/<用户目录指纹>/<profile>/<时间戳>/,跟数据一起走;
2492
+ // 多个 dsh 用户目录里同名 profile 靠指纹分开,互不覆盖。
2493
+ const PROFILE_BACKUP_FILES = ['cordis.patch.yml', 'package.json', 'pnpm-lock.yaml', '.npmrc']
2494
+ const PROFILE_BACKUP_KEEP = 10
2495
+ const PROFILE_BACKUP_ID = /^\d{8}-\d{6}(?:-\d+)?$/
2496
+
2497
+ function homeStamp(home = homeDir()) {
2498
+ return createHash('sha1').update(IS_WINDOWS ? home.toLowerCase() : home).digest('hex').slice(0, 12)
2499
+ }
2500
+
2501
+ function profileBackupRoot(profile, home = homeDir()) {
2502
+ return join(DATA, 'backups', 'profiles', homeStamp(home), safeProfile(profile))
2503
+ }
2504
+
2505
+ function profileBackupTimestamp(now = new Date()) {
2506
+ const pad = (value) => String(value).padStart(2, '0')
2507
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
2508
+ }
2509
+
2510
+ function readBackupManifest(dir) {
2511
+ try {
2512
+ const manifest = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8'))
2513
+ if (!manifest || !Array.isArray(manifest.files)) return null
2514
+ return manifest
2515
+ } catch { return null }
2516
+ }
2517
+
2518
+ /** 最近的排前面。目录名是时间戳,但 createdAt 才是权威(手改目录名也不会乱序)。 */
2519
+ function listProfileBackups(profile, home = homeDir()) {
2520
+ const root = profileBackupRoot(profile, home)
2521
+ if (!existsSync(root)) return []
2522
+ return readdirSync(root, { withFileTypes: true })
2523
+ .filter((entry) => entry.isDirectory() && PROFILE_BACKUP_ID.test(entry.name))
2524
+ .map((entry) => ({ id: entry.name, ...readBackupManifest(join(root, entry.name)) }))
2525
+ .filter((item) => item.files && item.createdAt)
2526
+ .sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))
2527
+ }
2528
+
2529
+ function profileConfigSnapshot(profileDirPath) {
2530
+ const files = []
2531
+ for (const name of PROFILE_BACKUP_FILES) {
2532
+ const file = join(profileDirPath, name)
2533
+ if (!existsSync(file)) continue
2534
+ try { files.push({ name, content: readFileSync(file) }) } catch { /* 读不了就跳过,别拦启动 */ }
2535
+ }
2536
+ const hash = createHash('sha256')
2537
+ for (const file of files) hash.update(file.name).update('\0').update(file.content)
2538
+ return { files, signature: hash.digest('hex') }
2539
+ }
2540
+
2541
+ async function rotateProfileBackups(profile, home = homeDir()) {
2542
+ const root = profileBackupRoot(profile, home)
2543
+ for (const stale of listProfileBackups(profile, home).slice(PROFILE_BACKUP_KEEP)) {
2544
+ await rm(join(root, stale.id), { recursive: true, force: true })
2545
+ }
2546
+ }
2547
+
2548
+ /**
2549
+ * 给 profile 的配置层拍一份快照;内容跟最近一份一样就不重复存(每次启动都会调用它)。
2550
+ * force 用在回退之前:那份「回退前」的备份即使内容没变也要留着,回退本身才可逆。
2551
+ */
2552
+ async function snapshotProfileConfig(profile, { force = false } = {}) {
2553
+ const name = safeProfile(profile || PROFILE_NAME)
2554
+ const dir = profileDirOf(name)
2555
+ if (!existsSync(join(dir, 'package.json'))) return null // 还没初始化,没什么可救
2556
+ const { files, signature } = profileConfigSnapshot(dir)
2557
+ if (!files.length) return null
2558
+ const home = homeDir()
2559
+ const root = profileBackupRoot(name, home)
2560
+ const latest = listProfileBackups(name, home)[0]
2561
+ if (!force && latest?.signature === signature) return null
2562
+ await mkdir(root, { recursive: true })
2563
+ const stamp = profileBackupTimestamp()
2564
+ let id = stamp
2565
+ for (let index = 2; existsSync(join(root, id)); index += 1) id = `${stamp}-${index}`
2566
+ const target = join(root, id)
2567
+ await mkdir(target)
2568
+ for (const file of files) await writeFile(join(target, file.name), file.content)
2569
+ const manifest = { profile: name, home, createdAt: Date.now(), signature, files: files.map((file) => ({ name: file.name, size: file.content.length })) }
2570
+ await writeFile(join(target, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
2571
+ await rotateProfileBackups(name, home)
2572
+ return { id, ...manifest }
2573
+ }
2574
+
2575
+ /** 回退到某份配置快照:先给当前状态再存一份,所以回退本身也能再退回来。 */
2576
+ async function restoreProfileConfig(profile, id) {
2577
+ const name = safeProfile(profile || PROFILE_NAME)
2578
+ if (!PROFILE_BACKUP_ID.test(String(id || ''))) throw new Error('备份编号不对')
2579
+ const home = homeDir()
2580
+ const root = profileBackupRoot(name, home)
2581
+ const source = join(root, String(id))
2582
+ const manifest = readBackupManifest(source)
2583
+ if (!manifest) throw new Error('找不到这份备份')
2584
+ if (!manifest.files.some((file) => file.name === 'package.json')) throw new Error('这份备份里没有 package.json,回退会让 Profile 起不来')
2585
+ if (instanceList().some((proc) => proc.profile === name && ['starting', 'running', 'stopping'].includes(proc.status))) throw new Error('请先停止这个 Profile 的实例再回退')
2586
+ const dir = profileDirOf(name)
2587
+ const saved = await snapshotProfileConfig(name, { force: true })
2588
+ for (const file of PROFILE_BACKUP_FILES) {
2589
+ const target = join(dir, file)
2590
+ const included = manifest.files.some((entry) => entry.name === file)
2591
+ if (included) await writeFile(target, readFileSync(join(source, file)))
2592
+ else if (existsSync(target)) await rm(target, { force: true })
2593
+ }
2594
+ pushLog(`[备份] Profile ${name} 回退到 ${id}${saved ? `(回退前已存一份 ${saved.id})` : ''}`)
2595
+ return { ok: true, restored: String(id), backup: saved }
2596
+ }
2597
+
2115
2598
  /**
2116
2599
  * 当前是否按局域网绑定:设置里选了局域网,或远程访问插件的「局域网访问」开关
2117
2600
  * 开着。两者都是用户明确的局域网意图,任一条成立启动器就不再注入 --host。
@@ -2316,11 +2799,70 @@ function profileBootsWebIn(home, name) {
2316
2799
  * @param {boolean} alongside
2317
2800
  * @param {number} pinned 手工固定或自动记忆的端口(0 = 首次,由系统挑)
2318
2801
  */
2802
+ /**
2803
+ * 右侧栏浏览器的覆盖补丁:外壳已经提供原生 webview 载体(见 shell/browser-guests.mjs),
2804
+ * 这里只把官方 `ui-sidebar-browser` 行从 web-app bundle 的「非 desktop profile 禁用」里放开。
2805
+ * 走 `--patch` 叠加层,不改用户的 profile 文件,也不要求 profile 改名叫 desktop。
2806
+ *
2807
+ * 「启用内置浏览器控制」管的是 agent 那一半(dsh-x-sidebar-browser):侧栏 Browser 本身照旧,
2808
+ * 用户手动看网页不受影响,只是 agent 不能再操作它。关掉时给已装的插件补一行 disabled: true
2809
+ * (没装就不写——不存在的行没必要出现在补丁里)。
2810
+ */
2811
+ const SIDEBAR_BROWSER_PATCH_HEADER = '# 由 DSH-X 外壳生成:右侧栏浏览器(原生 webview 载体见 shell/browser-guests.mjs)。'
2812
+ /** 让 agent 操作侧栏 Browser 的首方插件;ensureDesktopPlugins 也按同一个开关决定补不补装。 */
2813
+ const AGENT_BROWSER_PLUGIN = 'dsh-x-sidebar-browser'
2814
+
2815
+ /** profile 的清单里有没有这个插件(依赖或 bundle 列表任一命中,link: / file: 也算装)。 */
2816
+ function profileHasPlugin(profile, name) {
2817
+ try {
2818
+ const manifest = JSON.parse(readFileSync(join(profileDirOf(profile), 'package.json'), 'utf8'))
2819
+ const dependencies = manifest?.dependencies
2820
+ const bundles = manifest?.dsh?.profile?.bundles
2821
+ return (dependencies && Object.hasOwn(dependencies, name)) || (Array.isArray(bundles) && bundles.includes(name))
2822
+ } catch {
2823
+ return false
2824
+ }
2825
+ }
2826
+
2827
+ function sidebarBrowserPatchText(profile, enabled) {
2828
+ const disableAgent = !enabled && profileHasPlugin(profile, AGENT_BROWSER_PLUGIN)
2829
+ // 右键菜单的开关也搭同一趟 --patch:只在装过插件时写停用行,别给没装的环境留一条
2830
+ // 匹配不到的补丁(dsh 会对每条不匹配的补丁打一行警告)。
2831
+ const disableContextMenu = !CONTEXT_MENU && profileHasPlugin(profile, CONTEXT_MENU_PLUGIN)
2832
+ return `${SIDEBAR_BROWSER_PATCH_HEADER}\n- id: ui-sidebar-browser\n disabled: false\n`
2833
+ + (disableAgent ? `- id: ${AGENT_BROWSER_PLUGIN}\n disabled: true\n` : '')
2834
+ + (disableContextMenu ? `- id: ${CONTEXT_MENU_ROW_ID}\n disabled: true\n` : '')
2835
+ }
2836
+
2837
+ function sidebarBrowserPatchArgs(profile) {
2838
+ const enabled = BROWSER_CONTROL
2839
+ // desktop profile 默认就放开 ui-sidebar-browser,也不装首方插件:浏览器和右键菜单都开着时
2840
+ // 没有要覆盖的;任一个关着就得写补丁(右键菜单关掉在 desktop profile 上同样要生效)。
2841
+ if (enabled && CONTEXT_MENU && String(profile ?? '').toLowerCase() === 'desktop') return []
2842
+ try {
2843
+ const file = join(DATA, 'shell', 'sidebar-browser.patch.yml')
2844
+ mkdirSync(dirname(file), { recursive: true })
2845
+ let current = ''
2846
+ try {
2847
+ current = readFileSync(file, 'utf8')
2848
+ } catch { /* 还没有:写一份 */ }
2849
+ const text = sidebarBrowserPatchText(profile, enabled)
2850
+ if (current !== text) writeFileSync(file, text)
2851
+ return ['--patch', file]
2852
+ } catch (error) {
2853
+ pushLog(`右侧栏浏览器补丁写不出来:${error instanceof Error ? error.message : error}`)
2854
+ return []
2855
+ }
2856
+ }
2857
+
2319
2858
  function bootArgs(profile = PROFILE_NAME, alongside = false, pinned = 0) {
2320
2859
  // 不起 web 的 profile(headless / acp / sdk…)不提供 HTTP 服务,也不认这几个 flag:
2321
2860
  // 参数是透传给 profile 对应 app 的,会被它自己的 commander 打回 `unknown option`
2322
2861
  // 并 exit 1(`dsh headless --no-open` 就是这条),所以只对会起 web 的注入
2323
2862
  if (!bootsWebApp(profile)) return [profile, ...EXTRA_ARGS]
2863
+ // profile 名必须排在第一个;launcher 自己的 flag(--patch 等)放在 profile 之后、
2864
+ // app 参数之前(见 dsh 的 `dsh tui --patch ./extra.yml` 示例)。
2865
+ const overlay = sidebarBrowserPatchArgs(profile)
2324
2866
  const port = pinned > 0 ? String(pinned) : '0'
2325
2867
  if (lanBindActive(WEB_BIND, lanBindToggleOn(homeDir(), profile))) {
2326
2868
  // 局域网:--host 不传。CLI 硬禁 --host 0.0.0.0,绑定只能走配置层(远程插件的
@@ -2328,14 +2870,14 @@ function bootArgs(profile = PROFILE_NAME, alongside = false, pinned = 0) {
2328
2870
  // 把插件钉好的端口抹成随机值,所以端口也交给配置层决定。
2329
2871
  // 两个例外,都是「不显式给端口就出事」:多开时配置层里只有一个端口,第二个实例
2330
2872
  // 撞上去就是 EADDRINUSE;用户自己钉了端口,就是要它——这两种情况显式传 --port。
2331
- if (!pinned && !alongside) return [profile, '--no-open', ...EXTRA_ARGS]
2332
- return [profile, '--port', port, '--no-open', ...EXTRA_ARGS]
2873
+ if (!pinned && !alongside) return [profile, ...overlay, '--no-open', ...EXTRA_ARGS]
2874
+ return [profile, ...overlay, '--port', port, '--no-open', ...EXTRA_ARGS]
2333
2875
  }
2334
2876
  // 默认姿势:钉死回环 + 端口(首次 --port 0,让 OS 现挑一个;dsh 会把真实地址
2335
2877
  // 打出来,启动器读它,所以顺延也不影响页面拿到链接)。
2336
2878
  // 额外参数放最后:用户可以用它覆盖 --port 之类(启动器是从 dsh 的输出里读真实地址的,
2337
2879
  // 所以换个端口也不影响管理页拿到的链接)
2338
- return [profile, '--host', '127.0.0.1', '--port', port, '--no-open', ...EXTRA_ARGS]
2880
+ return [profile, ...overlay, '--host', '127.0.0.1', '--port', port, '--no-open', ...EXTRA_ARGS]
2339
2881
  }
2340
2882
 
2341
2883
  /** 这个「版本 × profile」手工固定的端口(0 = 自动分配并记忆)。 */
@@ -2594,6 +3136,12 @@ function spawnDsh(version, extra, profile = '', options = {}) {
2594
3136
  // 统一用 RUNTIME_NODE 拉起 dsh(打包后就是 Electron 自带的 Node,见 platform.js);#61 的诊断
2595
3137
  // 上下文仍要在这之前建好 env。
2596
3138
  const env = dshEnv(version, options)
3139
+ // 启动 CPU 账本:只有真正拉起实例(bootOnce)时才写报告,插件命令 / 修复命令不写,
3140
+ // 否则会用一次半截运行覆盖掉上一次正常启动的样本。报告按「版本@profile」落盘。
3141
+ if (options.pluginProfile) {
3142
+ env.DSH_X_PLUGIN_PROFILE = `${version}@${profile || PROFILE_NAME}`
3143
+ env.DSH_X_PLUGIN_PROFILE_DIR = join(APP_DIR, 'plugin-profile')
3144
+ }
2597
3145
  if (options.diagnosticContext) {
2598
3146
  env.DSH_X_DIAGNOSTIC_ATTEMPT = options.diagnosticContext.attemptId
2599
3147
  // 取本次实际传入的入口和环境存在性,不扫描另一套安装,也不记录变量值。
@@ -2606,7 +3154,10 @@ function spawnDsh(version, extra, profile = '', options = {}) {
2606
3154
  })
2607
3155
  }
2608
3156
  return spawn(RUNTIME_NODE, dshArgs(version, extra, profile), {
2609
- cwd: home,
3157
+ // 项目环境(见 launchWorkdir):子进程在项目根里跑,dsh 的默认工作目录就是 process.cwd(),
3158
+ // 新会话因此落在项目根;数据目录仍是 <项目>/.dsh(DSH_HOME 是绝对路径,不受 cwd 影响)。
3159
+ // 其它情况跟以前一样钉在 DSH_HOME —— 插件命令、修复脚本都靠它。
3160
+ cwd: options.cwd || home,
2610
3161
  env,
2611
3162
  stdio: ['ignore', 'pipe', 'pipe'],
2612
3163
  windowsHide: true,
@@ -2695,10 +3246,96 @@ async function checkSelfUpdate({ force = false } = {}) {
2695
3246
  let stagedApp = null
2696
3247
  let pendingAppUpdate = null
2697
3248
  let appUpdateCache = { at: 0, data: null }
3249
+ /** 下载可能被连点或两个标签页同时发起:并到同一份,应用只允许开一次(换了目录进程就退出了)。 */
3250
+ let appDownloadInFlight = null
3251
+ let appApplyStarted = false
2698
3252
  /** 完整安装包自更新的测试通道:DSH_UPDATE_VERSION 当最新版本。 */
2699
3253
  const TEST_UPDATE_VERSION = String(process.env.DSH_UPDATE_VERSION || '').trim().replace(/^v/i, '')
2700
3254
  const APP_UPDATE_CACHE_MS = 30 * 60 * 1000
2701
3255
 
3256
+ /** 下载核心增量包到暂存目录(自动更新与页面共用;并发由 appDownloadInFlight 合并)。 */
3257
+ async function downloadAppUpdate() {
3258
+ const data = await checkAppUpdate({ force: true })
3259
+ if (!data.available) {
3260
+ throw new Error(data.reason === 'electron' ? 'Electron 版本不同,需要完整安装包' : '当前没有可用的核心更新')
3261
+ }
3262
+ if (stagedApp && stagedApp.version === data.version && stagingExists(stagedApp.dir)) {
3263
+ return { ok: true, version: stagedApp.version, reused: true }
3264
+ }
3265
+ const tarball = join(tmpdir(), `DSH-X-core-${data.version}-${process.pid}.tgz`)
3266
+ const result = await downloadCoreTarball(pendingAppUpdate.tarball, tarball, {
3267
+ integrity: pendingAppUpdate.integrity,
3268
+ shasum: pendingAppUpdate.shasum,
3269
+ onProgress: ({ done, total }) => emit('selfUpdate', { phase: 'download', kind: 'app', done, total }),
3270
+ })
3271
+ const staging = join(tmpdir(), `DSH-X-core-${data.version}-${process.pid}`)
3272
+ try {
3273
+ const pkg = extractCoreTarball(tarball, staging)
3274
+ if (String(pkg.version) !== String(data.version)) throw new Error(`核心包版本不一致:${pkg.version} != ${data.version}`)
3275
+ } finally {
3276
+ rmSync(tarball, { force: true })
3277
+ }
3278
+ stagedApp = { version: data.version, dir: staging }
3279
+ pushLog(`核心更新已就绪:${data.version}(${(result.bytes / 1048576).toFixed(1)} MB),等用户确认后替换`)
3280
+ emit('selfUpdate', { phase: 'app-ready', latest: data.version })
3281
+ return { ok: true, version: data.version }
3282
+ }
3283
+
3284
+ /**
3285
+ * 换上暂存的核心并重启外壳。调用前由路由确认过「只开一次」;成功时进程很快会退出,
3286
+ * 所以返回值只是给调用方一个响应。
3287
+ */
3288
+ async function applyAppUpdate() {
3289
+ if (!stagedApp || !stagingExists(stagedApp.dir)) throw new Error('还没有下载好的应用更新')
3290
+ const stats = stagingStats(stagedApp.dir)
3291
+ if (stats.files < 100) throw new Error('暂存目录不完整,重新下载一次')
3292
+ const activeCore = join(APP_DIR, 'core', 'current')
3293
+ if (fileManager.samePath(ROOT, activeCore)) {
3294
+ // 已经在用户目录:助手等外壳退出 → 备份 → 换入 → 重启 → 等 update-ok,失败回滚
3295
+ const helper = join(tmpdir(), `dsh-x-apply-${process.pid}-${randomBytes(4).toString('hex')}.mjs`)
3296
+ copyFileSync(join(ROOT, 'apply-update.mjs'), helper)
3297
+ const child = spawn(RUNTIME_NODE, [helper,
3298
+ '--app', ROOT,
3299
+ '--staged', stagedApp.dir,
3300
+ // AppImage 的 process.execPath 是挂载目录里的临时二进制,重启要用 APPIMAGE 本身
3301
+ '--exe', process.env.APPIMAGE || process.execPath,
3302
+ '--pid', String(process.pid),
3303
+ '--backup', `${ROOT}.bak-${APP_VERSION}`,
3304
+ '--marker', join(APP_DIR, 'update-ok'),
3305
+ '--log', join(APP_DIR, 'update.log'),
3306
+ ], {
3307
+ cwd: tmpdir(),
3308
+ env: { ...process.env, ...RUNTIME_NODE_ENV },
3309
+ detached: true,
3310
+ stdio: 'ignore',
3311
+ windowsHide: true,
3312
+ })
3313
+ child.unref()
3314
+ pushLog(`核心更新:替换助手已启动(${stagedApp.version}),启动器即将退出`)
3315
+ setTimeout(() => shutdown().finally(() => host.exit(0)), 200)
3316
+ return { ok: true, version: stagedApp.version }
3317
+ }
3318
+ // 内置基线:复制到用户目录,写 pending 标记;外壳下次启动优先加载 current,
3319
+ // 起不来(外壳里的 recoverBaseline)会删掉 current 回退到基线
3320
+ const pending = join(APP_DIR, 'core', 'pending')
3321
+ const version = stagedApp.version
3322
+ const stagedDir = stagedApp.dir
3323
+ await rm(activeCore, { recursive: true, force: true })
3324
+ await mkdir(dirname(activeCore), { recursive: true })
3325
+ await cp(stagedDir, activeCore, { recursive: true })
3326
+ stagedApp = null
3327
+ try { rmSync(stagedDir, { recursive: true, force: true }) } catch { /* 临时目录清不掉不影响 */ }
3328
+ writeFileSync(pending, JSON.stringify({ version, at: Date.now() }))
3329
+ pushLog(`核心更新:已复制到 ${activeCore}(${version}),重启外壳`)
3330
+ // 不能直接 spawn DSH.exe:当前进程还占着单实例锁,新进程会立刻退出。
3331
+ // 外壳的 host.relaunch 用 app.relaunch(),等本进程退出后才拉起新外壳。
3332
+ setTimeout(() => shutdown().finally(() => {
3333
+ if (typeof host.relaunch === 'function') host.relaunch()
3334
+ else host.exit(0)
3335
+ }), 200)
3336
+ return { ok: true, version }
3337
+ }
3338
+
2702
3339
  /** 安装器建的两个快捷方式(用户取消勾选时可能不存在)。 */
2703
3340
  function shortcutLinks() {
2704
3341
  const links = []
@@ -2765,12 +3402,13 @@ export async function refreshShortcuts({ installRoot, appDir = APP_DIR, version
2765
3402
  */
2766
3403
  async function checkAppUpdate({ force = false } = {}) {
2767
3404
  if (NPM_MODE) return { available: false, reason: 'npm' }
2768
- if (!PACKAGED || (!IS_WINDOWS && !IS_MAC)) return { available: false, reason: PACKAGED ? 'platform' : 'source' }
3405
+ if (!PACKAGED || (!IS_WINDOWS && !IS_MAC && !IS_LINUX)) return { available: false, reason: PACKAGED ? 'platform' : 'source' }
2769
3406
  if (!force && appUpdateCache.data && Date.now() - appUpdateCache.at < APP_UPDATE_CACHE_MS) return appUpdateCache.data
2770
3407
  const remember = (data) => { appUpdateCache = { at: Date.now(), data }; return data }
2771
3408
  const latest = APP_VERSION
2772
3409
  let result
2773
3410
  try {
3411
+ // 通道跟着当前版本走(正式版只收正式版,rc 版收 rc / alpha),没有单独的通道开关。
2774
3412
  result = await checkCoreUpdate({
2775
3413
  current: APP_VERSION,
2776
3414
  source: UPDATE_SOURCE,
@@ -3287,6 +3925,66 @@ async function addPlugin(version, spec, { profile = PROFILE_NAME } = {}) {
3287
3925
  }
3288
3926
  }
3289
3927
 
3928
+ // ---- 桌面增强:启动前把缺的首方插件补进 profile ----
3929
+ /** 本进程正在自动装插件;和插件页的手动安装共用 addPlugin 的那把锁。 */
3930
+ let desktopPluginInstalling = false
3931
+ /**
3932
+ * 本进程里试过的「home + profile」:一次启动只尝试一轮。
3933
+ *
3934
+ * 失败(多半是离线)不在同一个进程里反复重试——每次点启动都等一遍超时可不行;
3935
+ * 下次启动启动器(新进程)会重新检查。
3936
+ */
3937
+ const desktopPluginsAttempted = new Set()
3938
+
3939
+ /**
3940
+ * 拉起实例前检查 web profile 缺哪个桌面增强插件,缺的用插件页同一条链路装上。
3941
+ *
3942
+ * - 只认「装没装」:用户在 dsh 里关掉的插件不会被这里重新打开;
3943
+ * - 失败只记日志、不拦启动:离线或 registry 抽风不该让 dsh 起不来,下次启动再试;
3944
+ * - 和用户手动安装撞上(pluginBusy)就跳过这一次,别让两条 pnpm 抢同一个目录。
3945
+ *
3946
+ * @param {string} version - 要启动的 dsh 版本。
3947
+ * @param {string} profile - profile 名。
3948
+ */
3949
+ async function ensureDesktopPlugins(version, profile) {
3950
+ if (desktopPluginInstalling) return
3951
+ if (!bootsWebApp(profile)) return
3952
+ if (DESKTOP_PLUGINS_SKIP_PROFILES.includes(String(profile).toLowerCase())) return
3953
+ const attemptKey = `${homeDir()}\0${profile}`
3954
+ if (desktopPluginsAttempted.has(attemptKey)) return
3955
+ let manifest
3956
+ try {
3957
+ manifest = JSON.parse(readFileSync(join(profileDirOf(profile), 'package.json'), 'utf8'))
3958
+ } catch {
3959
+ return
3960
+ }
3961
+ // 「启用内置浏览器控制」关掉时不再补装 agent 工具;「右键菜单」关掉时同样不补装。
3962
+ // 已经装过的由启动补丁停用。
3963
+ const missing = desktopPluginsToInstall(manifest, version)
3964
+ .filter((plugin) => plugin.name !== AGENT_BROWSER_PLUGIN || BROWSER_CONTROL)
3965
+ .filter((plugin) => plugin.name !== CONTEXT_MENU_PLUGIN || CONTEXT_MENU)
3966
+ if (!missing.length) return
3967
+ desktopPluginsAttempted.add(attemptKey)
3968
+ desktopPluginInstalling = true
3969
+ try {
3970
+ for (const plugin of missing) {
3971
+ if (pluginBusy) {
3972
+ pushLog(`桌面增强:${profile} 缺少 ${plugin.label}(${plugin.name}),但插件正在安装,下次启动再补`)
3973
+ continue
3974
+ }
3975
+ pushLog(`桌面增强:${profile} 缺少 ${plugin.label}(${plugin.name}),自动安装`)
3976
+ try {
3977
+ await addPlugin(version, `${plugin.name}@${plugin.spec}`, { profile })
3978
+ pushLog(`桌面增强:${plugin.name} 已装进 ${profile}`)
3979
+ } catch (error) {
3980
+ pushLog(`桌面增强:${plugin.name} 自动安装失败(下次启动再试):${error instanceof Error ? error.message : error}`)
3981
+ }
3982
+ }
3983
+ } finally {
3984
+ desktopPluginInstalling = false
3985
+ }
3986
+ }
3987
+
3290
3988
  // ---- 插件更新:查 registry 上的最新版本,按需升级 profile 里的包 ----
3291
3989
  // 升级复用 addPlugin(peer 404、目录链接读不了这两套回退都在里面),装和升走同一条路。
3292
3990
  const pluginUpdateCache = { at: 0, data: null, profile: '' }
@@ -4096,6 +4794,17 @@ async function terminateProcess(proc) {
4096
4794
  return false
4097
4795
  }
4098
4796
 
4797
+ /**
4798
+ * 任务通知:实例启动失败 / 意外退出。文案由外壳按当前语言拼(外壳知道 lang,
4799
+ * 也掌管 Notification 与提示音开关),这里只给结构化事实。
4800
+ */
4801
+ function notifyInstance(kind, proc, extra = {}) {
4802
+ if (shuttingDown) return
4803
+ try {
4804
+ host.notify({ kind, name: launchNameOf(proc), version: proc.version, profile: proc.profile, ...extra })
4805
+ } catch { /* 通知失败不能影响实例本身 */ }
4806
+ }
4807
+
4099
4808
  function attachProcess(version, profile, child, diagnosticContext) {
4100
4809
  const key = instanceKey(version, profile)
4101
4810
  let ready
@@ -4145,6 +4854,8 @@ function attachProcess(version, profile, child, diagnosticContext) {
4145
4854
  }
4146
4855
  }
4147
4856
  const onLine = (line) => {
4857
+ // 首个输出时间:等待就绪那一段里,从 spawn 到第一行之间通常是 Node 启动与依赖解析
4858
+ if (!proc.firstOutputAt) proc.firstOutputAt = performance.now()
4148
4859
  if (proc.diagnosis.addLine(line)) {
4149
4860
  scheduleDiagnosis()
4150
4861
  }
@@ -4168,6 +4879,8 @@ function attachProcess(version, profile, child, diagnosticContext) {
4168
4879
  scheduleDiagnosis()
4169
4880
  proc.readyAt = performance.now()
4170
4881
  ready()
4882
+ // 正常启动不发通知:用户点了启动就有进度页和窗口,再响一声只是噪音。
4883
+ // 只有失败 / 意外退出(下面那条)才值得打断。
4171
4884
  emitState()
4172
4885
  }
4173
4886
  }
@@ -4191,6 +4904,10 @@ function attachProcess(version, profile, child, diagnosticContext) {
4191
4904
  proc.exit = { code, signal }
4192
4905
  ready()
4193
4906
  pushLog(`${tag}已退出 code=${code ?? '-'} signal=${signal ?? '-'}`)
4907
+ // 没拿到就绪地址 = 启动失败;用户主动停止(stopping)不打扰
4908
+ if (proc.status !== 'stopping') {
4909
+ notifyInstance(proc.url ? 'exited' : 'failed', proc, { code, signal })
4910
+ }
4194
4911
  if (instances.get(key)?.child === child) {
4195
4912
  instances.delete(key)
4196
4913
  lastHealth = null
@@ -4234,7 +4951,7 @@ export async function checkWebPage(origin, token) {
4234
4951
  const base = String(origin).replace(/\/+$/, '')
4235
4952
  const first = await fetch(`${base}/?token=${encodeURIComponent(token)}`, {
4236
4953
  redirect: 'manual',
4237
- signal: AbortSignal.timeout(8000),
4954
+ signal: AbortSignal.timeout(30000), // 刚就绪时 dsh 还在合成客户端插件包,8 秒经常回不来
4238
4955
  })
4239
4956
  const cookie = (first.headers.getSetCookie?.() || []).map((item) => item.split(';')[0]).join('; ')
4240
4957
  const headers = cookie ? { cookie } : {}
@@ -4257,6 +4974,20 @@ export async function checkWebPage(origin, token) {
4257
4974
  return { origin: base, total: urls.length, ok, failed }
4258
4975
  }
4259
4976
 
4977
+ /**
4978
+ * 启动 CPU 账本:正常启动时由 perf/plugin-profile.mjs 在启动窗口内采样写入,这里只读。
4979
+ * 记录不存在时返回 missing,页面据此显示「重启一次后再看」。
4980
+ */
4981
+ async function pluginProfileReport(version, profile) {
4982
+ const file = join(APP_DIR, 'plugin-profile', `${version}@${profile}.json`)
4983
+ try {
4984
+ const report = JSON.parse(await readFile(file, 'utf8'))
4985
+ return { ...report, file }
4986
+ } catch {
4987
+ return { version, profile, missing: true, packages: [], native: [], entries: [] }
4988
+ }
4989
+ }
4990
+
4260
4991
  /**
4261
4992
  * 从 app 页面 HTML 里挑出客户端插件包的地址。
4262
4993
  *
@@ -4303,6 +5034,27 @@ async function selfCheckPage(url, version) {
4303
5034
  }
4304
5035
  }
4305
5036
 
5037
+ /**
5038
+ * 这次启动绑定的项目目录。项目环境是「这套数据目录」的属性,不是某次点击的属性:
5039
+ * 先看这次请求带着哪个启动项,再看有没有哪个启动项把项目目录挂在这个 DSH_HOME 上。
5040
+ * 后者是给手工启动 / 重启 / 自动恢复用的——它们不带启动项 ID,但环境还是那个项目环境。
5041
+ * 目录被用户删掉时不报错:退回老行为照样能起来,只是新会话不在项目根。
5042
+ */
5043
+ function launchWorkdir() {
5044
+ const id = launchScope.getStore()?.launchId
5045
+ const presets = safeLaunchPresets(loadSettingsSync().launchPresets)
5046
+ const entry = id
5047
+ ? presets.find((item) => item.id === id)
5048
+ : presets.find((item) => item.workdir && fileManager.samePath(item.dshHome || '', homeDir()))
5049
+ const dir = entry?.workdir || ''
5050
+ if (!dir) return ''
5051
+ if (!existsSync(dir)) {
5052
+ pushLog(`项目目录 ${dir} 不在了,这次启动按普通独立目录处理(新会话不在项目根)`)
5053
+ return ''
5054
+ }
5055
+ return dir
5056
+ }
5057
+
4306
5058
  /** 起一个 web 子进程并等到它打印就绪 URL;失败时把子进程输出尾巴留给 AI 当证据。 */
4307
5059
  async function bootOnce(ver, prof, onStarted) {
4308
5060
  const startedAt = performance.now()
@@ -4317,7 +5069,8 @@ async function bootOnce(ver, prof, onStarted) {
4317
5069
  // TIME_WAIT。真起不来时下面那条 catch 会把话说清楚。
4318
5070
  pushLog(`固定端口 ${pinned} 现在被占用,这次启动多半会失败`)
4319
5071
  }
4320
- pushLog(`启动 ${ver} · profile ${prof}${lanBindActive(WEB_BIND, lanBindToggleOn(homeDir(), prof)) ? ' · Web 绑定 局域网(0.0.0.0,由配置层决定)' : ''}${pinned ? ` · 固定端口 ${pinned}` : ''}${alongside ? '(与已在跑的实例并存)' : ''}`)
5072
+ const workdir = launchWorkdir()
5073
+ pushLog(`启动 ${ver} · profile ${prof}${workdir ? ` · 项目目录 ${workdir}` : ''}${lanBindActive(WEB_BIND, lanBindToggleOn(homeDir(), prof)) ? ' · Web 绑定 局域网(0.0.0.0,由配置层决定)' : ''}${pinned ? ` · 固定端口 ${pinned}` : ''}${alongside ? '(与已在跑的实例并存)' : ''}`)
4321
5074
  if (reused) {
4322
5075
  pushLog(remembered.key === `${ver}@${prof}`
4323
5076
  ? `复用自动端口:${ver}@${prof} → ${reused}`
@@ -4331,7 +5084,7 @@ async function bootOnce(ver, prof, onStarted) {
4331
5084
  home: homeDir(),
4332
5085
  fingerprint: profileFingerprint(homeDir(), prof),
4333
5086
  }
4334
- const child = spawnDsh(ver, bootArgs(prof, alongside, pinned || reused), prof, { diagnosticContext })
5087
+ const child = spawnDsh(ver, bootArgs(prof, alongside, pinned || reused), prof, { diagnosticContext, cwd: workdir, pluginProfile: true })
4335
5088
  const spawnAt = performance.now()
4336
5089
  const proc = attachProcess(ver, prof, child, diagnosticContext)
4337
5090
  onStarted?.(proc)
@@ -4341,8 +5094,24 @@ async function bootOnce(ver, prof, onStarted) {
4341
5094
  if (automatic) await rememberAutoPort(ver, prof, result.url)
4342
5095
  // 分清启动器准备、子进程输出就绪地址、管理器确认三段,避免把地址可用误当成会话首屏。
4343
5096
  const readyAt = proc.readyAt || performance.now()
5097
+ proc.startupMs = Math.round(readyAt - spawnAt)
4344
5098
  const confirmedAt = performance.now()
4345
- pushLog(`启动耗时 ${ver}/${prof}:准备 ${Math.round(spawnAt - startedAt)}ms,子进程就绪地址 ${Math.round(readyAt - spawnAt)}ms,确认 ${Math.round(confirmedAt - readyAt)}ms,总计 ${Math.round(confirmedAt - startedAt)}ms`)
5099
+ const prepareMs = Math.round(spawnAt - startedAt)
5100
+ const readyMs = Math.round(readyAt - spawnAt)
5101
+ const confirmMs = Math.round(confirmedAt - readyAt)
5102
+ recordLaunchRun(launchScope.getStore()?.launchId, {
5103
+ at: Date.now(),
5104
+ version: ver,
5105
+ profile: prof,
5106
+ totalMs: Math.round(confirmedAt - startedAt),
5107
+ prepareMs,
5108
+ readyMs,
5109
+ confirmMs,
5110
+ firstOutputMs: proc.firstOutputAt ? Math.round(proc.firstOutputAt - spawnAt) : null,
5111
+ // 启动结算时读一次子进程 RSS:进程级常驻,够用的“大概”
5112
+ startupRssMB: readProcessRssMB(proc.child?.pid),
5113
+ })
5114
+ pushLog(`启动耗时 ${ver}/${prof}:准备 ${prepareMs}ms,子进程就绪地址 ${readyMs}ms,确认 ${confirmMs}ms,总计 ${Math.round(confirmedAt - startedAt)}ms`)
4346
5115
  // 钉了端口却没落到上面(额外参数里另有 --port,或 dsh 自己顺延了):如实说一句,
4347
5116
  // 否则用户以为存下来的地址还有效
4348
5117
  if (pinned && urlPortOf(result.url) !== pinned) {
@@ -4521,6 +5290,17 @@ async function startWithRepair(version, profile = PROFILE_NAME) {
4521
5290
  // 早于任何一次 add 就有这行,市场里点安装才不会撞上 peer 求交那个坑。
4522
5291
  // 只对当前 profile 做——起别的 profile 不动它的文件,人家可能就是故意留空的
4523
5292
  if (profile === PROFILE_NAME) await ensureProfileNpmrc(profile).catch(() => {})
5293
+ // 启动前给 profile 的配置层留一份快照:插件装崩、补丁写坏时能退回到这次启动前的样子。
5294
+ // 只存配置层(几十 KB),node_modules 不动;内容没变时 snapshotProfileConfig 会跳过。
5295
+ // 快照失败不拦启动——备份是保险,不是启动的前提。
5296
+ await snapshotProfileConfig(profile).catch((error) => {
5297
+ pushLog(`[备份] Profile ${profile} 快照失败:${error instanceof Error ? error.message : error}`)
5298
+ })
5299
+ // 桌面增强(原生通知 / 右键菜单 / 侧栏浏览器控制):缺哪个补哪个。装在拉起子进程之前,
5300
+ // 这样第一次启动就能用上;失败不影响启动,下次再试。
5301
+ await ensureDesktopPlugins(version, profile).catch((error) => {
5302
+ pushLog(`桌面增强:自动安装检查失败:${error instanceof Error ? error.message : error}`)
5303
+ })
4524
5304
  for (;;) {
4525
5305
  try {
4526
5306
  return await startNow(version, profile)
@@ -4793,20 +5573,31 @@ async function renameProfile(profile, nextName) {
4793
5573
  return { ok: true, ...packsPayload(), renamedProfile: name, profile: name, state: await snapshot(), webProfiles: listProfiles().filter((profile) => profileBootsWebIn(home, profile)) }
4794
5574
  }
4795
5575
 
4796
- async function createProfile(profile, source = '') {
5576
+ /**
5577
+ * 建 Profile:`source` 给了就把那份配置复制过来(插件清单、开关、MCP、补丁层),
5578
+ * 复制完在目标环境里重装依赖。`sourceHome` 是来源所在的那个用户目录,空 = 当前环境;
5579
+ * 项目环境(issue #72)常从全局目录里已经调好的 Profile 复制,两个目录互不相干。
5580
+ */
5581
+ async function createProfile(profile, source = '', sourceHome = '') {
4797
5582
  const name = safeProfile(profile)
4798
5583
  const from = source ? safeProfile(source) : ''
4799
5584
  if (name.startsWith('.') || name.toLowerCase() === 'node_modules' || TEMPLATE_PROFILES.includes(name.toLowerCase()) || CLI_BLOCKED_PROFILES.has(name.toLowerCase())) {
4800
5585
  throw new Error('这个名称是系统保留名称,请换一个 Profile 名称')
4801
5586
  }
4802
- const root = join(homeDir(), 'profiles')
5587
+ // 目标永远是「当前作用域」这一份;来源可以另指一个用户目录。
5588
+ const home = homeDir()
5589
+ const fromHome = from ? safeDshHome(sourceHome) || home : ''
5590
+ const root = join(home, 'profiles')
4803
5591
  await mkdir(root, { recursive: true })
4804
5592
  if (readdirSync(root).some((entry) => entry.toLowerCase() === name.toLowerCase())) throw new Error(`Profile「${name}」已存在`)
4805
- const original = from ? profileDirOf(from) : ''
5593
+ const original = from ? join(fromHome, 'profiles', from) : ''
4806
5594
  if (from && !existsSync(join(original, 'package.json'))) throw new Error(`Profile「${from}」尚未初始化,不能复制`)
4807
- const target = profileDirOf(name)
4808
- await withIdleProfile(from || name, async () => {
4809
- if (from) maintainingProfiles.add(profileKey(name))
5595
+ const target = join(home, 'profiles', name)
5596
+ const targetKey = `${home}\0${name}`
5597
+ if (maintainingProfiles.has(targetKey)) throw new Error(`profile「${name}」正在修改,稍后再试`)
5598
+ // 锁的是来源那一份(同目录复制时就是自己):来源被删/被改到一半,复制出来的是坏环境。
5599
+ await withIdleProfileIn(from ? fromHome : home, from || name, async () => {
5600
+ if (from) maintainingProfiles.add(targetKey)
4810
5601
  const staging = mkdtempSync(join(root, '.dsh-profile-'))
4811
5602
  let created = false
4812
5603
  try {
@@ -4839,13 +5630,15 @@ async function createProfile(profile, source = '') {
4839
5630
  if (['dependencies', 'devDependencies', 'optionalDependencies'].some((key) => Object.keys(manifest[key] || {}).length)) {
4840
5631
  await runProfileInstall(await pluginCommandVersion(name), { profile: name })
4841
5632
  }
4842
- pushLog(from ? `已复制 Profile「${from}」为「${name}」` : `已创建 Profile「${name}」`)
5633
+ pushLog(from
5634
+ ? `已复制 Profile「${from}」为「${name}」${fromHome === home ? '' : `(来源目录 ${fromHome})`}`
5635
+ : `已创建 Profile「${name}」`)
4843
5636
  } catch (error) {
4844
5637
  if (created) await rm(target, { recursive: true, force: true })
4845
5638
  throw error
4846
5639
  } finally {
4847
5640
  await rm(staging, { recursive: true, force: true })
4848
- if (from) maintainingProfiles.delete(profileKey(name))
5641
+ if (from) maintainingProfiles.delete(targetKey)
4849
5642
  }
4850
5643
  })
4851
5644
  await emitState()
@@ -5026,6 +5819,22 @@ export function openRoute(mode, shellWindow, internalTabs = false) {
5026
5819
  function openExternal(target, mode = OPEN_MODE) {
5027
5820
  const url = String(target)
5028
5821
  if (openRoute(mode, shellWindowHost(), internalTabsHost()) === 'internal') {
5822
+ // 启动等待页占的正是这个实例的标签位(id 与 internalTabTarget 同一套拼法):
5823
+ // 页面自己跳到 dsh 之后就是实例页,标签条上不会先多出一个「DSH」再被实例页挤掉。
5824
+ const booting = new URL(url)
5825
+ if (booting.pathname === '/booting') {
5826
+ const version = booting.searchParams.get('version') || ''
5827
+ const profile = booting.searchParams.get('profile') || ''
5828
+ const home = booting.searchParams.get('home') || ''
5829
+ host.openTab({
5830
+ id: JSON.stringify([home, version, profile]),
5831
+ title: booting.searchParams.get('name') || [profile, version].filter(Boolean).join(' · ') || 'DSH',
5832
+ url,
5833
+ status: 'starting',
5834
+ managed: true,
5835
+ })
5836
+ return
5837
+ }
5029
5838
  const instance = instanceList().find((proc) => proc.url && new URL(proc.url).origin === new URL(url).origin)
5030
5839
  const tab = instance ? internalTabTarget(instance, launchNameOf(instance)) : { id: new URL(url).origin, title: 'DSH', url, status: 'running', managed: false }
5031
5840
  // 认证地址只在本机管道或同进程调用中传递;标题和实例身份都不包含 token,也不写入日志或设置。
@@ -5244,6 +6053,37 @@ async function exportLogs() {
5244
6053
  return redact(chunks.length ? chunks.join('') : logs.join('\n'), secretValues)
5245
6054
  }
5246
6055
 
6056
+ /**
6057
+ * 插件桌面 API 的能力清单(GET /api/desktop)。
6058
+ *
6059
+ * available 只有真外壳接上 host.desktop 时为真:浏览器模式(node start.js)和旧外壳
6060
+ * 都没有这层能力。通知开关和外壳读的是同一份 settings.json,插件可以先看它决定
6061
+ * 要不要退回页面内提示。
6062
+ */
6063
+ function desktopPayload() {
6064
+ const desktop = host.desktop
6065
+ // 外壳的 info 坏了不该让整份能力清单失败:插件至少还能看到 available 和 capabilities。
6066
+ let info = {}
6067
+ try {
6068
+ info = (desktop && typeof desktop.info === 'function' ? desktop.info() : null) || {}
6069
+ } catch {
6070
+ info = {}
6071
+ }
6072
+ const settings = loadSettingsSync()
6073
+ return {
6074
+ api: DESKTOP_API_VERSION,
6075
+ available: Boolean(desktop),
6076
+ platform: process.platform,
6077
+ capabilities: desktop ? [...DESKTOP_CAPABILITIES] : [],
6078
+ notifications: {
6079
+ enabled: settings.taskNotifications !== false,
6080
+ sound: settings.notificationSound !== false,
6081
+ },
6082
+ theme: typeof info.theme === 'string' ? info.theme : '',
6083
+ versions: { core: APP_VERSION, ...(info.versions && typeof info.versions === 'object' ? info.versions : {}) },
6084
+ }
6085
+ }
6086
+
5247
6087
  async function handleApi(req, res, url) {
5248
6088
  // 身份标记:端口被占用时我们要能分辨那是自己的另一个实例还是别人的程序
5249
6089
  if (url.pathname === '/api/ping') {
@@ -5320,23 +6160,7 @@ async function handleApi(req, res, url) {
5320
6160
  return
5321
6161
  }
5322
6162
  if (req.method === 'GET' && url.pathname === '/api/tray') {
5323
- // 保留旧外壳的标量字段;新外壳按命名启动项操作,不再把单项停止变成停止全部。
5324
- const running = primaryInstance()
5325
- const launches = trayLaunches()
5326
- send(res, 200, [
5327
- `status=${running?.status || 'stopped'}`,
5328
- `url=${running?.url || ''}`,
5329
- `installed=${trayInstalledVersions().length ? 1 : 0}`,
5330
- `lang=${LANG}`,
5331
- // 托盘照着它决定「打开 DSH」是叫内嵌窗口还是丢给系统浏览器
5332
- `openmode=${OPEN_MODE}`,
5333
- // 本次是不是由原生外壳托管:外壳缺失时窗口模式不成立,托盘据此退回浏览器
5334
- `shell=${shellWindowHost() ? 1 : 0}`,
5335
- `theme=${THEME}`,
5336
- // 原生标签页要跟随所有实例,不能用「最近启动的实例」去覆盖用户正在看的其他页面。
5337
- `instances=${JSON.stringify(instanceList().map((proc) => internalTabTarget(proc, launches.find((row) => row.proc === proc)?.name)))}`,
5338
- `launches=${JSON.stringify(launches.map(({ id, instanceId, name, version, profile, status, busy, installed, target }) => ({ id, instanceId, name, version, profile, status, busy, installed, target })))}`,
5339
- ].join('\n'), 'text/plain; charset=utf-8')
6163
+ send(res, 200, trayStateText(), 'text/plain; charset=utf-8')
5340
6164
  return
5341
6165
  }
5342
6166
  if (req.method === 'GET' && url.pathname === '/api/changelog') {
@@ -5348,6 +6172,51 @@ async function handleApi(req, res, url) {
5348
6172
  send(res, 200, await publicSettings())
5349
6173
  return
5350
6174
  }
6175
+ if (req.method === 'GET' && url.pathname === '/api/launch-log') {
6176
+ const id = String(url.searchParams.get('id') || '')
6177
+ if (!LAUNCH_ID_RE.test(id)) {
6178
+ send(res, 400, { error: '启动项 id 不合法' })
6179
+ return
6180
+ }
6181
+ const file = join(LAUNCH_LOG_DIR, `launch-${id}.log`)
6182
+ let text = ''
6183
+ let at = null
6184
+ try {
6185
+ text = readFileSync(file, 'utf8')
6186
+ at = statSync(file).mtimeMs
6187
+ } catch {
6188
+ // 还没启动过:空日志,页面显示提示
6189
+ }
6190
+ const limit = 120 * 1024
6191
+ const clipped = text.length > limit
6192
+ if (clipped) text = text.slice(-limit)
6193
+ let runs = []
6194
+ try {
6195
+ const parsed = JSON.parse(readFileSync(join(LAUNCH_LOG_DIR, `launch-${id}.json`), 'utf8'))
6196
+ if (Array.isArray(parsed?.runs)) runs = parsed.runs.slice(0, 20)
6197
+ } catch {
6198
+ // 没有耗时记录:只显示日志
6199
+ }
6200
+ send(res, 200, { id, text, at, clipped, runs })
6201
+ return
6202
+ }
6203
+ if (req.method === 'GET' && url.pathname === '/api/launch-log/export') {
6204
+ const id = String(url.searchParams.get('id') || '')
6205
+ if (!LAUNCH_ID_RE.test(id)) {
6206
+ send(res, 400, { error: '启动项 id 不合法' })
6207
+ return
6208
+ }
6209
+ let text = ''
6210
+ try {
6211
+ text = readFileSync(join(LAUNCH_LOG_DIR, `launch-${id}.log`), 'utf8')
6212
+ } catch {
6213
+ send(res, 404, { error: '这个启动项还没有日志' })
6214
+ return
6215
+ }
6216
+ res.setHeader('content-disposition', `attachment; filename="launch-${id}.log"`)
6217
+ send(res, 200, text, 'text/plain; charset=utf-8')
6218
+ return
6219
+ }
5351
6220
  if (req.method === 'GET' && url.pathname === '/api/logs/export') {
5352
6221
  res.setHeader('content-disposition', 'attachment; filename="dsh-x-logs.txt"')
5353
6222
  send(res, 200, await exportLogs(), 'text/plain; charset=utf-8')
@@ -5357,6 +6226,17 @@ async function handleApi(req, res, url) {
5357
6226
  send(res, 200, await snapshot())
5358
6227
  return
5359
6228
  }
6229
+ if (req.method === 'GET' && url.pathname === '/api/plugin-profile') {
6230
+ // 账本按「版本@profile」存在数据目录里;参数非法回 400,别让它拼进文件路径。
6231
+ try {
6232
+ const version = safeVersion(url.searchParams.get('version') || '')
6233
+ const profile = safeProfile(url.searchParams.get('profile') || PROFILE_NAME)
6234
+ send(res, 200, await pluginProfileReport(version, profile))
6235
+ } catch (error) {
6236
+ send(res, 400, { error: error instanceof Error ? error.message : String(error) })
6237
+ }
6238
+ return
6239
+ }
5360
6240
  if (req.method === 'GET' && url.pathname === '/api/launch-home/profiles') {
5361
6241
  const home = safeDshHome(url.searchParams.get('home')) || defaultHome()
5362
6242
  const profiles = listProfiles(join(home, 'profiles'))
@@ -5373,7 +6253,7 @@ async function handleApi(req, res, url) {
5373
6253
  const body = await readJson(req)
5374
6254
  const copying = url.pathname === '/api/profiles/copy'
5375
6255
  if (copying && !body.source) { send(res, 400, { error: '请选择要复制的 Profile' }); return }
5376
- try { send(res, 200, await createProfile(body.profile, copying ? body.source : '')) }
6256
+ try { send(res, 200, await createProfile(body.profile, copying ? body.source : '', copying ? body.sourceHome : '')) }
5377
6257
  catch (error) { send(res, 400, { error: error instanceof Error ? error.message : String(error) }) }
5378
6258
  return
5379
6259
  }
@@ -5382,6 +6262,16 @@ async function handleApi(req, res, url) {
5382
6262
  send(res, 200, recoveryPayload(profile))
5383
6263
  return
5384
6264
  }
6265
+ if (req.method === 'GET' && url.pathname === '/api/profile-backups') {
6266
+ // 参数坏了回 400 而不是 500:这接口会被页面直接拼 query,别让半截名字炸进目录操作。
6267
+ try {
6268
+ const profile = safeProfile(url.searchParams.get('profile') || PROFILE_NAME)
6269
+ const id = url.searchParams.get('id')
6270
+ if (id !== null && !PROFILE_BACKUP_ID.test(id)) throw new Error('备份编号不对')
6271
+ send(res, 200, { profile, home: homeDir(), backups: listProfileBackups(profile) })
6272
+ } catch (error) { send(res, 400, { error: error instanceof Error ? error.message : String(error) }) }
6273
+ return
6274
+ }
5385
6275
  if (req.method === 'GET' && url.pathname === '/api/plugins/updates') {
5386
6276
  try {
5387
6277
  const profile = safeProfile(url.searchParams.get('profile') || PROFILE_NAME)
@@ -5443,6 +6333,77 @@ async function handleApi(req, res, url) {
5443
6333
  }
5444
6334
 
5445
6335
  const body = req.method === 'POST' ? await readJson(req) : {}
6336
+ // 插件桌面 API:插件在 dsh 子进程里用本机 HTTP 调这里,真正的 Notification / dialog /
6337
+ // clipboard 由外壳的 host.desktop 执行(见 shell/main.mjs)。GET 只回能力清单。
6338
+ if (url.pathname === '/api/desktop' || url.pathname.startsWith('/api/desktop/')) {
6339
+ if (url.pathname === '/api/desktop') {
6340
+ if (req.method !== 'GET') send(res, 405, { error: '/api/desktop 只支持 GET' })
6341
+ else send(res, 200, desktopPayload())
6342
+ return
6343
+ }
6344
+ if (req.method !== 'POST') {
6345
+ send(res, 405, { error: '桌面能力只支持 POST' })
6346
+ return
6347
+ }
6348
+ if (!host.desktop) {
6349
+ // 浏览器模式 / 旧外壳:能力不存在。插件按 available=false 降级,别重试。
6350
+ send(res, 503, { available: false, error: '桌面能力不可用:当前不是 DSH-X 桌面外壳在运行' })
6351
+ return
6352
+ }
6353
+ const action = url.pathname.slice('/api/desktop/'.length)
6354
+ let request
6355
+ try {
6356
+ request = parseDesktopRequest(action, body)
6357
+ } catch (error) {
6358
+ send(res, 400, { error: error instanceof Error ? error.message : String(error) })
6359
+ return
6360
+ }
6361
+ if (!request) {
6362
+ send(res, 404, { error: `没有 /api/desktop/${action} 这个能力` })
6363
+ return
6364
+ }
6365
+ if (typeof host.desktop[request.action] !== 'function') {
6366
+ send(res, 503, { available: false, error: `这个外壳还不支持 ${request.action}` })
6367
+ return
6368
+ }
6369
+ // 只显示真实存在的路径:这条路由没有鉴权,不能变成任意路径的文件管理器入口。
6370
+ if (request.action === 'reveal' && !existsSync(request.path)) {
6371
+ send(res, 400, { error: '这个路径不存在' })
6372
+ return
6373
+ }
6374
+ try {
6375
+ const result = await host.desktop[request.action](request)
6376
+ send(res, 200, { ok: true, ...(result && typeof result === 'object' ? result : {}) })
6377
+ } catch (error) {
6378
+ const message = error instanceof Error ? error.message : String(error)
6379
+ pushLog(`桌面能力 ${request.action} 失败:${message}`)
6380
+ send(res, 500, { error: message })
6381
+ }
6382
+ return
6383
+ }
6384
+ // 设置页「浏览器控制」的两个清理按钮:guest 分区在 Electron 外壳里,核心只能转发。
6385
+ // 只影响内置浏览器(persist:dsh-sidebar-browser-*),管理页和 dsh 页面的数据不动。
6386
+ if (req.method === 'POST' && (url.pathname === '/api/browser/clear-cache' || url.pathname === '/api/browser/clear-data')) {
6387
+ if (!host.browser) {
6388
+ send(res, 503, { available: false, error: '浏览器数据清理不可用:当前不是 DSH-X 桌面外壳在运行,或外壳版本太旧' })
6389
+ return
6390
+ }
6391
+ const action = url.pathname.endsWith('/clear-cache') ? 'clearCache' : 'clearAll'
6392
+ if (typeof host.browser[action] !== 'function') {
6393
+ send(res, 503, { available: false, error: `这个外壳还不支持 ${action}` })
6394
+ return
6395
+ }
6396
+ try {
6397
+ const result = await host.browser[action]()
6398
+ pushLog(action === 'clearCache' ? '内置浏览器缓存已清除' : '内置浏览器的 Cookie、站点数据和缓存已清除')
6399
+ send(res, 200, { ok: true, ...(result && typeof result === 'object' ? result : {}) })
6400
+ } catch (error) {
6401
+ const message = error instanceof Error ? error.message : String(error)
6402
+ pushLog(`清理内置浏览器数据失败:${message}`)
6403
+ send(res, 500, { error: message })
6404
+ }
6405
+ return
6406
+ }
5446
6407
  if (req.method === 'POST' && ['/api/diagnosis/repair/preview', '/api/diagnosis/repair/execute', '/api/diagnosis/repair/rollback'].includes(url.pathname)) {
5447
6408
  try {
5448
6409
  let result
@@ -5510,31 +6471,11 @@ async function handleApi(req, res, url) {
5510
6471
  if (req.method === 'POST' && url.pathname === '/api/self/app-download') {
5511
6472
  // 核心更新:从 npm 下载 tarball → 校验 sha512 → 解到暂存目录;进度复用 selfUpdate 事件
5512
6473
  try {
5513
- const data = await checkAppUpdate({ force: true })
5514
- if (!data.available) {
5515
- throw new Error(data.reason === 'electron' ? 'Electron 版本不同,需要完整安装包' : '当前没有可用的核心更新')
5516
- }
5517
- if (stagedApp && stagedApp.version === data.version && stagingExists(stagedApp.dir)) {
5518
- send(res, 200, { ok: true, version: stagedApp.version, reused: true })
5519
- return
5520
- }
5521
- const tarball = join(tmpdir(), `DSH-X-core-${data.version}-${process.pid}.tgz`)
5522
- const result = await downloadCoreTarball(pendingAppUpdate.tarball, tarball, {
5523
- integrity: pendingAppUpdate.integrity,
5524
- shasum: pendingAppUpdate.shasum,
5525
- onProgress: ({ done, total }) => emit('selfUpdate', { phase: 'download', kind: 'app', done, total }),
5526
- })
5527
- const staging = join(tmpdir(), `DSH-X-core-${data.version}-${process.pid}`)
5528
- try {
5529
- const pkg = extractCoreTarball(tarball, staging)
5530
- if (String(pkg.version) !== String(data.version)) throw new Error(`核心包版本不一致:${pkg.version} != ${data.version}`)
5531
- } finally {
5532
- rmSync(tarball, { force: true })
6474
+ // 自动更新与页面可能同时发起:并到同一份下载上,免得两个 tarball 抢同一个暂存目录
6475
+ if (!appDownloadInFlight) {
6476
+ appDownloadInFlight = downloadAppUpdate().finally(() => { appDownloadInFlight = null })
5533
6477
  }
5534
- stagedApp = { version: data.version, dir: staging }
5535
- pushLog(`核心更新已就绪:${data.version}(${(result.bytes / 1048576).toFixed(1)} MB),等用户确认后替换`)
5536
- emit('selfUpdate', { phase: 'app-ready', latest: data.version })
5537
- send(res, 200, { ok: true, version: data.version })
6478
+ send(res, 200, await appDownloadInFlight)
5538
6479
  } catch (error) {
5539
6480
  const message = error instanceof Error ? error.message : String(error)
5540
6481
  pushLog(`核心更新下载失败:${message}`)
@@ -5546,55 +6487,15 @@ async function handleApi(req, res, url) {
5546
6487
  // 核心增量:内置基线 → 复制到用户目录 core/current,重启外壳(外壳优先加载它);
5547
6488
  // 已经在 core/current → 替换助手等外壳退出后原地换目录,失败回滚。
5548
6489
  try {
5549
- if (!stagedApp || !stagingExists(stagedApp.dir)) throw new Error('还没有下载好的应用更新')
5550
- const stats = stagingStats(stagedApp.dir)
5551
- if (stats.files < 100) throw new Error('暂存目录不完整,重新下载一次')
5552
- const activeCore = join(APP_DIR, 'core', 'current')
5553
- if (fileManager.samePath(ROOT, activeCore)) {
5554
- // 已经在用户目录:助手等外壳退出 → 备份 → 换入 → 重启 → 等 update-ok,失败回滚
5555
- const helper = join(tmpdir(), `dsh-x-apply-${process.pid}-${randomBytes(4).toString('hex')}.mjs`)
5556
- copyFileSync(join(ROOT, 'apply-update.mjs'), helper)
5557
- const child = spawn(RUNTIME_NODE, [helper,
5558
- '--app', ROOT,
5559
- '--staged', stagedApp.dir,
5560
- '--exe', process.execPath,
5561
- '--pid', String(process.pid),
5562
- '--backup', `${ROOT}.bak-${APP_VERSION}`,
5563
- '--marker', join(APP_DIR, 'update-ok'),
5564
- '--log', join(APP_DIR, 'update.log'),
5565
- ], {
5566
- cwd: tmpdir(),
5567
- env: { ...process.env, ...RUNTIME_NODE_ENV },
5568
- detached: true,
5569
- stdio: 'ignore',
5570
- windowsHide: true,
5571
- })
5572
- child.unref()
5573
- pushLog(`核心更新:替换助手已启动(${stagedApp.version}),启动器即将退出`)
5574
- send(res, 200, { ok: true, version: stagedApp.version })
5575
- setTimeout(() => shutdown().finally(() => host.exit(0)), 200)
6490
+ // 已经开过一次(连点两下或两个标签页同时点):重复替换会把刚换好的目录再挪一遍
6491
+ if (appApplyStarted) {
6492
+ send(res, 200, { ok: true, already: true })
5576
6493
  return
5577
6494
  }
5578
- // 内置基线:复制到用户目录,写 pending 标记;外壳下次启动优先加载 current,
5579
- // 起不来(外壳里的 recoverBaseline)会删掉 current 回退到基线
5580
- const pending = join(APP_DIR, 'core', 'pending')
5581
- const version = stagedApp.version
5582
- const stagedDir = stagedApp.dir
5583
- await rm(activeCore, { recursive: true, force: true })
5584
- await mkdir(dirname(activeCore), { recursive: true })
5585
- await cp(stagedDir, activeCore, { recursive: true })
5586
- stagedApp = null
5587
- try { rmSync(stagedDir, { recursive: true, force: true }) } catch { /* 临时目录清不掉不影响 */ }
5588
- writeFileSync(pending, JSON.stringify({ version, at: Date.now() }))
5589
- pushLog(`核心更新:已复制到 ${activeCore}(${version}),重启外壳`)
5590
- send(res, 200, { ok: true, version })
5591
- // 不能直接 spawn DSH.exe:当前进程还占着单实例锁,新进程会立刻退出。
5592
- // 外壳的 host.relaunch 用 app.relaunch(),等本进程退出后才拉起新外壳。
5593
- setTimeout(() => shutdown().finally(() => {
5594
- if (typeof host.relaunch === 'function') host.relaunch()
5595
- else host.exit(0)
5596
- }), 200)
6495
+ appApplyStarted = true
6496
+ send(res, 200, await applyAppUpdate())
5597
6497
  } catch (error) {
6498
+ appApplyStarted = false
5598
6499
  const message = error instanceof Error ? error.message : String(error)
5599
6500
  pushLog(`核心更新失败:${message}`)
5600
6501
  send(res, 400, { error: message })
@@ -5687,6 +6588,16 @@ async function handleApi(req, res, url) {
5687
6588
  send(res, 200, await saveLaunchPreset(body))
5688
6589
  return
5689
6590
  }
6591
+ if (req.method === 'POST' && url.pathname === '/api/launch-log/clear') {
6592
+ const id = String(body.id || '')
6593
+ if (!LAUNCH_ID_RE.test(id)) {
6594
+ send(res, 400, { error: '启动项 id 不合法' })
6595
+ return
6596
+ }
6597
+ clearLaunchLog(id)
6598
+ send(res, 200, { ok: true })
6599
+ return
6600
+ }
5690
6601
  if (req.method === 'POST' && url.pathname === '/api/launch-presets/remove') {
5691
6602
  send(res, 200, await removeLaunchPreset(String(body.id ?? '')))
5692
6603
  return
@@ -5751,6 +6662,22 @@ async function handleApi(req, res, url) {
5751
6662
  }
5752
6663
  return
5753
6664
  }
6665
+ if (req.method === 'POST' && url.pathname === '/api/profile-backups/snapshot') {
6666
+ try {
6667
+ const profile = safeProfile(String(body.profile || ''))
6668
+ send(res, 200, { ok: true, backup: await snapshotProfileConfig(profile, { force: body.force === true }) })
6669
+ } catch (error) { send(res, 400, { error: error instanceof Error ? error.message : String(error) }) }
6670
+ return
6671
+ }
6672
+ if (req.method === 'POST' && url.pathname === '/api/profile-backups/restore') {
6673
+ try { send(res, 200, await restoreProfileConfig(body.profile, body.id)) }
6674
+ catch (error) {
6675
+ // 备份不存在给 404,参数/状态不对给 400,页面靠这两个码区分提示。
6676
+ const message = error instanceof Error ? error.message : String(error)
6677
+ send(res, /找不到这份备份/.test(message) ? 404 : 400, { error: message })
6678
+ }
6679
+ return
6680
+ }
5754
6681
  if (req.method === 'POST' && url.pathname === '/api/plugins/toggle') {
5755
6682
  const name = String(body.name || '')
5756
6683
  const enabled = body.enabled !== false
@@ -6423,6 +7350,11 @@ export async function startServer() {
6423
7350
  LAN_TOGGLE = lanBindToggleOn(homeDir(), PROFILE_NAME)
6424
7351
  if (CLI_ARGS.length) pushLog(`DSH.exe 传入启动参数:${CLI_ARGS.join(' ')}`)
6425
7352
  const stored = await loadSettings()
7353
+ // 跑 dsh 的 node 不是自带的 Electron 时记一笔:出问题时日志里能看出用的是哪个
7354
+ if (IN_ELECTRON && !RUNTIME_NODE_ENV.ELECTRON_RUN_AS_NODE) {
7355
+ const version = nodeVersionOf(RUNTIME_NODE)
7356
+ pushLog(`跑 dsh 的 node:${RUNTIME_NODE}${version ? `(v${version})` : ''}`)
7357
+ }
6426
7358
  // 钉死端口的实例表:启动、重启都读它(页面改这张表走 /api/instance-port)
6427
7359
  INSTANCE_PORTS = safeInstancePorts(stored.instancePorts)
6428
7360
  ENVIRONMENT_PORTS = safeEnvironmentPorts(stored.environmentPorts)
@@ -6478,6 +7410,14 @@ export async function startServer() {
6478
7410
  }
6479
7411
  return
6480
7412
  }
7413
+ // 启动等待页:点「启动」后由外壳 / 浏览器新开一个页面,它自己盯着实例状态,
7414
+ // 就绪后跳到 dsh。放在静态分支之前是为了跟管理页共用 __APP_*__ 替换(语言、主题一致),
7415
+ // 并且不让它进条件缓存——升级后第一下就得是新页面。
7416
+ if (req.method === 'GET' && (url.pathname === '/booting' || url.pathname === '/booting/')) {
7417
+ const page = await readFile(join(PUBLIC, 'booting.html'), 'utf8')
7418
+ send(res, 200, page.replaceAll('__APP_LANG__', LANG).replaceAll('__APP_THEME__', THEME), 'text/html; charset=utf-8', { 'cache-control': 'no-store' })
7419
+ return
7420
+ }
6481
7421
  const file = url.pathname === '/' ? 'index.html' : url.pathname.slice(1)
6482
7422
  const path = join(PUBLIC, file)
6483
7423
  if (!path.startsWith(PUBLIC) || !existsSync(path)) {
@@ -6568,6 +7508,7 @@ export async function startServer() {
6568
7508
  }
6569
7509
 
6570
7510
  export async function stopAll() {
7511
+ shuttingDown = true
6571
7512
  // 退出可能发生在端口探测或设置落盘期间;禁止尚未 spawn 的后台启动在收尾后复活。
6572
7513
  startupGeneration++
6573
7514
  // 退出前也要确认子进程真没了:原来杀完就 clear,taskkill 没生效时 dsh 就成了没有父进程的