glasspane-install 1.11.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.
- package/cli.js +659 -83
- 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.
|
|
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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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
|
-
*
|
|
457
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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 不存在(${
|
|
895
|
+
message: `launchd plist 不存在(${resolvedPlist}):从未安装或已被删除,请重跑 install.sh 完整安装`,
|
|
696
896
|
}
|
|
697
897
|
}
|
|
698
|
-
const boot = bootstrapFn(label,
|
|
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(
|
|
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(
|
|
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(${
|
|
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') {
|
|
@@ -1177,11 +1384,28 @@ export function run(cmd, args, { cwd } = {}) {
|
|
|
1177
1384
|
})
|
|
1178
1385
|
}
|
|
1179
1386
|
|
|
1180
|
-
/**
|
|
1181
|
-
|
|
1182
|
-
|
|
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) {
|
|
1183
1401
|
return true
|
|
1184
1402
|
}
|
|
1403
|
+
if (!interactive) {
|
|
1404
|
+
return false
|
|
1405
|
+
}
|
|
1406
|
+
if (ask) {
|
|
1407
|
+
return Boolean(await ask(question))
|
|
1408
|
+
}
|
|
1185
1409
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
1186
1410
|
try {
|
|
1187
1411
|
const answer = await rl.question(`${question} (Enter 继续 / n 跳过) `)
|
|
@@ -1395,7 +1619,13 @@ export function usageText() {
|
|
|
1395
1619
|
'',
|
|
1396
1620
|
'选项:',
|
|
1397
1621
|
' --repo <目录> 指定项目根目录(默认自动向上查找)',
|
|
1398
|
-
' --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 时已经自动补上)。',
|
|
1399
1629
|
' --no-daemon 不启动 glasspaned',
|
|
1400
1630
|
' --replace-daemon 先结束已在跑的 glasspaned 实例(旧构建的 daemon 会继续占',
|
|
1401
1631
|
' socket,让系统设置里的授权条目与实际服务实例对不上)',
|
|
@@ -1454,10 +1684,14 @@ export function nextStepsText({
|
|
|
1454
1684
|
daemonVerified = true,
|
|
1455
1685
|
daemon = { viaBundle: false, path: null },
|
|
1456
1686
|
settingsApp = null,
|
|
1687
|
+
panel = null,
|
|
1457
1688
|
update = null,
|
|
1458
1689
|
}) {
|
|
1459
|
-
|
|
1460
|
-
|
|
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 })
|
|
1461
1695
|
const entryName = daemon.viaBundle ? 'GlassPane Daemon' : 'glasspaned'
|
|
1462
1696
|
return [
|
|
1463
1697
|
paint('后续使用说明', 'bold'),
|
|
@@ -1485,10 +1719,12 @@ export function nextStepsText({
|
|
|
1485
1719
|
...(guiOpened ? [
|
|
1486
1720
|
' 面板已打开(口径:启动命令已发出且在观察窗口内未报错退出——bundle 形态走的',
|
|
1487
1721
|
' `open` 在目标 bundle 缺失时会非零退出,未报错即产物确实在;窗口外的失败看不',
|
|
1488
|
-
` 到,所以没看到窗口属正常(-g
|
|
1722
|
+
` 到,所以没看到窗口属正常(-g 不抢焦点),再起一次:${panelHint.text}${panelHint.runnable ? '' : '(本轮没有可重开的产物,见上一条)'})。`,
|
|
1489
1723
|
' 点权限卡「授权」会让 daemon 以自身身份发起申请并跳转到对应系统面板。',
|
|
1724
|
+
] : panelHint.runnable ? [
|
|
1725
|
+
` 打开面板:${panelHint.text}(点权限卡「授权」即由 daemon 自身发起申请)。`,
|
|
1490
1726
|
] : [
|
|
1491
|
-
`
|
|
1727
|
+
` ${panelHint.text}`,
|
|
1492
1728
|
]),
|
|
1493
1729
|
` 验证 daemon 自报的席位与主体:${daemon.path ?? path.join(rootDir, 'engine', '.build', 'release', DAEMON_EXE_NAME)} --permissions`,
|
|
1494
1730
|
' 口径提醒:daemon 必须由 launchd/登录项拉起,权限自报才等于真实席位;从终端或',
|
|
@@ -1507,6 +1743,184 @@ export function nextStepsText({
|
|
|
1507
1743
|
].join('\n')
|
|
1508
1744
|
}
|
|
1509
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
|
+
|
|
1510
1924
|
/**
|
|
1511
1925
|
* 无本地仓库时的源码引导计划(纯函数,可单测)。与 install.sh 的源码定位策略
|
|
1512
1926
|
* 同构:clone 发布 tag 到 ~/glasspane,两条一键入口拿到同一份源码。
|
|
@@ -1528,10 +1942,14 @@ export function bootstrapPlan({
|
|
|
1528
1942
|
error: `目标目录已存在且不是 GlassPane 仓库:${targetDir}(用 --repo 指定仓库,或清理该目录后重试)`,
|
|
1529
1943
|
}
|
|
1530
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 }
|
|
1531
1949
|
return {
|
|
1532
1950
|
targetDir,
|
|
1533
|
-
ref,
|
|
1534
|
-
gitArgs:
|
|
1951
|
+
ref: clone.ref,
|
|
1952
|
+
gitArgs: clone.args,
|
|
1535
1953
|
}
|
|
1536
1954
|
}
|
|
1537
1955
|
|
|
@@ -1596,15 +2014,62 @@ export function installVerification({
|
|
|
1596
2014
|
return { verified: true, note: null }
|
|
1597
2015
|
}
|
|
1598
2016
|
|
|
1599
|
-
/** 本轮构建产物自报的版本(`glasspaned --version` 那一行);读不出返回 null,不猜。
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
+
}
|
|
1602
2028
|
if (res.error || res.status !== 0) return null
|
|
1603
|
-
|
|
2029
|
+
// 真产物这一行是 `glasspaned 1.11.0`(`DaemonVersionFlagTests` 钉的就是这个形状),
|
|
2030
|
+
// 不是裸版本号:按 `^<数字>` 找行的实现对本机每一份真 daemon 都读不出,于是
|
|
2031
|
+
// "本轮构建产物未被验证"这句话每次安装都得说一遍,退出码恒 1。取行里那个版本记号,
|
|
2032
|
+
// 并要求它是独立的一个词(不把 `v1.2.3`、`1.2.3.4` 之类读成版本)。
|
|
2033
|
+
const match = String(res.stdout ?? '')
|
|
1604
2034
|
.split('\n')
|
|
1605
2035
|
.map((text) => text.trim())
|
|
1606
|
-
.find((text) =>
|
|
1607
|
-
|
|
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
|
+
}
|
|
1608
2073
|
}
|
|
1609
2074
|
|
|
1610
2075
|
/** 未通过校验时的提示行(纯函数,可单测)。必须自带**用户真能执行**的补救命令,
|
|
@@ -1627,10 +2092,28 @@ export function verificationFailureText({
|
|
|
1627
2092
|
}
|
|
1628
2093
|
|
|
1629
2094
|
/** 主安装流程;所有副作用步骤均记录真实执行结果,失败即抛错终止。
|
|
1630
|
-
* 返回值是收尾校验结论(`installOutcome`),入口守卫据此设退出码。
|
|
1631
|
-
|
|
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
|
+
} = {}) {
|
|
1632
2105
|
const explicitRoot = options.repoDir ? path.resolve(options.repoDir) : null
|
|
1633
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
|
+
|
|
1634
2117
|
let rootDir = explicitRoot ?? envRoot ?? resolveProjectRoot()
|
|
1635
2118
|
|
|
1636
2119
|
if (!rootDir || !fs.existsSync(rootDir)) {
|
|
@@ -1638,19 +2121,29 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1638
2121
|
throw new Error(repoMissingText())
|
|
1639
2122
|
}
|
|
1640
2123
|
const plan = bootstrapPlan({
|
|
1641
|
-
homeDir
|
|
2124
|
+
homeDir,
|
|
1642
2125
|
installDir: env.GLASSPANE_INSTALL_DIR ?? null,
|
|
1643
|
-
|
|
2126
|
+
repoUrl: env.GLASSPANE_REPO_URL ?? REPO_URL,
|
|
2127
|
+
ref: pinRef,
|
|
1644
2128
|
})
|
|
1645
2129
|
if (plan.error) {
|
|
1646
2130
|
throw new Error(`${plan.error}\n\n${repoMissingText()}`)
|
|
1647
2131
|
}
|
|
1648
2132
|
printStep(`未定位到本地仓库,引导 clone:${plan.targetDir}(ref: ${plan.ref})……`)
|
|
1649
|
-
|
|
2133
|
+
// stderr 要留着归因(finding 9):任何非零都算"网络不可达或 tag 不存在"会把
|
|
2134
|
+
// "目标目录已存在且非空"这类本地故障说成网络问题,读者据此一直重试同一条死路。
|
|
2135
|
+
const clone = spawnSync('git', plan.gitArgs, {
|
|
2136
|
+
stdio: ['inherit', 'inherit', 'pipe'],
|
|
2137
|
+
encoding: 'utf8',
|
|
2138
|
+
})
|
|
1650
2139
|
if (clone.status !== 0) {
|
|
1651
2140
|
throw new Error(
|
|
1652
|
-
|
|
1653
|
-
|
|
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()}`,
|
|
1654
2147
|
)
|
|
1655
2148
|
}
|
|
1656
2149
|
if (resolveProjectRoot(plan.targetDir) === null) {
|
|
@@ -1660,7 +2153,6 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1660
2153
|
}
|
|
1661
2154
|
|
|
1662
2155
|
// 发布 tag GPG 验签:动作怎么落由 `tagVerifyDecision` 判(纯函数,有单测)。
|
|
1663
|
-
const pinRef = env.GLASSPANE_REF || REPO_REF
|
|
1664
2156
|
if (pinRef !== 'main' && repoHasRef(rootDir, pinRef)) {
|
|
1665
2157
|
const sig = verifyCloneTagGpg({ repoDir: rootDir, ref: pinRef })
|
|
1666
2158
|
// 先判"签名对不对",再判"要装的是不是被签的那棵树"——两问分开,绿字才只在对得上时出现。
|
|
@@ -1676,16 +2168,16 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1676
2168
|
|
|
1677
2169
|
const engineDir = path.join(rootDir, 'engine')
|
|
1678
2170
|
const buildDir = path.join(engineDir, '.build', 'release')
|
|
1679
|
-
const socketPath = path.join(
|
|
2171
|
+
const socketPath = path.join(homeDir, SOCKET_DIR_NAME, SOCKET_FILE_NAME)
|
|
1680
2172
|
const daemonBin = path.join(buildDir, 'glasspaned')
|
|
1681
2173
|
const settingsBin = path.join(buildDir, 'glasspane-settings')
|
|
1682
|
-
const daemonLog = path.join(
|
|
2174
|
+
const daemonLog = path.join(homeDir, SOCKET_DIR_NAME, DAEMON_LOG_NAME)
|
|
1683
2175
|
// 收尾那句"实测校验通过"要说实话,就得知道应答的实例是不是本次安装启动/重启的那一份。
|
|
1684
2176
|
let servedByEarlierInstance = false
|
|
1685
2177
|
// bundle 安置计划(P1 v1.2 §11.1):--no-app 时 appPackaged=false,后续一律
|
|
1686
2178
|
// 回退裸二进制并如实告警。
|
|
1687
2179
|
const plan = bundlePlan({
|
|
1688
|
-
homeDir
|
|
2180
|
+
homeDir,
|
|
1689
2181
|
buildDir,
|
|
1690
2182
|
appPackaged: options.app,
|
|
1691
2183
|
})
|
|
@@ -1736,11 +2228,31 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1736
2228
|
], { cwd: engineDir })
|
|
1737
2229
|
// 安置到 ~/Applications:TCC 条目要能被用户在系统设置的「+」选择器与
|
|
1738
2230
|
// Finder 里找到,隐藏目录 `.build` 里的产物做不到这一点(P1 v1.2 §11.1)。
|
|
1739
|
-
|
|
1740
|
-
|
|
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) {
|
|
1741
2250
|
printStep(`已安置:${app}`)
|
|
1742
2251
|
}
|
|
1743
|
-
|
|
2252
|
+
for (const app of bundles.declined) {
|
|
2253
|
+
printStep(paint(`未覆盖(回答跳过):${app}——里面还是上一轮的产物`, 'yellow'))
|
|
2254
|
+
}
|
|
2255
|
+
if (bundles.installed.length === 0 && bundles.declined.length === 0) {
|
|
1744
2256
|
printStep('未安置任何 .app(make-app.sh 未产出 bundle),后续按裸二进制形态运行')
|
|
1745
2257
|
}
|
|
1746
2258
|
}
|
|
@@ -1764,7 +2276,7 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1764
2276
|
// 下面这份 plist 的 StandardOutPath/StandardErrorPath 就写在那个目录里,而
|
|
1765
2277
|
// RunAtLoad 让 launchd 成为第一个往那里写的 spawner(全新机器上目录原本不存在)。
|
|
1766
2278
|
if (options.launchd) {
|
|
1767
|
-
const launchAgentsDir = path.join(
|
|
2279
|
+
const launchAgentsDir = path.join(homeDir, ...LAUNCHD_DIR_NAME.split('/'))
|
|
1768
2280
|
const plistPath = path.join(launchAgentsDir, LAUNCHD_FILE_NAME)
|
|
1769
2281
|
fs.mkdirSync(launchAgentsDir, { recursive: true })
|
|
1770
2282
|
if (!fs.existsSync(daemonLaunch.path)) {
|
|
@@ -1776,12 +2288,42 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1776
2288
|
socketPath,
|
|
1777
2289
|
logPath: daemonLog,
|
|
1778
2290
|
})
|
|
1779
|
-
|
|
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
|
+
}
|
|
1780
2317
|
const bootstrapResult = launchctlBootstrap(LAUNCHD_LABEL, plistPath, {
|
|
1781
2318
|
desiredProgram: daemonLaunch.path,
|
|
2319
|
+
allowBootout,
|
|
1782
2320
|
})
|
|
1783
2321
|
if (!bootstrapResult.ok) {
|
|
1784
|
-
throw new Error(
|
|
2322
|
+
throw new Error(
|
|
2323
|
+
bootstrapResult.bootoutDeclined
|
|
2324
|
+
? `launchd 未重注册(未确认 bootout):${bootstrapResult.message}`
|
|
2325
|
+
: `launchd 注册失败:${bootstrapResult.message}`,
|
|
2326
|
+
)
|
|
1785
2327
|
}
|
|
1786
2328
|
printStep(bootstrapResult.already ? bootstrapResult.message : `开机自启已注册(${plistPath})`)
|
|
1787
2329
|
}
|
|
@@ -1811,23 +2353,42 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1811
2353
|
if (staleDaemons.length > 0 || stalePanels.length > 0) {
|
|
1812
2354
|
const summary = `daemon ${staleDaemons.join(',') || '无'}|面板 ${stalePanels.join(',') || '无'}`
|
|
1813
2355
|
if (options.replaceDaemon) {
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
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 },
|
|
1819
2362
|
)
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
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
|
+
}
|
|
1831
2392
|
}
|
|
1832
2393
|
}
|
|
1833
2394
|
} else {
|
|
@@ -1866,10 +2427,21 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1866
2427
|
}
|
|
1867
2428
|
}
|
|
1868
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
|
+
|
|
1869
2442
|
let guiOpened = false
|
|
1870
2443
|
if (options.gui) {
|
|
1871
|
-
|
|
1872
|
-
if (!fs.existsSync(guiLaunch.path)) {
|
|
2444
|
+
if (!panel.exists) {
|
|
1873
2445
|
throw new Error(`设置面板产物不存在:${guiLaunch.path}`)
|
|
1874
2446
|
}
|
|
1875
2447
|
const guiStart = guiStartCommand({ guiLaunch, settingsApp: plan.settingsApp })
|
|
@@ -1931,6 +2503,10 @@ export async function install({ options = parseArgs([]).options, env = process.e
|
|
|
1931
2503
|
daemonVerified: verified,
|
|
1932
2504
|
daemon: daemonLaunch,
|
|
1933
2505
|
settingsApp: plan.settingsApp ?? undefined,
|
|
2506
|
+
// finding 8:面板那句"再起一次 open …"必须落在真的存在的东西上。这里给的是
|
|
2507
|
+
// **已经算好的** settingsLaunchPath 结果(bundle 优先、裸二进制兜底 + 实测存在),
|
|
2508
|
+
// 不是猜出来的路径。
|
|
2509
|
+
panel,
|
|
1934
2510
|
update,
|
|
1935
2511
|
})}\n`)
|
|
1936
2512
|
return outcome
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "glasspane-install",
|
|
3
|
-
"version": "1.
|
|
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",
|