glasspane-install 1.10.0 → 1.12.0

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 (2) hide show
  1. package/cli.js +764 -80
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -69,10 +69,15 @@ export const INSTALL_SH_URL =
69
69
  /** 发布线锚点(版本真源 = 根 package.json,由 scripts/set-version.mjs 统一改写,
70
70
  * 勿手改):npx 形态下本地没有仓库时,引导 clone 的就是这个 tag,与 install.sh
71
71
  * 的 `GLASSPANE_RELEASE` 同值——两条一键入口必须拿到同一份源码。 */
72
- export const RELEASE_VERSION = '1.10.0'
72
+ export const RELEASE_VERSION = '1.12.0'
73
73
  /** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
74
74
  export const REPO_REF = `v${RELEASE_VERSION}`
75
75
 
76
+ /** 发布公钥的 key id 与 uid:拒绝文案里两者要成对出现,读者才知道要比对的是**哪一把**
77
+ * 钥匙——"随便一把签过名的钥匙"不算证据。 */
78
+ export const GLASSPANE_SIGNING_KEY_ID = '0929EA31DF4F7429F63FC53189D88B1D043A1298'
79
+ export const GLASSPANE_SIGNING_KEY_UID = 'jingzhao-l (sign-github) <ET_lin@outlook.com>'
80
+
76
81
  /** 发布签名公钥(GPG,key 0929EA31DF4F7429F63FC53189D88B1D043A1298,uid
77
82
  * jingzhao-l (sign-github) <ET_lin@outlook.com>)。与 npm 安装链的 Integrity 不同,
78
83
  * GPG 校验的是"来源/作者身份"而不是"内容哈希":clone 到的发布 tag 若带 GPG 签名,
@@ -259,21 +264,44 @@ export function nodeMajorVersion(nodePath = process.execPath) {
259
264
 
260
265
  /** 检查命令是否存在:优先 `<bin> --version`,失败时退化检查 /usr/bin/<bin>
261
266
  * 可执行文件(覆盖 open 等不支持 --version 的命令)。真实探测,不伪造。 */
262
- export function commandAvailable(bin) {
263
- const byVersion = (() => {
267
+ /** 需要"探测命令本身跑成功"的工具(审计轮 3 finding 6)。
268
+ *
269
+ * macOS 把 `/usr/bin/swift` 与 `/usr/bin/git` 装成 **Command Line Tools 的 shim**:文件
270
+ * 确实在、也确实可执行(`accessSync(..., X_OK)` 通过),但 CLT 不在的时候它们**一跑就失败**
271
+ * (xcode-select 报 "no developer tools were found")。所以"文件在"对这两个工具不构成
272
+ * 存在性证明——旧实现据此把预检报成"依赖齐全",然后死在真正的 `swift build` 上。
273
+ * 对这些工具只认探测命令自己的退出码,不做 `/usr/bin/<bin>` 文件退化检查。 */
274
+ export const SHIM_PROBE_COMMANDS = {
275
+ swift: { cmd: 'xcode-select', args: ['-p'] },
276
+ git: { cmd: 'git', args: ['--version'] },
277
+ }
278
+
279
+ /** 检查命令是否存在:优先 `<bin> --version`;shim 易感工具(swift/git)改由
280
+ * SHIM_PROBE_COMMANDS 里那条探测命令的退出码决定;其余(open 等不支持 --version)
281
+ * 才退化到 /usr/bin 下的可执行文件。真实探测,不伪造。
282
+ * `spawn`/`access` 是测试接缝:这一条判据的正反两面都得由假探测跑出来,真机上
283
+ * "装了 Xcode 的机器"永远只能给出正面一半。 */
284
+ export function commandAvailable(bin, {
285
+ spawn = spawnSync,
286
+ access = (file) => fs.accessSync(file, fs.constants.X_OK),
287
+ } = {}) {
288
+ const succeeds = (cmd, args) => {
264
289
  try {
265
- const result = spawnSync(bin, ['--version'], { stdio: 'ignore', timeout: 5000 })
266
- return result.status === 0
290
+ return spawn(cmd, args, { stdio: 'ignore', timeout: 5000 }).status === 0
267
291
  } catch {
268
292
  return false
269
293
  }
270
- })()
271
- if (byVersion) {
294
+ }
295
+ if (succeeds(bin, ['--version'])) {
272
296
  return true
273
297
  }
298
+ const probe = SHIM_PROBE_COMMANDS[bin]
299
+ if (probe) {
300
+ // 到这一支为止:文件存在对 shim 易感工具不构成证据(见 SHIM_PROBE_COMMANDS 注释)。
301
+ return succeeds(probe.cmd, probe.args)
302
+ }
274
303
  try {
275
- const absolute = path.join('/usr/bin', bin)
276
- fs.accessSync(absolute, fs.constants.X_OK)
304
+ access(path.join('/usr/bin', bin))
277
305
  return true
278
306
  } catch {
279
307
  return false
@@ -434,29 +462,145 @@ export function launchdKickstartArgs({ label, uid }) {
434
462
  return ['kickstart', '-k', `gui/${uid}/${label}`]
435
463
  }
436
464
 
465
+ /** 判一次 `kill` 的失败到底是"进程不在了"还是"我没权动它"(finding 5)。
466
+ * `/bin/kill` 对两者都给退出码 1,能分开的只有 stderr 文案:
467
+ * `Operation not permitted` = EPERM(进程活着、属于别人,典型就是 root 拉起的老 daemon)。
468
+ * 认不出来的失败按"不在"处理(与旧实现 `status === 0` 才算活着同向),因为真正的兜底闸
469
+ * 是收尾那次 hello 版本核对——旧进程只要还在服务 socket,那里就会把它露出来。 */
470
+ export function classifyKillResult({ status, stderr = '', error = null } = {}) {
471
+ if (error) return 'unknown'
472
+ if (status === 0) return 'alive'
473
+ // 连退出码都没有(kill 自己没跑成、或被信号打死)= **没观测到这个进程**,不许当成"不在了"。
474
+ if (status === null || status === undefined) return 'unknown'
475
+ const text = String(stderr ?? '')
476
+ if (/not permitted|permission denied|eperm/i.test(text)) return 'denied'
477
+ // /bin/kill 对"进程不存在"必定打印 No such process;认不出的失败按"不在"处理(与旧实现
478
+ // 同向),真正的兜底闸是收尾那次版本核对。
479
+ return 'gone'
480
+ }
481
+
482
+ /** 这个 pid 是不是**僵尸**(已死、等父进程回收):`kill -0` 对僵尸回 0,光靠信号探测会把
483
+ * 一个已经不服务 socket 的进程报成"还活着、请你 sudo kill"。状态读不出来时保守回 false
484
+ * (宁可多报一个幸存者,也不放走真的还在跑的进程)。 */
485
+ export function isZombiePid(pid, { spawn = spawnSync } = {}) {
486
+ try {
487
+ const res = spawn('ps', ['-o', 'state=', '-p', String(pid)], { encoding: 'utf8' })
488
+ return String(res?.stdout ?? '').trim().startsWith('Z')
489
+ } catch {
490
+ return false
491
+ }
492
+ }
493
+
437
494
  /** 向指定 PID 发 SIGTERM(daemon 侧装有信号处理 → 清理 socket 后退出;
438
495
  * launchd 托管时异常退出会按新 plist 重新拉起)。1s 后仍存活的补 SIGKILL:
439
496
  * 实测存在收不到/不响应 SIGTERM 的旧 daemon 实例(主循环未跑起来时
440
- * SIG_IGN 生效而 handler 永不执行),它们占着 socket 却是死身份,必须清掉。 */
441
- export function terminatePids(pids, { spawn = spawnSync } = {}) {
442
- const kill = (pid, signal) => spawn('kill', signal ? ['-' + signal, String(pid)] : [String(pid)])
443
- for (const pid of pids) kill(pid)
444
- spawn('sleep', ['1'])
497
+ * SIG_IGN 生效而 handler 永不执行),它们占着 socket 却是死身份,必须清掉。
498
+ *
499
+ * 结论口径(finding 5):**只有 SIGKILL 之后再探一次仍不在,才算结束**。
500
+ * 旧实现把 kill 的退出码全丢掉、直接 `terminated: [...pids]`,于是安装器无权发信号的
501
+ * 进程(root 所有的陈旧 daemon,`kill` 与 `kill -0` 都回 EPERM)被报成"已结束既有实例",
502
+ * 而它还在服务同一个 socket。这类 pid 现在进 `survivors`,调用方必须按失败说。 */
503
+ export function terminatePids(pids, { spawn = spawnSync, sleepMs = 1000, reprobeMs = 150, sleep = null } = {}) {
504
+ const run = (args) => {
505
+ try {
506
+ return spawn('kill', args, { encoding: 'utf8' })
507
+ } catch (error) {
508
+ return { status: null, stderr: error?.message ?? String(error), error }
509
+ }
510
+ }
511
+ const signal = (pid, sig) => run(sig ? ['-' + sig, String(pid)] : [String(pid)])
512
+ const state = (pid) => classifyKillResult(signal(pid, '0'))
513
+ const terminated = []
445
514
  const forced = []
515
+ const survivors = []
516
+ for (const pid of pids) signal(pid)
517
+ // 给 SIGTERM 一个收尾窗口(daemon 侧 handler 会清 socket 再退出)。可注入 sleep,
518
+ // 免得单测每跑一次真等一秒;默认仍是旧的 /bin/sleep 有界等待。
519
+ if (sleep) sleep(sleepMs)
520
+ else spawn('sleep', [String(Math.ceil(sleepMs / 1000))], { encoding: 'utf8' })
446
521
  for (const pid of pids) {
447
- if (kill(pid, '0').status === 0) {
448
- kill(pid, '9')
449
- forced.push(pid)
522
+ const before = state(pid)
523
+ if (before === 'denied') {
524
+ survivors.push({ pid, reason: 'SIGTERM 被拒(EPERM):该进程不属于当前用户,安装器无权结束它' })
525
+ continue
526
+ }
527
+ if (before === 'gone') {
528
+ terminated.push(pid)
529
+ continue
530
+ }
531
+ forced.push(pid)
532
+ signal(pid, '9')
533
+ let after = state(pid)
534
+ if (after !== 'gone') {
535
+ // 刚被 SIGKILL 的进程要等它的父进程回收(父进程正 `wait` 着的话是毫秒级);
536
+ // 立刻探一次会把它读成"还活着",于是红字让人去 sudo kill 一个已经没了的 pid。
537
+ // 给一次**有界**的再探窗口,只有窗口过后还在的才算幸存者。
538
+ if (sleep) sleep(reprobeMs)
539
+ else spawn('sleep', [String(reprobeMs / 1000)], { encoding: 'utf8' })
540
+ after = state(pid)
541
+ // 还"活着"但状态是 Z = 它已经死了,只差父进程 wait() 收尸:这种东西不持有 socket、
542
+ // 不需要人去 sudo kill,报成幸存者就是把人支到一条没用的命令上。
543
+ if (after === 'alive' && isZombiePid(pid, { spawn })) after = 'gone'
544
+ }
545
+ if (after === 'gone') {
546
+ terminated.push(pid)
547
+ } else if (after === 'denied') {
548
+ survivors.push({ pid, reason: 'SIGKILL 被拒(EPERM):该进程不属于当前用户,安装器无法结束它' })
549
+ } else {
550
+ survivors.push({ pid, reason: `SIGKILL 后再探一次(+${reprobeMs}ms)它仍存在(${after})` })
450
551
  }
451
552
  }
452
- return { terminated: [...pids], forced }
553
+ return { terminated, forced, survivors }
554
+ }
555
+
556
+ /** 幸存者(安装器发不动信号的进程)的红字(finding 5,纯函数)。
557
+ * 这里给的必须是**人能直接执行**的动作,而不是"请检查权限":安装器没有 root 权限,
558
+ * 所以只有坐在键盘前的这个人能 `sudo kill -9`;他知道 pid、知道为什么必须他做、
559
+ * 也知道做完要重跑什么。socket 还挂在这些 pid 上,所以本轮的产物从未接管——
560
+ * 收尾那次版本核对会把这件事再钉一次。 */
561
+ export function survivorText(survivors, { socketPath } = {}) {
562
+ const pids = survivors.map((entry) => entry.pid).join(' ')
563
+ return [
564
+ `未能结束既有实例(它仍在服务 ${socketPath ?? 'engine.sock'}):`
565
+ + survivors.map((entry) => `pid ${entry.pid} — ${entry.reason}`).join(';'),
566
+ `安装器无权向这些进程发信号(它们不属于当前账号,典型是 root 拉起的旧 daemon)。`
567
+ + `上面"已结束"那句只对确实已经不在的 pid 成立,不含这些。`,
568
+ `由人来收尾:先看清是谁起的 \`ps -o pid=,user=,lstart=,command= -p ${pids}\`,`
569
+ + `再 \`sudo kill -9 ${pids}\`,然后重跑本安装器并加 --replace-daemon。`,
570
+ ].join('\n')
571
+ }
572
+
573
+ /** 目标已存在、这一轮**真的会覆盖掉**的 bundle 清单(纯函数)。确认门问的就是这份清单,
574
+ * 所以它必须与 installBundles 走的判断是同一份——否则问的是 A、覆盖的是 B。 */
575
+ export function bundlesToReplace({ plan, exists = fs.existsSync } = {}) {
576
+ if (!plan?.appsDir) return []
577
+ return [
578
+ [plan.builtSettingsApp, plan.settingsApp],
579
+ [plan.builtDaemonApp, plan.daemonApp],
580
+ ]
581
+ .filter(([source, target]) => source && target && exists(source) && exists(target))
582
+ .map(([, target]) => target)
453
583
  }
454
584
 
455
585
  /** 把 make-app.sh 的两个 bundle 安置到 ~/Applications(覆盖式,幂等)。
456
- * 返回实际安置的条目;源缺失即跳过(--no-app 或构建产物不全时不报错)。 */
457
- export function installBundles({ plan, exists = fs.existsSync, remove = fs.rmSync, copy = fs.cpSync, mkdir = fs.mkdirSync } = {}) {
586
+ * 返回 `{ installed, declined }`(finding 11):
587
+ * - `installed` = 本轮真的写进去的条目;
588
+ * - `declined` = 目标**已存在**而 `allowReplace=false`(人回答 n,或非交互环境没给
589
+ * `--no-prompt`)因此没有覆盖的条目——留着的是上一轮的旧 bundle,daemon 启动路径
590
+ * 会照旧选中它。这不是"无所谓"的分支:收尾那次版本核对正是靠它拦住"装了新版、
591
+ * 跑的还是旧版"。
592
+ * 源缺失即跳过(--no-app 或构建产物不全时不报错)。 */
593
+ export function installBundles({
594
+ plan,
595
+ exists = fs.existsSync,
596
+ remove = fs.rmSync,
597
+ copy = fs.cpSync,
598
+ mkdir = fs.mkdirSync,
599
+ allowReplace = true,
600
+ } = {}) {
458
601
  const installed = []
459
- if (!plan?.appsDir) return installed
602
+ const declined = []
603
+ if (!plan?.appsDir) return { installed, declined }
460
604
  mkdir(plan.appsDir, { recursive: true })
461
605
  const pairs = [
462
606
  [plan.builtSettingsApp, plan.settingsApp],
@@ -464,11 +608,15 @@ export function installBundles({ plan, exists = fs.existsSync, remove = fs.rmSyn
464
608
  ]
465
609
  for (const [source, target] of pairs) {
466
610
  if (!source || !target || !exists(source)) continue
611
+ if (exists(target) && !allowReplace) {
612
+ declined.push(target)
613
+ continue
614
+ }
467
615
  remove(target, { recursive: true, force: true })
468
616
  copy(source, target, { recursive: true })
469
617
  installed.push(target)
470
618
  }
471
- return installed
619
+ return { installed, declined }
472
620
  }
473
621
 
474
622
  /** 生成 launchd 用户代理 plist(纯函数,零副作用;XML 转义防路径注入)。
@@ -563,13 +711,39 @@ function readPlistProgram(readFile, plistPath) {
563
711
  }
564
712
  }
565
713
 
714
+ /** 只读探一次"重注册会不会动到在跑的东西"(纯函数,供确认门用)。
715
+ * 确认必须发生在**写盘之前**:先写 plist 再问,人回答"跳过"之后磁盘上是新定义、
716
+ * launchd 里还是旧定义,而 `bootoutDeclined` 那句"什么都没改"会当场变成假话。 */
717
+ export function launchdReregisterProbe(desiredProgram, { label = LAUNCHD_LABEL, spawn = spawnSync } = {}) {
718
+ const uid = String(spawn('id', ['-u'], { encoding: 'utf8' })?.stdout ?? '').trim()
719
+ if (!uid) return { loaded: false, program: null, uid: null, needsReregister: false }
720
+ const probe = spawn('launchctl', ['print', `gui/${uid}/${label}`], { encoding: 'utf8' })
721
+ const loaded = probe.status === 0
722
+ const program = loadedLaunchdProgram(probe.stdout)
723
+ return {
724
+ loaded,
725
+ program,
726
+ uid,
727
+ needsReregister: launchdNeedsReregister({
728
+ alreadyLoaded: loaded,
729
+ loadedProgram: program,
730
+ desiredProgram,
731
+ }),
732
+ }
733
+ }
734
+
566
735
  /** 注册 launchd 用户代理(幂等):已加载则跳过,未加载则 bootstrap
567
736
  * gui/<uid>。返回 { ok, already, message },失败携带真实 stderr。
568
737
  *
569
738
  * `desiredProgram` 缺失时**自己从 plist 读**:调用方之一是 `--restore-launchd`,它手里只有
570
739
  * plist 路径,而它偏偏是安装器交给 agent 的"daemon 不可达"补救命令。以前不传就等于跟 ''
571
740
  * 比,任何已加载作业都被判成"定义变了"→ bootout 一个健康作业(打断正在跑的 act)。 */
572
- export function launchctlBootstrap(label, plistPath, { desiredProgram = null, spawn = spawnSync, readFile = (file) => fs.readFileSync(file, 'utf8') } = {}) {
741
+ export function launchctlBootstrap(label, plistPath, {
742
+ desiredProgram = null,
743
+ spawn = spawnSync,
744
+ readFile = (file) => fs.readFileSync(file, 'utf8'),
745
+ allowBootout = true,
746
+ } = {}) {
573
747
  const uid = spawn('id', ['-u'], { encoding: 'utf8' }).stdout.trim()
574
748
  if (!uid) {
575
749
  return { ok: false, already: false, message: '无法解析当前 uid(id -u 失败),跳过 launchd 注册' }
@@ -586,6 +760,15 @@ export function launchctlBootstrap(label, plistPath, { desiredProgram = null, sp
586
760
  return { ok: true, already: true, message: `launchd 已加载 ${label}(${domain}),跳过 bootstrap` }
587
761
  }
588
762
  if (probe.status === 0) {
763
+ if (!allowBootout) {
764
+ // 没拿到确认就一个字节都不动:不写新 plist、不 bootout、不 bootstrap。
765
+ return {
766
+ ok: false,
767
+ already: false,
768
+ bootoutDeclined: true,
769
+ message: `没有卸载已加载的 ${label}(本轮未获得确认,${domain}):launchd 里现在跑的是 ${loadedLaunchdProgram(probe.stdout) ?? '(未读到)'},本轮要装的定义是 ${wantProgram ?? '(读不到)'},两者不一致而 bootout 是破坏性动作——因此**没有**写新 plist 也**没有**继续 bootstrap,什么都没改。要让新定义生效:在交互终端重跑本命令并回答继续,或在脚本/自动化里显式加 --no-prompt。`,
770
+ }
771
+ }
589
772
  // 定义变了:先卸掉旧作业,否则新 plist 不生效(真机 EX_CONFIG 的来路)。
590
773
  // bootout 的退出码必须判:它失败时紧随的 bootstrap 只会回 5 / "already loaded",
591
774
  // 而那一支被下面映射成 ok:true —— 于是安装器打印"开机自启已注册",launchd 却还在
@@ -679,8 +862,10 @@ export function socketHello(
679
862
  */
680
863
  export async function restoreLaunchd({
681
864
  label = LAUNCHD_LABEL,
682
- plistPath = path.join(process.env.HOME ?? '', ...LAUNCHD_DIR_NAME.split('/'), LAUNCHD_FILE_NAME),
683
- socketPath = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, SOCKET_FILE_NAME),
865
+ env = process.env,
866
+ uid = typeof process.getuid === 'function' ? process.getuid() : null,
867
+ plistPath = null,
868
+ socketPath = null,
684
869
  bootstrapFn = launchctlBootstrap,
685
870
  kickstartFn = launchctlKickstart,
686
871
  waitFn = waitForSocket,
@@ -688,14 +873,29 @@ export async function restoreLaunchd({
688
873
  existsFn = (p) => fs.existsSync(p),
689
874
  serveTimeoutMs = 10000,
690
875
  } = {}) {
691
- if (!existsFn(plistPath)) {
876
+ // finding 4:这条路径也是拿 HOME 拼出来的(旧代码在参数默认值里读
877
+ // `process.env.HOME ?? ''`,HOME 缺失时它变成 `Library/LaunchAgents/…` 这种相对位置,
878
+ // 然后照实去 bootstrap 一个 StandardOutPath 是相对路径的作业)。显式传了
879
+ // plistPath/socketPath 的调用方(单测、以及任何知道自己要修哪个文件的人)不受这条闸影响。
880
+ const paths = installGuard({ home: env.HOME ?? '', uid })
881
+ const resolvedPlist = plistPath ?? (paths.ok ? path.join(paths.home, ...LAUNCHD_DIR_NAME.split('/'), LAUNCHD_FILE_NAME) : null)
882
+ const resolvedSocket = socketPath ?? (paths.ok ? path.join(paths.home, SOCKET_DIR_NAME, SOCKET_FILE_NAME) : null)
883
+ if (!resolvedPlist || !resolvedSocket) {
884
+ return {
885
+ ok: false,
886
+ action: 'bad-home',
887
+ verified: null,
888
+ message: paths.error ?? '无法确定 plist/socket 路径(HOME 不可用且未显式传入)',
889
+ }
890
+ }
891
+ if (!existsFn(resolvedPlist)) {
692
892
  return {
693
893
  ok: false,
694
894
  action: 'missing-plist',
695
- message: `launchd plist 不存在(${plistPath}):从未安装或已被删除,请重跑 install.sh 完整安装`,
895
+ message: `launchd plist 不存在(${resolvedPlist}):从未安装或已被删除,请重跑 install.sh 完整安装`,
696
896
  }
697
897
  }
698
- const boot = bootstrapFn(label, plistPath)
898
+ const boot = bootstrapFn(label, resolvedPlist)
699
899
  if (!boot.ok) {
700
900
  // 两种"没注册上"要分开说:bootout 失败意味着旧作业还在跑旧定义(agent 下一步是把它卸掉),
701
901
  // bootstrap 失败才是"卸干净了但装不上"。合成一句会把人支到错的那条命令上去。
@@ -706,10 +906,10 @@ export async function restoreLaunchd({
706
906
  }
707
907
  }
708
908
  const verify = async () => {
709
- if (!(await waitFn(socketPath, { timeoutMs: serveTimeoutMs }))) {
909
+ if (!(await waitFn(resolvedSocket, { timeoutMs: serveTimeoutMs }))) {
710
910
  return { reachable: false, accessibility: null, pid: null, version: null }
711
911
  }
712
- const result = await helloFn(socketPath)
912
+ const result = await helloFn(resolvedSocket)
713
913
  return {
714
914
  reachable: result != null,
715
915
  accessibility: result?.permissions?.accessibility ?? null,
@@ -723,7 +923,7 @@ export async function restoreLaunchd({
723
923
  ok: false,
724
924
  action: boot.already ? 'already-loaded' : 'bootstrapped',
725
925
  verified: checked,
726
- message: `launchd 作业就位(${boot.message}),但 ${serveTimeoutMs}ms 内 daemon socket 未服务/未回 hello(${socketPath})`,
926
+ message: `launchd 作业就位(${boot.message}),但 ${serveTimeoutMs}ms 内 daemon socket 未服务/未回 hello(${resolvedSocket})`,
727
927
  }
728
928
  }
729
929
  let restarted = false
@@ -782,11 +982,18 @@ export function tagVerifyDecision({ sig, pinRef, repoDir }) {
782
982
  return {
783
983
  action: 'refuse',
784
984
  text: `发布 tag ${pinRef} 带着 GPG 签名,但签名验不过:${sig.detail}\n`
985
+ + (String(sig.raw ?? '').trim()
986
+ ? `git verify-tag 原话:${String(sig.raw).trim()}\n`
987
+ : '(git verify-tag 没有留下原话;上面那句是判据本身)\n')
785
988
  + '这不是"没签名"。签名在而校验失败,说明眼前这份 tag 指向的字节不是发布者签过的那一份。'
786
989
  + '安装到此为止:没有跑 npm install,没有 swift build,没有注册 launchd 作业。\n'
990
+ + `内置公钥:key ${GLASSPANE_SIGNING_KEY_ID}(uid ${GLASSPANE_SIGNING_KEY_UID}),`
991
+ + '就是本文件里那份 GLASSPANE_SIGNING_PUBLIC_KEY——要比对的是这一把,不是随便哪把签过名的。\n'
787
992
  + `下一步:核对远端与这个 tag(git -C "${repoDir}" remote -v;git -C "${repoDir}" tag -v ${pinRef});`
788
993
  + '确认仓库与发布者公钥都无误后改用更新的发布 tag(GLASSPANE_REF=v<版本> 重跑)。'
789
- + '要装开发主线用 GLASSPANE_REF=main(那条路不做 tag 验签,会如实告警)。',
994
+ + '要装开发主线用 GLASSPANE_REF=main(那条路不做 tag 验签,会如实告警)。\n'
995
+ + '该看的地方:SECURITY.md 的 "What the checks prove, and what they do not"——'
996
+ + '这一档写明是"任何同意都不覆盖的硬拒",所以这里既没有 flag 也没有确认能放行。',
790
997
  }
791
998
  }
792
999
  if (sig.status === 'unsigned' || sig.status === 'unavailable') {
@@ -811,6 +1018,46 @@ export function repoHasRef(repoDir, ref, { spawn = spawnSync } = {}) {
811
1018
  return spawn('git', ['-C', repoDir, 'rev-parse', '--verify', `refs/tags/${ref}`], { stdio: 'ignore' }).status === 0
812
1019
  }
813
1020
 
1021
+ /**
1022
+ * 要构建的这棵树,是不是被验签的那个 tag 本身(IO 探测,纯判定)。
1023
+ *
1024
+ * 存在的理由:`rootDir` 可以来自 `--repo`、`GLASSPANE_REPO`、当前目录或既有 clone,而
1025
+ * `install.sh` 自己就写着"既有 clone 不会被自动切到 $REF"。那条绿字说的若是
1026
+ * "签名验证通过",它证明的却只是 *tag 对象* 的签名——签的不是 HEAD 加一堆本地改动。
1027
+ * 签名与产物之间这层对应关系一旦断,绿字就成了对没签过的字节作保。
1028
+ */
1029
+ export function tagTreeAgreement({ repoDir, ref, spawn = spawnSync } = {}) {
1030
+ const head = spawn('git', ['-C', repoDir, 'rev-parse', '--verify', 'HEAD'], { encoding: 'utf8' })
1031
+ const tagged = spawn('git', ['-C', repoDir, 'rev-parse', '--verify', `${ref}^{commit}`], { encoding: 'utf8' })
1032
+ if (head.status !== 0 || tagged.status !== 0) {
1033
+ return { ok: false, same: false, changed: null, head: null, tagCommit: null }
1034
+ }
1035
+ const work = spawn('git', ['-C', repoDir, 'status', '--porcelain'], { encoding: 'utf8' })
1036
+ const changed = String(work.stdout ?? '').split('\n').filter((line) => line !== '').length
1037
+ const headSha = String(head.stdout ?? '').trim()
1038
+ const tagSha = String(tagged.stdout ?? '').trim()
1039
+ return { ok: true, same: headSha === tagSha && changed === 0, changed, head: headSha, tagCommit: tagSha }
1040
+ }
1041
+
1042
+ /**
1043
+ * "签名有效" 与 "要装的就是被签的那棵树" 是两件事,绿字必须两件事都说得出。
1044
+ * 树对不上时不拒装(装开发主线本来就合法),但绝不能沿用那句"验证通过"。
1045
+ */
1046
+ export function tagVerifyAgainstTree({ verdict, tree, pinRef, repoDir }) {
1047
+ if (verdict.action !== 'pass') return verdict
1048
+ if (tree.ok && tree.same) return verdict
1049
+ const why = !tree.ok
1050
+ ? '读不出这棵树的 HEAD 与该 tag 指向的 commit'
1051
+ : `HEAD 是 ${String(tree.head ?? '').slice(0, 7)},tag 指向 ${String(tree.tagCommit ?? '').slice(0, 7)},另有 ${tree.changed} 处未提交改动`
1052
+ return {
1053
+ action: 'warn',
1054
+ text: `发布 tag ${pinRef} 的 GPG 签名有效,但它签的不是本次要装的这棵树:${why}。\n`
1055
+ + '安装继续,装出来的是这棵树的内容,不是那个已签名发布的内容——这句不说清楚,屏幕上那行绿字就是替没验过的字节作保。\n'
1056
+ + `下一步(要装被签的那一版):git -C "${repoDir}" checkout ${pinRef} 后重跑,或用一键安装脚本从发布 tag 另起一份:`
1057
+ + `curl -fsSL ${INSTALL_SH_URL} | sh`,
1058
+ }
1059
+ }
1060
+
814
1061
  /** 对 clone 下来的发布 tag 做 GPG 验签(best-effort,不抛错)。
815
1062
  * 隔离 GNUPGHOME 并临时导入发布公钥,`git verify-tag` 只看退出码与 stderr;不动
816
1063
  * 用户钥匙环、无网络依赖。返回 classifyTagVerify 的口径(verified/unsigned/invalid/
@@ -1137,11 +1384,28 @@ export function run(cmd, args, { cwd } = {}) {
1137
1384
  })
1138
1385
  }
1139
1386
 
1140
- /** 暂停询问;非 TTY 或 --no-prompt 时自动视为同意(不阻塞自动化)。 */
1141
- export async function confirm(question, { autoYes = false } = {}) {
1142
- if (autoYes || !process.stdin.isTTY) {
1387
+ /** 破坏性动作前的确认(finding 11/12)。三档,且**默认不做事**:
1388
+ * - `autoYes`(`--no-prompt`):显式放行;
1389
+ * - 交互终端:问一句,回车继续、`n` 跳过;
1390
+ * - 非交互又没给 flag:**回 false**,不阻塞、也不"替用户同意"。
1391
+ * 旧实现在这里把非 TTY 当成"自动同意",于是 `curl | sh`(管道里没有终端)替每一条
1392
+ * 破坏性动作点了"是"——而 `install.sh` 正是文档里那条命令。现在由入口脚本在非 TTY 时
1393
+ * 显式补 `--no-prompt`(install.sh 的 SILENT),放行这件事变成一条写在脚本里的、可被读到的
1394
+ * 决定,而不是一个默认值。`interactive`/`ask` 是测试接缝:没有它们这三档只能靠真终端验。 */
1395
+ export async function confirm(question, {
1396
+ autoYes = false,
1397
+ interactive = Boolean(process.stdin.isTTY),
1398
+ ask = null,
1399
+ } = {}) {
1400
+ if (autoYes) {
1143
1401
  return true
1144
1402
  }
1403
+ if (!interactive) {
1404
+ return false
1405
+ }
1406
+ if (ask) {
1407
+ return Boolean(await ask(question))
1408
+ }
1145
1409
  const rl = createInterface({ input: process.stdin, output: process.stdout })
1146
1410
  try {
1147
1411
  const answer = await rl.question(`${question} (Enter 继续 / n 跳过) `)
@@ -1355,7 +1619,13 @@ export function usageText() {
1355
1619
  '',
1356
1620
  '选项:',
1357
1621
  ' --repo <目录> 指定项目根目录(默认自动向上查找)',
1358
- ' --no-prompt 跳过每步确认(脚本/自动化场景)',
1622
+ ' --no-prompt 跳过破坏性步骤的确认(脚本/自动化场景)。被它拦住的是三件:',
1623
+ ' ① bootout 已加载的 launchd daemon 作业(会打断正在跑的 act);',
1624
+ ' ② 覆盖 ~/Applications 里已存在的 .app;③ --replace-daemon 结束',
1625
+ ' 已在跑的实例。交互终端会逐条问;非交互(stdin 不是终端)**且没给**本 flag 时',
1626
+ ' 这三步一律不执行(不阻塞等待、也不默认同意)并打印说明——',
1627
+ ' 所以 CI / e2e / `curl | sh` 这类调用必须显式带 --no-prompt',
1628
+ ' (install.sh 在 stdin 非 TTY 时已经自动补上)。',
1359
1629
  ' --no-daemon 不启动 glasspaned',
1360
1630
  ' --replace-daemon 先结束已在跑的 glasspaned 实例(旧构建的 daemon 会继续占',
1361
1631
  ' socket,让系统设置里的授权条目与实际服务实例对不上)',
@@ -1414,10 +1684,14 @@ export function nextStepsText({
1414
1684
  daemonVerified = true,
1415
1685
  daemon = { viaBundle: false, path: null },
1416
1686
  settingsApp = null,
1687
+ panel = null,
1417
1688
  update = null,
1418
1689
  }) {
1419
- const settingsAppHint =
1420
- settingsApp ?? path.join(rootDir, 'engine', '.build', 'release', SETTINGS_APP_NAME)
1690
+ // finding 8:面板那句"再打开一次"必须落在**真存在**的东西上。旧写法在这里另拼一个
1691
+ // `~/Applications/GlassPane.app`(--no-app 时根本没安置)或
1692
+ // `<rootDir>/engine/.build/release/GlassPane.app`(从没构建过时它不存在),于是
1693
+ // `open "…"` 是一条没人能跑通的命令,而测试只断言字面量 `open "` 出现过。
1694
+ const panelHint = panelOpenHint({ panel, rootDir, settingsApp })
1421
1695
  const entryName = daemon.viaBundle ? 'GlassPane Daemon' : 'glasspaned'
1422
1696
  return [
1423
1697
  paint('后续使用说明', 'bold'),
@@ -1445,10 +1719,12 @@ export function nextStepsText({
1445
1719
  ...(guiOpened ? [
1446
1720
  ' 面板已打开(口径:启动命令已发出且在观察窗口内未报错退出——bundle 形态走的',
1447
1721
  ' `open` 在目标 bundle 缺失时会非零退出,未报错即产物确实在;窗口外的失败看不',
1448
- ` 到,所以没看到窗口属正常(-g 不抢焦点),再起一次:open "${settingsAppHint}")。`,
1722
+ ` 到,所以没看到窗口属正常(-g 不抢焦点),再起一次:${panelHint.text}${panelHint.runnable ? '' : '(本轮没有可重开的产物,见上一条)'})。`,
1449
1723
  ' 点权限卡「授权」会让 daemon 以自身身份发起申请并跳转到对应系统面板。',
1724
+ ] : panelHint.runnable ? [
1725
+ ` 打开面板:${panelHint.text}(点权限卡「授权」即由 daemon 自身发起申请)。`,
1450
1726
  ] : [
1451
- ` 打开面板:open "${settingsAppHint}"(点权限卡「授权」即由 daemon 自身发起申请)。`,
1727
+ ` ${panelHint.text}`,
1452
1728
  ]),
1453
1729
  ` 验证 daemon 自报的席位与主体:${daemon.path ?? path.join(rootDir, 'engine', '.build', 'release', DAEMON_EXE_NAME)} --permissions`,
1454
1730
  ' 口径提醒:daemon 必须由 launchd/登录项拉起,权限自报才等于真实席位;从终端或',
@@ -1467,6 +1743,184 @@ export function nextStepsText({
1467
1743
  ].join('\n')
1468
1744
  }
1469
1745
 
1746
+ /** GLASSPANE_REF 的唯一口径(finding 10,与 install.sh 同源):**未设置**与**显式置空**
1747
+ * 都钉发布 tag;要追主干必须显式写 `main`。旧行为是 install.sh 把空值读成 `main`、
1748
+ * 本文件把同一个空值读成 tag——同一条 `GLASSPANE_REF=` 在两条一键入口上会拿到两份源码。
1749
+ * 合并时这里一度退回成 `env.GLASSPANE_REF || REPO_REF` 的内联写法:判据本身对,但
1750
+ * install.sh 里那句"与 installer/cli.js 的 resolvePinRef 同源"就指向了一个不存在的东西,
1751
+ * 而这条口径再没有别的读者。 */
1752
+ export function resolvePinRef(env = process.env) {
1753
+ const raw = env?.GLASSPANE_REF
1754
+ if (raw === undefined || raw === null) return REPO_REF
1755
+ const trimmed = String(raw).trim()
1756
+ return trimmed ? trimmed : REPO_REF
1757
+ }
1758
+
1759
+ /** 允许的 clone 地址前缀(finding 3)。`https://` 是文档里那条一键安装;`ssh://` 给
1760
+ * 自带镜像的人;`file://` 保留 specs 里记着的离线/本地 clone 用法(P4-J3)。
1761
+ * 刻意**不**收 scp 式 `git@host:path`——它没有 scheme,正好和 `ext::sh -c …` 落在同一
1762
+ * 个无法白名单判定的形状里,需要它的人改写成 `ssh://git@host/path`。 */
1763
+ export const ALLOWED_GIT_URL_PREFIXES = ['https://', 'ssh://', 'file://']
1764
+
1765
+ /** 要被拼进 git argv 的三类值(ref / URL / 目录)的共同形状检查。
1766
+ * 拒绝的正是两类注入:以 `-` 开头(被 git 当成选项,`--upload-pack=…` 直接起进程),
1767
+ * 以及靠空白字符起 shell 的 `ext::sh -c …`。 */
1768
+ function unsafeArgvValue(value, label) {
1769
+ const text = String(value ?? '')
1770
+ if (!text) return `${label} 为空`
1771
+ if (text.startsWith('-')) return `${label} 以 "-" 开头,会被 git 当成命令行选项而不是值:${text}`
1772
+ if (/[\s\u0000-\u001f]/.test(text)) return `${label} 含空白/控制字符:${JSON.stringify(text)}`
1773
+ if (text.startsWith('+')) return `${label} 以 "+" 开头(强推语义),这里不收:${text}`
1774
+ return null
1775
+ }
1776
+
1777
+ /** clone 地址的形状检查(纯函数)。顺序按"读者的病因"排:以 - 开头是选项注入、不带 scheme
1778
+ * 是 ext:: 执行入口——这两条才是拒绝的理由。空白/控制字符只是同一批病因的手段,排在它们
1779
+ * 后面才不会把 `ext::sh -c …` 报成一句"含空白字符",让读者看不出真正被挡的是 ext 传输。 */
1780
+ export function validateGitRepoUrl(repoUrl) {
1781
+ const text = String(repoUrl ?? '')
1782
+ if (!text) {
1783
+ return { ok: false, error: `GLASSPANE_REPO_URL 为空。只接受这些地址形式:${ALLOWED_GIT_URL_PREFIXES.join('、')}` }
1784
+ }
1785
+ if (text.startsWith('-')) {
1786
+ return { ok: false, error: `GLASSPANE_REPO_URL 以 "-" 开头,会被 git 当成命令行选项而不是值:${text}` }
1787
+ }
1788
+ const lower = text.toLowerCase()
1789
+ if (!ALLOWED_GIT_URL_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
1790
+ return {
1791
+ ok: false,
1792
+ error: `GLASSPANE_REPO_URL 必须以 ${ALLOWED_GIT_URL_PREFIXES.join('、')} 之一开头(实为「${text}」)。`
1793
+ + '不带 scheme 的值会被 git 当成传输扩展名(`ext::…` 会直接执行后面的命令),因此一律拒绝;'
1794
+ + '本地/镜像 clone 请写 file:///<绝对路径>,scp 式 git@host:path 请写 ssh://git@host/path。',
1795
+ }
1796
+ }
1797
+ if (/[\s\u0000-\u001f]/.test(text)) {
1798
+ return { ok: false, error: `GLASSPANE_REPO_URL 含空白/控制字符:${JSON.stringify(text)}` }
1799
+ }
1800
+ return { ok: true, url: text }
1801
+ }
1802
+
1803
+ /** ref 的形状检查:除通用形状外,还要落在"tag/分支名字面量"的字符集里(要追主干就写 main)。 */
1804
+ export function validateGitRef(ref) {
1805
+ const shape = unsafeArgvValue(ref, 'GLASSPANE_REF')
1806
+ if (shape) return { ok: false, error: `${shape}。ref 只能是 tag/分支名字面量(要追主干就写 main)` }
1807
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/@+-]*$/.test(String(ref))) {
1808
+ return { ok: false, error: `GLASSPANE_REF 含不被接受的字符:${JSON.stringify(String(ref))}` }
1809
+ }
1810
+ return { ok: true, ref: String(ref) }
1811
+ }
1812
+
1813
+ /** 拼一条**不会被选项注入**的 `git clone` 参数(finding 3):位置参数前面放 `--`。
1814
+ * 返回 `{ args }` 或 `{ error }`——error 里带着是哪一个值、为什么。 */
1815
+ export function safeGitCloneArgs({ repoUrl = REPO_URL, ref = REPO_REF, targetDir } = {}) {
1816
+ const urlCheck = validateGitRepoUrl(repoUrl)
1817
+ if (!urlCheck.ok) return { error: urlCheck.error }
1818
+ const refCheck = validateGitRef(ref)
1819
+ if (!refCheck.ok) return { error: refCheck.error }
1820
+ const dirCheck = unsafeArgvValue(targetDir, 'clone 目标目录')
1821
+ if (dirCheck) return { error: `${dirCheck}(GLASSPANE_INSTALL_DIR/--repo 指定的目录不能以 "-" 开头)` }
1822
+ return {
1823
+ args: ['clone', '--branch', refCheck.ref, '--depth', '1', '--', urlCheck.url, String(targetDir)],
1824
+ ref: refCheck.ref,
1825
+ url: urlCheck.url,
1826
+ }
1827
+ }
1828
+
1829
+ /** 开跑前的两条硬闸(finding 4)。旧代码把 `process.env.HOME ?? ''` 直接拼进每一条路径:
1830
+ * HOME 没设置时状态根变成 `./.glasspane`、LaunchAgents 的 plist 落进当前目录,而 launchd
1831
+ * 会拒收相对的 StandardOutPath;`sudo` 下整棵安装无声无息落进 /var/root(授权主体、
1832
+ * socket、面板产物没有一样在用户手里)。两条都必须在**任何写入之前**拒绝。 */
1833
+ export function installGuard({ home = '', uid = null } = {}) {
1834
+ const homeText = String(home ?? '')
1835
+ if (!homeText) {
1836
+ return {
1837
+ ok: false,
1838
+ error:
1839
+ 'HOME 未设置:安装产物、socket 与 ~/Library/LaunchAgents 的路径全部由它拼出来,'
1840
+ + '缺了它这些会落到**当前目录**的相对位置(./.glasspane、./Library/LaunchAgents/…),'
1841
+ + '而 launchd 直接拒收相对的 StandardOutPath——本轮没有写入任何东西。'
1842
+ + '补救:在自己的登录 shell 里重跑(必要时 export HOME=/Users/<你>);'
1843
+ + '不要用 `sudo -u 某用户` / `env -u HOME` 这类形态起安装器。',
1844
+ }
1845
+ }
1846
+ if (!path.isAbsolute(homeText)) {
1847
+ return {
1848
+ ok: false,
1849
+ error: `HOME 必须是绝对路径,实为「${homeText}」:相对 HOME 会把状态根拼成 ${path.join(homeText, SOCKET_DIR_NAME)},`
1850
+ + 'plist 里的 StandardOutPath 也是相对的,launchd 不会收。补救:export HOME 成绝对路径后重跑。',
1851
+ }
1852
+ }
1853
+ if (uid !== null && uid !== undefined && Number(uid) === 0) {
1854
+ return {
1855
+ ok: false,
1856
+ error:
1857
+ '当前是 uid 0(root / sudo):GlassPane 是**用户级** launchd 代理与 TCC 授权,整棵安装会以 root 身份'
1858
+ + '落进 /var/root(产物、socket、plist 都不是你登录账号的那一份),辅助功能授权主体也永远对不上。'
1859
+ + '本安装不需要 sudo。补救:去掉 sudo 直接重跑同一条命令'
1860
+ + '(curl -fsSL https://raw.githubusercontent.com/jingzhao-l/GlassPane/main/install.sh | sh,'
1861
+ + '或 node installer/cli.js)。',
1862
+ }
1863
+ }
1864
+ return { ok: true, home: homeText }
1865
+ }
1866
+
1867
+ /** daemon 的 LaunchAgents plist 按 updater 同规格写:owner-only + 模式读回(finding 7)。
1868
+ * 旧写法 0644 把同一份文件(里面是本机装了哪些可执行、日志与 socket 的确切路径)摊给
1869
+ * 同机所有账号读;`updater/lib/launchd.js` 给自己的代理 plist 早就写 0600 并读回核对。
1870
+ * chmod 没落住按**写失败**处理(与 ensureLogDir/openPrivateLog、updater 的 tightenMode 同一判据)。 */
1871
+ export function writePrivatePlist(plistPath, xml, {
1872
+ write = (file, text) => fs.writeFileSync(file, text, { mode: STATE_FILE_MODE }),
1873
+ chmod = fs.chmodSync,
1874
+ statMode = (file) => fs.statSync(file).mode & 0o777,
1875
+ } = {}) {
1876
+ try {
1877
+ write(plistPath, xml)
1878
+ chmod(plistPath, STATE_FILE_MODE)
1879
+ const mode = statMode(plistPath)
1880
+ if (mode !== STATE_FILE_MODE) {
1881
+ return {
1882
+ ok: false,
1883
+ mode,
1884
+ error: `${plistPath} 在 chmod(0600) 之后仍是 ${formatMode(mode)}:这台机器的卷不保存权限位,`
1885
+ + 'owner-only 的 LaunchAgent 做不到(同一次写按失败算,不当成功报告)',
1886
+ }
1887
+ }
1888
+ return { ok: true, mode, error: null }
1889
+ } catch (error) {
1890
+ return { ok: false, mode: null, error: `launchd plist 写不进去:${plistPath}(${error.message})` }
1891
+ }
1892
+ }
1893
+
1894
+ /** clone 失败的归因(finding 9):把 **git 自己的原话**交给读者,而不是把任何非零退出
1895
+ * 都算成"网络不可达或该发布 tag 不存在"。"destination path … already exists and is not an
1896
+ * empty directory" 是真能走到的(bootstrapPlan 只拒绝"存在且父目录也不是仓库"的那种),
1897
+ * 被归因成网络问题时读者会一直重试一条永远不通的路。 */
1898
+ export function cloneFailureText({ ref, status, stderr = '', targetDir }) {
1899
+ const detail = String(stderr ?? '').trim()
1900
+ const head = `git clone 失败(ref '${ref}',目标 ${targetDir},退出码 ${status ?? 'null'})。`
1901
+ if (/already exists and is not an empty directory/i.test(detail)) {
1902
+ return [
1903
+ head,
1904
+ `git 的原话:${detail}`,
1905
+ `原因不是网络:目标目录已存在且不是空目录(${targetDir})。`,
1906
+ `补救:换目标目录(GLASSPANE_INSTALL_DIR=<空目录>,或 installer 用 --repo <目录>)重跑;`
1907
+ + '确认该目录可以删除后再自行清理。',
1908
+ ].join('\n')
1909
+ }
1910
+ if (detail) {
1911
+ return [
1912
+ head,
1913
+ `git 的原话:${detail}`,
1914
+ `(ref '${ref}' 不存在或网络不可达只是这份原话**可能**的成因之一,以原话为准。)`,
1915
+ ].join('\n')
1916
+ }
1917
+ return [
1918
+ head,
1919
+ 'git 没有留下 stderr,无法进一步归因;按 ref 不存在 / 网络不可达 / 目标目录不可写三条依次排查。',
1920
+ `可用 GLASSPANE_REF=main 追主干,或 GLASSPANE_REF=<tag> 指定发布版。`,
1921
+ ].join('\n')
1922
+ }
1923
+
1470
1924
  /**
1471
1925
  * 无本地仓库时的源码引导计划(纯函数,可单测)。与 install.sh 的源码定位策略
1472
1926
  * 同构:clone 发布 tag 到 ~/glasspane,两条一键入口拿到同一份源码。
@@ -1488,10 +1942,14 @@ export function bootstrapPlan({
1488
1942
  error: `目标目录已存在且不是 GlassPane 仓库:${targetDir}(用 --repo 指定仓库,或清理该目录后重试)`,
1489
1943
  }
1490
1944
  }
1945
+ // 三个值都会进 git argv:ref 来自 GLASSPANE_REF、目录来自 GLASSPANE_INSTALL_DIR,
1946
+ // 都是文档里写明的覆盖口子。判据与 install.sh 的 assert_ref/assert_repo_url 同源。
1947
+ const clone = safeGitCloneArgs({ repoUrl, ref, targetDir })
1948
+ if (clone.error) return { error: clone.error }
1491
1949
  return {
1492
1950
  targetDir,
1493
- ref,
1494
- gitArgs: ['clone', '--branch', ref, '--depth', '1', repoUrl, targetDir],
1951
+ ref: clone.ref,
1952
+ gitArgs: clone.args,
1495
1953
  }
1496
1954
  }
1497
1955
 
@@ -1518,6 +1976,102 @@ export function installOutcome({ verified = false, daemonExpected = true } = {})
1518
1976
  return { ok, exitCode: ok ? 0 : 1, verified: Boolean(verified), daemonExpected }
1519
1977
  }
1520
1978
 
1979
+ /**
1980
+ * 收尾那句"实测校验通过"到底能说什么,取决于两件事:有没有 daemon 应答,以及应答的那一份
1981
+ * **是不是本轮刚构建的产物**。从前这里只看 `hello?.version` 有没有值——旧实例占着 socket 时
1982
+ * (上面那条"daemon 已在监听…跳过启动"分支)应答的是上一版构建,新构建从未被启动也从未被验证,
1983
+ * 安装器却打印"实测校验通过…安装完成"并退 0,而 `install.sh` 是 exec 委托:按退出码办事的
1984
+ * agent 只看到 0。
1985
+ */
1986
+ export function installVerification({
1987
+ arrived = false,
1988
+ helloVersion = null,
1989
+ builtVersion = null,
1990
+ servedByEarlierInstance = false,
1991
+ } = {}) {
1992
+ if (!arrived || !helloVersion) return { verified: false, note: null }
1993
+ if (!builtVersion) {
1994
+ return {
1995
+ verified: false,
1996
+ note: '读不出本轮构建产物的版本(`glasspaned --version` 没给出可解析的一行),'
1997
+ + '所以无法证明应答 hello 的那一份就是本轮构建——本次不算校验通过。',
1998
+ }
1999
+ }
2000
+ if (helloVersion !== builtVersion) {
2001
+ return {
2002
+ verified: false,
2003
+ note: `应答 hello 的 daemon 自报 ${helloVersion},本轮构建产物是 ${builtVersion}:socket 被旧实例占着,`
2004
+ + '这一轮的构建从未被验证。用 --replace-daemon 重跑(它会先收拢在跑的实例)。',
2005
+ }
2006
+ }
2007
+ if (servedByEarlierInstance) {
2008
+ return {
2009
+ verified: true,
2010
+ note: `版本号一致(${builtVersion}),但应答的实例在本次安装之前就已经在服务:同一版本号只能说明它是`
2011
+ + '同一版本,不能证明它是本轮构建出来的那一份。要确证,用 --replace-daemon 重跑一次。',
2012
+ }
2013
+ }
2014
+ return { verified: true, note: null }
2015
+ }
2016
+
2017
+ /** 本轮构建产物自报的版本(`glasspaned --version` 那一行);读不出返回 null,不猜。
2018
+ * 探测必须带**有界**超时:一个卡死的产物会把整次安装挂在这一步,而它已经在
2019
+ * launchd 之后、收尾校验之前——没有超时的话连"安装失败"这句都说不到。 */
2020
+ export function builtDaemonVersion({ daemonBin, spawn = spawnSync, timeoutMs = 5000 } = {}) {
2021
+ if (!daemonBin) return null
2022
+ let res
2023
+ try {
2024
+ res = spawn(daemonBin, ['--version'], { encoding: 'utf8', timeout: timeoutMs })
2025
+ } catch {
2026
+ return null
2027
+ }
2028
+ if (res.error || res.status !== 0) return null
2029
+ // 真产物这一行是 `glasspaned 1.11.0`(`DaemonVersionFlagTests` 钉的就是这个形状),
2030
+ // 不是裸版本号:按 `^<数字>` 找行的实现对本机每一份真 daemon 都读不出,于是
2031
+ // "本轮构建产物未被验证"这句话每次安装都得说一遍,退出码恒 1。取行里那个版本记号,
2032
+ // 并要求它是独立的一个词(不把 `v1.2.3`、`1.2.3.4` 之类读成版本)。
2033
+ const match = String(res.stdout ?? '')
2034
+ .split('\n')
2035
+ .map((text) => text.trim())
2036
+ .find((text) => /(?:^|\s)\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?(?=$|\s)/.test(text))
2037
+ ?.match(/(?:^|\s)(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.]+)?)(?=$|\s)/)
2038
+ return match ? match[1] : null
2039
+ }
2040
+
2041
+ /** 面板那一句"再起一次"必须落在真存在的东西上(finding 8)。
2042
+ * `panel` 由 install() 用**已经算好的** settingsLaunchPath 结果传进来:
2043
+ * `{ viaBundle, executable, app, exists }`。缺省时这里按同一口径自己去文件系统查一次——
2044
+ * 不是为了兼容旧调用方而放宽,而是因为"印一条没人能跑的 open"本身就是这条要修的 bug。 */
2045
+ export function panelOpenHint({ panel, rootDir, settingsApp = null, exists = (p) => fs.existsSync(p) } = {}) {
2046
+ const fallbackExecutable = path.join(rootDir ?? '', 'engine', '.build', 'release', PANEL_EXE_NAME)
2047
+ const fallbackApp = settingsApp ?? path.join(rootDir ?? '', 'engine', '.build', 'release', SETTINGS_APP_NAME)
2048
+ const info = panel ?? {
2049
+ viaBundle: Boolean(settingsApp),
2050
+ executable: fallbackExecutable,
2051
+ app: settingsApp ?? fallbackApp,
2052
+ }
2053
+ const target = info.viaBundle ? (info.app ?? fallbackApp) : (info.executable ?? fallbackExecutable)
2054
+ const there = info.exists ?? Boolean(target && exists(target))
2055
+ if (!there) {
2056
+ return {
2057
+ runnable: false,
2058
+ target,
2059
+ looked: [info.app ?? fallbackApp, info.executable ?? fallbackExecutable],
2060
+ text: `本轮没有可执行的面板产物:找过 ${info.app ?? fallbackApp}(GlassPane.app)与 `
2061
+ + `${info.executable ?? fallbackExecutable}(裸二进制 glasspane-settings),两个都不存在——`
2062
+ + `没有 open 命令可给(用了 --no-app,或 make-app.sh 没有产出 bundle)。`
2063
+ + '去掉 --no-app 重跑安装即可得到面板;在那之前权限引导只能人工在系统设置里做。',
2064
+ }
2065
+ }
2066
+ return {
2067
+ runnable: true,
2068
+ target,
2069
+ text: info.viaBundle
2070
+ ? `open "${target}"`
2071
+ : `直接执行裸二进制面板:"${target}"(当前没有 .app bundle,所以不经 open、没有 bundle 身份)`,
2072
+ }
2073
+ }
2074
+
1521
2075
  /** 未通过校验时的提示行(纯函数,可单测)。必须自带**用户真能执行**的补救命令,
1522
2076
  * 并点明退出码非零——只说"安装未完成"的话,脚本调用方什么也拿不到。 */
1523
2077
  export function verificationFailureText({
@@ -1538,10 +2092,28 @@ export function verificationFailureText({
1538
2092
  }
1539
2093
 
1540
2094
  /** 主安装流程;所有副作用步骤均记录真实执行结果,失败即抛错终止。
1541
- * 返回值是收尾校验结论(`installOutcome`),入口守卫据此设退出码。 */
1542
- export async function install({ options = parseArgs([]).options, env = process.env } = {}) {
2095
+ * 返回值是收尾校验结论(`installOutcome`),入口守卫据此设退出码。
2096
+ *
2097
+ * `uid` 是**测试接缝**(finding 4):真实调用读 `process.getuid()`,单测注入 uid=0
2098
+ * 就能在不真的当 root 的前提下走同一条拒绝分支——这也是唯一能让"sudo 下静默装进
2099
+ * /var/root"这条可单测的地方。 */
2100
+ export async function install({
2101
+ options = parseArgs([]).options,
2102
+ env = process.env,
2103
+ uid = typeof process.getuid === 'function' ? process.getuid() : null,
2104
+ } = {}) {
1543
2105
  const explicitRoot = options.repoDir ? path.resolve(options.repoDir) : null
1544
2106
  const envRoot = env.GLASSPANE_REPO ? path.resolve(env.GLASSPANE_REPO) : null
2107
+
2108
+ // finding 4:在任何写入/clone 之前先闸掉 HOME 缺失、相对 HOME 与 uid 0 三种形态。
2109
+ // 旧代码把 `process.env.HOME ?? ''` 直接拼进每一条路径:状态根成了 `./.glasspane`、
2110
+ // plist 落进当前目录(launchd 拒收相对的 StandardOutPath),sudo 下整棵安装无声无息
2111
+ // 落进 /var/root。
2112
+ const guard = installGuard({ home: env.HOME ?? '', uid })
2113
+ if (!guard.ok) throw new Error(guard.error)
2114
+ const homeDir = guard.home
2115
+ const pinRef = resolvePinRef(env)
2116
+
1545
2117
  let rootDir = explicitRoot ?? envRoot ?? resolveProjectRoot()
1546
2118
 
1547
2119
  if (!rootDir || !fs.existsSync(rootDir)) {
@@ -1549,19 +2121,29 @@ export async function install({ options = parseArgs([]).options, env = process.e
1549
2121
  throw new Error(repoMissingText())
1550
2122
  }
1551
2123
  const plan = bootstrapPlan({
1552
- homeDir: env.HOME ?? process.env.HOME ?? '',
2124
+ homeDir,
1553
2125
  installDir: env.GLASSPANE_INSTALL_DIR ?? null,
1554
- ref: env.GLASSPANE_REF || REPO_REF,
2126
+ repoUrl: env.GLASSPANE_REPO_URL ?? REPO_URL,
2127
+ ref: pinRef,
1555
2128
  })
1556
2129
  if (plan.error) {
1557
2130
  throw new Error(`${plan.error}\n\n${repoMissingText()}`)
1558
2131
  }
1559
2132
  printStep(`未定位到本地仓库,引导 clone:${plan.targetDir}(ref: ${plan.ref})……`)
1560
- const clone = spawnSync('git', plan.gitArgs, { stdio: 'inherit' })
2133
+ // stderr 要留着归因(finding 9):任何非零都算"网络不可达或 tag 不存在"会把
2134
+ // "目标目录已存在且非空"这类本地故障说成网络问题,读者据此一直重试同一条死路。
2135
+ const clone = spawnSync('git', plan.gitArgs, {
2136
+ stdio: ['inherit', 'inherit', 'pipe'],
2137
+ encoding: 'utf8',
2138
+ })
1561
2139
  if (clone.status !== 0) {
1562
2140
  throw new Error(
1563
- `git clone 失败(ref '${plan.ref}',退出码 ${clone.status ?? 'null'}):`
1564
- + '网络不可达或该发布 tag 不存在。\n\n' + repoMissingText(),
2141
+ `${cloneFailureText({
2142
+ ref: plan.ref,
2143
+ status: clone.status,
2144
+ stderr: clone.stderr ?? clone.error?.message ?? '',
2145
+ targetDir: plan.targetDir,
2146
+ })}\n\n${repoMissingText()}`,
1565
2147
  )
1566
2148
  }
1567
2149
  if (resolveProjectRoot(plan.targetDir) === null) {
@@ -1571,24 +2153,31 @@ export async function install({ options = parseArgs([]).options, env = process.e
1571
2153
  }
1572
2154
 
1573
2155
  // 发布 tag GPG 验签:动作怎么落由 `tagVerifyDecision` 判(纯函数,有单测)。
1574
- const pinRef = env.GLASSPANE_REF || REPO_REF
1575
2156
  if (pinRef !== 'main' && repoHasRef(rootDir, pinRef)) {
1576
2157
  const sig = verifyCloneTagGpg({ repoDir: rootDir, ref: pinRef })
1577
- const verdict = tagVerifyDecision({ sig, pinRef, repoDir: rootDir })
2158
+ // 先判"签名对不对",再判"要装的是不是被签的那棵树"——两问分开,绿字才只在对得上时出现。
2159
+ const verdict = tagVerifyAgainstTree({
2160
+ verdict: tagVerifyDecision({ sig, pinRef, repoDir: rootDir }),
2161
+ tree: tagTreeAgreement({ repoDir: rootDir, ref: pinRef }),
2162
+ pinRef,
2163
+ repoDir: rootDir,
2164
+ })
1578
2165
  if (verdict.action === 'refuse') throw new Error(verdict.text)
1579
2166
  printStep(paint(verdict.text, verdict.action === 'pass' ? 'green' : 'yellow'))
1580
2167
  }
1581
2168
 
1582
2169
  const engineDir = path.join(rootDir, 'engine')
1583
2170
  const buildDir = path.join(engineDir, '.build', 'release')
1584
- const socketPath = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, SOCKET_FILE_NAME)
2171
+ const socketPath = path.join(homeDir, SOCKET_DIR_NAME, SOCKET_FILE_NAME)
1585
2172
  const daemonBin = path.join(buildDir, 'glasspaned')
1586
2173
  const settingsBin = path.join(buildDir, 'glasspane-settings')
1587
- const daemonLog = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, DAEMON_LOG_NAME)
2174
+ const daemonLog = path.join(homeDir, SOCKET_DIR_NAME, DAEMON_LOG_NAME)
2175
+ // 收尾那句"实测校验通过"要说实话,就得知道应答的实例是不是本次安装启动/重启的那一份。
2176
+ let servedByEarlierInstance = false
1588
2177
  // bundle 安置计划(P1 v1.2 §11.1):--no-app 时 appPackaged=false,后续一律
1589
2178
  // 回退裸二进制并如实告警。
1590
2179
  const plan = bundlePlan({
1591
- homeDir: process.env.HOME ?? '',
2180
+ homeDir,
1592
2181
  buildDir,
1593
2182
  appPackaged: options.app,
1594
2183
  })
@@ -1639,11 +2228,31 @@ export async function install({ options = parseArgs([]).options, env = process.e
1639
2228
  ], { cwd: engineDir })
1640
2229
  // 安置到 ~/Applications:TCC 条目要能被用户在系统设置的「+」选择器与
1641
2230
  // Finder 里找到,隐藏目录 `.build` 里的产物做不到这一点(P1 v1.2 §11.1)。
1642
- const installed = installBundles({ plan })
1643
- for (const app of installed) {
2231
+ //
2232
+ // finding 11/12:`rm -rf ~/Applications/GlassPane*.app` 是本流程里的破坏性动作之一
2233
+ // (另有 bootout 已加载作业、结束在跑的实例)。`--no-prompt` 过去是**惰性**的——
2234
+ // confirm() 没有任何调用方,README 与 --help 却写着"跳过每步确认"。现在它真的有
2235
+ // 一档可跳过:交互终端里会问一句;`--no-prompt` 直接放行;非交互又没给 flag 则不覆盖
2236
+ // (install.sh 在 stdin 非 TTY 时本来就会补 --no-prompt,CI/e2e 与 `curl | sh` 行为不变)。
2237
+ const replaceTargets = bundlesToReplace({ plan })
2238
+ let allowReplace = true
2239
+ if (replaceTargets.length > 0) {
2240
+ allowReplace = await confirm(
2241
+ `将用本轮产物覆盖已存在的 .app:${replaceTargets.join('、')}`,
2242
+ { autoYes: options.prompt === false },
2243
+ )
2244
+ if (!allowReplace) {
2245
+ printStep(paint('未确认覆盖:保留现有 .app,本轮不安置新 bundle(脚本场景请加 --no-prompt)', 'yellow'))
2246
+ }
2247
+ }
2248
+ const bundles = installBundles({ plan, allowReplace })
2249
+ for (const app of bundles.installed) {
1644
2250
  printStep(`已安置:${app}`)
1645
2251
  }
1646
- if (installed.length === 0) {
2252
+ for (const app of bundles.declined) {
2253
+ printStep(paint(`未覆盖(回答跳过):${app}——里面还是上一轮的产物`, 'yellow'))
2254
+ }
2255
+ if (bundles.installed.length === 0 && bundles.declined.length === 0) {
1647
2256
  printStep('未安置任何 .app(make-app.sh 未产出 bundle),后续按裸二进制形态运行')
1648
2257
  }
1649
2258
  }
@@ -1667,7 +2276,7 @@ export async function install({ options = parseArgs([]).options, env = process.e
1667
2276
  // 下面这份 plist 的 StandardOutPath/StandardErrorPath 就写在那个目录里,而
1668
2277
  // RunAtLoad 让 launchd 成为第一个往那里写的 spawner(全新机器上目录原本不存在)。
1669
2278
  if (options.launchd) {
1670
- const launchAgentsDir = path.join(process.env.HOME ?? '', ...LAUNCHD_DIR_NAME.split('/'))
2279
+ const launchAgentsDir = path.join(homeDir, ...LAUNCHD_DIR_NAME.split('/'))
1671
2280
  const plistPath = path.join(launchAgentsDir, LAUNCHD_FILE_NAME)
1672
2281
  fs.mkdirSync(launchAgentsDir, { recursive: true })
1673
2282
  if (!fs.existsSync(daemonLaunch.path)) {
@@ -1679,12 +2288,42 @@ export async function install({ options = parseArgs([]).options, env = process.e
1679
2288
  socketPath,
1680
2289
  logPath: daemonLog,
1681
2290
  })
1682
- fs.writeFileSync(plistPath, plistXml, { mode: 0o644 })
2291
+ // finding 12:bootout 一个**已加载且健康**的作业会打断它正在跑的 act,这一步和
2292
+ // 覆盖 .app 一样是破坏性的。先只读探一次(launchdReregisterProbe),只有真的会
2293
+ // 卸掉在跑的东西才问;不需要 bootout 的常规 bootstrap 照旧不问。
2294
+ // **顺序要紧**:确认在写盘之前(见 launchdReregisterProbe 的注释)。
2295
+ let allowBootout = true
2296
+ const reregister = launchdReregisterProbe(daemonLaunch.path)
2297
+ if (reregister.loaded && reregister.needsReregister) {
2298
+ allowBootout = await confirm(
2299
+ `launchd 里已加载 ${LAUNCHD_LABEL}(程序:${reregister.program ?? '(未读到)'}),`
2300
+ + '卸载它才能换新定义——这会打断正在跑的 daemon',
2301
+ { autoYes: options.prompt === false },
2302
+ )
2303
+ if (!allowBootout) {
2304
+ throw new Error(`开机自启未重注册,且**没有**改写 ${plistPath}:未确认 bootout 已加载的 `
2305
+ + `${LAUNCHD_LABEL}(launchd 里跑的还是 ${reregister.program ?? '(未读到)'})。`
2306
+ + '要保留旧作业就什么都不用做;要让新定义生效,在交互终端重跑本命令并回答继续,'
2307
+ + '或在脚本/自动化里显式加 --no-prompt。')
2308
+ }
2309
+ }
2310
+ // finding 7:这份 plist 写的是本机装了哪些可执行、socket 与日志的确切路径,
2311
+ // 过去以 0644 落在 ~/Library/LaunchAgents(同机任何账号可读),而
2312
+ // updater/lib/launchd.js 给自己的代理 plist 早就写 0600 并把模式读回。
2313
+ const plistWrite = writePrivatePlist(plistPath, plistXml)
2314
+ if (!plistWrite.ok) {
2315
+ throw new Error(`launchd plist 没能按 owner-only 落盘:${plistWrite.error}`)
2316
+ }
1683
2317
  const bootstrapResult = launchctlBootstrap(LAUNCHD_LABEL, plistPath, {
1684
2318
  desiredProgram: daemonLaunch.path,
2319
+ allowBootout,
1685
2320
  })
1686
2321
  if (!bootstrapResult.ok) {
1687
- throw new Error(`launchd 注册失败:${bootstrapResult.message}`)
2322
+ throw new Error(
2323
+ bootstrapResult.bootoutDeclined
2324
+ ? `launchd 未重注册(未确认 bootout):${bootstrapResult.message}`
2325
+ : `launchd 注册失败:${bootstrapResult.message}`,
2326
+ )
1688
2327
  }
1689
2328
  printStep(bootstrapResult.already ? bootstrapResult.message : `开机自启已注册(${plistPath})`)
1690
2329
  }
@@ -1714,23 +2353,42 @@ export async function install({ options = parseArgs([]).options, env = process.e
1714
2353
  if (staleDaemons.length > 0 || stalePanels.length > 0) {
1715
2354
  const summary = `daemon ${staleDaemons.join(',') || '无'}|面板 ${stalePanels.join(',') || '无'}`
1716
2355
  if (options.replaceDaemon) {
1717
- const result = terminatePids([...staleDaemons, ...stalePanels])
1718
- printStep(
1719
- result.forced.length > 0
1720
- ? `既有实例已收拢(SIGTERM 未生效、已强杀:${result.forced.join(', ')})`
1721
- : `已结束既有实例:${result.terminated.join(', ')}`,
2356
+ // finding 12:结束在跑的实例是第三件破坏性动作(它会打断正在进行的 act,
2357
+ // 而 daemon 侧的 SIGTERM 收尾是"清理 socket 后退出")。`--no-prompt` 放行、
2358
+ // 交互终端问一句、非交互且没给 flag 则不动别人的进程。
2359
+ const mayTerminate = await confirm(
2360
+ `将结束已在跑的实例(${summary}),其间正在执行的 act 会被打断`,
2361
+ { autoYes: options.prompt === false },
1722
2362
  )
1723
- // 收拢旧实例后主动让 launchd 接管:TERM 现在是 clean exit 0,KeepAlive
1724
- // (SuccessfulExit=false) 语义下 launchd 不会自动复活,必须 kickstart。
1725
- // 不这么做就会退化成"手动实例持有 socket",开机自启那份配置形同虚设。
1726
- if (staleDaemons.length > 0 && options.launchd) {
1727
- const uid = String(spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim())
1728
- if (uid) {
1729
- spawnSync('launchctl', launchdKickstartArgs({ label: LAUNCHD_LABEL, uid }))
1730
- relaunchedByLaunchd = await waitForSocket(socketPath)
1731
- printStep(relaunchedByLaunchd
1732
- ? 'launchd 已按新配置接管 daemon(kickstart)'
1733
- : 'launchd 未在时限内接管,改由安装器直接启动')
2363
+ if (!mayTerminate) {
2364
+ printStep(paint('未确认:没有结束任何在跑的实例(脚本场景请加 --no-prompt)', 'yellow'))
2365
+ } else {
2366
+ const result = terminatePids([...staleDaemons, ...stalePanels])
2367
+ printStep(
2368
+ result.forced.length > 0
2369
+ ? `既有实例已收拢(SIGTERM 未生效、已强杀:${result.forced.join(', ')})`
2370
+ : `已结束既有实例:${result.terminated.join(', ')}`,
2371
+ )
2372
+ // finding 5:`terminated`/`forced` 只覆盖确实已经不在了的 pid。安装器发不动
2373
+ // 信号的进程(root 拉起的旧 daemon)必须单独用红字点名——它还在服务同一个
2374
+ // socket,"已收拢"那句话对它不成立。
2375
+ if (result.survivors.length > 0) {
2376
+ for (const line of survivorText(result.survivors, { socketPath }).split('\n')) {
2377
+ printStep(paint(line, 'red'))
2378
+ }
2379
+ }
2380
+ // 收拢旧实例后主动让 launchd 接管:TERM 现在是 clean exit 0,KeepAlive
2381
+ // (SuccessfulExit=false) 语义下 launchd 不会自动复活,必须 kickstart。
2382
+ // 不这么做就会退化成"手动实例持有 socket",开机自启那份配置形同虚设。
2383
+ if (staleDaemons.length > 0 && options.launchd) {
2384
+ const runUid = String(spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim())
2385
+ if (runUid) {
2386
+ spawnSync('launchctl', launchdKickstartArgs({ label: LAUNCHD_LABEL, uid: runUid }))
2387
+ relaunchedByLaunchd = await waitForSocket(socketPath)
2388
+ printStep(relaunchedByLaunchd
2389
+ ? 'launchd 已按新配置接管 daemon(kickstart)'
2390
+ : 'launchd 未在时限内接管,改由安装器直接启动')
2391
+ }
1734
2392
  }
1735
2393
  }
1736
2394
  } else {
@@ -1744,6 +2402,9 @@ export async function install({ options = parseArgs([]).options, env = process.e
1744
2402
  }
1745
2403
  }
1746
2404
  if (relaunchedByLaunchd || await socketReachable(socketPath)) {
2405
+ // launchd 刚 kickstart 起来的那一份算"本次重启的";纯粹是 socket 早就有人听着,
2406
+ // 就是别人(很可能是上一版)在服务。
2407
+ servedByEarlierInstance = !relaunchedByLaunchd
1747
2408
  printStep(`daemon 已在监听 ${socketPath},跳过启动(若为旧构建实例,用 --replace-daemon 重跑)`)
1748
2409
  } else {
1749
2410
  const start = daemonStartCommand({ daemonLaunch, daemonApp: plan.daemonApp, socketPath })
@@ -1766,10 +2427,21 @@ export async function install({ options = parseArgs([]).options, env = process.e
1766
2427
  }
1767
2428
  }
1768
2429
 
2430
+ // 面板的真实落点(finding 8):bundle 优先/裸二进制兜底**在这里**算一次,GUI 启动与
2431
+ // 「后续使用说明」共用这一份结论。旧代码只在 options.gui 分支里算,说明文本另拼一个
2432
+ // `~/Applications/GlassPane.app` 或从没构建过的 `.build/release/GlassPane.app`——
2433
+ // `--no-app`/什么都没安置时印出来的那句 `open "…"` 指的是一条不存在的路径。
2434
+ const guiLaunch = settingsLaunchPath({ plan, bareBin: settingsBin })
2435
+ const panel = {
2436
+ viaBundle: guiLaunch.viaBundle,
2437
+ executable: guiLaunch.path,
2438
+ app: plan.settingsApp,
2439
+ exists: Boolean(guiLaunch.path) && fs.existsSync(guiLaunch.path),
2440
+ }
2441
+
1769
2442
  let guiOpened = false
1770
2443
  if (options.gui) {
1771
- const guiLaunch = settingsLaunchPath({ plan, bareBin: settingsBin })
1772
- if (!fs.existsSync(guiLaunch.path)) {
2444
+ if (!panel.exists) {
1773
2445
  throw new Error(`设置面板产物不存在:${guiLaunch.path}`)
1774
2446
  }
1775
2447
  const guiStart = guiStartCommand({ guiLaunch, settingsApp: plan.settingsApp })
@@ -1794,12 +2466,20 @@ export async function install({ options = parseArgs([]).options, env = process.e
1794
2466
  const daemonExpected = Boolean(options.daemon || options.launchd)
1795
2467
  const arrived = await waitForSocket(socketPath, { timeoutMs: daemonExpected ? 12000 : 1 })
1796
2468
  const hello = arrived ? await socketHello(socketPath) : null
1797
- const verified = Boolean(arrived && hello?.version)
2469
+ const builtVersion = builtDaemonVersion({ daemonBin })
2470
+ const { verified, note } = installVerification({
2471
+ arrived,
2472
+ helloVersion: hello?.version ?? null,
2473
+ builtVersion,
2474
+ servedByEarlierInstance,
2475
+ })
1798
2476
  const outcome = installOutcome({ verified, daemonExpected })
1799
2477
  if (verified) {
1800
- printStep(`实测校验通过:daemon 在 ${socketPath} 应答 hello(version=${hello.version}${hello.pid ? `,pid=${hello.pid}` : ''})`)
2478
+ printStep(`实测校验通过:daemon 在 ${socketPath} 应答 hello(version=${hello.version}${hello.pid ? `,pid=${hello.pid}` : ''},本轮构建产物=${builtVersion})`)
2479
+ if (note) printStep(paint(note, 'yellow'))
1801
2480
  printStep(`安装完成(退出码 ${outcome.exitCode})。`)
1802
2481
  } else if (daemonExpected) {
2482
+ if (note) printStep(paint(note, 'red'))
1803
2483
  for (const line of verificationFailureText({
1804
2484
  arrived,
1805
2485
  socketPath,
@@ -1823,6 +2503,10 @@ export async function install({ options = parseArgs([]).options, env = process.e
1823
2503
  daemonVerified: verified,
1824
2504
  daemon: daemonLaunch,
1825
2505
  settingsApp: plan.settingsApp ?? undefined,
2506
+ // finding 8:面板那句"再起一次 open …"必须落在真的存在的东西上。这里给的是
2507
+ // **已经算好的** settingsLaunchPath 结果(bundle 优先、裸二进制兜底 + 实测存在),
2508
+ // 不是猜出来的路径。
2509
+ panel,
1826
2510
  update,
1827
2511
  })}\n`)
1828
2512
  return outcome
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-install",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "GlassPane one-command installer: terminal onboarding → install deps → build TS + Swift engine → start daemon → open settings GUI for permission guide",
5
5
  "type": "module",
6
6
  "license": "MIT",