glasspane-install 1.3.1 → 1.5.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 (3) hide show
  1. package/README.md +11 -0
  2. package/cli.js +429 -1
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,11 +39,22 @@ a fixed artifact).
39
39
  | `--no-daemon` / `--no-gui` | 不启动 daemon / 不打开设置面板 |
40
40
  | `--no-app` | 不打包 `.app`(daemon 将以裸二进制运行,系统设置里只显示文件名) |
41
41
  | `--no-launchd` | 不注册开机自启 |
42
+ | `--no-auto-update` | 不注册每日自动更新代理(并把它卸载 + 状态记 `disabled=true`);手动 `check`/`apply` 仍可用 |
42
43
  | `--replace-daemon` | 先收拢已在跑的旧实例(旧构建会占住 socket) |
43
44
  | `--skip-build` | 跳过 npm/tsc/swift 编译 |
44
45
  | `--restore-launchd` | 只做 launchd 恢复 + 校验 daemon 自报席位 |
45
46
  | `-h, --help` | 完整用法 |
46
47
 
48
+ 默认**开**自动更新:安装器把 `{updaterCli, installRoot, agentLabel,
49
+ registeredAt}` 写进 `<stateRoot>/update-install.json`(0600,临时文件 + rename + 读回校验),
50
+ 并注册每日一次的 `com.glasspane.update` 作业。指针与作业都由 `updater/` 自己那套实现写出
51
+ (`writePointer` / `renderAgentPlist` / `registerAgent`),安装器不渲染 plist、不抄标签。
52
+ 安装树里没有 `updater/cli.js`(旧 checkout)时:**如实打印缺的是哪个文件、你失去什么**,
53
+ 不写指针、不注册作业,而安装本身仍然成功——静默跳过不是这个仓库允许的失败形态。
54
+
55
+ 卸载本机安装时,除了 daemon 那份作业,**也要**卸掉更新代理:
56
+ `node <repo>/updater/cli.js disable` 一条命令做这件事(bootout + 删 plist + 记 `disabled=true`)。
57
+
47
58
  ## What still needs a human / 仍需人工的两件事
48
59
 
49
60
  1. **TCC permissions.** Accessibility, Input Monitoring and Screen Recording must be ticked in
package/cli.js CHANGED
@@ -21,6 +21,7 @@ import { spawn, spawnSync } from 'node:child_process'
21
21
  import { createInterface } from 'node:readline/promises'
22
22
  import { pathToFileURL } from 'node:url'
23
23
  import fs from 'node:fs'
24
+ import os from 'node:os'
24
25
  import path from 'node:path'
25
26
  import process from 'node:process'
26
27
  import net from 'node:net'
@@ -30,10 +31,20 @@ export const MIN_NODE_MAJOR = 18
30
31
  export const SOCKET_DIR_NAME = '.glasspane'
31
32
  export const SOCKET_FILE_NAME = 'engine.sock'
32
33
  export const DAEMON_LOG_NAME = 'installer-daemon.log'
34
+ /** daemon 作业名。**真源是 updater 的 `DAEMON_JOB_LABEL`**(规格 §2/§3 都在那边用它);
35
+ * 这里保留一份同值常量是因为安装器必须能脱离 updater/ 独立 import(npx 形态、旧
36
+ * checkout),而 `test/auto-update.test.mjs` 把两者钉成相等——改任何一边都会红。 */
33
37
  export const LAUNCHD_LABEL = 'com.glasspane.daemon'
34
38
  export const LAUNCHD_DIR_NAME = 'Library/LaunchAgents'
35
39
  export const LAUNCHD_FILE_NAME = `${LAUNCHD_LABEL}.plist`
36
40
 
41
+ /** 安装树里 updater 各模块的相对位置(规格 §7:指针与定时代理都必须指向**安装产物里**
42
+ * 的那一份 CLI,而不是 installer 自己旁边的副本)。 */
43
+ export const UPDATER_CLI_SEGMENTS = ['updater', 'cli.js']
44
+ export const UPDATER_STATE_SEGMENTS = ['updater', 'lib', 'state.js']
45
+ export const UPDATER_LAUNCHD_SEGMENTS = ['updater', 'lib', 'launchd.js']
46
+ export const UPDATER_CODES_SEGMENTS = ['updater', 'lib', 'codes.js']
47
+
37
48
  /** 分离启动后的**有界观察窗口**:spawn 失败(ENOENT、参数被拒)以异步 error 事件
38
49
  * 落地,`/usr/bin/open` 目标缺失则以非零退出落地,两者都发生在启动后的几百毫秒
39
50
  * 内,所以窗口要给足才能真的看见失败(旧值 150ms 常常在 `open` 退出前就收工,
@@ -58,10 +69,72 @@ export const INSTALL_SH_URL =
58
69
  /** 发布线锚点(版本真源 = 根 package.json,由 scripts/set-version.mjs 统一改写,
59
70
  * 勿手改):npx 形态下本地没有仓库时,引导 clone 的就是这个 tag,与 install.sh
60
71
  * 的 `GLASSPANE_RELEASE` 同值——两条一键入口必须拿到同一份源码。 */
61
- export const RELEASE_VERSION = '1.3.1'
72
+ export const RELEASE_VERSION = '1.5.0'
62
73
  /** 发布 ref(tag 名)。GLASSPANE_REF 环境变量可覆盖(追主干用 `main`)。 */
63
74
  export const REPO_REF = `v${RELEASE_VERSION}`
64
75
 
76
+ /** 发布签名公钥(GPG,key 0929EA31DF4F7429F63FC53189D88B1D043A1298,uid
77
+ * jingzhao-l (sign-github) <ET_lin@outlook.com>)。与 npm 安装链的 Integrity 不同,
78
+ * GPG 校验的是"来源/作者身份"而不是"内容哈希":clone 到的发布 tag 若带 GPG 签名,
79
+ * 用这把公钥验签通过,才能确证源码出自发布者之手(而非同名 tag 或中间人)。
80
+ * 恒为 best-effort:未签名/不可用只告警不阻断,因为 HTTPS clone + tag 钉版本已保证
81
+ * 内容完整性,GPG 是额外的一层来源证明。 */
82
+ export const GLASSPANE_SIGNING_PUBLIC_KEY = [
83
+ '-----BEGIN PGP PUBLIC KEY BLOCK-----',
84
+ '',
85
+ 'mQINBGq45VQBEACdjAoLYyfgPpHjvscmGqxlSsBkcBvSAoGHdCI0p2Rn5cBDaPie',
86
+ 'oPU17VmUiK4FBZf8FcaX0L+EeMRO4Bcj5NgoFaSgQPK0YarvoPssClNiWf71hDlg',
87
+ 'QmC5IlwM4WuVUeKi3+YoPmRSf0sYHzSYM7vEIoCFzEilYi4iEK/NMihNSktlUsQx',
88
+ 'jhIaXtnVJi+7GkO+dhckKmIHhcR76dUfIAsS/R0RzzH4ZXfuKi+B94mfCntURpM4',
89
+ 'G+NrxZx7Xv5UDpv9XsrmiWKzNpT+Th9GbNQREjrT1mmKbMEOmD/PWTqxNycJfgdW',
90
+ 'hWA++1oassOib3jd44+z5f7FKpp//C+SK8V7vuxNI0jRM/VYrbyrfON30hHwtbFM',
91
+ '0J/quDsAUzlOBNzNVPAyvGsOuSEULFtjaJ2q+JYjF+ZKDPy+lyY8V9sbYrqCCUb9',
92
+ 'v3wPn4tDvat30Q0A0rheMZPTMO7tRNSzFOd25H0w3ZF3F5Np8D/aX9VHMkA12xTm',
93
+ 'l7gpb1OIEJ1vdQl0twiF6SDz5jsteHfdUXha6CtM7tv7IZ4MIZi/qPkvv4zOKmsE',
94
+ 'utLUs1alD671Eez2sQow8NO5IXfd7bX2d34kU5JiM1tF9qhBxOZlpzd/Vm8FZS4V',
95
+ 'URF6Y7myW4ildqBeMzLt0to8WjHnHoV4v9rh5581qWEqvNIJ4/R3lKRhxwARAQAB',
96
+ 'tC1qaW5nemhhby1sIChzaWduLWdpdGh1YikgPEVUX2xpbkBvdXRsb29rLmNvbT6J',
97
+ 'Am0EEwEIAFcWIQQJKeox3090KfY/xTGJ2IsdBDoSmAUCarjlVBsUgAAAAAAEAA5t',
98
+ 'YW51MiwyLjUrMS4xMiwwLDMCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AA',
99
+ 'CgkQidiLHQQ6EpharQ//VhcNiug3cHsgvb/tTqWp1CQV8heSfqoKrW51RPhcGAHW',
100
+ 'VMHpbPRO0wBKKE5mybyGAWhGDhh5mZt1MxnBN3lC7RsWBLEaXyJAqW4UPjR5LN8Q',
101
+ 'scapkCzFwrF5lisELdqKqkd/ACKR8h6U/fBf0eKE+TMDSrXZ/LkRcFRJErfsC7rx',
102
+ 'hy1WQnzQBT2+86HmfW9rrw5RSyCp8MZ0TJhYr0ZdgB4zvLwvVYQCnlRaskkLjGrh',
103
+ '6vUHAjDCUwoDFEpecadCJg34cOEAMRjnTt6Q0t8SnVHDH9PLq1MwGON2VzuSp5rY',
104
+ 'rthT3+VRzbzGpBu4wl4/GJiWJMfGTusEu8Ver6MTwHx9pFBDtH3cawIB5BT1RrXk',
105
+ 'cYdhtjzJ61RXIrSCeD8yoxEgDOj73Ll6oQ4+fJ+EpOc+SvP9FREeQ4k/uc8MpwtV',
106
+ 'YyD/6EPJu1lLMAzgd2Xm2ljokTRhm/Blft9Y0OEEWzsoDGv+jr3Jb3Dgw62OuL6B',
107
+ 'pLiZ5XNCYBHYhQhtleGnSpJtD9ooi1UUTbVZftunzYGKafMCgc9nnzPIGVtlzX+d',
108
+ 'K10CtPOX7ylS+lKukaIOSStGGSl3I2Fd66yb3ujIH6n/KAKLfMmmy48pxB3+t6WW',
109
+ 'StdD7QEASWIyW2wTrq7RyDwmzWMSFtgPzCOWFmcQfFykkvvQEqxMeyCrcPY6ZIy5',
110
+ 'Ag0EarjlVAEQAOjPGVDb8zGIc7XQelHhjyd8yLCVpNBWwYLmaSLfI+EQsfVVDJqT',
111
+ 'VAAeO82woHELPun06lbJRW59eH8BkVgzGhNkb5vKhrdvmZydYElC1NuRB9ag6/k/',
112
+ '0IaLwedKZscy1k3oG2LqsayzUO3L2d8BxO8zdLEmIl7FqtTdsYwj6DDRgZdA4Aj0',
113
+ 'VoUXOgWaR+7qA9GHnnucrE5n0zhrTd7F3mtZErWr6Edo/V9EHQ1PszsQTVH2artr',
114
+ 'lYJWjSsAv/ajEvAjaZ1mJoDvz/UzUk7hCCPeNcpy41SpDb3uey38qqxLYOGVgEeD',
115
+ '7JTrhC51VNHj2CCxSgyrlvED+resJtgWnE65Sa8g9cGAVpXlOBRQrPZGMHS+BcAg',
116
+ '9lQExMGrWt76ZPgT5Gygzj09oGx1q6/IyATphit4TblFGl2z1JDnlUQqfz/aCmZN',
117
+ 'Vg+fKBPQ93dLLGPKpmY022X3abJ197VaQ0WWB2cA8pcrcfJ8GY7Lm8xjm6nY/cER',
118
+ 'RQl72dE1NJsPGrp3Ad/s7fAJuEdR8UmMUPLDQRpiNRTcK6RC2AaRD7wpdyG/1csF',
119
+ 'P30IIY97aVSjD5nnkrHxNQKZ17yPef+bFIoJ7OS+WhLZKcr7P0DfgJoTbKc6GK6M',
120
+ 'ScQm5N7lRJ9Mfzu4R6576hDrgb3fmgflmpKOIVrZ6GkTGDvQ/FE16QdrABEBAAGJ',
121
+ 'AlIEGAEIADwWIQQJKeox3090KfY/xTGJ2IsdBDoSmAUCarjlVBsUgAAAAAAEAA5t',
122
+ 'YW51MiwyLjUrMS4xMiwwLDMCGwwACgkQidiLHQQ6Epi4aw//Qu00vxGtvRb+VQl9',
123
+ 'lMZLwIP2AgB0lAgKAqYeK6jZh/15GAKJqRh0u2jdgqXj2Sfm79X7Qwn7wAuFUAmx',
124
+ 'D1eegOtdAnEP6O8DUtZWWmy2TSRIqjfTcGXlZ12WHiOwwdG5VOUZERWi/rPj0zTs',
125
+ '5V1H4qyPOqgrFx9nNvavzo3zeJVpwYuuFkT2Ne0cZLXGglCQ6MJtuK0Qwk6iYsvy',
126
+ 'p3eZ1YCKmAi1v0UFojtFHqJEAsc3PnZb+48veE9b2whrL9DIIkNrrFlfFC1cjm7T',
127
+ 'wuRQuH6aYyrRoZiLxgwkW5xmc1Biitw7bMIX6eqYVn8hb1lQUKhTL8aZ2xQa6IsJ',
128
+ 'RHIJFYeEIEUtIl/GKQ3MeHQlJrXsfnZ1e8MHgwgMw3o4Nq4xww3Ch0pddYhBskmV',
129
+ '/QUaOHVqmuur9dnRvx9L+FGbzHEjvYDr0MkSe30hUhyBIg1uOLd2elATB/wg33Ow',
130
+ 'vcdqgewqpYdSg6g6KZYl6NhmWWNEMgX8KITUXFoTiV5CrSsrptBPJWsyIq+CuseL',
131
+ 'CKFdMHrkzbjFLGfdiPqykwttwHBAEk01aWArDP65gXRXmxGzDHVkA7Px1hdo/kMo',
132
+ 'Ouw6bEGpHtx7UJJMSMA9ywbTrOyaG4xzVDa7ixUslFtgxts1R/eLoC4I11grxE50',
133
+ 'YVXa4IEQs7aBxKO+n+T2AvUYiKQ=',
134
+ '=DYXk',
135
+ '-----END PGP PUBLIC KEY BLOCK-----',
136
+ ].join('\n')
137
+
65
138
  const ANSI = {
66
139
  reset: '\x1b[0m',
67
140
  bold: '\x1b[1m',
@@ -93,6 +166,7 @@ export function parseArgs(argv) {
93
166
  replaceDaemon: false,
94
167
  skipBuild: false,
95
168
  restoreLaunchd: false,
169
+ autoUpdate: true,
96
170
  bootstrap: true,
97
171
  help: false,
98
172
  }
@@ -124,6 +198,11 @@ export function parseArgs(argv) {
124
198
  case '--no-launchd':
125
199
  opts.launchd = false
126
200
  break
201
+ case '--no-auto-update':
202
+ // 规格 §5:默认开,一键关。关掉 = 不注册每日代理 + 状态里记 disabled=true
203
+ // (由 updater 自己的 writer 写,面板才会显示"自动更新已关闭"而不是"从未检查")。
204
+ opts.autoUpdate = false
205
+ break
127
206
  case '--no-daemon':
128
207
  opts.daemon = false
129
208
  break
@@ -610,6 +689,320 @@ export async function restoreLaunchd({
610
689
  }
611
690
  }
612
691
 
692
+ /** 判定 `git verify-tag` 结果的语义(纯函数,可单测)。
693
+ * git 对"未签名 tag"与"签名无效"都返回退出码 1,必须靠 stderr 里的
694
+ * "no signature found" 区分——前者是"本来就没签"(未签名),后者才是"签名对不上"
695
+ * (invalid)。退出码 0 = 用已导入的发布公钥验签通过。 */
696
+ export function classifyTagVerify({ status, stdout = '', stderr = '' } = {}) {
697
+ const text = `${stdout ?? ''}\n${stderr ?? ''}`
698
+ if (status === 0) {
699
+ return { status: 'verified', detail: '发布 tag 的 GPG 签名有效,出自发布者私钥' }
700
+ }
701
+ if (/no signature found/i.test(text)) {
702
+ return { status: 'unsigned', detail: '该发布 tag 未做 GPG 签名(不是内容被篡改,只是缺签名层)' }
703
+ }
704
+ return { status: 'invalid', detail: `git verify-tag 失败(退出码 ${status}),签名无法确证` }
705
+ }
706
+
707
+ /** 仓库里是否存在指定 tag ref(IO 探测,纯判定)。 */
708
+ export function repoHasRef(repoDir, ref, { spawn = spawnSync } = {}) {
709
+ return spawn('git', ['-C', repoDir, 'rev-parse', '--verify', `refs/tags/${ref}`], { stdio: 'ignore' }).status === 0
710
+ }
711
+
712
+ /** 对 clone 下来的发布 tag 做 GPG 验签(best-effort,不抛错)。
713
+ * 隔离 GNUPGHOME 并临时导入发布公钥,`git verify-tag` 只看退出码与 stderr;不动
714
+ * 用户钥匙环、无网络依赖。返回 classifyTagVerify 的口径(verified/unsigned/invalid/
715
+ * unavailable)。不能确证来源时不阻断——被验的 tag 与安装用的源码是同一个 ref,
716
+ * HTTPS + tag 钉版本已保证内容完整。 */
717
+ export function verifyCloneTagGpg({
718
+ repoDir,
719
+ ref,
720
+ tmpDir = null,
721
+ spawn = spawnSync,
722
+ mkdtemp = fs.mkdtempSync,
723
+ remove = fs.rmSync,
724
+ } = {}) {
725
+ const gpgVersion = spawn('gpg', ['--version'], { encoding: 'utf8' })
726
+ if (gpgVersion.error || gpgVersion.status !== 0) {
727
+ return { status: 'unavailable', detail: 'gpg 不可用,跳过发布 tag GPG 验签' }
728
+ }
729
+ if (!repoHasRef(repoDir, ref, { spawn })) {
730
+ return { status: 'unavailable', detail: `仓库中不存在 tag ${ref},跳过验签` }
731
+ }
732
+ let gnupgHome = tmpDir
733
+ let created = false
734
+ try {
735
+ if (!gnupgHome) {
736
+ gnupgHome = mkdtemp(path.join(os.tmpdir(), 'glasspane-gnupg-'))
737
+ created = true
738
+ }
739
+ fs.mkdirSync(gnupgHome, { recursive: true })
740
+ const keyPath = path.join(gnupgHome, 'publish-key.asc')
741
+ fs.writeFileSync(keyPath, GLASSPANE_SIGNING_PUBLIC_KEY, 'utf8')
742
+ const imp = spawn('gpg', ['--homedir', gnupgHome, '--batch', '--import', keyPath], { encoding: 'utf8' })
743
+ if (imp.status !== 0) {
744
+ return { status: 'unavailable', detail: '导入发布公钥失败,跳过验签' }
745
+ }
746
+ const res = spawn('git', ['-C', repoDir, 'verify-tag', ref], {
747
+ encoding: 'utf8',
748
+ env: { ...process.env, GNUPGHOME: gnupgHome },
749
+ })
750
+ return classifyTagVerify({ status: res.status, stdout: res.stdout, stderr: res.stderr })
751
+ } catch (error) {
752
+ return { status: 'unavailable', detail: `发布 tag GPG 验签不可用:${error.message}` }
753
+ } finally {
754
+ if (created) {
755
+ try { remove(gnupgHome, { recursive: true, force: true }) } catch { /* 临时目录清理失败不致命 */ }
756
+ }
757
+ }
758
+ }
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // 自动更新接线(规格 2026-09-27 §5「默认开、一键关」+ §7「指针文档」)
762
+ //
763
+ // 这里**不**渲染 plist、**不**拼 LaunchAgents 路径、**不**自己实现 0600 writer、
764
+ // **不**再判一次"用环境变量关掉自动更新"那个开关:四样都从安装树里的 updater 模块取
765
+ // (`renderAgentPlist` / `registerAgent` / `agentPlistPath` / `AGENT_LABEL` /
766
+ // `writePointer` / `resolveStateRoot` / `runCommand`)。注册与卸载走
767
+ // `runCommand('enable'|'disable')`——与面板按钮、与定时代理跑的是同一条实现
768
+ // (§7「一处实现,一处真相」),也因此那个环境开关只由 updater 自己读一次:它答
769
+ // "环境已经关了"(返回 auto-disabled 这个 code,安装器读的是它的**结论**而不是变量本身),
770
+ // 安装器就再请它执行自己的 disable,好让状态里落下与 --no-auto-update 同样的
771
+ // disabled=true,而不是在 installer 里做第二套解读。
772
+ // 选择"import 模块并调 runCommand"而不是"spawn node <root>/updater/cli.js enable":
773
+ // 后者无法在不碰真实 ~/Library/LaunchAgents 的前提下被单测驱动(updater 自己的
774
+ // cli.test.mjs 只能跑 status/usage 这类无副作用子命令)。注入 deps 里的
775
+ // `runLaunchctl`/`uid`/`homeDir` 就能把整条注册链留在临时 HOME 内,这正是本仓
776
+ // 测试一直在用的口径。
777
+ // ---------------------------------------------------------------------------
778
+
779
+ /** 安装树里 updater 各入口的绝对路径(指针 §7 要写的就是这一份的位置)。 */
780
+ export function updaterTreePaths(rootDir) {
781
+ return {
782
+ cli: path.join(rootDir, ...UPDATER_CLI_SEGMENTS),
783
+ state: path.join(rootDir, ...UPDATER_STATE_SEGMENTS),
784
+ launchd: path.join(rootDir, ...UPDATER_LAUNCHD_SEGMENTS),
785
+ codes: path.join(rootDir, ...UPDATER_CODES_SEGMENTS),
786
+ }
787
+ }
788
+
789
+ /**
790
+ * 安装树里没有更新脚本(旧 checkout / 未打包 updater/)时的文案(纯函数,可单测)。
791
+ * 三件事必须说齐:缺的是**哪个文件的绝对路径**、因此没写成什么、用户因此失去什么。
792
+ * 刻意不写代理标签——那个名字只存在于 updater 的常量里,此处读不到,宁可说"每日
793
+ * 定时更新代理"也不在 installer 里抄一份(抄了就是改名时会漂移的第二真源)。
794
+ */
795
+ export function autoUpdateMissingText({ updaterCli, rootDir, detail = null } = {}) {
796
+ return [
797
+ `自动更新未接线:安装树里找不到更新脚本 ${updaterCli}`,
798
+ `(${rootDir} 这份源码缺 updater/ 目录——旧 checkout,或安装产物里没打包该目录。)`,
799
+ '本轮因此**没有**写安装指针,也**没有**注册每日定时更新代理。你失去:每日定时的自动检查与',
800
+ '换版、设置面板的「立即检查 / 安装更新」两个按钮(面板读不到安装指针会显示"读不到更新状态",',
801
+ '不会假装成功)、以及 gp_diagnose 响应里的 update 摘要。',
802
+ 'daemon 与开机自启不受影响,安装结论仍由收尾的 hello 校验决定;要拿回自动更新:',
803
+ '把源码更新到含 updater/ 的版本后重跑安装器(它会补写指针并注册代理)。',
804
+ ...(detail ? [`(读取 ${updaterCli} 时的原始错误:${detail})`] : []),
805
+ ].join('\n')
806
+ }
807
+
808
+ /**
809
+ * 取安装树里的 updater 模块。文件不存在、或它自己的依赖不在(ERR_MODULE_NOT_FOUND/
810
+ * ENOENT)→ `{ missing, detail }`,由调用方**如实打印**后跳过;其它加载错误一律抛——
811
+ * 把"坏了"说成"没有"是本仓禁止的那类静默。
812
+ */
813
+ async function importUpdaterModule(file, { exists }) {
814
+ if (!exists(file)) return { missing: file, detail: '文件不存在' }
815
+ try {
816
+ return { module: await import(pathToFileURL(file).href) }
817
+ } catch (error) {
818
+ if (error?.code === 'ERR_MODULE_NOT_FOUND' || error?.code === 'ENOENT') {
819
+ return { missing: file, detail: error.message }
820
+ }
821
+ throw new Error(`安装树里的更新脚本无法加载:${file}(${error?.message ?? String(error)})`)
822
+ }
823
+ }
824
+
825
+ /**
826
+ * 写 §7 的指针文档并注册/卸载每日代理。
827
+ *
828
+ * 三个注入位是给单测留的(`homeDir`/`uid`/`runLaunchctl`):整条注册链——plist 落盘、
829
+ * `launchctl bootout/bootstrap`——都留在临时 HOME 里,开发机的 `~/Library/LaunchAgents`
830
+ * 一个字节都不动,口径与 test/cli.test.mjs 的 restoreLaunchd 假件、
831
+ * updater/test/launchd.test.mjs 的 `run` 注入一致。真实安装只传 `rootDir/env/autoUpdate`。
832
+ *
833
+ * 幂等:指针是整份覆盖写(`writePointer` 临时文件 + rename + 读回校验 mode),代理
834
+ * 那边 `registerAgent` 先 `bootout` 再 `bootstrap`——重跑安装既不会留下两个作业,也
835
+ * 不会留下指向已挪走 checkout 的旧 plist(launchd 缓存 bootstrap 时读到的定义,
836
+ * 这正是本仓 EX_CONFIG 栽过两轮的坑)。
837
+ *
838
+ * 返回的是给使用说明与调用方读的结论;`state: 'missing'` 是**已如实打印告警**的跳过
839
+ * (安装继续、退出码不受影响),其余失败一律抛给 install() 走非零退出——静默跳过是
840
+ * 本仓禁止的失败形态。
841
+ */
842
+ export async function registerAutoUpdate({
843
+ rootDir,
844
+ env = process.env,
845
+ homeDir = null,
846
+ uid = null,
847
+ runLaunchctl = null,
848
+ autoUpdate = true,
849
+ now = new Date(),
850
+ exists = fs.existsSync,
851
+ say = (line) => printStep(line),
852
+ // §9:注册那一趟现在会导出系统根证书束并起子进程探测一次 release 端点。
853
+ // 单测必须能替换掉这两件事——否则 `npm test` 变成一次联网测试,慢且不稳。
854
+ updaterDeps = {},
855
+ } = {}) {
856
+ const tree = updaterTreePaths(rootDir)
857
+ const modules = {}
858
+ for (const [key, file] of [['cli', tree.cli], ['state', tree.state], ['launchd', tree.launchd], ['codes', tree.codes]]) {
859
+ const got = await importUpdaterModule(file, { exists })
860
+ if (got.missing) {
861
+ for (const line of autoUpdateMissingText({ updaterCli: file, rootDir, detail: got.detail }).split('\n')) {
862
+ say(paint(line, 'yellow'))
863
+ }
864
+ return {
865
+ ok: true,
866
+ registered: false,
867
+ state: 'missing',
868
+ reason: 'updater-missing',
869
+ agentLabel: null,
870
+ schedule: null,
871
+ stateRoot: null,
872
+ updaterCli: file,
873
+ pointer: null,
874
+ pointerPath: null,
875
+ detail: got.detail,
876
+ }
877
+ }
878
+ modules[key] = got.module
879
+ }
880
+ const { cli, state, launchd, codes } = modules
881
+
882
+ // 状态根只由 updater 的 resolveStateRoot 决定(flag → GLASSPANE_STATE_DIR → 家目录
883
+ // /.glasspane);homeDir 为空时不传,让它按自己的口径取口令记录里的那个家。
884
+ const homeArgs = homeDir ? { homeDir } : {}
885
+ const stateRoot = cli.resolveStateRoot({ flags: {}, env, ...homeArgs })
886
+ const agentLabel = launchd.AGENT_LABEL
887
+
888
+ // §7 指针四字段,写盘用 updater 自己的 writer(0600 + 临时文件 + rename + 读回校验)。
889
+ const written = state.writePointer(
890
+ stateRoot,
891
+ { updaterCli: tree.cli, installRoot: rootDir, agentLabel },
892
+ { now },
893
+ )
894
+ const readBack = state.readPointer(stateRoot)
895
+ if (!readBack.ok) {
896
+ throw new Error(`自动更新指针写完读不回来(${readBack.code}):${readBack.message}`)
897
+ }
898
+
899
+ const flags = { json: false, auto: false, help: false, consents: [], overrides: {}, 'state-dir': stateRoot }
900
+ const deps = { cliPath: tree.cli, ...homeArgs, ...updaterDeps }
901
+ if (uid !== null && uid !== undefined) deps.uid = String(uid)
902
+ if (runLaunchctl) deps.runLaunchctl = runLaunchctl
903
+
904
+ let outcome = await cli.runCommand({ command: autoUpdate ? 'enable' : 'disable', flags, env, deps })
905
+ let switchedOffByEnv = false
906
+ if (autoUpdate && outcome.code === codes.CODES.autoDisabled) {
907
+ // updater 自己读了那个环境变量并拒绝注册。安装器不重复解读它,只把它的原话留着,
908
+ // 再请它跑自己的 disable——这样面板读到的是 disabled=true("自动更新已关闭"),
909
+ // 而不是"从未检查"。
910
+ const updaterSentence = outcome.message
911
+ const second = await cli.runCommand({ command: 'disable', flags, env, deps })
912
+ if (!second.ok) {
913
+ throw new Error(`自动更新状态卸载写入失败:${second.message ?? '未知原因'}(updater 关于环境变量的一手原话:${updaterSentence})`)
914
+ }
915
+ outcome = { ...second, message: `${updaterSentence}|${second.message}` }
916
+ switchedOffByEnv = true
917
+ }
918
+ if (!outcome.ok) {
919
+ throw new Error(`${autoUpdate ? '自动更新代理注册' : '自动更新代理卸载'}失败:${outcome.message ?? '未知原因'}`)
920
+ }
921
+
922
+ const registered = autoUpdate && !switchedOffByEnv
923
+ const schedule = `${launchd.DEFAULT_HOUR}:${String(launchd.DEFAULT_MINUTE).padStart(2, '0')}`
924
+ const summary = {
925
+ ok: true,
926
+ registered,
927
+ state: registered ? 'registered' : 'off',
928
+ reason: switchedOffByEnv ? 'env' : (autoUpdate ? null : 'flag'),
929
+ agentLabel,
930
+ schedule,
931
+ stateRoot,
932
+ updaterCli: tree.cli,
933
+ pointer: written.pointer,
934
+ pointerPath: written.path,
935
+ message: outcome.message,
936
+ // `undefined`(这份 updater 根本不回报)与 `null`(回报了但没有束)对读者是同一句话:
937
+ // 没有证据。合成一个形状,下游就不必为两种"没有"各写一条分支——而两种分支里,
938
+ // 漏掉一种就是崩溃。
939
+ caRoots: outcome.state && outcome.state.caRoots ? outcome.state.caRoots : null,
940
+ }
941
+ if (registered) {
942
+ say(`每日自动更新已注册(${agentLabel},默认 ${schedule},状态根 ${stateRoot};指针 ${written.path})——一键关:node "${tree.cli}" disable`)
943
+ // §9.8:node 不读 macOS 信任库,装了 HTTPS 中间人的机器上这件事只有 updater 自己能发现。
944
+ // 安装日志必须把它说出来——否则每天失败的是一个看起来在正常工作的代理。措辞与 remedy
945
+ // 都来自 outcome.message(唯一作者),这里只转述,不重写。
946
+ const ca = summary.caRoots
947
+ if (ca && ca.status === 'ok') {
948
+ say(`node 的系统根证书束:已导出 ${ca.certs} 张 → ${ca.path},并已写进该作业的 NODE_EXTRA_CA_CERTS`)
949
+ } else if (ca) {
950
+ say(paint(`注意(自动更新的网络这一跳):${outcome.message}`, 'yellow'))
951
+ } else {
952
+ say(paint(`注意:这份 updater 没有回报根证书束的导出结果(旧版本);若本机 HTTPS 被中间人替换,每日检查会在第一道校验上失败,跑 node "${tree.cli}" enable 可重新导出。`, 'yellow'))
953
+ }
954
+ } else {
955
+ say(
956
+ `自动更新已关闭(${switchedOffByEnv ? 'updater 报环境变量已关,见下面它自己的原话' : '--no-auto-update'}):`
957
+ + `${agentLabel} 未注册/已卸载,状态里记 disabled=true——面板显示"自动更新已关闭(上次检查 …)"而不是"从未检查"。`
958
+ + `手动 node "${tree.cli}" check / apply 仍可用;重开:node "${tree.cli}" enable。`
959
+ + `(updater 原话:${outcome.message})`,
960
+ )
961
+ }
962
+ return summary
963
+ }
964
+
965
+ /**
966
+ * §5 在「后续使用说明」里的落点:把代理名、时刻、状态根与**真路径**的开关命令写清楚。
967
+ * `update` 缺省(老调用方)时一行不出,以免给没接线的环境编出一段说明。
968
+ * 末行是卸载口径——移除 daemon 作业的那些手工命令必须同时移除这个更新代理,
969
+ * 否则装完再删的用户会留下一个每天照跑、却没有界面入口的定时作业。
970
+ */
971
+ export function updateGuidanceText(update) {
972
+ if (!update) return []
973
+ if (update.state === 'missing') {
974
+ return [
975
+ `6. 自动更新:本轮未接线(安装树缺 ${update.updaterCli},详见上面那条告警)。`,
976
+ ' 这一份装出来的 GlassPane 不会自己更新,面板上的两个更新按钮也读不到状态;',
977
+ ' 把源码更新到含 updater/ 的版本后重跑安装器即可补上(指针 + 每日代理)。',
978
+ ]
979
+ }
980
+ if (update.state === 'registered') {
981
+ const ca = update.caRoots
982
+ const caLine = !ca
983
+ ? ` ⚠ 这份 updater 没有回报 node 要用的系统根证书束(旧版本);本机 HTTPS 若被中间人替换,每日检查会在第一道校验上失败。补上:node "${update.updaterCli}" enable。`
984
+ : ca.status === 'ok'
985
+ ? ` node 用本机导出的系统根证书束 ${ca.certs} 张(${ca.path},已写进作业的 NODE_EXTRA_CA_CERTS)。`
986
+ : ` ⚠ 本机没能导出 node 要用的系统根证书束(${ca.status});HTTPS 被中间人替换的机器上,每日检查会在第一道校验上失败,`
987
+ + '面板与 gp_diagnose 会一直显示这句话,修复办法是它给的那条。'
988
+ return [
989
+ `6. 自动更新:默认开。launchd 每日 ${update.schedule} 跑 "${update.updaterCli}" check && apply`,
990
+ ` (作业 ${update.agentLabel},RunAtLoad=false,日志 ${update.stateRoot}/update.log);`,
991
+ ' 只在 daemon 空闲应答探针、且规格 §1 八道校验全过时才换版,major 永远等人点按钮。',
992
+ ...(caLine ? [caLine] : []),
993
+ ` 一键关:node "${update.updaterCli}" disable(卸该作业 + 状态记 disabled=true);重开:enable。`,
994
+ ` 卸载本机安装时:bootout daemon 作业的同时**也必须** bootout ${update.agentLabel} 并删它的 plist。`,
995
+ ]
996
+ }
997
+ return [
998
+ `6. 自动更新:已关闭(${update.reason === 'env' ? 'updater 判定环境变量为关,安装器没有第二套解读' : '--no-auto-update'})。`,
999
+ ` ${update.agentLabel} 未注册,状态记 disabled=true:面板显示"自动更新已关闭(上次检查 …)",`,
1000
+ ' 不会静默消失;手动检查/安装仍是同一条 CLI(面板上那两个按钮也是它)。',
1001
+ ` 重开:node "${update.updaterCli}" enable${update.reason === 'env' ? '(先把那个环境变量去掉,否则 updater 按 §7 拒绝注册)' : ''}。`,
1002
+ ` 它也负责卸载:bootout ${update.agentLabel} + 删 ~/Library/LaunchAgents 下那份 plist。`,
1003
+ ]
1004
+ }
1005
+
613
1006
  // ---------------------------------------------------------------------------
614
1007
  // 执行层
615
1008
  // ---------------------------------------------------------------------------
@@ -792,6 +1185,10 @@ export function usageText() {
792
1185
  ' --no-app 不打包/安置 .app(仅裸二进制产物;权限条目将只显示文件',
793
1186
  ' 名且无图标,见 P1 v1.2 §11.1)',
794
1187
  ' --no-launchd 不注册开机自启(launchd bootstrap)',
1188
+ ' --no-auto-update 不注册每日自动更新代理(规格 2026-09-27 §5):卸载该代理并在',
1189
+ ' 状态里记 disabled=true,面板显示"自动更新已关闭"而不是"从未',
1190
+ ' 检查";手动 check/apply 仍可用。等价的环境开关由 updater 自己',
1191
+ ' 判定(本安装器不重复解读那个环境变量)',
795
1192
  ' --restore-launchd 只做 launchd 恢复:检测作业被 bootout → bootstrap → hello',
796
1193
  ' 校验 daemon 自报席位(勾完 TCC 框后重跑即自动换进程复验)',
797
1194
  ' --skip-build 跳过 npm/tsc/swift 编译(已构建过时使用)',
@@ -839,6 +1236,7 @@ export function nextStepsText({
839
1236
  daemonVerified = true,
840
1237
  daemon = { viaBundle: false, path: null },
841
1238
  settingsApp = null,
1239
+ update = null,
842
1240
  }) {
843
1241
  const settingsAppHint =
844
1242
  settingsApp ?? path.join(rootDir, 'engine', '.build', 'release', SETTINGS_APP_NAME)
@@ -886,6 +1284,7 @@ export function nextStepsText({
886
1284
  '5. registry 发布形态:MCP 服务器已可独立安装(`npm i -g glasspane-mcp`,命令名',
887
1285
  ' glasspane-mcp);但它只是转发层,daemon 与权限仍由本次安装产出,缺 daemon 时',
888
1286
  ' 工具调用会返回带补救步骤的结构化错误。',
1287
+ ...updateGuidanceText(update),
889
1288
  '',
890
1289
  ].join('\n')
891
1290
  }
@@ -993,6 +1392,19 @@ export async function install({ options = parseArgs([]).options, env = process.e
993
1392
  rootDir = plan.targetDir
994
1393
  }
995
1394
 
1395
+ // 发布 tag GPG 验签(best-effort):定位/ clone 到的根目录若含发布 tag,就用发布
1396
+ // 公钥验签其签名。未签名/不可用只告警不阻断——HTTPS clone + tag 钉版本已保证内容
1397
+ // 完整性,GPG 是来源证明的增量(见 verifyCloneTagGpg)。
1398
+ const pinRef = env.GLASSPANE_REF || REPO_REF
1399
+ if (pinRef !== 'main' && repoHasRef(rootDir, pinRef)) {
1400
+ const sig = verifyCloneTagGpg({ repoDir: rootDir, ref: pinRef })
1401
+ if (sig.status === 'verified') {
1402
+ printStep(paint(`发布 tag ${pinRef} GPG 签名验证通过`, 'green'))
1403
+ } else {
1404
+ printStep(paint(`发布 tag ${pinRef} 未通过 GPG 验签(${sig.status}:${sig.detail})——继续安装`, 'yellow'))
1405
+ }
1406
+ }
1407
+
996
1408
  const engineDir = path.join(rootDir, 'engine')
997
1409
  const buildDir = path.join(engineDir, '.build', 'release')
998
1410
  const socketPath = path.join(process.env.HOME ?? '', SOCKET_DIR_NAME, SOCKET_FILE_NAME)
@@ -1103,6 +1515,21 @@ export async function install({ options = parseArgs([]).options, env = process.e
1103
1515
  printStep(bootstrapResult.already ? bootstrapResult.message : `开机自启已注册(${plistPath})`)
1104
1516
  }
1105
1517
 
1518
+ // 自动更新接线(规格 2026-09-27 §5/§7):daemon 作业注册之后就做,且**不**因为
1519
+ // --no-launchd 而跳过——每日定时代理与开机自启是两件事(前者 RunAtLoad=false)。
1520
+ // 装树里没有 updater/ 时是"如实告警 + 不写指针 + 安装继续";其余失败(指针读不回、
1521
+ // launchctl 拒绝 bootstrap、状态写不进)一律抛出 → 退出码 1,绝不静默跳过。
1522
+ //
1523
+ // 这里**不**传 homeDir:状态根与代理 plist 的落点都由 updater 自己算(状态根取口令
1524
+ // 记录里的家目录,见 updater/cli.js 的 recordHomeDir 注释与它的 home.test.mjs),
1525
+ // 安装器其余路径仍按 process.env.HOME 拼——那是 SECURITY §2.5 记着的已知未闭口,
1526
+ // 本轮不给它再添第三种家目录口径;只保证"指针落在 `updater enable` 会落的那一处"。
1527
+ const update = await registerAutoUpdate({
1528
+ rootDir,
1529
+ env,
1530
+ autoUpdate: options.autoUpdate,
1531
+ })
1532
+
1106
1533
  if (options.daemon) {
1107
1534
  if (!fs.existsSync(daemonLaunch.path)) {
1108
1535
  throw new Error(`daemon 产物不存在:${daemonLaunch.path}(请勿在未构建时使用 --skip-build 启动 daemon)`)
@@ -1222,6 +1649,7 @@ export async function install({ options = parseArgs([]).options, env = process.e
1222
1649
  daemonVerified: verified,
1223
1650
  daemon: daemonLaunch,
1224
1651
  settingsApp: plan.settingsApp ?? undefined,
1652
+ update,
1225
1653
  })}\n`)
1226
1654
  return outcome
1227
1655
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "glasspane-install",
3
- "version": "1.3.1",
3
+ "version": "1.5.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",