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 +14 -0
- package/README.md +72 -25
- package/README.zh-CN.md +79 -25
- package/bin/codex-ringside.cjs +23 -1
- package/native/bridge.swift +4 -0
- package/package.json +4 -2
- package/src/agent.cjs +2 -0
- package/src/install.cjs +18 -6
- package/src/launchd.cjs +15 -1
- package/src/standalone.cjs +30 -0
- package/src/update.cjs +271 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
38
|
+
export CODEX_RINGSIDE_APP="/Applications/ChatGPT.app"
|
|
27
39
|
```
|
|
28
40
|
|
|
29
|
-
|
|
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` |
|
|
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
|
-
|
|
80
|
+
Host resolution order: `CODEX_RINGSIDE_APP`, legacy `CODEX_PLUS_APP`, saved path, then `/Applications/ChatGPT.app`.
|
|
39
81
|
|
|
40
|
-
|
|
41
|
-
CODEX_RINGSIDE_APP="/Applications/ChatGPT.app" npx codex-ringside@latest install
|
|
42
|
-
```
|
|
82
|
+
## Upgrade and migrate from Codex Plus
|
|
43
83
|
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
### 方式一:一键安装(推荐)
|
|
25
|
+
|
|
26
|
+
在终端粘贴这一条命令:
|
|
25
27
|
|
|
26
28
|
```bash
|
|
27
|
-
|
|
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
|
-
|
|
70
|
+
### 方式三:npm / 源码安装
|
|
31
71
|
|
|
32
|
-
|
|
72
|
+
适用于已有 **Node.js 22+ 和 Xcode Command Line Tools** 的开发者:
|
|
33
73
|
|
|
34
74
|
```bash
|
|
35
|
-
npx codex-ringside@latest install
|
|
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` |
|
|
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
|
-
|
|
87
|
+
应用路径按 `CODEX_RINGSIDE_APP`、旧变量 `CODEX_PLUS_APP`、保存的应用路径、默认 `/Applications/ChatGPT.app` 的顺序选择。
|
|
47
88
|
|
|
48
89
|
## 升级和从 Codex Plus 迁移
|
|
49
90
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
备份保留在 `~/Library/Application Support/CodexRingside Backups/install-*`。`receipt.json` 记录原路径及后台运行状态,`0` 是原 Ringside 安装,`1` 是原 Codex Plus 安装。卸载不会删除备份,确认新版正常后可手动清理。
|
|
91
|
+
从本版起,**自动更新默认开启**。后台启动约一分钟后检查 GitHub 的最新正式发行版,之后每 6 小时检查一次;检查时间会保存,重启后台不会重复下载。发现更高版本后,自动下载对应架构的完整安装包,校验 SHA-256、版本及运行时,再升级程序和专用 Node。跳过草稿和预发行版,不会降级。网络或安装失败会记录原因,一小时后重试。
|
|
53
92
|
|
|
54
|
-
|
|
93
|
+
更新由独立任务执行,只重启插件后台,不关闭正在使用的客户端。现有应用路径、观察模式、背景、图片和自动更新开关都会保留;新后台未就绪时恢复原文件、运行时和运行状态。下载期间卸载、重新安装或关闭自动更新,会取消这次自动安装。
|
|
55
94
|
|
|
56
|
-
|
|
95
|
+
也可以立即更新、只检查或控制自动更新:
|
|
57
96
|
|
|
58
97
|
```bash
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
+
"$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background --layout contain --zoom 120 --y 20
|
|
98
146
|
```
|
|
99
147
|
|
|
100
148
|
```bash
|
|
101
|
-
|
|
149
|
+
"$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background --crop 10,0,80,60
|
|
102
150
|
```
|
|
103
151
|
|
|
104
152
|
```bash
|
|
105
|
-
|
|
153
|
+
"$HOME/Library/Application Support/CodexRingside/bin/codex-ringside" background default
|
|
106
154
|
```
|
|
107
155
|
|
|
108
|
-
只写选项时保留当前来源,不带参数时打印当前设置。`--crop` 的四个数是左、上、宽、高的百分比,`--crop none`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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) 提交。
|
package/bin/codex-ringside.cjs
CHANGED
|
@@ -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>');
|
package/native/bridge.swift
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|