codex-ringside 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-10-31)
4
+
5
+ - Automatically check stable GitHub releases every six hours, verify and install newer standalone bundles, and retry failures after one hour. Preserve settings, back up and roll back failed updates without closing the desktop client.
6
+ - Add `update`, `update --check`, `update --enable`, `update --disable` and update diagnostics in `status`; serialize updates with installation/uninstallation and cancel stale downloads.
7
+ - Add a one-command macOS 13+ installer that verifies release checksums and selects native Apple Silicon or Intel bundles, including when Terminal runs under Rosetta.
8
+ - Ship private Node 22.23.4 and precompiled Swift helpers so users do not need Node, Homebrew or Xcode Command Line Tools. LaunchAgents and the installed CLI use the private Node.
9
+ - Preserve settings, migration backups and transactional rollback across standalone upgrades; uninstall removes the private runtime too.
10
+ - Build/check standalone bundles on both macOS architectures in CI, publish both archives and the installer with checksums, and update English/Chinese installation and release documentation.
11
+
12
+ ## 0.1.1
13
+
14
+ - Add copyable English and Chinese AI installation prompts to the README, covering prerequisites, installation, existing settings and status verification.
15
+ - Present AI-assisted and terminal installation as separate options. Runtime behavior is unchanged.
16
+
3
17
  ## 0.1.0
4
18
 
5
19
  First stable public release. Includes all candidate features, with verified Codex Plus migration, preserved backgrounds and a single active renderer component on the development Mac.
package/README.md CHANGED
@@ -12,46 +12,93 @@ See your remaining allowance at a glance, hover for reset times, and click a rin
12
12
 
13
13
  ## Install
14
14
 
15
- Requirements: macOS, Node.js 22+, Xcode Command Line Tools (`xcode-select --install`), and a signed-in compatible desktop client. The development host is `/Applications/ChatGPT.app`, version `26.1027.30824`, on Apple Silicon. Other host versions depend on compatible internal interfaces and are not individually verified. The CLI and settings panel currently use Chinese labels.
15
+ One-command installation requires **macOS 13+** (Apple Silicon or Intel) and a signed-in compatible desktop client. **You do not need Node.js, npm, Homebrew or Xcode Command Line Tools.** The release includes a private Node runtime and a precompiled helper. The development host is `/Applications/ChatGPT.app`, version `26.1027.30824`; other host versions depend on compatible internal interfaces. CLI and panel labels currently use Chinese.
16
+
17
+ ### Option 1: One-command install (recommended)
18
+
19
+ Paste into Terminal:
16
20
 
17
21
  ```bash
18
- npx codex-ringside@latest install
22
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash
19
23
  ```
20
24
 
25
+ Available starting with release **0.2.0**. The script selects your native architecture (including Apple Silicon terminals running under Rosetta), downloads a release bundle over HTTPS, checks its SHA-256, and installs under your user account. It does not use or change your system Node, shell configuration or Homebrew installation; no `sudo` is required. The background task also uses the private runtime. Installation leaves an already-open desktop session running.
26
+
21
27
  If rings do not appear, save your work, quit the desktop app with ⌘Q, and reopen its original icon. The add-on may reopen a freshly launched client once, subject to the safeguards below.
22
28
 
23
- Observe mode installs the agent without allowing it to restart the desktop client:
29
+ Observe mode disables that startup handoff:
30
+
31
+ ```bash
32
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash -s -- --observe
33
+ ```
34
+
35
+ To inspect the installer before running it, download `install.sh` from [Releases](https://github.com/tccpc/codex-ringside/releases), read it, then run `/bin/bash install.sh`. Use `--version 0.2.0` to select an exact release. To set a custom host path, export it before running the one-command installer:
24
36
 
25
37
  ```bash
26
- npx codex-ringside@latest install --observe
38
+ export CODEX_RINGSIDE_APP="/Applications/ChatGPT.app"
27
39
  ```
28
40
 
29
- You can also use `npm install -g codex-ringside`, then `codex-ringside install`. Downloading the npm package alone never installs or starts the agent: there are no install lifecycle hooks. No `sudo` is needed with npx.
41
+ After installation, status and uninstall work without npm:
42
+
43
+ ```bash
44
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
45
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" uninstall
46
+ ```
47
+
48
+ ### Option 2: Ask an AI assistant
49
+
50
+ Copy this prompt into an assistant that can run commands on your Mac:
51
+
52
+ ```text
53
+ Please install Codex Ringside on my Mac: https://github.com/tccpc/codex-ringside
54
+ Read the README and explain what will be installed, including the local debug port.
55
+ Check macOS 13+ and a compatible signed-in desktop client. If unsupported, stop.
56
+ Use the README's one-command installer. It bundles Node and the helper; do not install Homebrew, Node or Xcode tools for this task.
57
+ Preserve existing settings. If the app is outside the default location, export CODEX_RINGSIDE_APP with its detected path.
58
+ After installation, run "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status.
59
+ Report whether the agent is running and the ring is placed, and give the uninstall command.
60
+ If needed, ask me to save my work and reopen the client manually. Do not close or force-quit it.
61
+ ```
62
+
63
+ ### Option 3: npm / source installation
64
+
65
+ For developers who already have **Node.js 22+ and Xcode Command Line Tools**:
66
+
67
+ ```bash
68
+ npx codex-ringside@latest install
69
+ ```
70
+
71
+ This path compiles the Swift helper locally. It requires a working system Node; if `npx` crashes with a missing Homebrew dynamic library, use Option 1. Downloading the npm package alone never installs or starts the agent: there are no install lifecycle hooks. npm users can continue using `npx codex-ringside@latest <command>` for status, background settings and uninstall.
30
72
 
31
73
  | Location | Contents |
32
74
  | --- | --- |
33
- | `~/Library/Application Support/CodexRingside` | Runtime, locally compiled bridge, settings and state |
75
+ | `~/Library/Application Support/CodexRingside` | Program, private Node and its license (one-command install), helper, settings and state |
34
76
  | `~/Library/LaunchAgents/com.codexringside.agent.plist` | Background agent |
35
77
  | `~/Library/Logs/CodexRingside` | Agent logs |
36
78
  | `~/Library/Application Support/CodexRingside Backups` | Local installation/migration backups |
37
79
 
38
- The installed runtime is independent of the checkout. For a custom application path:
80
+ Host resolution order: `CODEX_RINGSIDE_APP`, legacy `CODEX_PLUS_APP`, saved path, then `/Applications/ChatGPT.app`.
39
81
 
40
- ```bash
41
- CODEX_RINGSIDE_APP="/Applications/ChatGPT.app" npx codex-ringside@latest install
42
- ```
82
+ ## Upgrade and migrate from Codex Plus
43
83
 
44
- Resolution order: `CODEX_RINGSIDE_APP`, legacy `CODEX_PLUS_APP`, saved path, then `/Applications/ChatGPT.app`.
84
+ **Automatic updates are enabled by default.** About one minute after the agent starts, it checks the latest stable GitHub Release, then checks every six hours. Check times survive restarts. A newer release is downloaded for the running architecture, verified against its SHA-256 manifest, checked for the expected version/runtime, and installed with its private Node. Drafts, prereleases and downgrades are skipped. Failed checks or installations retry after one hour.
45
85
 
46
- ## Upgrade and migrate from Codex Plus
86
+ An independent update job restarts only the Ringside agent, leaving the desktop session open. The app path, observe mode, background, images and update preference are preserved. Files are backed up before replacement; if the new watcher fails to become ready, the installer restores previous files, runtime and service state. Uninstalling, reinstalling or disabling updates during a download cancels that automatic installation. A lock prevents overlapping installation, update replacement and uninstall operations.
87
+
88
+ ```bash
89
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update
90
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update --check
91
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update --disable
92
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update --enable
93
+ ```
47
94
 
48
- Run `npx codex-ringside@latest install` again. Existing Ringside settings take precedence. First migration preserves the old application path, observe mode, background settings and images, then retires `com.codexplus.agent`.
95
+ These commands update immediately, check only, disable or enable automatic updates. `status` includes an `update` object with the installed/latest versions, preference, check times and last error; timestamps are Unix milliseconds. Update logs are in `~/Library/Logs/CodexRingside/update.log` and `update.err.log`. A version-pinned install still follows future stable releases; disable automatic updates to keep a pinned version or source changes.
49
96
 
50
- The helper is compiled in a staging directory before services are stopped. Files are backed up before replacement; if the new watcher fails to become ready, the installer restores previous files and service state. A lock prevents overlapping install/uninstall operations. Installation itself does not restart an existing desktop session.
97
+ Older versions need one manual reinstall to receive this feature. Existing Ringside settings take precedence over Codex Plus settings; first migration preserves the old settings and retires `com.codexplus.agent`.
51
98
 
52
99
  Backups remain in `CodexRingside Backups/install-*`. `receipt.json` records original paths and service states; `0` contains the previous Ringside installation and `1` the previous Codex Plus installation. Backups can contain private paths and images. Keep them local and remove them manually when satisfied with the upgrade. Uninstall preserves backups.
53
100
 
54
- Reinstalling preserves observe mode; `install --observe` explicitly disables startup handoff. To re-enable it, unload the agent, set `allowClientRestart` to `true` in the installed `settings.json`, then run `install` again.
101
+ Reinstalling preserves observe mode; `--observe` explicitly disables startup handoff. To re-enable it, unload the agent, set `allowClientRestart` to `true` in the installed `settings.json`, then reinstall. npm/source users can use `npx codex-ringside@latest update`; a successful update switches the managed installation to the standalone bundle with private Node, without changing the source checkout. `npx codex-ringside@latest install` continues to require development tools.
55
102
 
56
103
  ## Background controls
57
104
 
@@ -69,10 +116,10 @@ Transparency and blur control the effect. Images support cover, contain, stretch
69
116
  The appearance switch uses the host's own system/light/dark setting and synchronizes with its settings page. It is hidden when compatible internal functions cannot be found.
70
117
 
71
118
  ```bash
72
- npx codex-ringside@latest background image ~/Pictures/photo.jpg --transparency 50 --blur 8
73
- npx codex-ringside@latest background --layout contain --zoom 120 --y 20
74
- npx codex-ringside@latest background --crop 10,0,80,60
75
- npx codex-ringside@latest background default
119
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background image ~/Pictures/photo.jpg --transparency 50 --blur 8
120
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background --layout contain --zoom 120 --y 20
121
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background --crop 10,0,80,60
122
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background default
76
123
  ```
77
124
 
78
125
  Options without a source keep the current source. `background` without arguments prints the saved configuration. Crop values are left, top, width and height in percentages; `--crop none` clears the crop. `--help` lists flags and `--version` prints the version; both work without an installed host.
@@ -88,12 +135,12 @@ Options without a source keep the current source. `background` without arguments
88
135
  ## Status and troubleshooting
89
136
 
90
137
  ```bash
91
- npx codex-ringside@latest status
138
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
92
139
  ```
93
140
 
94
141
  Look for `agent: "running"`, `cdp.windows[].card.placed`, and `cdp.windows[].background.applied`. `nativeGlass: false` means the host disables native glass. `startup.event` explains skipped handoffs. Status may contain private paths and window URLs; redact it before sharing.
95
142
 
96
- If no ring appears, verify the signed-in host, application path, Node version, Command Line Tools and whether port 39222 is occupied. Installing while the host is open does not trigger a restart. Save your work and reopen its original icon when convenient. Check `~/Library/Logs/CodexRingside` for errors.
143
+ If no ring appears, verify the signed-in host, application path and whether port 39222 is occupied. Node and Command Line Tools checks apply only to npm/source installations. A missing release asset means that version does not contain a standalone bundle; use 0.2.0 or later. A checksum or download failure stops before changing the installed app; retry when the network is available. Installing while the host is open does not trigger a restart. Save your work and reopen its original icon when convenient. Check `~/Library/Logs/CodexRingside` for errors.
97
144
 
98
145
  Missing or expired allowance data is shown as unavailable. Account switches reset the app-server connection. The number of rings follows the valid allowance windows returned for your account.
99
146
 
@@ -106,10 +153,10 @@ An app already running when the agent starts is never handed off. Only normal sy
106
153
  ## Uninstall
107
154
 
108
155
  ```bash
109
- npx codex-ringside@latest uninstall
156
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" uninstall
110
157
  ```
111
158
 
112
- This removes current and legacy agents, moves runtime/settings/logs to the Trash and removes injected UI. Backups remain. The desktop application bundle is unchanged. The debug port remains active until that desktop process exits; the next ordinary launch has no Ringside debug flag.
159
+ This removes current and legacy agents and the update job, moves the private Node runtime and program/settings/logs to the Trash and removes injected UI. Backups remain. The desktop application bundle is unchanged. The debug port remains active until that desktop process exits; the next ordinary launch has no Ringside debug flag.
113
160
 
114
161
  ## Security and privacy
115
162
 
@@ -117,7 +164,7 @@ Ringside starts the host's bundled `codex app-server`, reads `account/rateLimits
117
164
 
118
165
  **The debug endpoint has no authentication.** Local software can execute JavaScript in the desktop renderer through it. Never expose, forward or proxy port 39222. Use this add-on only where you trust the other local software.
119
166
 
120
- Ringside does not read project files, chat content or the conversation database, and has no telemetry. It checks only the modification time of `auth.json`. The host CLI makes its own network requests. Wallpaper and chosen image processing is local; images/configuration are not uploaded. See [SECURITY.md](SECURITY.md).
167
+ Ringside does not read project files, chat content or the conversation database, and has no telemetry. It checks only the modification time of `auth.json`. The host CLI makes its own network requests. Automatic updates request the public GitHub release API and assets without account tokens or local settings, paths or images; `update --disable` stops automatic requests. Wallpaper and chosen image processing is local; images/configuration are not uploaded. See [SECURITY.md](SECURITY.md).
121
168
 
122
169
  ## Development
123
170
 
@@ -128,7 +175,7 @@ npm ci --ignore-scripts
128
175
  npm run verify
129
176
  ```
130
177
 
131
- Verification runs syntax checks, unit tests and tests the actual packed CLI and Swift source. There are no runtime npm dependencies. CI covers Node.js 22/24/26 and compiles on Apple Silicon and Intel macOS. Full host compatibility requires a real signed-in client.
178
+ Development requires Node.js 22+ and Xcode Command Line Tools. Verification runs syntax checks, unit tests and tests the actual packed CLI and Swift source. `npm run build:standalone` builds a native release bundle; `npm run check:standalone` tests its real runtime, helper, installation and rollback with simulated services and unusable system development tools. There are no runtime npm dependencies. CI covers Node.js 22/24/26 and compiles on Apple Silicon and Intel macOS. Full host compatibility requires a real signed-in client.
132
179
 
133
180
  For the browser-only fixture, run `python3 -m http.server 4173 --bind 127.0.0.1` from the checkout and open `http://127.0.0.1:4173/test/fixtures/card.html`. It uses simulated data and gradient images.
134
181
 
package/README.zh-CN.md CHANGED
@@ -19,47 +19,95 @@
19
19
 
20
20
  ## 安装
21
21
 
22
- 需要 macOS、Node.js 22+ 和 Xcode Command Line Tools(`xcode-select --install`),以及已登录的兼容桌面客户端。开发与真机验证使用 `/Applications/ChatGPT.app`,版本 `26.1027.30824`。其他客户端版本依赖相同内部结构,未逐一验证。
22
+ 一键安装需要 **macOS 13 或更高版本**(Apple Silicon 或 Intel),以及已登录的兼容桌面客户端。**无需自行安装 Node.js、npm、Homebrew 或 Xcode Command Line Tools。** 安装包自带专用 Node 和预编译 helper。开发验证使用 `/Applications/ChatGPT.app`,版本 `26.1027.30824`;其他客户端版本依赖相同内部结构,未逐一验证。命令行和设置面板目前使用中文。
23
23
 
24
- 以下命令显式安装后台任务;仅下载 npm 包不会安装或启动后台。命令行和设置面板目前使用中文。
24
+ ### 方式一:一键安装(推荐)
25
+
26
+ 在终端粘贴这一条命令:
25
27
 
26
28
  ```bash
27
- npx codex-ringside@latest install
29
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash
30
+ ```
31
+
32
+ 从 **0.2.0** 版本开始提供。脚本自动识别芯片架构(包括 Rosetta 下的终端),通过 HTTPS 下载对应安装包,校验 SHA-256 后安装到当前用户目录。安装和后台运行均使用插件自带的 Node,不依赖或更改系统 Node、Homebrew、终端配置,也不需要 `sudo`。安装过程不会关闭正在使用的客户端。
33
+
34
+ 如果圆环还没出现,保存工作,用 ⌘Q 退出客户端,再点原图标打开。后台可能在符合下文条件时将刚启动的客户端重开一次。
35
+
36
+ 只观察、不自动重开客户端:
37
+
38
+ ```bash
39
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash -s -- --observe
40
+ ```
41
+
42
+ 想先审阅脚本,可以从 [Releases](https://github.com/tccpc/codex-ringside/releases) 下载 `install.sh`,阅读后执行 `/bin/bash install.sh`。加上 `--version 0.2.0` 可指定发行版本。应用不在默认位置时,先设置路径,再执行一键安装命令:
43
+
44
+ ```bash
45
+ export CODEX_RINGSIDE_APP="/Applications/ChatGPT.app"
46
+ ```
47
+
48
+ 安装后查看状态和卸载也不需要 npm:
49
+
50
+ ```bash
51
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
52
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" uninstall
53
+ ```
54
+
55
+ ### 方式二:让 AI 帮你安装
56
+
57
+ 把下面整段提示词复制给能在你的 Mac 上运行命令的 AI 助手:
58
+
59
+ ```text
60
+ 请帮我在这台 Mac 上安装 Codex Ringside:https://github.com/tccpc/codex-ringside
61
+ 先阅读 README,说明安装内容和本机调试端口的用途。
62
+ 检查 macOS 13+ 和已登录的兼容客户端,不支持时停止。
63
+ 使用 README 的一键安装入口;安装包自带 Node 和 helper,不要为此安装 Homebrew、Node 或 Xcode 命令行工具。
64
+ 保留已有设置;应用不在默认位置时,先 export CODEX_RINGSIDE_APP 为检测到的路径。
65
+ 安装后运行:"$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
66
+ 告诉我后台是否正常、圆环是否显示,并给出卸载命令。
67
+ 需要重开客户端时提醒我保存工作后手动操作,不要主动关闭或强制结束客户端。
28
68
  ```
29
69
 
30
- 然后保存工作,用 ⌘Q 退出 Codex,再点原图标打开。
70
+ ### 方式三:npm / 源码安装
31
71
 
32
- 只想看后台会做什么、不重启 Codex:
72
+ 适用于已有 **Node.js 22+ 和 Xcode Command Line Tools** 的开发者:
33
73
 
34
74
  ```bash
35
- npx codex-ringside@latest install --observe
75
+ npx codex-ringside@latest install
36
76
  ```
37
77
 
38
- 安装内容:
78
+ 此方式在本机编译 Swift helper,需要本机 Node 能正常运行。如果 `npx` 因 Homebrew 动态库缺失崩溃,直接使用方式一。仅下载 npm 包不会安装或启动后台;没有安装生命周期脚本。npm 用户仍可使用 `npx codex-ringside@latest <命令>` 查看状态、设置背景和卸载。
39
79
 
40
80
  | 位置 | 内容 |
41
81
  |---|---|
42
- | `~/Library/Application Support/CodexRingside` | 程序、`bridge`、设置和状态 |
82
+ | `~/Library/Application Support/CodexRingside` | 程序、专用 Node 及许可证(一键安装)、helper、设置和状态 |
43
83
  | `~/Library/LaunchAgents/com.codexringside.agent.plist` | 后台任务 |
44
84
  | `~/Library/Logs/CodexRingside` | 日志 |
85
+ | `~/Library/Application Support/CodexRingside Backups` | 安装和迁移备份 |
45
86
 
46
- 运行时不依赖这个仓库,改代码后要重新 `install`。
87
+ 应用路径按 `CODEX_RINGSIDE_APP`、旧变量 `CODEX_PLUS_APP`、保存的应用路径、默认 `/Applications/ChatGPT.app` 的顺序选择。
47
88
 
48
89
  ## 升级和从 Codex Plus 迁移
49
90
 
50
- 升级时重新运行 `npx codex-ringside@latest install`。已有新版设置优先;首次迁移保留旧版应用路径、观察模式、背景设置和图片,并停用 `com.codexplus.agent`。先暂存编译、备份,再切换后台;新后台未就绪时恢复原文件和运行状态。
51
-
52
- 备份保留在 `~/Library/Application Support/CodexRingside Backups/install-*`。`receipt.json` 记录原路径及后台运行状态,`0` 是原 Ringside 安装,`1` 是原 Codex Plus 安装。卸载不会删除备份,确认新版正常后可手动清理。
91
+ 从本版起,**自动更新默认开启**。后台启动约一分钟后检查 GitHub 的最新正式发行版,之后每 6 小时检查一次;检查时间会保存,重启后台不会重复下载。发现更高版本后,自动下载对应架构的完整安装包,校验 SHA-256、版本及运行时,再升级程序和专用 Node。跳过草稿和预发行版,不会降级。网络或安装失败会记录原因,一小时后重试。
53
92
 
54
- 迁移和重装保留观察模式;`install --observe` 可明确关闭自动接管。要重新开启,退出后台后将安装目录 `settings.json` 的 `allowClientRestart` 改为 `true`,再运行 `install`。备份包含本地路径及图片,不要上传。
93
+ 更新由独立任务执行,只重启插件后台,不关闭正在使用的客户端。现有应用路径、观察模式、背景、图片和自动更新开关都会保留;新后台未就绪时恢复原文件、运行时和运行状态。下载期间卸载、重新安装或关闭自动更新,会取消这次自动安装。
55
94
 
56
- 非默认应用路径:
95
+ 也可以立即更新、只检查或控制自动更新:
57
96
 
58
97
  ```bash
59
- CODEX_RINGSIDE_APP="/Applications/ChatGPT.app" npx codex-ringside@latest install
98
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update
99
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update --check
100
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update --disable
101
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" update --enable
60
102
  ```
61
103
 
62
- 新环境变量优先于旧变量 `CODEX_PLUS_APP`,然后使用保存的应用路径,最后使用默认 `/Applications/ChatGPT.app`。
104
+ `status` 的 `update` 字段显示当前/最新版本、自动更新开关、检查时间、下次检查时间和失败原因;时间字段为 Unix 毫秒时间戳。更新日志在 `~/Library/Logs/CodexRingside/update.log` 和 `update.err.log`。指定版本安装后仍会跟随正式版;需要固定版本或保留源码改动时,运行 `update --disable`。
105
+
106
+ 旧版用户先重新执行一次一键安装命令,获得自动更新功能。已有新版设置优先;首次从 Codex Plus 迁移会保留旧版设置,并停用 `com.codexplus.agent`。安装、更新切换和卸载共用互斥锁,不会互相覆盖。
107
+
108
+ 备份保留在 `~/Library/Application Support/CodexRingside Backups/install-*`。`receipt.json` 记录原路径及后台运行状态,`0` 是原 Ringside 安装,`1` 是原 Codex Plus 安装。卸载不会删除备份,确认新版正常后可手动清理。
109
+
110
+ 迁移和重装保留观察模式;`--observe` 可明确关闭自动接管。要重新开启,退出后台后将安装目录 `settings.json` 的 `allowClientRestart` 改为 `true`,再重新安装。备份包含本地路径及图片,不要上传。npm / 源码用户可用 `npx codex-ringside@latest update` 执行相同更新;更新成功后,已安装副本转为包含专用 Node 的独立安装,不修改源码工作区。继续用 `npx codex-ringside@latest install` 则仍需开发工具。
63
111
 
64
112
  ## 自定义背景
65
113
 
@@ -90,22 +138,22 @@ CODEX_RINGSIDE_APP="/Applications/ChatGPT.app" npx codex-ringside@latest install
90
138
  也可以用命令行设置,5 秒内生效:
91
139
 
92
140
  ```bash
93
- npx codex-ringside@latest background image ~/Pictures/photo.jpg --transparency 50 --blur 8
141
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background image ~/Pictures/photo.jpg --transparency 50 --blur 8
94
142
  ```
95
143
 
96
144
  ```bash
97
- npx codex-ringside@latest background --layout contain --zoom 120 --y 20
145
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background --layout contain --zoom 120 --y 20
98
146
  ```
99
147
 
100
148
  ```bash
101
- npx codex-ringside@latest background --crop 10,0,80,60
149
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background --crop 10,0,80,60
102
150
  ```
103
151
 
104
152
  ```bash
105
- npx codex-ringside@latest background default
153
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background default
106
154
  ```
107
155
 
108
- 只写选项时保留当前来源,不带参数时打印当前设置。`--crop` 的四个数是左、上、宽、高的百分比,`--crop none` 取消裁剪。完整选项见不带参数运行 `npx codex-ringside@latest` 的用法提示。
156
+ 只写选项时保留当前来源,不带参数时打印当前设置。`--crop` 的四个数是左、上、宽、高的百分比,`--crop none` 取消裁剪。完整选项见不带参数运行安装目录内的 `bin/codex-ringside` 的用法提示。
109
157
 
110
158
  使用限制:
111
159
 
@@ -117,8 +165,10 @@ npx codex-ringside@latest background default
117
165
 
118
166
  ## 查看状态
119
167
 
168
+ 下载失败或 SHA-256 校验失败时,安装器会在更改现有安装前停止,可在网络恢复后重试。发行版缺少安装包时,请选择 0.2.0 或更高版本。通过一键入口安装后,无需检查系统 Node 或编译工具。
169
+
120
170
  ```bash
121
- npx codex-ringside@latest status
171
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
122
172
  ```
123
173
 
124
174
  重点看 `cdp.windows[].card.placed`。背景看 `background`(保存的设置)和 `cdp.windows[].background`:`applied` 表示已生效,`nativeGlass` 为 `false` 表示 Codex 此刻关闭了毛玻璃。输出可能包含本机路径,分享前请先脱敏。
@@ -126,10 +176,10 @@ npx codex-ringside@latest status
126
176
  ## 卸载
127
177
 
128
178
  ```bash
129
- npx codex-ringside@latest uninstall
179
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" uninstall
130
180
  ```
131
181
 
132
- 新旧后台任务会被移除,程序、设置和日志进废纸篓,安装备份保留。当前窗口的注入界面会移除;调试端口会保持到客户端退出,下次正常启动后关闭。桌面应用文件未改动。
182
+ 新旧后台任务和自动更新任务会被移除,专用 Node 随程序一起卸载,程序、设置和日志进废纸篓,安装备份保留。当前窗口的注入界面会移除;调试端口会保持到客户端退出,下次正常启动后关闭。桌面应用文件未改动。
133
183
 
134
184
  ## 什么时候会重启 Codex
135
185
 
@@ -152,15 +202,19 @@ npx codex-ringside@latest uninstall
152
202
 
153
203
  ## 隐私
154
204
 
155
- 只读取额度数据,不读项目文件、对话内容和 Codex 数据库,也没有任何上报。`auth.json` 只看修改时间,用来发现账号切换,不读内容。app-server 自身的网络请求由 Codex CLI 发出。背景功能只读取系统壁纸和你选的图片,转换后存在本机的 `CodexRingside/background` 目录,不会上传。
205
+ 只读取额度数据,不读项目文件、对话内容和 Codex 数据库,也没有遥测或数据上传。`auth.json` 只看修改时间,用来发现账号切换,不读内容。app-server 自身的网络请求由 Codex CLI 发出。自动更新会请求 GitHub 的公开发行接口并下载发行包,不使用账号令牌,不发送本机设置、路径或图片;可以用 `update --disable` 关闭自动请求。背景功能只读取系统壁纸和你选的图片,转换后存在本机的 `CodexRingside/background` 目录,不会上传。
156
206
 
157
207
  ## 开发
158
208
 
209
+ 开发和源码构建仍需要 Node.js 22+ 与 Xcode Command Line Tools。一键安装的普通用户不需要这些工具。
210
+
159
211
  ```bash
160
212
  npm ci --ignore-scripts
161
213
  npm run verify
162
214
  ```
163
215
 
216
+ `npm run build:standalone` 构建本机架构的完整安装包,`npm run check:standalone` 使用包内真实 Node 和 helper,在系统开发工具不可用的隔离环境下验证安装、重装和回滚。CI 分别在 Apple Silicon 与 Intel 上运行此检查。
217
+
164
218
  卡片样式可以用 `test/fixtures/card.html` 调试,不需要真实的 Codex。
165
219
 
166
220
  完整开发和发布流程见 [贡献指南](CONTRIBUTING.md)、[发布指南](docs/RELEASING.md);漏洞请通过 [私密安全报告](https://github.com/tccpc/codex-ringside/security/advisories/new) 提交。
@@ -7,6 +7,7 @@ const { BackgroundStore, parseBackgroundArgs } = require('../src/background.cjs'
7
7
  const { CDP_PORT, CdpSession, isMainWindow, listTargets } = require('../src/cdp.cjs');
8
8
  const launchd = require('../src/launchd.cjs');
9
9
  const installer = require('../src/install.cjs');
10
+ const updater = require('../src/update.cjs');
10
11
  const { version } = require('../package.json');
11
12
 
12
13
  const { installDir } = launchd.paths;
@@ -28,6 +29,7 @@ async function install({ observe }) {
28
29
  ? '如果圆环还没出现:保存工作后用 ⌘Q 退出 Codex,再点原图标打开。'
29
30
  : '当前为观察模式:不会重启 Codex。');
30
31
  console.log(`原安装备份:${result.backup}`);
32
+ console.log(result.settings.autoUpdate ? '自动更新已开启,每 6 小时检查正式发行版。' : '自动更新已关闭。');
31
33
  }
32
34
 
33
35
  // Reachable only if Codex is running with the port; without it there is no card to remove.
@@ -86,6 +88,7 @@ async function status() {
86
88
  console.log(JSON.stringify({
87
89
  agent: job ? job.match(/state = (\S+)/)?.[1] ?? 'loaded' : 'not installed',
88
90
  settings: readJson(settingsFile),
91
+ update: fs.existsSync(settingsFile) ? updater.updateStatus(installDir) : null,
89
92
  background: new BackgroundStore({ root: installDir }).config,
90
93
  startup: state && { event: state.event, updatedAt: new Date(state.updatedAt).toISOString() },
91
94
  listenAddresses: listeners(),
@@ -105,6 +108,23 @@ async function background(args) {
105
108
  console.log(`背景来源:${config.source},5 秒内生效。`);
106
109
  }
107
110
 
111
+ async function update(args) {
112
+ if (args.length > 1 || args.some(arg => !['--check', '--enable', '--disable'].includes(arg))) {
113
+ throw Error('用法:codex-ringside update [--check | --enable | --disable]');
114
+ }
115
+ if (args[0] === '--enable' || args[0] === '--disable') {
116
+ const enabled = args[0] === '--enable';
117
+ await updater.setAutoUpdate(enabled);
118
+ console.log(enabled ? '自动更新已开启,后台将在一分钟内检查。' : '自动更新已关闭。');
119
+ return;
120
+ }
121
+ console.log('正在检查 GitHub 正式发行版…');
122
+ const result = await updater.runUpdate({ checkOnly: args[0] === '--check' });
123
+ if (result.status === 'updated') console.log(`已更新到 ${result.installedVersion},后台已重启。`);
124
+ else if (result.status === 'available') console.log(`发现新版本 ${result.latestVersion}。`);
125
+ else console.log('当前没有更新的正式版本。');
126
+ }
127
+
108
128
  async function main() {
109
129
  const [command, ...args] = process.argv.slice(2);
110
130
  if (command === '--version' || command === '-v') { console.log(version); return; }
@@ -116,8 +136,10 @@ async function main() {
116
136
  else if (command === 'uninstall') await uninstall();
117
137
  else if (command === 'status') await status();
118
138
  else if (command === 'background') await background(args);
139
+ else if (command === 'update') await update(args);
119
140
  else {
120
- console.log('用法:codex-ringside <install [--observe] | uninstall | status | background [来源] [选项]>');
141
+ console.log('用法:codex-ringside <install [--observe] | uninstall | status | update [选项] | background [来源] [选项]>');
142
+ console.log('更新选项:--check 仅检查 | --enable 开启自动更新 | --disable 关闭自动更新;无选项立即更新');
121
143
  console.log('背景来源:default | image <图片路径> | wallpaper | glass');
122
144
  console.log('背景效果:--transparency <0-100> --blur <0-60>');
123
145
  console.log('图片调整:--layout <cover|contain|stretch|center|tile> --zoom <10-400> --x <0-100> --y <0-100>');
@@ -155,6 +155,10 @@ func wallpaper() -> [String: Any] {
155
155
  }
156
156
 
157
157
  let args = CommandLine.arguments
158
+ if args.count == 2 && args[1] == "version" {
159
+ emit(["protocol": 1])
160
+ exit(0)
161
+ }
158
162
  guard args.count >= 3 else { fail("usage: bridge <command> <app> [...]") }
159
163
  let command = args[1]
160
164
  let appPath = canonical(args[2])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-ringside",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Usage rings and customizable backgrounds for the Codex desktop app. Unofficial macOS add-on.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -20,7 +20,9 @@
20
20
  "status": "node bin/codex-ringside.cjs status",
21
21
  "check": "node scripts/check.cjs",
22
22
  "check:package": "node scripts/check-package.cjs",
23
- "verify": "npm run check && npm test && npm run check:package"
23
+ "verify": "npm run check && npm test && npm run check:package",
24
+ "build:standalone": "node scripts/build-standalone.cjs",
25
+ "check:standalone": "node scripts/check-standalone.cjs"
24
26
  },
25
27
  "repository": {
26
28
  "type": "git",
package/src/agent.cjs CHANGED
@@ -151,6 +151,7 @@ function loadSettings(root) {
151
151
 
152
152
  async function main() {
153
153
  const { startWatcher } = require('./startup/watch.cjs');
154
+ const { startAutoUpdater } = require('./update.cjs');
154
155
  const root = path.resolve(__dirname, '..');
155
156
  const settings = loadSettings(root);
156
157
  const abort = new AbortController();
@@ -165,6 +166,7 @@ async function main() {
165
166
  process.once('SIGINT', shutdown);
166
167
  process.once('SIGTERM', shutdown);
167
168
 
169
+ startAutoUpdater({ root, signal: abort.signal, log });
168
170
  await runAgent({ appPath: settings.app, root, signal: abort.signal });
169
171
  await watcher.exited;
170
172
  }
package/src/install.cjs CHANGED
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { execFileSync } = require('node:child_process');
6
6
  const launchd = require('./launchd.cjs');
7
7
  const { DEFAULT_APP, isExecutable, resolveCodexBin, resolveNode } = require('./resolve.cjs');
8
+ const { inspectBundle, copyBundle } = require('./standalone.cjs');
8
9
 
9
10
  const SWIFTC = '/usr/bin/swiftc';
10
11
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
@@ -29,10 +30,12 @@ function writeJson(file, data) {
29
30
  fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
30
31
  }
31
32
 
32
- function preflight(settings) {
33
+ function preflight(settings, repoRoot, current) {
33
34
  if (process.platform !== 'darwin') throw new Error('只支持 macOS');
34
35
  const app = fs.realpathSync(settings.app);
35
36
  resolveCodexBin(app);
37
+ const bundle = inspectBundle(repoRoot);
38
+ if (bundle) return { app, node: path.join(current.installDir, 'runtime/node'), bundle };
36
39
  if (!isExecutable(SWIFTC)) throw new Error('需要 Xcode Command Line Tools:xcode-select --install');
37
40
  return { app, node: resolveNode() };
38
41
  }
@@ -98,12 +101,13 @@ function selectSettings(current, legacy, env, observe) {
98
101
  ?? readJson(path.join(legacy.installDir, 'settings.json')) ?? {};
99
102
  return {
100
103
  app: env.CODEX_RINGSIDE_APP || env.CODEX_PLUS_APP || saved.app || DEFAULT_APP,
101
- allowClientRestart: observe === undefined ? saved.allowClientRestart !== false : !observe
104
+ allowClientRestart: observe === undefined ? saved.allowClientRestart !== false : !observe,
105
+ autoUpdate: saved.autoUpdate !== false
102
106
  };
103
107
  }
104
108
 
105
109
  function copyUserData(source, stage, destination) {
106
- for (const name of ['background.json', 'background', 'user-images']) {
110
+ for (const name of ['background.json', 'background', 'user-images', 'update-state.json']) {
107
111
  copyIfPresent(path.join(source, name), path.join(stage, name));
108
112
  }
109
113
  const file = path.join(stage, 'background.json');
@@ -123,8 +127,10 @@ function copyUserData(source, stage, destination) {
123
127
  async function install(options = {}) {
124
128
  const { current, legacy, backupRoot, repoRoot, services } = { ...defaults, ...options };
125
129
  return withLock(current, async () => {
130
+ // Recheck under the install lock: an update downloaded before uninstall/reinstall is stale.
131
+ options.beforeInstall?.();
126
132
  const settings = selectSettings(current, legacy, options.env ?? process.env, options.observe);
127
- const checked = await (options.preflight ?? preflight)(settings);
133
+ const checked = await (options.preflight ?? preflight)(settings, repoRoot, current);
128
134
  settings.app = checked.app;
129
135
  const stage = fs.mkdtempSync(`${current.installDir}.staging-`);
130
136
  const locations = [current, legacy];
@@ -134,8 +140,13 @@ async function install(options = {}) {
134
140
  let touched = false;
135
141
  try {
136
142
  fs.cpSync(path.join(repoRoot, 'src'), path.join(stage, 'src'), { recursive: true });
143
+ fs.mkdirSync(path.join(stage, 'bin'));
144
+ fs.copyFileSync(path.join(repoRoot, 'bin/codex-ringside.cjs'), path.join(stage, 'bin/codex-ringside.cjs'));
145
+ if (checked.bundle) fs.copyFileSync(path.join(repoRoot, 'bin/codex-ringside'), path.join(stage, 'bin/codex-ringside'));
137
146
  for (const name of ['package.json', 'LICENSE', 'NOTICE']) fs.copyFileSync(path.join(repoRoot, name), path.join(stage, name));
138
- await (options.compile ?? compile)(repoRoot, stage);
147
+ if (checked.bundle) copyBundle(repoRoot, stage);
148
+ else await (options.compile ?? compile)(repoRoot, stage);
149
+ options.beforeInstall?.();
139
150
  fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
140
151
  backup = fs.mkdtempSync(path.join(backupRoot, 'install-'));
141
152
  snapshots = capture(locations, backup);
@@ -194,6 +205,7 @@ async function install(options = {}) {
194
205
  async function uninstall(options = {}) {
195
206
  const { current, legacy, trashRoot, services } = { ...defaults, ...options };
196
207
  return withLock(current, async () => {
208
+ await services.bootout(launchd.UPDATE_LABEL);
197
209
  const locations = [current, legacy];
198
210
  const running = locations.map(location => !!services.printJob(location.label));
199
211
  const items = locations.flatMap((location, i) => launchd.trashItems(location)
@@ -219,4 +231,4 @@ async function uninstall(options = {}) {
219
231
  });
220
232
  }
221
233
 
222
- module.exports = { install, uninstall, selectSettings };
234
+ module.exports = { install, uninstall, selectSettings, withLock };
package/src/launchd.cjs CHANGED
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { execFileSync } = require('node:child_process');
6
6
 
7
7
  const LABEL = 'com.codexringside.agent';
8
+ const UPDATE_LABEL = 'com.codexringside.update';
8
9
  const home = os.homedir();
9
10
  const paths = {
10
11
  installDir: path.join(home, 'Library/Application Support/CodexRingside'),
@@ -59,6 +60,19 @@ function agentConfig({ node, installDir = paths.installDir, logDir = paths.logDi
59
60
  };
60
61
  }
61
62
 
63
+ // An independent, one-shot job survives bootout of the agent it is upgrading.
64
+ function updateConfig({ node, installDir = paths.installDir, logDir = paths.logDir }) {
65
+ return {
66
+ Label: UPDATE_LABEL,
67
+ ProgramArguments: [node, path.join(installDir, 'src/update.cjs'), '--auto'],
68
+ RunAtLoad: true,
69
+ ProcessType: 'Background',
70
+ EnvironmentVariables: { NODE_OPTIONS: '', NODE_PATH: '' },
71
+ StandardOutPath: path.join(logDir, 'update.log'),
72
+ StandardErrorPath: path.join(logDir, 'update.err.log')
73
+ };
74
+ }
75
+
62
76
  const launchctl = (...args) => execFileSync('/bin/launchctl', args, {
63
77
  encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe']
64
78
  });
@@ -88,4 +102,4 @@ function writePlist(config, file = paths.plist) {
88
102
  execFileSync('/usr/bin/plutil', ['-lint', file], { stdio: 'ignore' });
89
103
  }
90
104
 
91
- module.exports = { LABEL, LEGACY_LABEL, legacyPaths, agentConfig, bootout, bootstrap, paths, plistXml, printJob, trashItems, writePlist };
105
+ module.exports = { LABEL, UPDATE_LABEL, LEGACY_LABEL, legacyPaths, agentConfig, updateConfig, bootout, bootstrap, paths, plistXml, printJob, trashItems, writePlist };
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const { execFileSync } = require('node:child_process');
5
+
6
+ // A manifest is only shipped in a release bundle, never in the npm source package.
7
+ function inspectBundle(root) {
8
+ const file = path.join(root, 'standalone.json');
9
+ if (!fs.existsSync(file)) return null;
10
+ const info = JSON.parse(fs.readFileSync(file, 'utf8'));
11
+ if (info.format !== 1 || info.platform !== process.platform || info.arch !== process.arch
12
+ || info.version !== JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version) {
13
+ throw new Error('一键安装包的平台、架构或版本不匹配,请重新下载');
14
+ }
15
+ const node = path.join(root, 'runtime/node');
16
+ const version = execFileSync(node, ['--version'], { encoding: 'utf8', timeout: 10000 }).trim();
17
+ if (version !== `v${info.nodeVersion}` || Number(info.nodeVersion.split('.')[0]) < 22) throw new Error('安装包内 Node 版本不匹配');
18
+ // A side-effect-free command confirms the helper can load on this macOS version.
19
+ const helper = JSON.parse(execFileSync(path.join(root, 'bridge'), ['version'], { encoding: 'utf8', timeout: 10000 }));
20
+ if (helper.protocol !== 1) throw new Error('安装包内 helper 版本不匹配');
21
+ return info;
22
+ }
23
+
24
+ function copyBundle(root, stage) {
25
+ fs.cpSync(path.join(root, 'runtime'), path.join(stage, 'runtime'), { recursive: true });
26
+ for (const name of ['bridge', 'standalone.json']) fs.copyFileSync(path.join(root, name), path.join(stage, name));
27
+ fs.chmodSync(path.join(stage, 'bridge'), 0o755);
28
+ }
29
+
30
+ module.exports = { inspectBundle, copyBundle };
package/src/update.cjs ADDED
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { execFile } = require('node:child_process');
7
+ const { promisify } = require('node:util');
8
+ const launchd = require('./launchd.cjs');
9
+ const installer = require('./install.cjs');
10
+ const { inspectBundle } = require('./standalone.cjs');
11
+
12
+ const run = promisify(execFile);
13
+ const REPOSITORY = 'tccpc/codex-ringside';
14
+ const RELEASE_API = `https://api.github.com/repos/${REPOSITORY}/releases/latest`;
15
+ const CHECK_MS = 6 * 60 * 60 * 1000;
16
+ const RETRY_MS = 60 * 60 * 1000;
17
+ const SCAN_MS = 60000;
18
+ const STATE_FILE = 'update-state.json';
19
+
20
+ function readJson(file) {
21
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
22
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
23
+ }
24
+
25
+ function writeJson(file, value) {
26
+ const temp = `${file}.${process.pid}.tmp`;
27
+ try {
28
+ fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
29
+ fs.renameSync(temp, file);
30
+ } finally { fs.rmSync(temp, { force: true }); }
31
+ }
32
+
33
+ function readState(root) {
34
+ try { return readJson(path.join(root, STATE_FILE)) ?? {}; }
35
+ catch { return {}; } // A damaged diagnostic file must not disable updates forever.
36
+ }
37
+
38
+ function installation(root) {
39
+ const file = path.join(root, 'settings.json');
40
+ const settings = readJson(file);
41
+ const pkg = readJson(path.join(root, 'package.json'));
42
+ if (!settings || pkg?.name !== 'codex-ringside') throw Error('尚未安装,请先运行 install');
43
+ const stat = fs.statSync(file);
44
+ return { settings, version: pkg.version, stamp: `${stat.dev}:${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}` };
45
+ }
46
+
47
+ function versionParts(value) {
48
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value);
49
+ if (!match) throw Error(`无效的版本号:${value}`);
50
+ return { numbers: match.slice(1, 4).map(BigInt), prerelease: !!match[4] };
51
+ }
52
+
53
+ // The remote channel only accepts stable versions; a matching stable beats its RC.
54
+ function isNewer(latest, current) {
55
+ const a = versionParts(latest);
56
+ const b = versionParts(current);
57
+ if (a.prerelease) return false;
58
+ for (let i = 0; i < 3; i++) {
59
+ if (a.numbers[i] !== b.numbers[i]) return a.numbers[i] > b.numbers[i];
60
+ }
61
+ return b.prerelease;
62
+ }
63
+
64
+ async function curl(url, destination, signal) {
65
+ const args = ['--fail', '--location', '--silent', '--show-error', '--retry', '2',
66
+ '--connect-timeout', '20', '--max-time', destination ? '600' : '30',
67
+ '--max-filesize', String(destination ? 256 * 1024 * 1024 : 1024 * 1024),
68
+ '--proto', '=https', '--proto-redir', '=https', '--user-agent', 'codex-ringside-updater'];
69
+ if (destination) args.push('--output', destination);
70
+ else args.push('--header', 'Accept: application/vnd.github+json');
71
+ args.push(url);
72
+ try {
73
+ const { stdout } = await run('/usr/bin/curl', args, {
74
+ encoding: 'utf8', timeout: destination ? 1900000 : 120000, maxBuffer: 1024 * 1024, signal
75
+ });
76
+ return stdout;
77
+ } catch (error) {
78
+ throw Error(`更新下载失败,请检查网络或稍后重试(${error.code ?? 'timeout'})`);
79
+ }
80
+ }
81
+
82
+ async function fetchRelease(signal) {
83
+ return JSON.parse(await curl(RELEASE_API, undefined, signal));
84
+ }
85
+
86
+ function releaseInfo(release, arch = process.arch) {
87
+ if (release?.draft || release?.prerelease) return null;
88
+ if (!/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(release?.tag_name ?? '')) {
89
+ throw Error('远程发行版不是有效的正式版本');
90
+ }
91
+ if (!['arm64', 'x64'].includes(arch)) throw Error('不支持的芯片架构');
92
+ const version = release.tag_name.slice(1);
93
+ const asset = `codex-ringside-v${version}-darwin-${arch}.tar.gz`;
94
+ const base = `https://github.com/${REPOSITORY}/releases/download/v${version}`;
95
+ return { version, asset, base, assets: release.assets };
96
+ }
97
+
98
+ function checksum(manifest, asset) {
99
+ const matches = manifest.split(/\r?\n/).map(line => /^([a-fA-F0-9]{64})\s+\*?(.+)$/.exec(line))
100
+ .filter(match => match?.[2] === asset);
101
+ if (matches.length !== 1) throw Error('发行版缺少唯一的安装包 SHA-256 校验值');
102
+ return matches[0][1].toLowerCase();
103
+ }
104
+
105
+ async function digest(file) {
106
+ const hash = crypto.createHash('sha256');
107
+ for await (const bytes of fs.createReadStream(file)) hash.update(bytes);
108
+ return hash.digest('hex');
109
+ }
110
+
111
+ async function extractBundle(archive, work, signal) {
112
+ const options = { encoding: 'utf8', timeout: 60000, maxBuffer: 4 * 1024 * 1024, signal };
113
+ const { stdout: names } = await run('/usr/bin/tar', ['-tzf', archive], options);
114
+ const { stdout: entries } = await run('/usr/bin/tar', ['-tvzf', archive], options);
115
+ const safePath = name => name.startsWith('codex-ringside/')
116
+ && !name.includes('\\') && !name.split('/').some(part => part === '..' || part === '.');
117
+ if (!names.trim() || !names.trimEnd().split('\n').every(safePath)
118
+ || !entries.trimEnd().split('\n').every(line => /^[-d]/.test(line))) {
119
+ throw Error('安装包包含不安全的路径或链接');
120
+ }
121
+ await run('/usr/bin/tar', ['-xzf', archive, '--no-same-owner', '--no-same-permissions', '-C', work], options);
122
+ return path.join(work, 'codex-ringside');
123
+ }
124
+
125
+ async function prepareBundle(release, work, { download = curl, inspect = inspectBundle, signal } = {}) {
126
+ for (const name of [release.asset, 'SHA256SUMS.txt']) {
127
+ if (!release.assets?.some(asset => asset.name === name && asset.state === 'uploaded')) {
128
+ throw Error(`发行版缺少安装文件:${name}`);
129
+ }
130
+ }
131
+ const sums = path.join(work, 'SHA256SUMS.txt');
132
+ const archive = path.join(work, release.asset);
133
+ await download(`${release.base}/SHA256SUMS.txt`, sums, signal);
134
+ const expected = checksum(fs.readFileSync(sums, 'utf8'), release.asset);
135
+ await download(`${release.base}/${release.asset}`, archive, signal);
136
+ if (await digest(archive) !== expected) throw Error('安装包 SHA-256 校验失败,未更改现有安装');
137
+ const payload = await extractBundle(archive, work, signal);
138
+ const pkg = readJson(path.join(payload, 'package.json'));
139
+ if (pkg?.name !== 'codex-ringside' || pkg.version !== release.version) throw Error('安装包版本不匹配');
140
+ const bundle = inspect(payload);
141
+ if (!bundle || bundle.version !== release.version) throw Error('安装包缺少有效的独立运行时');
142
+ return payload;
143
+ }
144
+
145
+ function updateStatus(root = launchd.paths.installDir) {
146
+ const installed = installation(root);
147
+ return { ...readState(root), enabled: installed.settings.autoUpdate !== false,
148
+ currentVersion: installed.version, source: RELEASE_API, checkIntervalHours: 6 };
149
+ }
150
+
151
+ async function setAutoUpdate(enabled, root = launchd.paths.installDir) {
152
+ await installer.withLock({ installDir: root }, async () => {
153
+ const { settings } = installation(root);
154
+ writeJson(path.join(root, 'settings.json'), { ...settings, autoUpdate: enabled });
155
+ if (enabled) writeJson(path.join(root, STATE_FILE), { ...readState(root), nextCheckAt: 0 });
156
+ });
157
+ }
158
+
159
+ async function runUpdate({ root = launchd.paths.installDir, automatic = false, checkOnly = false,
160
+ getRelease = fetchRelease, prepare = prepareBundle, install = installer.install, now = Date.now, signal } = {}) {
161
+ // A separate lock keeps manual checks and automatic downloads from running together.
162
+ return installer.withLock({ installDir: `${root}.update` }, async () => {
163
+ const initial = installation(root);
164
+ const beforeInstall = () => {
165
+ signal?.throwIfAborted();
166
+ const current = installation(root);
167
+ if (current.stamp !== initial.stamp || current.version !== initial.version
168
+ || (automatic && current.settings.autoUpdate === false)) {
169
+ throw Error('安装或更新设置已变化,取消本次更新');
170
+ }
171
+ };
172
+ const saved = readState(root);
173
+ if (automatic && (initial.settings.autoUpdate === false || (!isDue(saved, now()) && saved.status !== 'scheduled'))) {
174
+ return { status: 'skipped', currentVersion: initial.version };
175
+ }
176
+ let state = { ...readState(root), status: 'checking', lastAttemptAt: now(),
177
+ nextCheckAt: now() + RETRY_MS, error: null, attemptId: crypto.randomUUID() };
178
+ const save = patch => {
179
+ state = { ...state, ...patch };
180
+ // Never recreate an uninstalled root.
181
+ if (fs.existsSync(path.join(root, 'settings.json'))) writeJson(path.join(root, STATE_FILE), state);
182
+ };
183
+ save({});
184
+ let work;
185
+ try {
186
+ const release = releaseInfo(await getRelease(signal));
187
+ beforeInstall();
188
+ save({ lastCheckedAt: now(), latestVersion: release?.version ?? null });
189
+ if (!release || !isNewer(release.version, initial.version)) {
190
+ save({ status: 'up-to-date', nextCheckAt: now() + CHECK_MS });
191
+ } else if (checkOnly) {
192
+ save({ status: 'available', nextCheckAt: now() });
193
+ } else {
194
+ save({ status: 'downloading' });
195
+ work = fs.mkdtempSync(path.join(os.tmpdir(), 'ringside-update-'));
196
+ const payload = await prepare(release, work, { signal });
197
+ beforeInstall();
198
+ save({ status: 'installing' });
199
+ const result = await install({ repoRoot: payload, env: {}, beforeInstall });
200
+ save({ status: 'updated', installedVersion: release.version, updatedAt: now(),
201
+ backup: result.backup, nextCheckAt: now() + CHECK_MS });
202
+ }
203
+ return state;
204
+ } catch (error) {
205
+ if (readState(root).attemptId === state.attemptId) {
206
+ save({ status: 'failed', error: error.message, nextCheckAt: now() + RETRY_MS });
207
+ }
208
+ throw error;
209
+ } finally {
210
+ if (work) fs.rmSync(work, { recursive: true, force: true });
211
+ }
212
+ });
213
+ }
214
+
215
+ function isDue(state, now = Date.now()) {
216
+ // Clamp future timestamps so a clock correction cannot suppress updates indefinitely.
217
+ return !Number.isFinite(state.nextCheckAt) || state.nextCheckAt <= now || state.nextCheckAt > now + CHECK_MS;
218
+ }
219
+
220
+ async function scheduleUpdate({ root = launchd.paths.installDir, node = process.execPath,
221
+ logDir = launchd.paths.logDir, services = launchd, now = Date.now } = {}) {
222
+ const current = installation(root);
223
+ if (current.settings.autoUpdate === false || !isDue(readState(root), now())) return false;
224
+ return installer.withLock({ installDir: root }, async () => {
225
+ if (installation(root).settings.autoUpdate === false || !isDue(readState(root), now())) return false;
226
+ // Do not bootout a worker that is still downloading or completing a rollback.
227
+ if (/\bpid = \d+/.test(services.printJob(launchd.UPDATE_LABEL) ?? '')) return false;
228
+ await services.bootout(launchd.UPDATE_LABEL);
229
+ const file = path.join(root, `${launchd.UPDATE_LABEL}.plist`);
230
+ const state = { ...readState(root), status: 'scheduled', nextCheckAt: now() + RETRY_MS, error: null };
231
+ writeJson(path.join(root, STATE_FILE), state);
232
+ try {
233
+ services.writePlist(launchd.updateConfig({ node, installDir: root, logDir }), file);
234
+ services.bootstrap(file);
235
+ } catch (error) {
236
+ writeJson(path.join(root, STATE_FILE), { ...state, status: 'failed', error: error.message });
237
+ throw error;
238
+ }
239
+ return true;
240
+ });
241
+ }
242
+
243
+ function startAutoUpdater({ root, signal, log }) {
244
+ let running = false;
245
+ const tick = async () => {
246
+ if (running || signal.aborted) return;
247
+ running = true;
248
+ try { await scheduleUpdate({ root }); }
249
+ catch (error) { log(`update: ${error.message}`); }
250
+ finally { running = false; }
251
+ };
252
+ // Let installation finish its readiness check before launching the first update job.
253
+ const timer = setInterval(tick, SCAN_MS);
254
+ signal.addEventListener('abort', () => clearInterval(timer), { once: true });
255
+ }
256
+
257
+ module.exports = { CHECK_MS, RETRY_MS, RELEASE_API, isNewer, isDue, checksum, releaseInfo,
258
+ extractBundle, prepareBundle, runUpdate, updateStatus, setAutoUpdate, scheduleUpdate, startAutoUpdater };
259
+
260
+ if (require.main === module) {
261
+ const abort = new AbortController();
262
+ const stop = () => abort.abort(Error('更新已取消'));
263
+ process.once('SIGTERM', stop);
264
+ process.once('SIGINT', stop);
265
+ runUpdate({ automatic: process.argv.includes('--auto'), signal: abort.signal }).then(result => {
266
+ console.log(`[${new Date().toISOString()}] update: ${result.status}`);
267
+ }).catch(error => {
268
+ console.error(`[${new Date().toISOString()}] update: ${error.message}`);
269
+ process.exitCode = 1;
270
+ });
271
+ }