glasspane-install 1.15.1 → 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.
- package/cli.js +135 -19
- 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.
|
|
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 =
|
|
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
|
|
749
|
-
if (!uid) {
|
|
750
|
-
return {
|
|
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
|
|
811
|
-
if (!uid) {
|
|
812
|
-
return {
|
|
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
|
|
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
|
|
2448
|
-
if (
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
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.
|
|
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",
|