glasspane-install 1.15.0 → 1.16.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 +135 -19
  2. package/package.json +1 -1
package/cli.js CHANGED
@@ -70,7 +70,7 @@ export const INSTALL_SH_URL =
70
70
  /** 发布线锚点(版本真源 = 根 package.json,由 scripts/set-version.mjs 统一改写,
71
71
  * 勿手改):npx 形态下本地没有仓库时,引导 clone 的就是这个 tag,与 install.sh
72
72
  * 的 `GLASSPANE_RELEASE` 同值——两条一键入口必须拿到同一份源码。 */
73
- export const RELEASE_VERSION = '1.15.0'
73
+ export const RELEASE_VERSION = '1.16.0'
74
74
  /** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
75
75
  export const REPO_REF = `v${RELEASE_VERSION}`
76
76
 
@@ -712,11 +712,38 @@ function readPlistProgram(readFile, plistPath) {
712
712
  }
713
713
  }
714
714
 
715
+ /** 当前 uid 的**唯一**读法(四处调用点共用:launchdReregisterProbe、launchctlBootstrap、
716
+ * launchctlKickstart、install() 里收拢旧实例后的 launchd 接管)。
717
+ *
718
+ * 为什么要单独抽出来:`spawnSync('id', ['-u'])` 在**起不来**时给的是
719
+ * `{ error, status: null, stdout: undefined }`,而那三处从前写的都是
720
+ * `.stdout.trim()`——TypeError 不是拒绝:它把紧跟其后那句"无法解析当前 uid(id -u 失败)…"
721
+ * 的补救话连同整条命令的退出码一起吞掉,屏幕上只剩一个未捕获异常的栈。判据("读不到
722
+ * 就不猜 uid")一直是对的,可达性没了;只有 launchdReregisterProbe 一处早写了 `?? ""`。
723
+ * 这里统一成带保护的读法,并把失败原因带出来,供调用点拼那句可执行的补救。 */
724
+ export function readCurrentUid({ spawn = spawnSync } = {}) {
725
+ const probe = spawn('id', ['-u'], { encoding: 'utf8' })
726
+ const uid = String(probe?.stdout ?? '').trim()
727
+ const reason = probe?.error?.message
728
+ ?? (probe?.signal ? `被信号 ${probe.signal} 终止` : null)
729
+ ?? (uid ? null : '没有给出可解析的 uid')
730
+ return { uid: uid || null, reason }
731
+ }
732
+
733
+ /** uid 读不出来时那句拒绝的**共同骨架**:说清哪一步没做、为什么本轮什么都没改、下一步跑什么。 */
734
+ function uidUnresolvedText({ action, label, reason }) {
735
+ return `无法解析当前 uid(id -u 失败:${reason ?? '没有输出'}):launchd 的席位是 gui/<uid>/${label} 这个域,`
736
+ + `uid 拼不出来就${action}不了 ${label}——本轮**没有**动 launchd,什么都没改。`
737
+ + '补救:先让 `/usr/bin/id -u` 在本机能跑出一个数字(`ls -l /usr/bin/id` 看它在不在,'
738
+ + '缺了就 `xcode-select --install` 修 Command Line Tools),再在自己的登录 shell 里重跑本命令;'
739
+ + '不要用 `sudo -u 某用户` / `env -u …` 这类形态起安装器。'
740
+ }
741
+
715
742
  /** 只读探一次"重注册会不会动到在跑的东西"(纯函数,供确认门用)。
716
743
  * 确认必须发生在**写盘之前**:先写 plist 再问,人回答"跳过"之后磁盘上是新定义、
717
744
  * launchd 里还是旧定义,而 `bootoutDeclined` 那句"什么都没改"会当场变成假话。 */
718
745
  export function launchdReregisterProbe(desiredProgram, { label = LAUNCHD_LABEL, spawn = spawnSync } = {}) {
719
- const uid = String(spawn('id', ['-u'], { encoding: 'utf8' })?.stdout ?? '').trim()
746
+ const uid = readCurrentUid({ spawn }).uid ?? ''
720
747
  if (!uid) return { loaded: false, program: null, uid: null, needsReregister: false }
721
748
  const probe = spawn('launchctl', ['print', `gui/${uid}/${label}`], { encoding: 'utf8' })
722
749
  const loaded = probe.status === 0
@@ -745,10 +772,16 @@ export function launchctlBootstrap(label, plistPath, {
745
772
  readFile = (file) => fs.readFileSync(file, 'utf8'),
746
773
  allowBootout = true,
747
774
  } = {}) {
748
- const uid = spawn('id', ['-u'], { encoding: 'utf8' }).stdout.trim()
749
- if (!uid) {
750
- return { ok: false, already: false, message: '无法解析当前 uid(id -u 失败),跳过 launchd 注册' }
775
+ const uidProbe = readCurrentUid({ spawn })
776
+ if (!uidProbe.uid) {
777
+ return {
778
+ ok: false,
779
+ already: false,
780
+ uidUnresolved: true,
781
+ message: uidUnresolvedText({ action: '注册', label, reason: uidProbe.reason }),
782
+ }
751
783
  }
784
+ const uid = uidProbe.uid
752
785
  const domain = `gui/${uid}`
753
786
  const probe = spawn('launchctl', ['print', domain + '/' + label], { encoding: 'utf8' })
754
787
  const wantProgram = desiredProgram ?? readPlistProgram(readFile, plistPath)
@@ -806,12 +839,17 @@ export function launchctlBootstrap(label, plistPath, {
806
839
  /** 以 daemon 自身席位重启已加载的 launchd 作业(`kickstart -k`)。恢复流程里
807
840
  * 只在"作业已加载但自报非 granted"时调用——此时 daemon 本就不可用,中断
808
841
  * 面为零;TCC 判定按进程缓存,勾选授权后必须新进程才看得到(P1 v1.2 §11)。 */
809
- export function launchctlKickstart(label) {
810
- const uid = spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim()
811
- if (!uid) {
812
- return { ok: false, message: '无法解析当前 uid(id -u 失败),跳过 kickstart' }
842
+ export function launchctlKickstart(label, { spawn = spawnSync } = {}) {
843
+ const uidProbe = readCurrentUid({ spawn })
844
+ if (!uidProbe.uid) {
845
+ return {
846
+ ok: false,
847
+ uidUnresolved: true,
848
+ message: uidUnresolvedText({ action: '重启', label, reason: uidProbe.reason }),
849
+ }
813
850
  }
814
- const kick = spawnSync('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], { encoding: 'utf8' })
851
+ const uid = uidProbe.uid
852
+ const kick = spawn('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], { encoding: 'utf8' })
815
853
  const stderr = (kick.stderr ?? '').trim()
816
854
  if (kick.status === 0) {
817
855
  return { ok: true, message: `launchd 已重启作业(gui/${uid}/${label})` }
@@ -819,6 +857,37 @@ export function launchctlKickstart(label) {
819
857
  return { ok: false, message: stderr || `launchctl kickstart 失败,退出码 ${kick.status}` }
820
858
  }
821
859
 
860
+ /** 收拢旧实例后让 launchd 按新配置接管(kickstart -k)+ 有界等 socket。
861
+ *
862
+ * 为什么 TERM 之后还要主动 kickstart:daemon 的 SIGTERM 收尾是 clean exit 0,而 plist 里
863
+ * KeepAlive 用的是 SuccessfulExit=false——按那个语义 launchd 本就不该复活它。不接管就退化
864
+ * 成"手动实例持有 socket",开机自启那份配置形同虚设。
865
+ *
866
+ * uid 读不出来这一支必须**说出来**:它从前是 `if (runUid) {...}` 的静默跳过(而它上面那句
867
+ * `.stdout.trim()` 在 `id` 起不来时直接抛 TypeError——同一个缺陷的两种表现),跳过之后
868
+ * 安装器会自己启动一份手动实例,屏幕上却没有一句"launchd 这次没接管",读者会以为开机自启
869
+ * 已经按新配置在跑。返回 `{relaunched, uid, message}`,`uid === null` 就是那一次没做成。 */
870
+ export async function launchdTakeoverAfterTerminate({
871
+ label = LAUNCHD_LABEL,
872
+ socketPath,
873
+ spawn = spawnSync,
874
+ waitFn = waitForSocket,
875
+ } = {}) {
876
+ const uidProbe = readCurrentUid({ spawn })
877
+ if (!uidProbe.uid) {
878
+ return { relaunched: false, uid: null, message: uidUnresolvedText({ action: '重启', label, reason: uidProbe.reason }) }
879
+ }
880
+ spawn('launchctl', launchdKickstartArgs({ label, uid: uidProbe.uid }))
881
+ const relaunched = await waitFn(socketPath)
882
+ return {
883
+ relaunched,
884
+ uid: uidProbe.uid,
885
+ message: relaunched
886
+ ? 'launchd 已按新配置接管 daemon(kickstart)'
887
+ : 'launchd 未在时限内接管,改由安装器直接启动',
888
+ }
889
+ }
890
+
822
891
  /** 向 daemon socket 发一行 hello(NDJSON 单行协议,与 FrameCodec 对齐),返回
823
892
  * 解析后的 result;不可达/超时/坏响应一律 null——恢复判定必须把"拿不到"
824
893
  * 如实降级为未验证,不得冒充 granted。 */
@@ -1571,7 +1640,13 @@ export function openPrivateLog(logPath, {
1571
1640
  * error 事件都看不见;daemon 到底活没活,只有收尾那次 socket hello 校验说了算。
1572
1641
  *
1573
1642
  * 日志目录(`~/.glasspane`)由 install() 在建 plist、bootstrap 之前一次性创建
1574
- * (见 ensureLogDir);这里的 mkdirSync 只是给直接调用方与单测留的兜底。 */
1643
+ * (见 ensureLogDir);这里的 mkdirSync 只是给直接调用方与单测留的兜底。
1644
+ *
1645
+ * `sleep(settleMs, child)` 的第二个参数:窗口的**长度**由注入的实现决定,所以它需要知道
1646
+ * 自己等的是哪个子进程。默认实现按真实时间等 settleMs(产品语义一字未改:有界窗口、
1647
+ * 窗口结束后采样一次退出状态);测试可以注入"子进程一报退出就收窗、最长不超过某个上界"
1648
+ * 的那一档——在高负载的机器上,"400ms 内排得上一次 fork + exit"并不是事实,拿它当判据的
1649
+ * 控制只在机器空时成立(这类控制会在CI上随机红,也会在真出问题时随机绿)。 */
1575
1650
  export async function startDetached(binPath, args, logPath, { settleMs = LAUNCH_SETTLE_MS, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), openLog = openPrivateLog } = {}) {
1576
1651
  try {
1577
1652
  fs.mkdirSync(path.dirname(logPath), { recursive: true, mode: STATE_DIR_MODE })
@@ -1592,7 +1667,7 @@ export async function startDetached(binPath, args, logPath, { settleMs = LAUNCH_
1592
1667
  child.on('error', (error) => { failure = `${error.message}` })
1593
1668
  child.unref()
1594
1669
  // 给 spawn 的 error 事件一个落地窗口(ENOENT 在下一 tick 才到)。
1595
- await sleep(settleMs)
1670
+ await sleep(settleMs, child)
1596
1671
  if (child.pid == null) {
1597
1672
  return { ok: false, pid: null, error: failure ?? '子进程未起来(拿不到 PID)', exit: null }
1598
1673
  }
@@ -2035,12 +2110,20 @@ export function installOutcome({ verified = false, daemonExpected = true } = {})
2035
2110
  * (上面那条"daemon 已在监听…跳过启动"分支)应答的是上一版构建,新构建从未被启动也从未被验证,
2036
2111
  * 安装器却打印"实测校验通过…安装完成"并退 0,而 `install.sh` 是 exec 委托:按退出码办事的
2037
2112
  * agent 只看到 0。
2113
+ *
2114
+ * `declinedBundles`(2026-10-15 补的那一格):`installBundles` 有一条"目标已存在而覆盖被
2115
+ * 回答跳过"的路(finding 11),跳过时**磁盘上留的是上一轮的 bundle**,daemon 的启动路径也
2116
+ * 照旧选中它。于是"同版本号"这件事再也分不出字节是谁家的:本轮重建了产物却没装上,
2117
+ * hello 应答的版本号与 `glasspaned --version` 一模一样,收尾却要说"实测校验通过…
2118
+ * 本轮构建产物=<v>"——那句在这一层无从验证,写出来就是把未验证写成已验证。
2119
+ * 所以 declined 非空时 verified 只能是 false。
2038
2120
  */
2039
2121
  export function installVerification({
2040
2122
  arrived = false,
2041
2123
  helloVersion = null,
2042
2124
  builtVersion = null,
2043
2125
  servedByEarlierInstance = false,
2126
+ declinedBundles = [],
2044
2127
  } = {}) {
2045
2128
  if (!arrived || !helloVersion) return { verified: false, note: null }
2046
2129
  if (!builtVersion) {
@@ -2057,6 +2140,18 @@ export function installVerification({
2057
2140
  + '这一轮的构建从未被验证。用 --replace-daemon 重跑(它会先收拢在跑的实例)。',
2058
2141
  }
2059
2142
  }
2143
+ const declined = (Array.isArray(declinedBundles) ? declinedBundles : []).filter(Boolean)
2144
+ if (declined.length > 0) {
2145
+ return {
2146
+ verified: false,
2147
+ note: `应答 hello 的 daemon 自报 ${helloVersion},与本轮构建产物同号——但这一轮的 .app 覆盖被回答跳过:`
2148
+ + `${declined.join('、')} 里装的还是**上一轮**的产物,daemon 启动路径也照旧选中它。`
2149
+ + '同号只可能是同一版本的两份字节,这一层分不出来,所以"应答的就是本轮构建"这句话没有依据——'
2150
+ + '按"不得把未验证写成已验证",本轮不算校验通过(磁盘上的 bundle 不是本轮构建的那一份)。'
2151
+ + '补救:在交互终端重跑本命令,并对"将用本轮产物覆盖已存在的 .app"回答继续;'
2152
+ + '脚本/自动化里显式加 --no-prompt。',
2153
+ }
2154
+ }
2060
2155
  if (servedByEarlierInstance) {
2061
2156
  return {
2062
2157
  verified: true,
@@ -2133,7 +2228,21 @@ export function verificationFailureText({
2133
2228
  daemonLog,
2134
2229
  cliPath,
2135
2230
  exitCode = 1,
2231
+ declinedBundles = [],
2136
2232
  } = {}) {
2233
+ const declined = (Array.isArray(declinedBundles) ? declinedBundles : []).filter(Boolean)
2234
+ if (declined.length > 0) {
2235
+ // 这一档的病因不是"hello 没答上来",也不是 launchd 作业没注册——是**本轮产物压根没装上盘**。
2236
+ // 把 --restore-launchd 那条补救派给它是误归因(它修注册,不覆盖 bundle),所以单独一支。
2237
+ return [
2238
+ `安装未完成:daemon 在 ${socketPath} 应答了 hello,但这一轮的 .app 覆盖被回答跳过——`
2239
+ + `${declined.join('、')} 里还是上一轮的产物,本轮构建的字节没被装上,也就没被验证。`,
2240
+ `本命令退出码 ${exitCode}:install.sh 与调用它的脚本/agent 按安装失败处理,先别接 MCP。`,
2241
+ '补救:在交互终端重跑本命令,并对"将用本轮产物覆盖已存在的 .app"回答继续;'
2242
+ + '脚本/自动化里显式加 --no-prompt。(--restore-launchd 修的是作业注册,不覆盖 bundle,这条用不上。)',
2243
+ `对照口径:本轮构建产物自己报的版本取自 ${daemonLog} 同一次安装的 glasspaned --version;装上盘才算数。`,
2244
+ ].join('\n')
2245
+ }
2137
2246
  return [
2138
2247
  arrived
2139
2248
  ? `安装未完成:socket 在(${socketPath}),但 daemon 没有正确应答 hello——见 ${daemonLog}`
@@ -2231,6 +2340,9 @@ export async function install({
2231
2340
  const daemonLog = path.join(homeDir, SOCKET_DIR_NAME, DAEMON_LOG_NAME)
2232
2341
  // 收尾那句"实测校验通过"要说实话,就得知道应答的实例是不是本次安装启动/重启的那一份。
2233
2342
  let servedByEarlierInstance = false
2343
+ // 本轮**没有**覆盖掉的 bundle(覆盖被回答跳过时磁盘上留的是上一轮的产物):收尾的版本核对
2344
+ // 需要它,否则"同版本号"会被当成"就是本轮构建"(见 installVerification 的 declinedBundles)。
2345
+ let declinedBundles = []
2234
2346
  // bundle 安置计划(P1 v1.2 §11.1):--no-app 时 appPackaged=false,后续一律
2235
2347
  // 回退裸二进制并如实告警。
2236
2348
  const plan = bundlePlan({
@@ -2309,6 +2421,7 @@ export async function install({
2309
2421
  }
2310
2422
  }
2311
2423
  const bundles = installBundles({ plan, allowReplace })
2424
+ declinedBundles = bundles.declined
2312
2425
  for (const app of bundles.installed) {
2313
2426
  printStep(`已安置:${app}`)
2314
2427
  }
@@ -2443,14 +2556,15 @@ export async function install({
2443
2556
  // 收拢旧实例后主动让 launchd 接管:TERM 现在是 clean exit 0,KeepAlive
2444
2557
  // (SuccessfulExit=false) 语义下 launchd 不会自动复活,必须 kickstart。
2445
2558
  // 不这么做就会退化成"手动实例持有 socket",开机自启那份配置形同虚设。
2559
+ // uid 读不出来时**红字说出来**(判据与句子见 launchdTakeoverAfterTerminate),
2560
+ // 静默跳过会让读者以为开机自启已经按新配置在跑。
2446
2561
  if (staleDaemons.length > 0 && options.launchd) {
2447
- const runUid = String(spawnSync('id', ['-u'], { encoding: 'utf8' }).stdout.trim())
2448
- if (runUid) {
2449
- spawnSync('launchctl', launchdKickstartArgs({ label: LAUNCHD_LABEL, uid: runUid }))
2450
- relaunchedByLaunchd = await waitForSocket(socketPath)
2451
- printStep(relaunchedByLaunchd
2452
- ? 'launchd 已按新配置接管 daemon(kickstart)'
2453
- : 'launchd 未在时限内接管,改由安装器直接启动')
2562
+ const takeover = await launchdTakeoverAfterTerminate({ socketPath })
2563
+ if (takeover.uid === null) {
2564
+ printStep(paint(takeover.message, 'red'))
2565
+ } else {
2566
+ relaunchedByLaunchd = takeover.relaunched
2567
+ printStep(takeover.message)
2454
2568
  }
2455
2569
  }
2456
2570
  }
@@ -2535,6 +2649,7 @@ export async function install({
2535
2649
  helloVersion: hello?.version ?? null,
2536
2650
  builtVersion,
2537
2651
  servedByEarlierInstance,
2652
+ declinedBundles,
2538
2653
  })
2539
2654
  const outcome = installOutcome({ verified, daemonExpected })
2540
2655
  if (verified) {
@@ -2549,6 +2664,7 @@ export async function install({
2549
2664
  daemonLog,
2550
2665
  cliPath: path.join(rootDir, 'installer', 'cli.js'),
2551
2666
  exitCode: outcome.exitCode,
2667
+ declinedBundles,
2552
2668
  }).split('\n')) {
2553
2669
  printStep(paint(line, 'red'))
2554
2670
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-install",
3
- "version": "1.15.0",
3
+ "version": "1.16.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",