codex-ringside 0.1.1 → 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,14 @@
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
+
3
12
  ## 0.1.1
4
13
 
5
14
  - Add copyable English and Chinese AI installation prompts to the README, covering prerequisites, installation, existing settings and status verification.
package/README.md CHANGED
@@ -12,74 +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
16
 
17
- ### Option 1: Ask an AI assistant
17
+ ### Option 1: One-command install (recommended)
18
18
 
19
- Copy the prompt below into an AI assistant that can run commands on your Mac. The assistant can check prerequisites, install Ringside and verify the result. Complete any macOS authorization or host sign-in prompts yourself when needed.
19
+ Paste into Terminal:
20
20
 
21
- ```text
22
- Please install Codex Ringside on my Mac: https://github.com/tccpc/codex-ringside
21
+ ```bash
22
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash
23
+ ```
23
24
 
24
- Read the repository's README first and briefly explain what will be installed, including the local debug port.
25
- Check macOS, Node.js 22+, Xcode Command Line Tools and a compatible signed-in desktop client.
26
- If this is not macOS, explain that it is unsupported and stop.
27
- Help resolve missing prerequisites, and tell me when a system dialog needs my input.
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.
28
26
 
29
- Install from the official npm registry:
30
- npx --yes --registry=https://registry.npmjs.org/ codex-ringside@latest install
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.
31
28
 
32
- Preserve existing Ringside or Codex Plus settings.
33
- If the compatible desktop app is outside the default location, use CODEX_RINGSIDE_APP to specify the detected path.
29
+ Observe mode disables that startup handoff:
34
30
 
35
- After installation, run:
36
- npx --yes --registry=https://registry.npmjs.org/ codex-ringside@latest status
31
+ ```bash
32
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash -s -- --observe
33
+ ```
37
34
 
38
- Report whether the background agent is running and the usage ring is placed, then give me the uninstall command.
39
- If the ring is not visible yet, explain the status and tell me to save my work and reopen the client manually.
40
- Do not close or force-quit the client I am using.
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:
36
+
37
+ ```bash
38
+ export CODEX_RINGSIDE_APP="/Applications/ChatGPT.app"
41
39
  ```
42
40
 
43
- ### Option 2: Install from the terminal
41
+ After installation, status and uninstall work without npm:
44
42
 
45
43
  ```bash
46
- npx codex-ringside@latest install
44
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
45
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" uninstall
47
46
  ```
48
47
 
49
- 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.
48
+ ### Option 2: Ask an AI assistant
50
49
 
51
- Observe mode installs the agent without allowing it to restart the desktop client:
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**:
52
66
 
53
67
  ```bash
54
- npx codex-ringside@latest install --observe
68
+ npx codex-ringside@latest install
55
69
  ```
56
70
 
57
- 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.
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.
58
72
 
59
73
  | Location | Contents |
60
74
  | --- | --- |
61
- | `~/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 |
62
76
  | `~/Library/LaunchAgents/com.codexringside.agent.plist` | Background agent |
63
77
  | `~/Library/Logs/CodexRingside` | Agent logs |
64
78
  | `~/Library/Application Support/CodexRingside Backups` | Local installation/migration backups |
65
79
 
66
- 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`.
67
81
 
68
- ```bash
69
- CODEX_RINGSIDE_APP="/Applications/ChatGPT.app" npx codex-ringside@latest install
70
- ```
82
+ ## Upgrade and migrate from Codex Plus
71
83
 
72
- 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.
73
85
 
74
- ## 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
+ ```
75
94
 
76
- 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.
77
96
 
78
- 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`.
79
98
 
80
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.
81
100
 
82
- 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.
83
102
 
84
103
  ## Background controls
85
104
 
@@ -97,10 +116,10 @@ Transparency and blur control the effect. Images support cover, contain, stretch
97
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.
98
117
 
99
118
  ```bash
100
- npx codex-ringside@latest background image ~/Pictures/photo.jpg --transparency 50 --blur 8
101
- npx codex-ringside@latest background --layout contain --zoom 120 --y 20
102
- npx codex-ringside@latest background --crop 10,0,80,60
103
- 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
104
123
  ```
105
124
 
106
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.
@@ -116,12 +135,12 @@ Options without a source keep the current source. `background` without arguments
116
135
  ## Status and troubleshooting
117
136
 
118
137
  ```bash
119
- npx codex-ringside@latest status
138
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
120
139
  ```
121
140
 
122
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.
123
142
 
124
- 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.
125
144
 
126
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.
127
146
 
@@ -134,10 +153,10 @@ An app already running when the agent starts is never handed off. Only normal sy
134
153
  ## Uninstall
135
154
 
136
155
  ```bash
137
- npx codex-ringside@latest uninstall
156
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" uninstall
138
157
  ```
139
158
 
140
- 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.
141
160
 
142
161
  ## Security and privacy
143
162
 
@@ -145,7 +164,7 @@ Ringside starts the host's bundled `codex app-server`, reads `account/rateLimits
145
164
 
146
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.
147
166
 
148
- 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).
149
168
 
150
169
  ## Development
151
170
 
@@ -156,7 +175,7 @@ npm ci --ignore-scripts
156
175
  npm run verify
157
176
  ```
158
177
 
159
- 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.
160
179
 
161
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.
162
181
 
package/README.zh-CN.md CHANGED
@@ -19,75 +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
- ### 方式一:让 AI 帮你安装
24
+ ### 方式一:一键安装(推荐)
25
25
 
26
- 把下面整段提示词复制给能在你的 Mac 上运行终端命令的 AI 助手,让它完成环境检查、安装和结果验证。遇到 macOS 授权弹窗或客户端登录时,由你本人完成操作。
26
+ 在终端粘贴这一条命令:
27
27
 
28
- ```text
29
- 请帮我在这台 Mac 上安装 Codex Ringside,项目地址:https://github.com/tccpc/codex-ringside
30
-
31
- 先阅读仓库的 README,简要说明安装内容和本机调试端口的用途。
32
- 检查 macOS、Node.js 22+、Xcode Command Line Tools,以及已登录的兼容桌面客户端。
33
- 如果不是 macOS,请说明不支持并停止安装。
34
- 缺少依赖时协助补齐,需要我处理系统弹窗时再提示我。
28
+ ```bash
29
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash
30
+ ```
35
31
 
36
- 从官方 npm 源安装:
37
- npx --yes --registry=https://registry.npmjs.org/ codex-ringside@latest install
32
+ 从 **0.2.0** 版本开始提供。脚本自动识别芯片架构(包括 Rosetta 下的终端),通过 HTTPS 下载对应安装包,校验 SHA-256 后安装到当前用户目录。安装和后台运行均使用插件自带的 Node,不依赖或更改系统 Node、Homebrew、终端配置,也不需要 `sudo`。安装过程不会关闭正在使用的客户端。
38
33
 
39
- 保留已有的 Ringside 或 Codex Plus 设置;
40
- 如果兼容桌面应用不在默认位置,用 CODEX_RINGSIDE_APP 指定检测到的应用路径。
34
+ 如果圆环还没出现,保存工作,用 ⌘Q 退出客户端,再点原图标打开。后台可能在符合下文条件时将刚启动的客户端重开一次。
41
35
 
42
- 安装后运行:
43
- npx --yes --registry=https://registry.npmjs.org/ codex-ringside@latest status
36
+ 只观察、不自动重开客户端:
44
37
 
45
- 告诉我后台是否正常运行、侧栏圆环是否已显示,并给出卸载命令。
46
- 圆环尚未显示时,解释当前状态,提醒我保存工作后手动重开客户端;
47
- 不要主动关闭或强制结束我正在使用的客户端。
38
+ ```bash
39
+ curl -fsSL https://github.com/tccpc/codex-ringside/releases/latest/download/install.sh | /bin/bash -s -- --observe
48
40
  ```
49
41
 
50
- ### 方式二:终端安装
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
+ ```
51
47
 
52
- 以下命令显式安装后台任务;仅下载 npm 包不会安装或启动后台。命令行和设置面板目前使用中文。
48
+ 安装后查看状态和卸载也不需要 npm:
53
49
 
54
50
  ```bash
55
- npx codex-ringside@latest install
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
+ 需要重开客户端时提醒我保存工作后手动操作,不要主动关闭或强制结束客户端。
56
68
  ```
57
69
 
58
- 然后保存工作,用 ⌘Q 退出 Codex,再点原图标打开。
70
+ ### 方式三:npm / 源码安装
59
71
 
60
- 只想看后台会做什么、不重启 Codex:
72
+ 适用于已有 **Node.js 22+ 和 Xcode Command Line Tools** 的开发者:
61
73
 
62
74
  ```bash
63
- npx codex-ringside@latest install --observe
75
+ npx codex-ringside@latest install
64
76
  ```
65
77
 
66
- 安装内容:
78
+ 此方式在本机编译 Swift helper,需要本机 Node 能正常运行。如果 `npx` 因 Homebrew 动态库缺失崩溃,直接使用方式一。仅下载 npm 包不会安装或启动后台;没有安装生命周期脚本。npm 用户仍可使用 `npx codex-ringside@latest <命令>` 查看状态、设置背景和卸载。
67
79
 
68
80
  | 位置 | 内容 |
69
81
  |---|---|
70
- | `~/Library/Application Support/CodexRingside` | 程序、`bridge`、设置和状态 |
82
+ | `~/Library/Application Support/CodexRingside` | 程序、专用 Node 及许可证(一键安装)、helper、设置和状态 |
71
83
  | `~/Library/LaunchAgents/com.codexringside.agent.plist` | 后台任务 |
72
84
  | `~/Library/Logs/CodexRingside` | 日志 |
85
+ | `~/Library/Application Support/CodexRingside Backups` | 安装和迁移备份 |
73
86
 
74
- 运行时不依赖这个仓库,改代码后要重新 `install`。
87
+ 应用路径按 `CODEX_RINGSIDE_APP`、旧变量 `CODEX_PLUS_APP`、保存的应用路径、默认 `/Applications/ChatGPT.app` 的顺序选择。
75
88
 
76
89
  ## 升级和从 Codex Plus 迁移
77
90
 
78
- 升级时重新运行 `npx codex-ringside@latest install`。已有新版设置优先;首次迁移保留旧版应用路径、观察模式、背景设置和图片,并停用 `com.codexplus.agent`。先暂存编译、备份,再切换后台;新后台未就绪时恢复原文件和运行状态。
91
+ 从本版起,**自动更新默认开启**。后台启动约一分钟后检查 GitHub 的最新正式发行版,之后每 6 小时检查一次;检查时间会保存,重启后台不会重复下载。发现更高版本后,自动下载对应架构的完整安装包,校验 SHA-256、版本及运行时,再升级程序和专用 Node。跳过草稿和预发行版,不会降级。网络或安装失败会记录原因,一小时后重试。
79
92
 
80
- 备份保留在 `~/Library/Application Support/CodexRingside Backups/install-*`。`receipt.json` 记录原路径及后台运行状态,`0` 是原 Ringside 安装,`1` 是原 Codex Plus 安装。卸载不会删除备份,确认新版正常后可手动清理。
81
-
82
- 迁移和重装保留观察模式;`install --observe` 可明确关闭自动接管。要重新开启,退出后台后将安装目录 `settings.json` 的 `allowClientRestart` 改为 `true`,再运行 `install`。备份包含本地路径及图片,不要上传。
93
+ 更新由独立任务执行,只重启插件后台,不关闭正在使用的客户端。现有应用路径、观察模式、背景、图片和自动更新开关都会保留;新后台未就绪时恢复原文件、运行时和运行状态。下载期间卸载、重新安装或关闭自动更新,会取消这次自动安装。
83
94
 
84
- 非默认应用路径:
95
+ 也可以立即更新、只检查或控制自动更新:
85
96
 
86
97
  ```bash
87
- 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
88
102
  ```
89
103
 
90
- 新环境变量优先于旧变量 `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` 则仍需开发工具。
91
111
 
92
112
  ## 自定义背景
93
113
 
@@ -118,22 +138,22 @@ CODEX_RINGSIDE_APP="/Applications/ChatGPT.app" npx codex-ringside@latest install
118
138
  也可以用命令行设置,5 秒内生效:
119
139
 
120
140
  ```bash
121
- 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
122
142
  ```
123
143
 
124
144
  ```bash
125
- 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
126
146
  ```
127
147
 
128
148
  ```bash
129
- 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
130
150
  ```
131
151
 
132
152
  ```bash
133
- npx codex-ringside@latest background default
153
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background default
134
154
  ```
135
155
 
136
- 只写选项时保留当前来源,不带参数时打印当前设置。`--crop` 的四个数是左、上、宽、高的百分比,`--crop none` 取消裁剪。完整选项见不带参数运行 `npx codex-ringside@latest` 的用法提示。
156
+ 只写选项时保留当前来源,不带参数时打印当前设置。`--crop` 的四个数是左、上、宽、高的百分比,`--crop none` 取消裁剪。完整选项见不带参数运行安装目录内的 `bin/codex-ringside` 的用法提示。
137
157
 
138
158
  使用限制:
139
159
 
@@ -145,8 +165,10 @@ npx codex-ringside@latest background default
145
165
 
146
166
  ## 查看状态
147
167
 
168
+ 下载失败或 SHA-256 校验失败时,安装器会在更改现有安装前停止,可在网络恢复后重试。发行版缺少安装包时,请选择 0.2.0 或更高版本。通过一键入口安装后,无需检查系统 Node 或编译工具。
169
+
148
170
  ```bash
149
- npx codex-ringside@latest status
171
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" status
150
172
  ```
151
173
 
152
174
  重点看 `cdp.windows[].card.placed`。背景看 `background`(保存的设置)和 `cdp.windows[].background`:`applied` 表示已生效,`nativeGlass` 为 `false` 表示 Codex 此刻关闭了毛玻璃。输出可能包含本机路径,分享前请先脱敏。
@@ -154,10 +176,10 @@ npx codex-ringside@latest status
154
176
  ## 卸载
155
177
 
156
178
  ```bash
157
- npx codex-ringside@latest uninstall
179
+ "$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" uninstall
158
180
  ```
159
181
 
160
- 新旧后台任务会被移除,程序、设置和日志进废纸篓,安装备份保留。当前窗口的注入界面会移除;调试端口会保持到客户端退出,下次正常启动后关闭。桌面应用文件未改动。
182
+ 新旧后台任务和自动更新任务会被移除,专用 Node 随程序一起卸载,程序、设置和日志进废纸篓,安装备份保留。当前窗口的注入界面会移除;调试端口会保持到客户端退出,下次正常启动后关闭。桌面应用文件未改动。
161
183
 
162
184
  ## 什么时候会重启 Codex
163
185
 
@@ -180,15 +202,19 @@ npx codex-ringside@latest uninstall
180
202
 
181
203
  ## 隐私
182
204
 
183
- 只读取额度数据,不读项目文件、对话内容和 Codex 数据库,也没有任何上报。`auth.json` 只看修改时间,用来发现账号切换,不读内容。app-server 自身的网络请求由 Codex CLI 发出。背景功能只读取系统壁纸和你选的图片,转换后存在本机的 `CodexRingside/background` 目录,不会上传。
205
+ 只读取额度数据,不读项目文件、对话内容和 Codex 数据库,也没有遥测或数据上传。`auth.json` 只看修改时间,用来发现账号切换,不读内容。app-server 自身的网络请求由 Codex CLI 发出。自动更新会请求 GitHub 的公开发行接口并下载发行包,不使用账号令牌,不发送本机设置、路径或图片;可以用 `update --disable` 关闭自动请求。背景功能只读取系统壁纸和你选的图片,转换后存在本机的 `CodexRingside/background` 目录,不会上传。
184
206
 
185
207
  ## 开发
186
208
 
209
+ 开发和源码构建仍需要 Node.js 22+ 与 Xcode Command Line Tools。一键安装的普通用户不需要这些工具。
210
+
187
211
  ```bash
188
212
  npm ci --ignore-scripts
189
213
  npm run verify
190
214
  ```
191
215
 
216
+ `npm run build:standalone` 构建本机架构的完整安装包,`npm run check:standalone` 使用包内真实 Node 和 helper,在系统开发工具不可用的隔离环境下验证安装、重装和回滚。CI 分别在 Apple Silicon 与 Intel 上运行此检查。
217
+
192
218
  卡片样式可以用 `test/fixtures/card.html` 调试,不需要真实的 Codex。
193
219
 
194
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.1",
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
+ }