diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 82126ee..13f4775 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -9,7 +9,7 @@ body:
id: environment
attributes:
label: Environment
- description: macOS, Apple Silicon/Intel, Xcode, Node and Codex versions
+ description: macOS, Apple Silicon/Intel, app version, download or source install, Codex version (Xcode/Node versions only for source installs)
validations:
required: true
- type: textarea
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4799b9c..f55c7f6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,8 +18,13 @@ jobs:
- run: node scripts/check-release.mjs
native:
runs-on: macos-15
- timeout-minutes: 15
+ timeout-minutes: 30
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
- run: bash scripts/test-native.sh
- - run: bash scripts/build.sh
+ - run: bash scripts/package-release.sh
+ - name: Test the shipped Node runtime
+ run: '"build/Codex Pulse.app/Contents/Resources/runtime/node" --test tests/*.test.mjs'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..47aefaa
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,44 @@
+name: Release preview
+
+on:
+ push:
+ tags: ['v*']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ release:
+ runs-on: macos-15
+ timeout-minutes: 30
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: '22'
+ - run: npm test
+ - run: bash scripts/test-native.sh
+ - run: node scripts/check-release.mjs
+ - name: Validate release tag
+ run: |
+ [ "$GITHUB_REF_TYPE" = tag ] || { echo 'Run this workflow from a tag.' >&2; exit 1; }
+ TAG="$GITHUB_REF_NAME"
+ VERSION="$(node -p "require('./package.json').version")"
+ [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid release tag: $TAG" >&2; exit 1; }
+ [ "$TAG" = "v$VERSION" ] || { echo "Tag $TAG does not match package version $VERSION" >&2; exit 1; }
+ [ "$TAG" = "$(git describe --exact-match --tags HEAD)" ] || { echo 'Workflow ref does not point at the checked-out tag.' >&2; exit 1; }
+ - run: bash scripts/package-release.sh
+ - name: Test the shipped Node runtime
+ run: '"build/Codex Pulse.app/Contents/Resources/runtime/node" --test tests/*.test.mjs'
+ - name: Create draft prerelease
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ TAG="$GITHUB_REF_NAME"
+ VERSION="$(node -p "require('./package.json').version")"
+ gh release create "$TAG" dist/Codex-Pulse-"$VERSION"-universal-unsigned.dmg dist/Codex-Pulse-"$VERSION"-universal-unsigned.zip dist/SHA256SUMS.txt --draft --prerelease --title "Codex Pulse $VERSION" --notes-file "docs/releases/v$VERSION.md"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fcc512..038c4ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 0.1.1 — downloadable unnotarized prerelease
+
+- Universal macOS 14+ DMG and ZIP distribution with bundled Node.js and local bridge.
+- GUI actions: **Enable/repair bridge**, **Remove local bridge**, and explicit reset radar enable/disable controls (radar off by default).
+- Requires Codex CLI or desktop app already signed in with a ChatGPT account.
+- Ad-hoc signed and unnotarized; prerelease only. Intel execution and clean-machine installation remain unverified.
+
## 0.1.0 — source preview
- Native small and medium macOS widgets, with English and Simplified Chinese.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d02045a..4520202 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,3 +15,7 @@ Before opening a pull request:
Please preserve the read-only boundary, explicit missing/stale states, optional social feed, and small dependency surface. No credential scraping, automatic quota resets, account switching, or fake engagement features.
Good first contributions: clean-machine setup feedback, Intel build/install verification, accessible text sizing, and additional languages. Contributions are licensed under the repository's MIT license.
+
+## Release changes
+
+For packaging, signing and draft prereleases, follow [RELEASING.md](docs/RELEASING.md). CI tests the bundled runtime and verifies ZIP/DMG contents as well as the native build. Keep unsigned preview limitations explicit; do not claim clean-Mac or Intel execution results without recording evidence.
diff --git a/README.md b/README.md
index ae40109..009acab 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
A native macOS desktop widget for **5-hour and weekly usage**, with separate reset countdowns — days included.
-[English](README.md) · [简体中文](README.zh-CN.md) · [Install](#quick-start) · [Security](SECURITY.md) · [Contribute](CONTRIBUTING.md)
+[Download v0.1.1](https://github.com/18637168668a-cpu/codex-pulse/releases/tag/v0.1.1) · [简体中文](README.zh-CN.md) · [Install](#quick-start) · [Security](SECURITY.md) · [Contribute](CONTRIBUTING.md)

@@ -21,6 +21,7 @@ I wanted to glance at my desktop and know two things: **how much have I used, an
Codex Pulse keeps the answer small, readable, and on the desktop.
- **Used, not remaining:** 5-hour and weekly percentages side by side.
+- **CP menu meter:** C represents 5-hour usage and P represents weekly usage; each shifts from green at 0% through yellow to red at 100%. Missing or cached values are gray.
- **Two reset clocks:** each quota has its own countdown, including days.
- **Native SwiftUI + WidgetKit:** small and medium widgets; English and Simplified Chinese follow your system language.
- **Read-only local bridge:** Node.js built-ins only. No Electron, web development server, telemetry, or extra API key.
@@ -31,14 +32,23 @@ If this saves you a few trips to the usage page, a ⭐ helps other Codex users f
## Quick start
-**v0.1 is a source-build preview, not a signed/notarized app download.**
+**Download, open, enable the bridge, add your widget. No Xcode or Node installation needed.**
-You need:
+[Download v0.1.1 — DMG or ZIP](https://github.com/18637168668a-cpu/codex-pulse/releases/tag/v0.1.1)
-- macOS 14 or later. Apple Silicon has been locally tested; Intel installation is not yet verified.
-- Full **Xcode 15+**, selected as the active developer directory. Command Line Tools alone are insufficient. The release build was tested with Xcode 26.4.
-- **Node.js 22+** on your PATH.
-- **Codex CLI or desktop app**, already signed in with a ChatGPT account whose quota windows are available. API-key-only login is not the intended mode.
+> **Unnotarized prerelease:** the `unsigned` downloads have an ad-hoc signature, not an Apple Developer ID signature. macOS may block first launch. Only approve this specific app if you trust the download; see [first-launch guidance](docs/INSTALL.md). Intel and clean-Mac installation still need validation.
+
+1. On **macOS 14+**, install Codex and sign in with your **ChatGPT account**. API-key-only login is not supported for quota display.
+2. Download the DMG/ZIP and move **Codex Pulse.app** into **Applications** (or your user Applications folder).
+3. Open Codex Pulse and click **Enable/repair bridge**. It registers a local service that starts at login; no sudo or API key is needed.
+4. **Right-click desktop → Edit Widgets → Codex Pulse → add small or medium.** You can close the companion app afterwards.
+
+The app bundles the local bridge and Node runtime. Setup only runs when you click the button. The companion app runs outside App Sandbox for service setup; the widget remains sandboxed. [Security details](SECURITY.md).
+
+
+Build from source instead
+
+Requires full **Xcode 15+** (Command Line Tools alone are insufficient) and **Node.js 22+**. The local build uses an ad-hoc signature.
```bash
git clone https://github.com/18637168668a-cpu/codex-pulse.git
@@ -47,20 +57,16 @@ bash scripts/install.sh --dry-run
bash scripts/install.sh
```
-The installer builds locally, uses an ad-hoc signature, installs into `~/Applications`, and registers a user LaunchAgent for the bridge. No sudo or Apple signing certificate is requested. It refuses to overwrite an unrelated app.
+Source installs go to `~/Applications`. For a custom CLI location, set `CODEX_BIN=/absolute/path/to/codex`. Pull and rerun the installer to update a source installation.
-Then **right-click the desktop → Edit Widgets → search “Codex Pulse” → add a small or medium widget**. macOS requires this final placement step. You can close the companion app afterwards.
+
-If Codex is installed elsewhere, pass its executable path:
-
-```bash
-CODEX_BIN="/path/to/codex" bash scripts/install.sh
-```
-
-To update, pull the repository and rerun the installer. Remove/re-add the widget if macOS keeps an old rendered view. See [installation and troubleshooting](docs/INSTALL.md) for paths, manual operation, and removal.
+For downloads, close the app, replace it in the **same Applications folder**, reopen it, and click **Enable/repair bridge**. Use **Remove local bridge** before moving the app to Trash. [Installation, upgrades and troubleshooting](docs/INSTALL.md).
## Optional reset radar
+In the app, choose **Enable reset radar** and confirm the third-party request notice. Choose **Disable reset radar** to turn it off. Source installs also support:
+
```bash
bash scripts/social.sh on
# Later:
@@ -105,7 +111,7 @@ bash scripts/build.sh # local native app build + signature verification
## Status & roadmap
-This is an early, deliberately small release. The local bridge and native build are tested; installation still needs feedback across clean Macs and Codex versions. There is no App Store distribution or notarized binary yet.
+This is an early, deliberately small prerelease. The native build architecture is prepared for universal distribution; Intel installation and clean-machine support remain pending validation. There is no App Store distribution or notarized binary.
Next priorities: clean-machine/Intel testing, better installation diagnostics, accessible larger-text layouts, and additional languages. Feature requests and reproducible reports are welcome; please remove account data from screenshots and logs.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 68b6172..dbd2762 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -4,7 +4,7 @@
原生 macOS 桌面小组件:并排展示 **5 小时已用量、本周已用量**,分别显示重置倒计时,超过一天明确显示天数。
-[English](README.md) · [安装与排查](docs/INSTALL.md) · [隐私与安全](SECURITY.md)
+[下载 v0.1.1](https://github.com/18637168668a-cpu/codex-pulse/releases/tag/v0.1.1) · [English](README.md) · [安装与排查](docs/INSTALL.md) · [隐私与安全](SECURITY.md)

@@ -15,6 +15,7 @@
想知道额度用了多少、什么时候恢复,不应该每次打开一个网页。这个项目来自一个很具体的日常需求:在桌面留一小块地方,把两组额度和两个重置时间说清楚。
- 百分比统一表示**已用量**,不是剩余量。
+- 菜单栏的 **C/P** 分别代表 5 小时和本周已用量:0% 为绿色,中间过渡为黄色,100% 为红色;无数据或缓存数据显示灰色。
- 原生 WidgetKit,小号和中号,跟随系统显示中英文。
- 本机只读服务,Node 内置模块即可,不需要 Electron、网页开发服务器或额外 API Key。
- 无数据显示 `—`,旧数据显示“缓存”,不使用演示数字冒充实时数据。
@@ -24,9 +25,23 @@
## 安装
-**v0.1 是源码构建预览版,不是签名公证好的即装即用 App。**
+**下载 → 打开 → 启用本机服务 → 添加组件。无需另装 Xcode 或 Node。**
-需要 macOS 14+、完整 Xcode 15+、Node.js 22+,以及已通过 ChatGPT 账号登录的 Codex CLI 或桌面 App。只安装 Command Line Tools 不够。已在 Apple Silicon / Xcode 26.4 本机验证构建;Intel 和其他干净机器仍需要测试反馈。
+[下载 v0.1.1(DMG / ZIP)](https://github.com/18637168668a-cpu/codex-pulse/releases/tag/v0.1.1)
+
+> **这是未公证的预发布版。** 文件名中的 `unsigned` 表示没有 Apple Developer ID 签名,仅有临时签名。macOS 可能阻止首次打开;仅在信任下载来源时按[安装指南](docs/INSTALL.md)批准此应用。Intel 实机和全新 Mac 安装仍待验证。
+
+1. 使用 **macOS 14+**,先在 Codex 中以 **ChatGPT 账号**登录;仅 API Key 登录无法用于本组件的额度显示。
+2. 下载 DMG/ZIP,将 **Codex Pulse.app** 放入“应用程序”(`/Applications` 或 `~/Applications`)。
+3. 打开 App,点击 **启用/修复本机服务**。服务会在登录时启动,无需 sudo 或额外 API Key。
+4. **右键桌面 → 编辑小组件 → Codex Pulse → 添加小号或中号**。之后可关闭主应用。
+
+内置 Node 运行时和只读服务。仅在点击按钮后配置服务;主应用因需配置登录服务不使用沙盒,WidgetKit 扩展仍保留沙盒。[隐私与安全](SECURITY.md)。
+
+
+从源码安装
+
+需要完整 **Xcode 15+** 和 **Node.js 22+**;只有 Command Line Tools 不够。
```bash
git clone https://github.com/18637168668a-cpu/codex-pulse.git
@@ -35,12 +50,16 @@ bash scripts/install.sh --dry-run
bash scripts/install.sh
```
-脚本本机构建并临时签名,安装到 `~/Applications`,注册登录时启动的本机后台服务。无需 sudo,不要求你的 Apple 开发证书,也不会覆盖不同标识的同名 App。
+源码安装到 `~/Applications`,使用临时签名,不要求 Apple 开发证书。更新时拉取代码并重新运行安装脚本。
+
+
-最后:**右键桌面 → 编辑小组件 → 搜索 Codex Pulse → 添加小号或中号**。位置需要通过系统界面添加,之后可以关闭主应用。
+下载版更新:先关闭 App,在**原应用目录**替换后重新打开并点击 **启用/修复本机服务**。卸载时先点击 **移除本机服务**,再将 App 移到废纸篓,最后手动移除桌面组件。
### 可选雷达
+在 App 中点击 **开启重置雷达**并确认第三方请求提示,可随时点击 **关闭重置雷达**。源码版也支持:
+
```bash
bash scripts/social.sh on
bash scripts/social.sh off
@@ -48,7 +67,7 @@ bash scripts/social.sh off
开启后访问第三方公开源 [codex-reset.com](https://codex-reset.com),它会收到你的 IP 等普通 HTTPS 请求信息,但不会收到用量、登录凭证或聊天。只分析最近七天、链接指向 @thsottiaux 的动态。规则可能误判、漏报,第三方源也可能延迟或停止;所有判断均不应视为官方确认。
-### 卸载
+### 源码版卸载
```bash
bash scripts/uninstall.sh
diff --git a/SECURITY.md b/SECURITY.md
index f1b0943..b50bfa4 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -8,7 +8,9 @@ The bridge binds to IPv4 loopback and checks Host, Origin and cross-site fetch m
Optional social radar is off by default. When enabled, it contacts `https://codex-reset.com/api/feed`; that provider receives the usual network metadata. No account data is sent. Feed content is untrusted plain text, not executable instructions. Only recent HTTPS links to the intended X account are accepted. Classification is heuristic and may be incorrect. Clicking a source link opens X in your browser.
-Local builds use ad-hoc signatures, not Apple notarization. The installer does not disable Gatekeeper, change global security settings, install system-wide services, or request sudo. Inspect scripts before running them.
+The companion app runs outside App Sandbox so an explicit setup button can register a user LaunchAgent and launch the local bridge. The WidgetKit extension remains sandboxed with network-client access only. Setup does not run automatically on first launch. The bundled Node runtime is downloaded from nodejs.org at build time, checked against pinned SHA-256 digests, and shipped with its license; no runtime is downloaded during installation.
+
+Local builds and unsigned preview downloads use ad-hoc signatures, not Apple notarization. Downloaded artifacts include SHA-256 checksums for integrity, not proof of publisher identity. The installer does not disable Gatekeeper, change global security settings, install system-wide services, or request sudo. Inspect scripts before running them.
## Reporting
diff --git a/bridge/codex.mjs b/bridge/codex.mjs
index 5dffda0..eefeadc 100644
--- a/bridge/codex.mjs
+++ b/bridge/codex.mjs
@@ -1,12 +1,15 @@
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline';
import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
export function codexBinary() {
if (process.env.CODEX_BIN) return process.env.CODEX_BIN;
return [
'/Applications/Codex.app/Contents/Resources/codex',
'/Applications/ChatGPT.app/Contents/Resources/codex',
+ join(homedir(), 'Applications/Codex.app/Contents/Resources/codex'),
].find(existsSync) || 'codex';
}
@@ -48,7 +51,7 @@ export class CodexBridge {
child.once('error', failed);
child.once('exit', failed);
this.ready = this.send('initialize', {
- clientInfo: { name: 'codex-pulse', title: 'Codex Pulse', version: '0.1.0' },
+ clientInfo: { name: 'codex-pulse', title: 'Codex Pulse', version: '0.1.1' },
}).then(() => {
child.stdin.write(JSON.stringify({ method: 'initialized' }) + '\n');
}).catch(error => { this.stop(); throw error; });
diff --git a/bridge/server.mjs b/bridge/server.mjs
index 242c169..eb2b93f 100644
--- a/bridge/server.mjs
+++ b/bridge/server.mjs
@@ -37,7 +37,7 @@ export function createPulseServer({ bridge = new CodexBridge(), social = false,
if (request.method !== 'GET') return json(405, { error: 'Read-only service.' });
let path;
try { path = new URL(request.url, 'http://localhost').pathname; } catch { return json(400, { error: 'Invalid request.' }); }
- if (path === '/' || path === '/health') return json(200, { app: 'codex-pulse', version: '0.1.0', readOnly: true, socialEnabled: social });
+ if (path === '/' || path === '/health') return json(200, { app: 'codex-pulse', version: '0.1.1', readOnly: true, socialEnabled: social });
try {
if (path === '/api/local/usage') return json(200, await cached('usage', 30000, async () => normalizeUsage(await bridge.rateLimits())));
if (path === '/api/local/tibo') return json(200, social
diff --git a/bridge/social.mjs b/bridge/social.mjs
index 4b48462..24756cc 100644
--- a/bridge/social.mjs
+++ b/bridge/social.mjs
@@ -30,7 +30,7 @@ export function filterSignals(data, now = Date.now()) {
export async function fetchSocial() {
const response = await fetch(FEED_URL, {
- headers: { Accept: 'application/json', 'User-Agent': 'Codex-Pulse/0.1.0' },
+ headers: { Accept: 'application/json', 'User-Agent': 'Codex-Pulse/0.1.1' },
signal: AbortSignal.timeout(10000), redirect: 'error',
});
if (!response.ok) throw new Error('Public feed unavailable.');
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index 19a7107..2644646 100644
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -1,17 +1,43 @@
# Installation & troubleshooting
+## Downloaded prerelease (v0.1.1)
+
+[Download the v0.1.1 DMG or ZIP](https://github.com/18637168668a-cpu/codex-pulse/releases/tag/v0.1.1). This is an ad-hoc signed, unnotarized prerelease. The universal app bundles Node.js and the bridge. Intel installation and clean-machine support remain pending validation.
+
+Move the app to `/Applications` or `~/Applications` before opening it. On first launch, the non-sandboxed companion app asks you to click **Enable/repair bridge**. This user-triggered action installs or repairs the per-user LaunchAgent; the WidgetKit extension remains sandboxed. **Remove local bridge** removes that service while retaining the app. The radar is off by default; use **Enable reset radar** or **Disable reset radar**.
+
+If macOS says the developer cannot be verified, first confirm you downloaded from this repository and check the checksum. If you trust it, use **System Settings → Privacy & Security → Open Anyway** for this app, following [Apple’s unnotarized-app guidance](https://support.apple.com/en-us/102445). If macOS reports malware or that the app will damage your computer, **stop and do not override the warning**. Do not disable global protections or remove quarantine attributes. Managed Macs may prohibit exceptions.
+
+Codex must already be installed and signed in with ChatGPT. If it is not detected, click **Choose Codex…** to select its executable, then retry setup. The download includes Node; no developer tools are required.
+
+After setup, right-click your desktop → Edit Widgets → Codex Pulse. Placement is a macOS step, not automatic.
+
+When Codex Pulse is running, its menu-bar **C/P** meter shows 5-hour and weekly used quota. Each letter changes from green at 0% through yellow to red at 100%; gray means missing or cached data. This menu has no floating quota window.
+
+## Update a download
+
+Close Codex Pulse, replace the app in the same Applications folder, reopen it and click **Enable/repair bridge**. Keep the install location unchanged so the login service can find its bundled runtime. When changing from a source install in `~/Applications` to `/Applications`, remove the old bridge and app first to avoid duplicate widgets.
+
+## Verify a download
+
+Download `SHA256SUMS.txt` alongside both assets. In that download folder run `shasum -a 256 -c SHA256SUMS.txt`. If you downloaded only one asset, the missing other file is expected; the downloaded file must say OK. Checksums detect corruption but do not authenticate the publisher.
+
+
+
+*Rendered from the real SwiftUI setup view; this is not a clean-Mac installation test.*
+
## What gets installed
| Item | Location |
| --- | --- |
-| App and WidgetKit extension | `~/Applications/Codex Pulse.app` |
+| App and WidgetKit extension | `/Applications/Codex Pulse.app` or `~/Applications/Codex Pulse.app` (source default) |
| Bridge and install settings | `~/Library/Application Support/Codex Pulse OSS/` |
| User LaunchAgent | `~/Library/LaunchAgents/io.github.codexpulse.bridge.plist` |
| Local endpoint | `http://127.0.0.1:43187` |
Only a logged-in user's service is installed. No root service, firewall rule or global security change is needed. `install.sh --dry-run` checks tools, existing app identity and port availability without installing. It does not verify your account login or claim full clean-machine compatibility.
-## Prerequisites
+## Source fallback prerequisites
Install full Xcode from Apple, open it once and complete its first-run setup. Ensure `xcodebuild -version` resolves to that Xcode; selecting a different developer directory is a system setup choice you perform yourself.
@@ -42,9 +68,9 @@ The second command prints personal usage values. Do not paste them publicly with
**Port conflict:** stop the other service or choose another port in both the native source and bridge configuration, then rebuild. The packaged native app expects port 43187. `PULSE_PORT` by itself only changes the standalone bridge.
-**Node or Codex moved after an upgrade:** rerun the installer to update the LaunchAgent's absolute executable paths. If another unrelated app already uses the install location, move it aside manually; the installer will not overwrite it.
+**Node or Codex moved after an upgrade:** click Enable/repair bridge (download), or rerun the installer (source) to update the LaunchAgent's absolute executable paths. If another unrelated app already uses the install location, move it aside manually; the installer will not overwrite it.
-**Signing warnings:** the source build uses a local ad-hoc signature. It is not a notarized downloadable app. If your managed Mac blocks local builds, follow your organization's policy; this project does not provide a security-bypass script.
+**Signing warnings:** source builds and `unsigned` downloads use ad-hoc signatures, not Apple notarization. If your managed Mac blocks local builds, follow your organization's policy; this project does not provide a security-bypass script.
## Manual development mode
@@ -58,10 +84,12 @@ This starts no LaunchAgent. `PULSE_SOCIAL=1 node bridge/server.mjs` explicitly e
## Remove
+For downloads, click **Remove local bridge**, quit the app, move it to Trash, and remove the desktop widget. Codex login is untouched. Source installations can also run:
+
```bash
bash scripts/uninstall.sh
```
The managed app, bridge directory and LaunchAgent are moved to a timestamped folder in Trash. They can be recovered; reinstallation is the simplest way to start again. Codex credentials are not touched. Remove the desktop widget separately. macOS may retain the widget sandbox's last-known sanitized payload after removal.
-Build scripts retain generated files under `build/` and temporary `codex-pulse-build.*` / `codex-pulse-preview.*` directories. These are not part of the repository or the running installation.
+Build output and runtime cache live under `build/`; packages live under `dist/`. Build staging is cleaned automatically. Native preview renders use temporary `codex-pulse-preview.*` directories. These are not part of the repository or the running installation.
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
new file mode 100644
index 0000000..2156daf
--- /dev/null
+++ b/docs/RELEASING.md
@@ -0,0 +1,55 @@
+# Releasing Codex Pulse
+
+## Preview release contract
+
+The v0.1.1 preview is a universal macOS 14+ app bundle. Its downloadable files are named `Codex-Pulse-VERSION-universal-unsigned.dmg` and `.zip`. “Unsigned” means **not Developer ID signed**: the app is ad-hoc signed only so its nested code can be checked locally. It is published as a draft prerelease, and users may need to approve it in macOS security settings.
+
+The package includes the official Node.js v22.23.2 Darwin arm64 and x64 runtimes merged into one universal executable. The two official tarballs are pinned by SHA-256 in `scripts/fetch-node-runtime.sh`; that script verifies each checksum before merging them with `lipo`. The Node.js license is shipped as `Codex Pulse.app/Contents/Resources/runtime/NODE-LICENSE.txt`.
+
+No Xcode or external Node installation is required to run a packaged app. The app owns `Contents/Resources/bridge`, `scripts/manage.mjs`, `scripts/installer.mjs`, `package.json`, and `runtime/node`. The GUI invokes:
+
+```text
+Contents/Resources/runtime/node Contents/Resources/scripts/manage.mjs install-prebuilt --app /actual/path/Codex Pulse.app
+```
+
+Source checkout behavior remains separate: `bash scripts/build.sh` does not download Node and still uses the developer's Node installation.
+
+## Build a preview locally
+
+Use a clean checkout on macOS with full Xcode and Node 22+. The first release build downloads the two pinned Node archives from nodejs.org into the ignored `build/node-runtime-cache/`; later builds reuse the verified cache.
+
+```bash
+npm test
+bash scripts/test-native.sh
+node scripts/check-release.mjs
+bash scripts/package-release.sh
+```
+
+This produces the DMG and ZIP in `dist/` and verifies the app, universal architectures, embedded resources, ZIP contents, and mounted DMG. Do not rename an artifact to remove `unsigned` unless Developer ID signing and notarization completed.
+
+To refresh a corrupt or intentionally replaced cache:
+
+```bash
+bash scripts/fetch-node-runtime.sh --output /tmp/codex-pulse-runtime --refresh
+```
+
+## GitHub draft prerelease
+
+Push a tag in the exact form `vMAJOR.MINOR.PATCH` whose value equals `package.json` (for example `v0.1.1`). The workflow checks out that tagged commit, runs bridge and native tests, validates release hygiene, packages the artifacts, and creates a **draft prerelease**. It uses only pinned actions and the workflow token; it does not receive signing credentials.
+
+`workflow_dispatch` is safe only when run from the release tag itself. It repeats exact-tag validation before it can create a release.
+
+## Developer ID signing and notarization
+
+Do not publish a signed-looking build without a valid Developer ID Application certificate and Apple notarization credentials. The current project has only Apple Development credentials, which are insufficient.
+
+When those credentials exist, build the bundled app, then run `scripts/sign-and-notarize.sh` with these environment variables supplied by a secret manager or secure local shell:
+
+```text
+DEVELOPER_ID_APPLICATION
+APPLE_ID
+APPLE_APP_SPECIFIC_PASSWORD
+APPLE_TEAM_ID
+```
+
+The script fails before modifying the app if any value is absent. It signs the embedded Node binary, widget extension, and app with hardened runtime, submits a temporary ZIP to `notarytool`, waits for acceptance, staples the app, then verifies Gatekeeper assessment. It never prints credential values. Package only this stapled app and state that its release is Developer ID signed and notarized.
diff --git a/docs/images/setup-en.png b/docs/images/setup-en.png
new file mode 100644
index 0000000..c6495fd
Binary files /dev/null and b/docs/images/setup-en.png differ
diff --git a/docs/images/setup-zh.png b/docs/images/setup-zh.png
new file mode 100644
index 0000000..2a4a19d
Binary files /dev/null and b/docs/images/setup-zh.png differ
diff --git a/docs/releases/v0.1.1.md b/docs/releases/v0.1.1.md
new file mode 100644
index 0000000..aa49cae
--- /dev/null
+++ b/docs/releases/v0.1.1.md
@@ -0,0 +1,33 @@
+# Codex Pulse v0.1.1
+
+v0.1.1 is a downloadable macOS 14+ prerelease.
+
+[Download the DMG or ZIP](https://github.com/18637168668a-cpu/codex-pulse/releases/tag/v0.1.1)
+
+The app bundles Node.js and the read-only local bridge. End users do not need Node.js or Xcode. The companion app is non-sandboxed so an explicit user action can install or repair its per-user LaunchAgent; the WidgetKit extension remains sandboxed.
+
+## Install
+
+1. Download the DMG or ZIP and move **Codex Pulse.app** to `/Applications` or `~/Applications`.
+2. Open the app and click **Enable/repair bridge**.
+3. Add the widget from the macOS widget gallery.
+
+Codex CLI or the Codex desktop app must already be signed in with a ChatGPT account. API-key-only login is not the intended mode. **Remove local bridge** removes the local service but keeps the app. The reset radar is off by default; use **Enable reset radar** or **Disable reset radar** when needed.
+
+This build is AD HOC signed and unnotarized. It remains a prerelease; clean-Mac and Intel execution tests are pending. If Gatekeeper blocks it, follow Apple's app-specific approval guidance: [Apple Support 102445](https://support.apple.com/en-us/102445). Do not disable global security protections or remove quarantine attributes.
+
+## Downloads and verification
+
+- `Codex-Pulse-0.1.1-universal-unsigned.dmg` — app plus Applications shortcut.
+- `Codex-Pulse-0.1.1-universal-unsigned.zip` — the same self-contained app.
+- `SHA256SUMS.txt` — integrity checksums, not a substitute for publisher signing.
+
+Both Apple Silicon and Intel code are included. Intel execution and clean-Mac Gatekeeper/widget registration remain unverified.
+
+## Source fallback
+
+Source installation remains available for developers and troubleshooting. It requires full Xcode 15+ and Node.js 22+; see [installation guide](https://github.com/18637168668a-cpu/codex-pulse/blob/v0.1.1/docs/INSTALL.md). The source path retains the existing scripts and local-only setup.
+
+## Privacy and limits
+
+The bridge is read-only and local. It does not read chats, start model turns, redeem resets, or request purchases. The optional third-party reset radar makes public HTTPS requests only when enabled. Screenshots and rendered previews use synthetic demo values and are not live account data. Widget refresh timing remains controlled by macOS.
diff --git a/native/CodexPulse.xcodeproj/project.pbxproj b/native/CodexPulse.xcodeproj/project.pbxproj
index 57c1660..bbe76f3 100644
--- a/native/CodexPulse.xcodeproj/project.pbxproj
+++ b/native/CodexPulse.xcodeproj/project.pbxproj
@@ -146,10 +146,10 @@
/* Begin XCBuildConfiguration section */
H10000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_TESTABILITY = YES; GCC_OPTIMIZATION_LEVEL = 0; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; };
H10000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CLANG_ENABLE_MODULES = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; MACOSX_DEPLOYMENT_TARGET = 14.0; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; };
- H11000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = CodexPulse/CodexPulse.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulse/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app; PRODUCT_NAME = "Codex Pulse"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; };
- H11000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = CodexPulse/CodexPulse.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulse/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app; PRODUCT_NAME = "Codex Pulse"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; };
- H12000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = CodexPulseWidget/CodexPulseWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulseWidget/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../../../../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app.widget; PRODUCT_NAME = CodexPulseWidgetExtension; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; };
- H12000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = CodexPulseWidget/CodexPulseWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulseWidget/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../../../../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app.widget; PRODUCT_NAME = CodexPulseWidgetExtension; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; };
+ H11000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = CodexPulse/CodexPulse.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulse/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app; PRODUCT_NAME = "Codex Pulse"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; };
+ H11000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_ENTITLEMENTS = CodexPulse/CodexPulse.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulse/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app; PRODUCT_NAME = "Codex Pulse"; SDKROOT = macosx; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; };
+ H12000000000000000000001 /* Debug */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = CodexPulseWidget/CodexPulseWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulseWidget/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../../../../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app.widget; PRODUCT_NAME = CodexPulseWidgetExtension; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Debug; };
+ H12000000000000000000002 /* Release */ = {isa = XCBuildConfiguration; buildSettings = {APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = CodexPulseWidget/CodexPulseWidget.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; INFOPLIST_FILE = CodexPulseWidget/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/../../../../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = io.github.codexpulse.app.widget; PRODUCT_NAME = CodexPulseWidgetExtension; SDKROOT = macosx; SKIP_INSTALL = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; }; name = Release; };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
diff --git a/native/CodexPulse/CodexPulse.entitlements b/native/CodexPulse/CodexPulse.entitlements
index a8c1072..899d535 100644
--- a/native/CodexPulse/CodexPulse.entitlements
+++ b/native/CodexPulse/CodexPulse.entitlements
@@ -1,10 +1,3 @@
-
-
- com.apple.security.app-sandbox
-
- com.apple.security.network.client
-
-
-
+
diff --git a/native/CodexPulse/CodexPulseApp.swift b/native/CodexPulse/CodexPulseApp.swift
index 70dcf4b..ca19128 100644
--- a/native/CodexPulse/CodexPulseApp.swift
+++ b/native/CodexPulse/CodexPulseApp.swift
@@ -6,64 +6,292 @@ private func tr(_ en: String, _ zh: String) -> String {
Locale.preferredLanguages.first?.hasPrefix("zh") == true ? zh : en
}
+#if !SETUP_PREVIEW
@main
struct CodexPulseApp: App {
+ @NSApplicationDelegateAdaptor(PulseMenuDelegate.self) private var menuDelegate
var body: some Scene {
WindowGroup {
ContentView()
- .frame(width: 480, height: 340)
+ .frame(width: 570, height: 510)
.onAppear { WidgetCenter.shared.reloadAllTimelines() }
.onOpenURL { _ in WidgetCenter.shared.reloadAllTimelines() }
}
.windowResizability(.contentSize)
}
}
+#endif
struct ContentView: View {
@State private var status = tr("Checking local bridge…", "正在检查本机服务…")
+ @State private var result = ""
+ @State private var busy = false
+ @State private var connected = false
+ @State private var socialEnabled = false
+ @State private var confirmRemoval = false
+ @State private var confirmRadar = false
+ @AppStorage("customCodexBinary") private var customCodexBinary = ""
var body: some View {
- VStack(alignment: .leading, spacing: 20) {
+ VStack(alignment: .leading, spacing: 16) {
Label("Codex Pulse", systemImage: "gauge.with.dots.needle.33percent")
- .font(.system(size: 28, weight: .bold, design: .rounded))
- .foregroundStyle(.cyan)
+ .font(.system(size: 28, weight: .bold, design: .rounded)).foregroundStyle(.cyan)
Text(tr("Your Codex quota. A glance away.", "Codex 额度,抬眼即见。"))
.font(.headline)
- VStack(alignment: .leading, spacing: 8) {
- Text(tr("1. Right-click your desktop → Edit Widgets", "1. 右键桌面 → 编辑小组件"))
- Text(tr("2. Search Codex Pulse → add small or medium", "2. 搜索 Codex Pulse → 添加小号或中号"))
- Text(tr("3. Both percentages mean USED, not remaining", "3. 两组百分比都是已用量,不是剩余量"))
+ VStack(alignment: .leading, spacing: 7) {
+ Text(tr("1. Sign into Codex with your ChatGPT account", "1. 在 Codex 中使用 ChatGPT 账号登录"))
+ Text(tr("2. Enable the local bridge below (starts at login)", "2. 点击下方按钮启用本机服务(登录时启动)"))
+ Text(tr("3. Right-click desktop → Edit Widgets → Codex Pulse", "3. 右键桌面 → 编辑小组件 → Codex Pulse"))
}.font(.system(size: 13))
- Text(status).font(.caption).foregroundStyle(.secondary)
HStack {
+ Button(tr("Enable/repair bridge", "启用/修复本机服务")) {
+ perform(["install-prebuilt", "--app", Bundle.main.bundlePath])
+ }.buttonStyle(.borderedProminent)
Button(tr("Refresh widgets", "刷新组件")) {
WidgetCenter.shared.reloadAllTimelines()
Task { await checkBridge() }
- }.buttonStyle(.borderedProminent)
- Button(tr("Setup & help", "安装与帮助")) {
- NSWorkspace.shared.open(URL(string: "https://github.com/18637168668a-cpu/codex-pulse#quick-start")!)
}.buttonStyle(.bordered)
+ if busy { ProgressView().controlSize(.small) }
+ }.disabled(busy)
+ Text(status).font(.caption).foregroundStyle(connected ? .green : .secondary)
+ if !result.isEmpty {
+ ScrollView { Text(result).font(.caption).frame(maxWidth: .infinity, alignment: .leading).textSelection(.enabled) }
+ .frame(height: 56)
}
- Text(tr("Read-only · local bridge · macOS schedules widget refreshes", "只读 · 本机服务 · 小组件刷新由 macOS 调度"))
+ Divider()
+ HStack {
+ Button(tr(socialEnabled ? "Disable reset radar" : "Enable reset radar", socialEnabled ? "关闭重置雷达" : "开启重置雷达")) {
+ if socialEnabled { perform(["social", "off"]) } else { confirmRadar = true }
+ }.disabled(busy || !connected)
+ Button(tr("Remove local bridge", "移除本机服务")) { confirmRemoval = true }.disabled(busy)
+ }
+ HStack {
+ Button(tr("Choose Codex…", "选择 Codex 程序…")) { chooseCodex() }.disabled(busy)
+ Button(tr("Setup & help", "安装与帮助")) {
+ NSWorkspace.shared.open(URL(string: "https://github.com/18637168668a-cpu/codex-pulse/blob/main/docs/INSTALL.md")!)
+ }
+ }
+ Text(tr("Both percentages show USED quota. Read-only · no telemetry. macOS schedules widget refreshes. You may close this setup window; keep the app running to show the CP menu meter.", "百分比均为已用额度。只读、无遥测;刷新由 macOS 调度。可关闭设置窗口;保持 App 运行即可显示 CP 菜单栏用量。"))
.font(.caption2).foregroundStyle(.secondary)
}
- .padding(28)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .background(Color(red: 0.04, green: 0.05, blue: 0.075))
- .preferredColorScheme(.dark)
+ .padding(26).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .background(Color(red: 0.04, green: 0.05, blue: 0.075)).preferredColorScheme(.dark)
.task { await checkBridge() }
+ .alert(tr("Enable third-party reset radar?", "开启第三方重置雷达?"), isPresented: $confirmRadar) {
+ Button(tr("Enable", "开启")) { perform(["social", "on"]) }
+ Button(tr("Cancel", "取消"), role: .cancel) {}
+ } message: {
+ Text(tr("codex-reset.com receives normal HTTPS metadata, including your IP. Codex credentials and quota data are never sent. Posts are heuristic signals, not official reset confirmation.", "codex-reset.com 会收到 IP 等普通 HTTPS 请求信息,不会收到 Codex 凭证或用量。动态判断仅供参考,不是官方重置确认。"))
+ }
+ .alert(tr("Remove local bridge?", "移除本机服务?"), isPresented: $confirmRemoval) {
+ Button(tr("Remove", "移除"), role: .destructive) { perform(["remove-bridge"]) }
+ Button(tr("Cancel", "取消"), role: .cancel) {}
+ } message: {
+ Text(tr("Stops the login service and moves its files to Trash. The app and Codex login remain untouched. Remove the desktop widget separately.", "停止登录服务并将其文件移到废纸篓,保留应用和 Codex 登录资料。桌面小组件需另行移除。"))
+ }
+ }
+
+ @MainActor private func chooseCodex() {
+ let panel = NSOpenPanel()
+ panel.title = tr("Choose the Codex executable (not the .app)", "选择 Codex 可执行文件(不是 .app)")
+ panel.canChooseDirectories = false
+ panel.allowsMultipleSelection = false
+ if panel.runModal() == .OK, let url = panel.url {
+ customCodexBinary = url.path
+ result = tr("Codex path selected. Click Enable/repair bridge to apply.", "已选择 Codex 路径,点击启用/修复本机服务后生效。")
+ }
}
- private func checkBridge() async {
+ @MainActor private func perform(_ arguments: [String]) {
+ guard !busy, let resources = Bundle.main.resourceURL else { return }
+ busy = true
+ result = tr("Working…", "正在处理…")
+ let selectedCodex = customCodexBinary
+ Task {
+ let outcome = await Task.detached(priority: .userInitiated) {
+ SetupRunner.run(resources: resources, arguments: arguments, codex: selectedCodex)
+ }.value
+ result = outcome
+ busy = false
+ WidgetCenter.shared.reloadAllTimelines()
+ await checkBridge()
+ }
+ }
+
+ @MainActor private func checkBridge() async {
do {
- var request = URLRequest(url: URL(string: "http://localhost:43187/api/local/usage")!)
- request.timeoutInterval = 20
- let (_, response) = try await URLSession.shared.data(for: request)
- status = (response as? HTTPURLResponse)?.statusCode == 200
- ? tr("Bridge connected. Quota data is available.", "服务已连接,额度数据可用。")
- : tr("Bridge is running. Check your Codex ChatGPT login.", "服务运行中,请检查 Codex 的 ChatGPT 登录。")
+ var health = URLRequest(url: URL(string: "http://127.0.0.1:43187/health")!)
+ health.timeoutInterval = 3
+ let (data, response) = try await URLSession.shared.data(for: health)
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ guard (response as? HTTPURLResponse)?.statusCode == 200, json?["app"] as? String == "codex-pulse" else { throw URLError(.badServerResponse) }
+ connected = true
+ socialEnabled = json?["socialEnabled"] as? Bool ?? false
+ status = tr("Local bridge connected. Checking Codex login…", "本机服务已连接,正在检查 Codex 登录…")
+ var usage = URLRequest(url: URL(string: "http://127.0.0.1:43187/api/local/usage")!)
+ usage.timeoutInterval = 20
+ let (payload, usageResponse) = try await URLSession.shared.data(for: usage)
+ let value = try? JSONSerialization.jsonObject(with: payload) as? [String: Any]
+ if (usageResponse as? HTTPURLResponse)?.statusCode == 200 {
+ status = value?["stale"] as? Bool == true
+ ? tr("Bridge connected. Showing cached quota; check Codex login/network.", "服务已连接,当前为缓存额度;请检查 Codex 登录和网络。")
+ : tr("Bridge connected. Quota data is available.", "服务已连接,额度数据可用。")
+ } else {
+ status = tr("Bridge connected. Open Codex and check your ChatGPT login.", "服务已连接,请打开 Codex 检查 ChatGPT 登录。")
+ }
} catch {
- status = tr("Bridge offline. Run bash scripts/install.sh from the repository.", "服务离线,请在项目目录运行 bash scripts/install.sh。")
+ connected = false
+ status = tr("Bridge offline. Use Enable/repair bridge to set up.", "服务离线,请点击启用/修复本机服务。")
}
}
}
+
+private enum SetupRunner {
+ static func run(resources: URL, arguments: [String], codex: String) -> String {
+ let candidates = [resources.appendingPathComponent("runtime/node").path, "/opt/homebrew/bin/node", "/usr/local/bin/node"]
+ guard let node = candidates.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else {
+ return tr("Node runtime missing. Download the complete release or install Node.js 22+ for a source build.", "未找到 Node 运行时。请下载完整发行版;源码版需安装 Node.js 22+。")
+ }
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: node)
+ process.arguments = [resources.appendingPathComponent("scripts/manage.mjs").path] + arguments
+ var environment = ProcessInfo.processInfo.environment
+ environment["PATH"] = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:" + (environment["PATH"] ?? "")
+ // Avoid user-injected Node options in the bundled installer/runtime.
+ environment.removeValue(forKey: "NODE_OPTIONS")
+ environment.removeValue(forKey: "NODE_PATH")
+ if !codex.isEmpty { environment["CODEX_BIN"] = codex }
+ process.environment = environment
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+ do {
+ try process.run()
+ let output = pipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ let text = String(data: output, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ return text.isEmpty ? tr("Setup finished. Check the bridge status above.", "操作结束,请查看上方服务状态。") : String(text.prefix(1800))
+ } catch {
+ return tr("Could not start setup. Re-download the app and check the installation guide.", "无法启动设置。请重新下载应用并查看安装说明。")
+ }
+ }
+}
+
+
+struct QuotaMeterState {
+ var fiveHour: Double?
+ var weekly: Double?
+ var stale = false
+
+ static func decode(_ data: Data) throws -> QuotaMeterState {
+ let response = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
+ let byID = response["rateLimitsByLimitId"] as? [String: [String: Any]]
+ let bucket = byID?["codex"] ?? response["rateLimits"] as? [String: Any] ?? [:]
+ let windows = [bucket["primary"], bucket["secondary"]].compactMap { $0 as? [String: Any] }
+ func used(_ duration: Int) -> Double? {
+ guard let window = windows.first(where: { ($0["windowDurationMins"] as? Int) == duration }),
+ let value = window["usedPercent"] as? Double, value.isFinite,
+ (0...100).contains(value) else { return nil }
+ return value
+ }
+ let updatedAt = (response["updatedAt"] as? Double).map { Date(timeIntervalSince1970: $0 / 1000) }
+ let stale = response["stale"] as? Bool == true || updatedAt.map { Date().timeIntervalSince($0) > 900 } == true
+ return QuotaMeterState(fiveHour: used(300), weekly: used(10080), stale: stale)
+ }
+}
+
+enum QuotaMeterColor {
+ // Green at 0%, yellow at 50%, red at 100%. Missing data has no usage color.
+ static func components(_ used: Double?) -> [Double]? {
+ guard let used, used.isFinite else { return nil }
+ let value = max(0, min(100, used))
+ let green = [0.12, 0.75, 0.32], yellow = [1.0, 0.80, 0.10], red = [0.95, 0.18, 0.18]
+ let start = value <= 50 ? green : yellow
+ let end = value <= 50 ? yellow : red
+ let progress = value <= 50 ? value / 50 : (value - 50) / 50
+ return zip(start, end).map { $0 + ($1 - $0) * progress }
+ }
+
+ static func color(_ used: Double?) -> NSColor {
+ guard let rgb = components(used) else { return .secondaryLabelColor }
+ return NSColor(srgbRed: rgb[0], green: rgb[1], blue: rgb[2], alpha: 1)
+ }
+
+ @MainActor static func image(_ state: QuotaMeterState) -> NSImage {
+ let image = NSImage(size: NSSize(width: 28, height: 20))
+ image.lockFocus()
+ for (letter, value, x) in [("C", state.fiveHour, 1.0), ("P", state.weekly, 15.0)] {
+ (letter as NSString).draw(at: NSPoint(x: x, y: 2), withAttributes: [
+ .font: NSFont.monospacedSystemFont(ofSize: 14, weight: .bold),
+ .foregroundColor: color(state.stale ? nil : value)
+ ])
+ }
+ image.unlockFocus()
+ image.isTemplate = false
+ return image
+ }
+}
+
+@MainActor final class PulseMenuDelegate: NSObject, NSApplicationDelegate {
+ private var item: NSStatusItem?
+ private var timer: Timer?
+ private var fetching = false
+ private var fiveHourItem: NSMenuItem?
+ private var weeklyItem: NSMenuItem?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ item = NSStatusBar.system.statusItem(withLength: 32)
+ let menu = NSMenu()
+ fiveHourItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
+ weeklyItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
+ menu.addItem(fiveHourItem!)
+ menu.addItem(weeklyItem!)
+ menu.addItem(.separator())
+ for entry in [
+ NSMenuItem(title: tr("Refresh usage", "刷新用量"), action: #selector(refreshFromMenu), keyEquivalent: ""),
+ NSMenuItem(title: tr("Setup & help", "安装与帮助"), action: #selector(openHelp), keyEquivalent: ""),
+ NSMenuItem(title: tr("Quit Codex Pulse", "退出 Codex Pulse"), action: #selector(quit), keyEquivalent: "q")
+ ] { entry.target = self; menu.addItem(entry) }
+ item?.menu = menu
+ update(QuotaMeterState())
+ Task { await refreshUsage() }
+ timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
+ Task { @MainActor in await self?.refreshUsage() }
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) { timer?.invalidate() }
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
+
+ private func update(_ state: QuotaMeterState) {
+ func value(_ used: Double?) -> String { used.map { String(format: "%.0f%%", $0) } ?? "—" }
+ let suffix = state.stale ? tr(" (cached)", "(缓存)") : ""
+ let short = tr("C · 5h used: ", "C · 5 小时已用:") + value(state.fiveHour) + suffix
+ let week = tr("P · Week used: ", "P · 本周已用:") + value(state.weekly) + suffix
+ item?.button?.image = QuotaMeterColor.image(state)
+ item?.button?.toolTip = "Codex Pulse\n" + short + "\n" + week
+ item?.button?.setAccessibilityLabel("Codex Pulse. " + short + ". " + week)
+ fiveHourItem?.title = short
+ weeklyItem?.title = week
+ }
+
+ private func refreshUsage() async {
+ guard !fetching else { return }
+ fetching = true
+ defer { fetching = false }
+ do {
+ var request = URLRequest(url: URL(string: "http://127.0.0.1:43187/api/local/usage")!)
+ request.timeoutInterval = 10
+ request.cachePolicy = .reloadIgnoringLocalCacheData
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) }
+ update(try QuotaMeterState.decode(data))
+ } catch { update(QuotaMeterState()) }
+ }
+
+ @objc private func refreshFromMenu() { Task { await refreshUsage() } }
+ @objc private func openHelp() {
+ NSWorkspace.shared.open(URL(string: "https://github.com/18637168668a-cpu/codex-pulse/blob/main/docs/INSTALL.md")!)
+ }
+ @objc private func quit() { NSApp.terminate(nil) }
+}
diff --git a/native/CodexPulse/Info.plist b/native/CodexPulse/Info.plist
index be494a8..52299b6 100644
--- a/native/CodexPulse/Info.plist
+++ b/native/CodexPulse/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.1.0
+ $(MARKETING_VERSION)
CFBundleVersion
- 1
+ $(CURRENT_PROJECT_VERSION)
CFBundleURLTypes
CFBundleURLSchemescodexpulse
LSMinimumSystemVersion
diff --git a/native/CodexPulseWidget/CodexPulseWidget.swift b/native/CodexPulseWidget/CodexPulseWidget.swift
index 73651f5..ec5f3c3 100644
--- a/native/CodexPulseWidget/CodexPulseWidget.swift
+++ b/native/CodexPulseWidget/CodexPulseWidget.swift
@@ -107,7 +107,7 @@ enum PulseLoader {
if !feed.enabled {
payload.signalLabel = PulseText.t("Radar off", "雷达已关闭")
payload.signalText = PulseText.t("Optional reset radar. Your quota monitor works without it.", "可选重置信号雷达。不影响额度监控。")
- payload.signalReason = PulseText.t("Enable with: bash scripts/social.sh on", "启用:bash scripts/social.sh on")
+ payload.signalReason = PulseText.t("Enable reset radar in the Codex Pulse app.", "在 Codex Pulse 应用中开启重置雷达。")
} else if let signal = feed.signals?.first {
payload.signalLabel = feed.stale ? PulseText.t("Radar cached", "雷达缓存") : (signal.resetAnalysis.level == "reported" ? PulseText.t("Reset reported", "动态称已重置") : PulseText.t("Possible reset", "可能涉及重置"))
payload.signalText = signal.text
diff --git a/native/CodexPulseWidget/Info.plist b/native/CodexPulseWidget/Info.plist
index ae9ec53..76ec0ab 100644
--- a/native/CodexPulseWidget/Info.plist
+++ b/native/CodexPulseWidget/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
XPC!
CFBundleShortVersionString
- 0.1.0
+ $(MARKETING_VERSION)
CFBundleVersion
- 1
+ $(CURRENT_PROJECT_VERSION)
NSExtension
NSExtensionPointIdentifier
diff --git a/package.json b/package.json
index 619ad34..8813aae 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "codex-pulse",
- "version": "0.1.0",
+ "version": "0.1.1",
"private": true,
"description": "Native macOS widgets for Codex usage and reset countdowns, with a local read-only bridge.",
"type": "module",
diff --git a/scripts/build.sh b/scripts/build.sh
old mode 100644
new mode 100755
index caa6316..5cf2292
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -1,21 +1,48 @@
#!/bin/bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUNDLE_RUNTIME=0
+if [ "${1:-}" = '--bundle-runtime' ]; then BUNDLE_RUNTIME=1; shift; fi
+if [ "$#" -ne 0 ]; then echo 'Usage: bash scripts/build.sh [--bundle-runtime]' >&2; exit 2; fi
if [ "$(uname -s)" != Darwin ]; then echo 'Native widgets require macOS.' >&2; exit 1; fi
xcrun --find swiftc >/dev/null
xcodebuild -version >/dev/null
+VERSION="$(cd "$ROOT" && node -p "require('./package.json').version")"
+for file in "$ROOT/native/CodexPulse/Info.plist" "$ROOT/native/CodexPulseWidget/Info.plist"; do
+ [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$file")" = '$(MARKETING_VERSION)' ] || { echo "Expected MARKETING_VERSION template in $file." >&2; exit 1; }
+done
+grep -Fq "MARKETING_VERSION = $VERSION;" "$ROOT/native/CodexPulse.xcodeproj/project.pbxproj" || { echo "Version mismatch in Xcode project (expected $VERSION)." >&2; exit 1; }
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/codex-pulse-build.XXXXXX")"
+trap 'rm -rf "$STAGE"' EXIT
xcodebuild -quiet -project "$ROOT/native/CodexPulse.xcodeproj" -scheme CodexPulse \
- -configuration Release -derivedDataPath "$STAGE" CODE_SIGNING_ALLOWED=NO build
+ -configuration Release -derivedDataPath "$STAGE" CODE_SIGNING_ALLOWED=NO ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO build
APP="$STAGE/Build/Products/Release/Codex Pulse.app"
+[ -d "$APP" ] || { echo 'Xcode did not produce the app bundle.' >&2; exit 1; }
+RESOURCES="$APP/Contents/Resources"
+mkdir -p "$RESOURCES/scripts" "$RESOURCES/runtime"
+ditto --norsrc --noextattr "$ROOT/bridge" "$RESOURCES/bridge"
+ditto --norsrc --noextattr "$ROOT/scripts/manage.mjs" "$RESOURCES/scripts/manage.mjs"
+ditto --norsrc --noextattr "$ROOT/scripts/installer.mjs" "$RESOURCES/scripts/installer.mjs"
+ditto --norsrc --noextattr "$ROOT/package.json" "$RESOURCES/package.json"
+ditto --norsrc --noextattr "$ROOT/LICENSE" "$RESOURCES/LICENSE.txt"
+if [ "$BUNDLE_RUNTIME" -eq 1 ]; then "$ROOT/scripts/fetch-node-runtime.sh" --output "$RESOURCES/runtime"; fi
xattr -cr "$APP"
-codesign --force --timestamp=none --options runtime --sign - \
- --entitlements "$ROOT/native/CodexPulseWidget/CodexPulseWidget.entitlements" \
- "$APP/Contents/PlugIns/CodexPulseWidgetExtension.appex"
-codesign --force --timestamp=none --options runtime --sign - \
- --entitlements "$ROOT/native/CodexPulse/CodexPulse.entitlements" "$APP"
+if [ -f "$APP/Contents/Resources/runtime/node" ]; then codesign --force --timestamp=none --options runtime --sign - --entitlements "$ROOT/scripts/node-runtime.entitlements.plist" "$APP/Contents/Resources/runtime/node"; fi
+codesign --force --timestamp=none --options runtime --sign - --entitlements "$ROOT/native/CodexPulseWidget/CodexPulseWidget.entitlements" "$APP/Contents/PlugIns/CodexPulseWidgetExtension.appex"
+codesign --force --timestamp=none --options runtime --sign - --entitlements "$ROOT/native/CodexPulse/CodexPulse.entitlements" "$APP"
codesign --verify --deep --strict "$APP"
+lipo "$APP/Contents/MacOS/Codex Pulse" -verify_arch arm64 x86_64
+WIDGET_EXECUTABLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Contents/PlugIns/CodexPulseWidgetExtension.appex/Contents/Info.plist")"
+lipo "$APP/Contents/PlugIns/CodexPulseWidgetExtension.appex/Contents/MacOS/$WIDGET_EXECUTABLE" -verify_arch arm64 x86_64
+[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")" = "$VERSION" ]
+[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/PlugIns/CodexPulseWidgetExtension.appex/Contents/Info.plist")" = "$VERSION" ]
+[ "$BUNDLE_RUNTIME" -eq 0 ] || lipo "$APP/Contents/Resources/runtime/node" -verify_arch arm64 x86_64
mkdir -p "$ROOT/build"
+rm -rf "$ROOT/build/Codex Pulse.app"
ditto --norsrc --noextattr "$APP" "$ROOT/build/Codex Pulse.app"
-echo "Built for $(uname -m): $ROOT/build/Codex Pulse.app"
-echo 'Local ad-hoc signature only; this is not a notarized download.'
+# File-provider folders can add Finder metadata during copy. This is only our
+# freshly generated build output, never a downloaded app or a user's installation.
+xattr -cr "$ROOT/build/Codex Pulse.app"
+codesign --verify --deep --strict "$ROOT/build/Codex Pulse.app"
+if [ "$BUNDLE_RUNTIME" -eq 1 ]; then echo "Built universal ad-hoc preview with bundled Node: $ROOT/build/Codex Pulse.app"; else echo "Built universal ad-hoc source preview: $ROOT/build/Codex Pulse.app"; fi
+echo 'This build is not Developer ID signed or notarized.'
diff --git a/scripts/fetch-node-runtime.sh b/scripts/fetch-node-runtime.sh
new file mode 100755
index 0000000..24c14a1
--- /dev/null
+++ b/scripts/fetch-node-runtime.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+NODE_VERSION='22.23.2'
+ARM64_SHA256='61130f394c1630d211dd50aecc4353d379480f36d3ac913cd85dbba1aed585c6'
+X64_SHA256='58e99022c2ff89395576cc7fd4d98cea24bb68081475d5f88b801ee8729fb026'
+OUTPUT=''; REFRESH=0
+while [ "$#" -gt 0 ]; do case "$1" in --output) OUTPUT="${2:-}"; shift 2 ;; --refresh) REFRESH=1; shift ;; *) echo 'Usage: bash scripts/fetch-node-runtime.sh --output DIR [--refresh]' >&2; exit 2 ;; esac; done
+[ -n "$OUTPUT" ] || { echo '--output is required.' >&2; exit 2; }
+[ "$(uname -s)" = Darwin ] || { echo 'The bundled runtime is only built on macOS.' >&2; exit 1; }
+CACHE="$ROOT/build/node-runtime-cache/v$NODE_VERSION"; mkdir -p "$CACHE"
+fetch() { local arch sha archive; arch="$1"; sha="$2"; archive="$CACHE/node-v$NODE_VERSION-darwin-$arch.tar.gz"; if [ "$REFRESH" -eq 1 ] || [ ! -f "$archive" ]; then curl --fail --location --proto '=https' --tlsv1.2 --silent --show-error "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-darwin-$arch.tar.gz" -o "$archive"; fi; printf '%s %s\n' "$sha" "$archive" | shasum -a 256 -c -; }
+fetch arm64 "$ARM64_SHA256"; fetch x64 "$X64_SHA256"
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/codex-pulse-node.XXXXXX")"; trap 'rm -rf "$WORK"' EXIT
+tar -xzf "$CACHE/node-v$NODE_VERSION-darwin-arm64.tar.gz" -C "$WORK"; tar -xzf "$CACHE/node-v$NODE_VERSION-darwin-x64.tar.gz" -C "$WORK"
+mkdir -p "$OUTPUT"
+lipo -create "$WORK/node-v$NODE_VERSION-darwin-arm64/bin/node" "$WORK/node-v$NODE_VERSION-darwin-x64/bin/node" -output "$OUTPUT/node"
+chmod 755 "$OUTPUT/node"; cp "$WORK/node-v$NODE_VERSION-darwin-arm64/LICENSE" "$OUTPUT/NODE-LICENSE.txt"
+lipo "$OUTPUT/node" -verify_arch arm64 x86_64
+codesign --force --timestamp=none --options runtime --sign - --entitlements "$ROOT/scripts/node-runtime.entitlements.plist" "$OUTPUT/node"
+[ "$("$OUTPUT/node" --version)" = "v$NODE_VERSION" ]
+echo "Bundled official Node.js v$NODE_VERSION runtime at $OUTPUT/node"
diff --git a/scripts/installer.mjs b/scripts/installer.mjs
new file mode 100644
index 0000000..e57b044
--- /dev/null
+++ b/scripts/installer.mjs
@@ -0,0 +1,176 @@
+import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, isAbsolute, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { spawnSync } from 'node:child_process';
+import { codexBinary } from '../bridge/codex.mjs';
+
+const defaultRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const label = 'io.github.codexpulse.bridge';
+const appId = 'io.github.codexpulse.app';
+export const xml = value => String(value).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
+function systemRun(command, args, optional = false) {
+ const result = spawnSync(command, args, { encoding: 'utf8', timeout: 30000 });
+ // Never surface arbitrary subprocess diagnostics: Codex can include account details.
+ if (result.status !== 0 && !optional) throw new Error(`${command.split('/').at(-1)} failed. Check installation, permissions and the setup guide.`);
+ return result.stdout?.trim() || '';
+}
+
+// Dependency injection isolates installer tests from real user applications and services.
+export function createInstaller({ root = defaultRoot, homeDir = homedir(), run = systemRun,
+ fetcher = fetch, platform = process.platform, env = process.env,
+ node = process.execPath, uid = process.getuid?.(), codex = () => codexBinary() } = {}) {
+ const support = join(homeDir, 'Library/Application Support/Codex Pulse OSS');
+ const defaultApp = join(homeDir, 'Applications/Codex Pulse.app');
+ const plist = join(homeDir, 'Library/LaunchAgents', `${label}.plist`);
+ const marker = join(support, 'install.json');
+ const domain = `gui/${uid}`;
+ const version = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version;
+ const allowedApp = app => [defaultApp, '/Applications/Codex Pulse.app'].includes(app);
+ function assertApp(app) {
+ if (!allowedApp(app)) throw new Error('Move Codex Pulse.app into Applications (or your user Applications folder), then reopen it before setup.');
+ if (existsSync(app) && run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(app, 'Contents/Info.plist')]) !== appId) {
+ throw new Error('A different app occupies the install location. Move it aside yourself; nothing was overwritten.');
+ }
+ }
+ function settings() {
+ if (!existsSync(marker)) {
+ if (existsSync(plist)) throw new Error('Unmanaged launch agent exists; refusing to change it.');
+ return null;
+ }
+ const value = JSON.parse(readFileSync(marker, 'utf8'));
+ if (value.appId !== appId || !allowedApp(value.app) || !isAbsolute(value.node || '') || !isAbsolute(value.codex || '')) {
+ throw new Error('Unknown installation. Nothing changed.');
+ }
+ if (existsSync(plist)) {
+ const installed = readFileSync(plist, 'utf8');
+ if (!installed.includes(`${label}`) || !installed.includes(`${xml(join(support, 'bridge/server.mjs'))}`) || !installed.includes(`${xml(value.node)}`)) {
+ throw new Error('Launch agent does not match the managed installation. Nothing changed.');
+ }
+ }
+ return value;
+ }
+ async function preflight(prebuiltApp) {
+ if (platform !== 'darwin') throw new Error('macOS is required.');
+ if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Node.js 22+ is required.');
+ const app = prebuiltApp ? resolve(prebuiltApp) : defaultApp;
+ assertApp(app);
+ const previous = settings();
+ if (previous && previous.app !== app && existsSync(previous.app)) throw new Error('Another Codex Pulse installation exists. Remove its local bridge and app before changing install locations.');
+ if (prebuiltApp) {
+ if (!existsSync(app)) throw new Error('The downloaded app was not found.');
+ if (resolve(root) !== join(app, 'Contents/Resources')) throw new Error('Run setup from the downloaded app itself.');
+ run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]);
+ } else run('xcodebuild', ['-version']);
+ let binary = env.CODEX_BIN || codex();
+ if (!isAbsolute(binary)) binary = run('/usr/bin/which', [binary]);
+ if (!isAbsolute(binary)) throw new Error('Codex was not found. Install Codex and sign in with ChatGPT first.');
+ run(binary, ['--version']);
+ try {
+ const response = await fetcher('http://127.0.0.1:43187/health', { signal: AbortSignal.timeout(1500) });
+ const data = await response.json();
+ if (!response.ok || data.app !== 'codex-pulse' || !previous) throw new Error('Unmanaged service.');
+ } catch (error) {
+ if (error.cause?.code !== 'ECONNREFUSED') throw new Error('Cannot safely use local port 43187. Stop any conflicting service and retry.');
+ }
+ console.log('Preflight passed. No changes made.');
+ return { app, previous, binary };
+ }
+ function agentText(value) {
+ const variables = {
+ PATH: env.PATH || '/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin',
+ CODEX_BIN: value.codex, PULSE_SOCIAL: value.social ? '1' : '0',
+ ...(value.codexHome ? { CODEX_HOME: value.codexHome } : {}),
+ };
+ return `
+
+
+Label${label}
+ProgramArguments${xml(value.node)}${xml(join(support, 'bridge/server.mjs'))}
+WorkingDirectory${xml(support)}
+EnvironmentVariables${Object.entries(variables).map(([k, v]) => `${xml(k)}${xml(v)}`).join('')}
+RunAtLoadKeepAliveThrottleInterval30
+\n`;
+ }
+ function saveAgent(value, beforeReload = () => {}) {
+ const oldPlist = existsSync(plist) ? readFileSync(plist) : null;
+ const oldMarker = existsSync(marker) ? readFileSync(marker) : null;
+ mkdirSync(dirname(plist), { recursive: true });
+ mkdirSync(support, { recursive: true, mode: 0o700 });
+ try {
+ run('/bin/launchctl', ['bootout', `${domain}/${label}`], true);
+ writeFileSync(plist, agentText(value), { mode: 0o600 });
+ writeFileSync(marker, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 });
+ run('/bin/launchctl', ['bootstrap', domain, plist]);
+ } catch (error) {
+ beforeReload();
+ if (oldPlist) writeFileSync(plist, oldPlist); else rmSync(plist, { force: true });
+ if (oldMarker) writeFileSync(marker, oldMarker); else rmSync(marker, { force: true });
+ if (oldPlist) run('/bin/launchctl', ['bootstrap', domain, plist], true);
+ throw error;
+ }
+ }
+ async function install(prebuiltApp) {
+ const { app, previous, binary } = await preflight(prebuiltApp);
+ const built = join(root, 'build/Codex Pulse.app');
+ if (!prebuiltApp && !existsSync(built)) throw new Error('Run bash scripts/build.sh first.');
+ mkdirSync(support, { recursive: true, mode: 0o700 });
+ const backup = join(support, 'backups', `${Date.now()}-${process.pid}`);
+ mkdirSync(backup, { recursive: true });
+ const bridge = join(support, 'bridge');
+ const hadBridge = existsSync(bridge);
+ const hadApp = existsSync(app);
+ if (hadBridge) cpSync(bridge, join(backup, 'bridge'), { recursive: true });
+ if (!prebuiltApp && hadApp) run('/usr/bin/ditto', [app, join(backup, 'Codex Pulse.app')]);
+ let rolledBack = false;
+ const rollback = () => {
+ if (rolledBack) return;
+ rolledBack = true;
+ rmSync(bridge, { recursive: true, force: true });
+ if (hadBridge) cpSync(join(backup, 'bridge'), bridge, { recursive: true });
+ if (!prebuiltApp) {
+ rmSync(app, { recursive: true, force: true });
+ if (hadApp) run('/usr/bin/ditto', [join(backup, 'Codex Pulse.app'), app]);
+ }
+ };
+ try {
+ if (!prebuiltApp) {
+ mkdirSync(dirname(app), { recursive: true });
+ rmSync(app, { recursive: true, force: true });
+ run('/usr/bin/ditto', ['--norsrc', '--noextattr', built, app]);
+ run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]);
+ }
+ rmSync(bridge, { recursive: true, force: true });
+ cpSync(join(root, 'bridge'), bridge, { recursive: true });
+ saveAgent({ version, appId, app, node, codex: binary,
+ codexHome: env.CODEX_HOME || previous?.codexHome,
+ social: previous?.social ?? (env.PULSE_SOCIAL === '1') }, rollback);
+ } catch (error) { rollback(); throw error; }
+ run('/usr/bin/pluginkit', ['-a', join(app, 'Contents/PlugIns/CodexPulseWidgetExtension.appex')], true);
+ if (!prebuiltApp) run('/usr/bin/open', [app], true);
+ console.log('Local bridge enabled at login. Add Codex Pulse using Edit Widgets.');
+ }
+ function social(value) {
+ if (!['on', 'off'].includes(value)) throw new Error('Expected social on|off.');
+ const previous = settings();
+ if (!previous) throw new Error('Enable the local bridge first.');
+ saveAgent({ ...previous, social: value === 'on' });
+ console.log(value === 'on' ? 'Reset radar enabled. codex-reset.com receives normal HTTPS request metadata, never Codex credentials or quota data.' : 'Reset radar disabled.');
+ }
+ function uninstall(removeApp) {
+ const previous = settings();
+ if (!previous) throw new Error('No managed local bridge found. Nothing removed.');
+ if (removeApp) assertApp(previous.app);
+ run('/bin/launchctl', ['bootout', `${domain}/${label}`], true);
+ const recovery = join(homeDir, '.Trash', `Codex-Pulse-${Date.now()}-${process.pid}`);
+ mkdirSync(recovery, { recursive: true });
+ if (removeApp && existsSync(previous.app)) {
+ run('/usr/bin/pluginkit', ['-r', join(previous.app, 'Contents/PlugIns/CodexPulseWidgetExtension.appex')], true);
+ renameSync(previous.app, join(recovery, 'Codex Pulse.app'));
+ }
+ if (existsSync(plist)) renameSync(plist, join(recovery, `${label}.plist`));
+ renameSync(support, join(recovery, 'Support'));
+ console.log('Managed local bridge moved to Trash. Codex login was not touched.' + (removeApp ? ' Remove the desktop widget separately.' : ' You can now move the app to Trash separately.'));
+ }
+ return { preflight, install, social, uninstall };
+}
diff --git a/scripts/manage.mjs b/scripts/manage.mjs
index 2d9b3f2..3adda56 100644
--- a/scripts/manage.mjs
+++ b/scripts/manage.mjs
@@ -1,120 +1,12 @@
-import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
-import { homedir } from 'node:os';
-import { dirname, join, resolve } from 'node:path';
-import { fileURLToPath } from 'node:url';
-import { spawnSync } from 'node:child_process';
-import { codexBinary } from '../bridge/codex.mjs';
-
-const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
-const label = 'io.github.codexpulse.bridge';
-const appId = 'io.github.codexpulse.app';
-const homeDir = homedir();
-const support = join(homeDir, 'Library/Application Support/Codex Pulse OSS');
-const app = join(homeDir, 'Applications/Codex Pulse.app');
-const plist = join(homeDir, 'Library/LaunchAgents', `${label}.plist`);
-const marker = join(support, 'install.json');
-const domain = `gui/${process.getuid?.()}`;
-const xml = value => String(value).replace(/[<>&"']/g, c => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c]));
-const run = (command, args, optional = false) => {
- const result = spawnSync(command, args, { encoding: 'utf8' });
- if (result.status !== 0 && !optional) throw new Error(`${command} failed: ${result.stderr || result.error?.message || result.status}`);
- return result.stdout?.trim();
-};
-function assertOwnedApp() {
- if (!existsSync(app)) return;
- if (run('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', join(app, 'Contents/Info.plist')]) !== appId) {
- throw new Error('A different app already exists at ~/Applications/Codex Pulse.app. Move it aside yourself before installing.');
- }
-}
-async function preflight() {
- if (process.platform !== 'darwin') throw new Error('macOS is required.');
- if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Node.js 22+ is required.');
- run('xcodebuild', ['-version']);
- run(codexBinary(), ['--version']);
- assertOwnedApp();
- if (existsSync(plist) && !existsSync(marker)) throw new Error('Unmanaged launch agent exists; refusing to overwrite it.');
- try {
- const response = await fetch('http://127.0.0.1:43187/health', { signal: AbortSignal.timeout(1000) });
- const data = await response.json();
- if (data.app !== 'codex-pulse' || !existsSync(marker)) throw new Error('Port 43187 is already in use. Stop that service first.');
- } catch (error) {
- if (error.cause?.code !== 'ECONNREFUSED') throw error;
- }
- console.log('Preflight passed: macOS, Xcode, Node and Codex detected. No changes made.');
-}
-function saveAgent(settings) {
- mkdirSync(dirname(plist), { recursive: true });
- const env = {
- PATH: process.env.PATH || '/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin',
- CODEX_BIN: settings.codex,
- PULSE_SOCIAL: settings.social ? '1' : '0',
- ...(settings.codexHome ? { CODEX_HOME: settings.codexHome } : {}),
- };
- writeFileSync(plist, `
-
-
-Label${label}
-ProgramArguments${xml(settings.node)}${xml(join(support, 'bridge/server.mjs'))}
-WorkingDirectory${xml(support)}
-EnvironmentVariables${Object.entries(env).map(([k, v]) => `${xml(k)}${xml(v)}`).join('')}
-RunAtLoadKeepAliveThrottleInterval30
-`, { mode: 0o600 });
- writeFileSync(marker, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
- run('launchctl', ['bootout', `${domain}/${label}`], true);
- run('launchctl', ['bootstrap', domain, plist]);
-}
-async function install() {
- await preflight();
- const built = join(root, 'build/Codex Pulse.app');
- if (!existsSync(built)) throw new Error('Run bash scripts/build.sh first.');
- mkdirSync(support, { recursive: true, mode: 0o700 });
- mkdirSync(dirname(app), { recursive: true });
- const previous = existsSync(marker) ? JSON.parse(readFileSync(marker, 'utf8')) : null;
- if (existsSync(app)) {
- const backup = join(support, 'backups', String(Date.now()));
- mkdirSync(backup, { recursive: true });
- run('ditto', ['--norsrc', '--noextattr', app, join(backup, 'Codex Pulse.app')]);
- }
- run('ditto', ['--norsrc', '--noextattr', built, app]);
- // iCloud source folders can attach Finder metadata. Strip only our installed build's metadata.
- run('xattr', ['-cr', app]);
- run('codesign', ['--verify', '--deep', '--strict', app]);
- cpSync(join(root, 'bridge'), join(support, 'bridge'), { recursive: true });
- const binary = codexBinary();
- const resolved = binary === 'codex' ? run('/usr/bin/which', ['codex']) : binary;
- saveAgent({ version: '0.1.0', appId, app, node: process.execPath, codex: resolved, codexHome: process.env.CODEX_HOME || previous?.codexHome, social: previous?.social ?? (process.env.PULSE_SOCIAL === '1') });
- run('pluginkit', ['-a', join(app, 'Contents/PlugIns/CodexPulseWidgetExtension.appex')]);
- run('open', [app]);
- console.log('Installed. Right-click desktop → Edit Widgets → Codex Pulse. The bridge starts at login.');
-}
-function social(value) {
- if (!['on', 'off'].includes(value)) throw new Error('Usage: bash scripts/social.sh on|off');
- if (!existsSync(marker)) throw new Error('Install first.');
- const settings = JSON.parse(readFileSync(marker, 'utf8'));
- if (settings.appId !== appId) throw new Error('Unknown installation.');
- saveAgent({ ...settings, social: value === 'on' });
- run('open', ['codexpulse://refresh']);
- console.log(`Optional third-party feed ${value}. ${value === 'on' ? 'codex-reset.com receives normal HTTPS requests (including your IP), never your quota or Codex credentials.' : ''}`);
-}
-function uninstall() {
- if (!existsSync(marker)) throw new Error('No managed installation found. Nothing removed.');
- const settings = JSON.parse(readFileSync(marker, 'utf8'));
- if (settings.appId !== appId) throw new Error('Unknown installation. Nothing removed.');
- assertOwnedApp();
- run('launchctl', ['bootout', `${domain}/${label}`], true);
- if (existsSync(app)) run('pluginkit', ['-r', join(app, 'Contents/PlugIns/CodexPulseWidgetExtension.appex')], true);
- const recovery = join(homeDir, '.Trash', `Codex-Pulse-${Date.now()}`);
- mkdirSync(recovery, { recursive: true });
- if (existsSync(app)) renameSync(app, join(recovery, 'Codex Pulse.app'));
- if (existsSync(plist)) renameSync(plist, join(recovery, `${label}.plist`));
- renameSync(support, join(recovery, 'Support'));
- console.log(`App, bridge and launch agent moved to Trash: ${recovery}. Codex credentials were not touched. Remove the widget from your desktop separately.`);
-}
+import { createInstaller } from './installer.mjs';
try {
- const action = process.argv[2];
- if (action === 'preflight') await preflight();
- else if (action === 'install') await install();
- else if (action === 'social') social(process.argv[3]);
- else if (action === 'uninstall') uninstall();
- else throw new Error('Expected preflight, install, social or uninstall.');
+ const installer = createInstaller();
+ const [action, ...args] = process.argv.slice(2);
+ if (action === 'preflight' && !args.length) await installer.preflight();
+ else if (action === 'install' && !args.length) await installer.install();
+ else if (action === 'install-prebuilt' && args.length === 2 && args[0] === '--app') await installer.install(args[1]);
+ else if (action === 'social' && args.length === 1) installer.social(args[0]);
+ else if (action === 'uninstall' && !args.length) installer.uninstall(true);
+ else if (action === 'remove-bridge' && !args.length) installer.uninstall(false);
+ else throw new Error('Expected preflight, install, install-prebuilt --app , social on|off, remove-bridge or uninstall.');
} catch (error) { console.error(error.message); process.exitCode = 1; }
diff --git a/scripts/node-runtime.entitlements.plist b/scripts/node-runtime.entitlements.plist
new file mode 100644
index 0000000..e8d5435
--- /dev/null
+++ b/scripts/node-runtime.entitlements.plist
@@ -0,0 +1,6 @@
+
+
+
+ com.apple.security.cs.allow-jit
+ com.apple.security.cs.allow-unsigned-executable-memory
+
diff --git a/scripts/package-release.sh b/scripts/package-release.sh
new file mode 100755
index 0000000..757bcb7
--- /dev/null
+++ b/scripts/package-release.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"; SIGNED=0
+if [ "${1:-}" = '--signed' ]; then SIGNED=1; shift; fi
+[ "$#" -eq 0 ] || { echo 'Usage: bash scripts/package-release.sh [--signed]' >&2; exit 2; }
+VERSION="$(cd "$ROOT" && node -p "require('./package.json').version")"
+[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Invalid package version: $VERSION" >&2; exit 1; }
+SUFFIX='unsigned'; [ "$SIGNED" -eq 0 ] || SUFFIX='signed-notarized'
+NAME="Codex-Pulse-$VERSION-universal-$SUFFIX"; DIST="$ROOT/dist"; APP="$ROOT/build/Codex Pulse.app"
+ZIP="$DIST/$NAME.zip"; DMG="$DIST/$NAME.dmg"
+[ "$SIGNED" -eq 0 ] && bash "$ROOT/scripts/build.sh" --bundle-runtime
+bash "$ROOT/scripts/verify-release.sh" --app "$APP"
+[ "$SIGNED" -eq 0 ] && "$APP/Contents/Resources/runtime/node" --test "$ROOT"/tests/*.test.mjs
+if [ "$SIGNED" -eq 1 ]; then spctl --assess --type execute --verbose=4 "$APP"; xcrun stapler validate "$APP"; fi
+mkdir -p "$DIST"; rm -f "$ZIP" "$DMG"
+STAGE="$(mktemp -d "${TMPDIR:-/tmp}/codex-pulse-dmg-stage.XXXXXX")"; trap 'rm -rf "$STAGE"' EXIT
+ditto --norsrc --noextattr "$APP" "$STAGE/Codex Pulse.app"; ln -s /Applications "$STAGE/Applications"
+ditto -c -k --sequesterRsrc --keepParent "$APP" "$ZIP"
+hdiutil create -quiet -volname "Codex Pulse $VERSION" -srcfolder "$STAGE" -format UDZO -ov "$DMG"
+bash "$ROOT/scripts/verify-release.sh" --app "$APP" --zip "$ZIP" --dmg "$DMG"
+(cd "$DIST" && shasum -a 256 "$NAME.dmg" "$NAME.zip" > SHA256SUMS.txt)
+echo "Packaged $SUFFIX artifacts: $ZIP and $DMG"
diff --git a/scripts/sign-and-notarize.sh b/scripts/sign-and-notarize.sh
new file mode 100755
index 0000000..1dbace0
--- /dev/null
+++ b/scripts/sign-and-notarize.sh
@@ -0,0 +1,15 @@
+#!/bin/bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"; APP="${1:-$ROOT/build/Codex Pulse.app}"
+for key in DEVELOPER_ID_APPLICATION APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do [ -n "${!key:-}" ] || { echo "Missing required environment variable: $key" >&2; exit 1; }; done
+[[ "$DEVELOPER_ID_APPLICATION" == 'Developer ID Application:'* ]] || { echo 'DEVELOPER_ID_APPLICATION must name a Developer ID Application identity.' >&2; exit 1; }
+[ -d "$APP" ] || { echo "Missing app: $APP" >&2; exit 1; }
+codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APPLICATION" --entitlements "$ROOT/scripts/node-runtime.entitlements.plist" "$APP/Contents/Resources/runtime/node"
+codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APPLICATION" --entitlements "$ROOT/native/CodexPulseWidget/CodexPulseWidget.entitlements" "$APP/Contents/PlugIns/CodexPulseWidgetExtension.appex"
+codesign --force --options runtime --timestamp --sign "$DEVELOPER_ID_APPLICATION" --entitlements "$ROOT/native/CodexPulse/CodexPulse.entitlements" "$APP"
+codesign --verify --deep --strict --verbose=2 "$APP"
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/codex-pulse-notarize.XXXXXX")"; ARCHIVE="$WORK/archive.zip"; trap 'rm -rf "$WORK"' EXIT
+ditto -c -k --sequesterRsrc --keepParent "$APP" "$ARCHIVE"
+xcrun notarytool submit "$ARCHIVE" --apple-id "$APPLE_ID" --password "$APPLE_APP_SPECIFIC_PASSWORD" --team-id "$APPLE_TEAM_ID" --wait
+xcrun stapler staple "$APP"; codesign --verify --deep --strict "$APP"; spctl --assess --type execute --verbose=4 "$APP"
+echo 'Developer ID signing and notarization passed. Run bash scripts/package-release.sh --signed to package this stapled app without rebuilding it.'
diff --git a/scripts/test-native.sh b/scripts/test-native.sh
index 6b92e7c..089a776 100644
--- a/scripts/test-native.sh
+++ b/scripts/test-native.sh
@@ -8,4 +8,9 @@ xcrun swiftc -D LAYOUT_PREVIEW -parse-as-library \
export PULSE_PREVIEW_DIR="${PULSE_PREVIEW_DIR:-$STAGE}"
"$STAGE/preview" -AppleLanguages '(en)'
"$STAGE/preview" -AppleLanguages '(zh-Hans)'
+xcrun swiftc -D SETUP_PREVIEW -parse-as-library \
+ "$ROOT/native/CodexPulse/CodexPulseApp.swift" "$ROOT/tests/SetupPreview.swift" \
+ -o "$STAGE/setup-preview"
+PULSE_SETUP_PREVIEW="$PULSE_PREVIEW_DIR/setup-en.png" "$STAGE/setup-preview" -AppleLanguages '(en)'
+PULSE_SETUP_PREVIEW="$PULSE_PREVIEW_DIR/setup-zh.png" "$STAGE/setup-preview" -AppleLanguages '(zh-Hans)'
echo "Preview output: $PULSE_PREVIEW_DIR"
diff --git a/scripts/verify-release.sh b/scripts/verify-release.sh
new file mode 100755
index 0000000..2272637
--- /dev/null
+++ b/scripts/verify-release.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"; APP="$ROOT/build/Codex Pulse.app"; ZIP=''; DMG=''
+while [ "$#" -gt 0 ]; do case "$1" in --app) APP="${2:-}"; shift 2 ;; --zip) ZIP="${2:-}"; shift 2 ;; --dmg) DMG="${2:-}"; shift 2 ;; *) echo 'Usage: bash scripts/verify-release.sh [--app APP] [--zip ZIP] [--dmg DMG]' >&2; exit 2 ;; esac; done
+VERSION="$(cd "$ROOT" && node -p "require('./package.json').version")"
+entitlement_is_true() {
+ local binary="$1" key="$2" plist value
+ plist="$(mktemp "${TMPDIR:-/tmp}/codex-pulse-entitlements.XXXXXX")"
+ codesign -d --entitlements :- "$binary" > "$plist" 2>/dev/null
+ value="$(/usr/libexec/PlistBuddy -c "Print :$key" "$plist")"
+ rm -f "$plist"
+ [ "$value" = true ]
+}
+verify_app() {
+ local candidate="$1" plist="$1/Contents/Info.plist" runtime="$1/Contents/Resources/runtime/node" widget widget_executable
+ [ -d "$candidate" ] || { echo "Missing app: $candidate" >&2; return 1; }
+ [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$plist")" = "$VERSION" ]
+ widget="$candidate/Contents/PlugIns/CodexPulseWidgetExtension.appex"
+ [ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$widget/Contents/Info.plist")" = "$VERSION" ]
+ codesign --verify --deep --strict "$candidate"; lipo "$candidate/Contents/MacOS/Codex Pulse" -verify_arch arm64 x86_64
+ widget_executable="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$widget/Contents/Info.plist")"
+ lipo "$widget/Contents/MacOS/$widget_executable" -verify_arch arm64 x86_64
+ for path in "$candidate/Contents/Resources/bridge" "$candidate/Contents/Resources/scripts/manage.mjs" "$candidate/Contents/Resources/scripts/installer.mjs" "$candidate/Contents/Resources/package.json" "$candidate/Contents/Resources/LICENSE.txt" "$runtime" "$candidate/Contents/Resources/runtime/NODE-LICENSE.txt"; do [ -e "$path" ] || { echo "Missing bundled resource: $path" >&2; return 1; }; done
+ lipo "$runtime" -verify_arch arm64 x86_64; "$runtime" --version | grep -qx 'v22.23.2'
+ entitlement_is_true "$runtime" 'com.apple.security.cs.allow-jit'
+ entitlement_is_true "$widget" 'com.apple.security.app-sandbox'
+ entitlement_is_true "$widget" 'com.apple.security.network.client'
+}
+verify_app "$APP"
+if [ -n "$ZIP" ]; then
+ ZIP_STAGE="$(mktemp -d "${TMPDIR:-/tmp}/codex-pulse-zip.XXXXXX")"; trap 'rm -rf "$ZIP_STAGE"' EXIT
+ ditto -x -k "$ZIP" "$ZIP_STAGE"; verify_app "$ZIP_STAGE/Codex Pulse.app"; rm -rf "$ZIP_STAGE"; trap - EXIT
+fi
+if [ -n "$DMG" ]; then
+ MOUNT="$(mktemp -d "${TMPDIR:-/tmp}/codex-pulse-dmg.XXXXXX")"; trap 'hdiutil detach "$MOUNT" -quiet 2>/dev/null || true; rmdir "$MOUNT" 2>/dev/null || true' EXIT
+ hdiutil attach -quiet -readonly -nobrowse -mountpoint "$MOUNT" "$DMG"; verify_app "$MOUNT/Codex Pulse.app"; [ "$(readlink "$MOUNT/Applications")" = '/Applications' ]; hdiutil detach "$MOUNT" -quiet; rmdir "$MOUNT"; trap - EXIT
+fi
+echo 'Release verification passed.'
diff --git a/tests/SetupPreview.swift b/tests/SetupPreview.swift
new file mode 100644
index 0000000..5c76936
--- /dev/null
+++ b/tests/SetupPreview.swift
@@ -0,0 +1,32 @@
+import AppKit
+import SwiftUI
+
+@main
+struct SetupPreview {
+ @MainActor static func main() throws {
+ precondition(QuotaMeterColor.components(0) == [0.12, 0.75, 0.32])
+ precondition(QuotaMeterColor.components(50) == [1.0, 0.80, 0.10])
+ let red = QuotaMeterColor.components(100)!
+ precondition(abs(red[0] - 0.95) < 0.00001 && abs(red[1] - 0.18) < 0.00001)
+ precondition(QuotaMeterColor.components(nil) == nil && QuotaMeterColor.components(.nan) == nil)
+ precondition(QuotaMeterColor.components(-10) == QuotaMeterColor.components(0))
+ precondition(QuotaMeterColor.components(120) == QuotaMeterColor.components(100))
+ let fixture = Data(#"{"rateLimits":{"primary":{"usedPercent":0,"windowDurationMins":300},"secondary":{"usedPercent":100,"windowDurationMins":10080}},"stale":false}"#.utf8)
+ let meter = try QuotaMeterState.decode(fixture)
+ precondition(meter.fiveHour == 0 && meter.weekly == 100 && !meter.stale)
+ let missing = try QuotaMeterState.decode(Data(#"{"rateLimits":{"primary":{"usedPercent":30,"windowDurationMins":60}},"stale":true}"#.utf8))
+ precondition(missing.fiveHour == nil && missing.weekly == nil && missing.stale)
+ print("Menu CP color boundaries and quota mapping passed")
+ let renderer = ImageRenderer(content: ContentView().frame(width: 570, height: 510).environment(\.colorScheme, .dark))
+ renderer.scale = 2
+ guard let image = renderer.nsImage,
+ let tiff = image.tiffRepresentation,
+ let bitmap = NSBitmapImageRep(data: tiff),
+ let data = bitmap.representation(using: .png, properties: [:]) else {
+ fatalError("Setup preview rendering failed")
+ }
+ let file = ProcessInfo.processInfo.environment["PULSE_SETUP_PREVIEW"] ?? "/tmp/codex-pulse-setup.png"
+ try data.write(to: URL(fileURLWithPath: file))
+ print("Setup preview rendered")
+ }
+}
diff --git a/tests/bridge.test.mjs b/tests/bridge.test.mjs
index f8f7cf7..cb301e5 100644
--- a/tests/bridge.test.mjs
+++ b/tests/bridge.test.mjs
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { request as httpRequest } from 'node:http';
-import { mkdtempSync, writeFileSync } from 'node:fs';
+import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { CodexBridge, normalizeUsage } from '../bridge/codex.mjs';
@@ -112,8 +112,9 @@ test('bridge permits only initialization and read, and handles a real stdio hand
const rl = require('node:readline').createInterface({input:process.stdin});
rl.on('line', line => { const m=JSON.parse(line); if(m.id) process.stdout.write(JSON.stringify({id:m.id,result:m.method==='initialize'?{}:{rateLimits:{primary:{usedPercent:7,windowDurationMins:300,resetsAt:1900000000}}}})+'\\n'); });
`, { mode: 0o700 });
- const bridge = new CodexBridge({ binary: fixture, timeout: 1000 });
- t.after(() => bridge.stop());
+ // Allow cold process startup under concurrent builds; timeout behavior is tested separately.
+ const bridge = new CodexBridge({ binary: fixture, timeout: 5000 });
+ t.after(() => { bridge.stop(); rmSync(folder, { recursive: true, force: true }); });
await assert.rejects(bridge.send('thread/start'), /not allowed/);
assert.equal((await bridge.rateLimits()).rateLimits.primary.usedPercent, 7);
});
@@ -123,3 +124,15 @@ test('missing Codex binary rejects cleanly and allows a later retry', async () =
await assert.rejects(bridge.rateLimits());
bridge.stop();
});
+
+test('a silent Codex process times out, stops and can be retried', async t => {
+ const folder = mkdtempSync(join(tmpdir(), 'pulse-timeout-'));
+ const fixture = join(folder, 'silent-codex');
+ writeFileSync(fixture, '#!/usr/bin/env node\nprocess.stdin.resume();\n', { mode: 0o700 });
+ const bridge = new CodexBridge({ binary: fixture, timeout: 100 });
+ t.after(() => { bridge.stop(); rmSync(folder, { recursive: true, force: true }); });
+ await assert.rejects(bridge.rateLimits(), /timed out/);
+ assert.equal(bridge.child, null);
+ await assert.rejects(bridge.rateLimits(), /timed out/);
+ assert.equal(bridge.child, null);
+});
diff --git a/tests/installer.test.mjs b/tests/installer.test.mjs
new file mode 100644
index 0000000..b7e7fc5
--- /dev/null
+++ b/tests/installer.test.mjs
@@ -0,0 +1,157 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { createInstaller, xml } from '../scripts/installer.mjs';
+
+function fixture(t, overrides = {}) {
+ const homeDir = mkdtempSync(join(tmpdir(), 'pulse-installer-'));
+ t.after(() => rmSync(homeDir, { recursive: true, force: true }));
+ const app = join(homeDir, 'Applications/Codex Pulse.app');
+ const root = join(app, 'Contents/Resources');
+ const support = join(homeDir, 'Library/Application Support/Codex Pulse OSS');
+ const plist = join(homeDir, 'Library/LaunchAgents/io.github.codexpulse.bridge.plist');
+ const marker = join(support, 'install.json');
+ mkdirSync(join(root, 'bridge'), { recursive: true });
+ writeFileSync(join(root, 'bridge/server.mjs'), 'fixture-new');
+ writeFileSync(join(root, 'package.json'), '{"version":"0.1.1"}');
+ const calls = [];
+ const state = { failBootstrap: false, appId: 'io.github.codexpulse.app', busy: false };
+ const run = (command, args, optional) => {
+ calls.push([command, args, optional]);
+ if (command.endsWith('PlistBuddy')) return state.appId;
+ if (command.endsWith('which')) return '/fixture/codex';
+ if (command.endsWith('ditto')) { cpSync(args.at(-2), args.at(-1), { recursive: true }); return ''; }
+ if (command.endsWith('launchctl') && args[0] === 'bootstrap' && state.failBootstrap && !optional) throw new Error('bootstrap failed');
+ return '';
+ };
+ const fetcher = async () => {
+ if (state.busy) return { ok: true, json: async () => ({ app: 'codex-pulse' }) };
+ throw Object.assign(new Error('offline'), { cause: { code: 'ECONNREFUSED' } });
+ };
+ const config = { root, homeDir, run, fetcher, platform: 'darwin', env: { PATH: '/fixture/bin', CODEX_BIN: '/fixture/codex' }, node: join(root, 'runtime/node'), uid: 501, ...overrides };
+ return { app, root, support, plist, marker, calls, state, config, installer: createInstaller(config) };
+}
+
+test('prebuilt installation needs neither Xcode nor a source checkout and defaults radar off', async t => {
+ const f = fixture(t);
+ await f.installer.install(f.app);
+ assert.ok(!f.calls.some(([command]) => command === 'xcodebuild'));
+ assert.ok(f.calls.some(([command]) => command.endsWith('codesign')));
+ const value = JSON.parse(readFileSync(f.marker, 'utf8'));
+ assert.equal(value.app, f.app);
+ assert.equal(value.version, '0.1.1');
+ assert.equal(value.social, false);
+ assert.equal(value.node, join(f.root, 'runtime/node'));
+ assert.equal(readFileSync(join(f.support, 'bridge/server.mjs'), 'utf8'), 'fixture-new');
+ assert.ok(readFileSync(f.plist, 'utf8').includes('RunAtLoad'));
+});
+
+test('source preflight still checks Xcode but creates no files', async t => {
+ const f = fixture(t);
+ await f.installer.preflight();
+ assert.ok(f.calls.some(([command]) => command === 'xcodebuild'));
+ assert.ok(!existsSync(f.support));
+ assert.ok(!existsSync(f.plist));
+});
+
+test('refuses setup from mounted disk images or downloads', async t => {
+ const f = fixture(t);
+ await assert.rejects(f.installer.install('/Volumes/Codex Pulse/Codex Pulse.app'), /Move Codex Pulse/);
+ assert.ok(!existsSync(f.plist));
+});
+
+test('refuses unrelated same-name applications without changes', async t => {
+ const f = fixture(t);
+ f.state.appId = 'org.someone.else';
+ await assert.rejects(f.installer.install(f.app), /different app/);
+ assert.ok(!existsSync(f.support));
+});
+
+test('refuses existing unmanaged LaunchAgents', async t => {
+ const f = fixture(t);
+ mkdirSync(join(f.config.homeDir, 'Library/LaunchAgents'), { recursive: true });
+ writeFileSync(f.plist, 'unmanaged');
+ await assert.rejects(f.installer.install(f.app), /Unmanaged launch agent/);
+ assert.equal(readFileSync(f.plist, 'utf8'), 'unmanaged');
+});
+
+test('refuses an unmanaged server even if it claims the correct application name', async t => {
+ const f = fixture(t);
+ f.state.busy = true;
+ await assert.rejects(f.installer.install(f.app), /port 43187/);
+ assert.ok(!existsSync(f.support));
+});
+
+test('repair preserves custom Codex home and explicit radar choice', async t => {
+ const f = fixture(t, { env: { CODEX_BIN: '/fixture/codex', CODEX_HOME: '/custom/codex & data' } });
+ await f.installer.install(f.app);
+ f.installer.social('on');
+ const next = createInstaller({ ...f.config, env: { CODEX_BIN: '/fixture/codex' } });
+ await next.install(f.app);
+ const value = JSON.parse(readFileSync(f.marker, 'utf8'));
+ assert.equal(value.codexHome, '/custom/codex & data');
+ assert.equal(value.social, true);
+ assert.ok(readFileSync(f.plist, 'utf8').includes('/custom/codex & data'));
+});
+
+test('failed first bootstrap removes new settings and bridge', async t => {
+ const f = fixture(t);
+ f.state.failBootstrap = true;
+ await assert.rejects(f.installer.install(f.app), /bootstrap failed/);
+ assert.ok(!existsSync(f.marker));
+ assert.ok(!existsSync(f.plist));
+ assert.ok(!existsSync(join(f.support, 'bridge')));
+ assert.ok(existsSync(f.app));
+});
+
+test('failed repair restores previous bridge and settings before restarting it', async t => {
+ const f = fixture(t);
+ await f.installer.install(f.app);
+ const previousPlist = readFileSync(f.plist, 'utf8');
+ const previousMarker = readFileSync(f.marker, 'utf8');
+ writeFileSync(join(f.root, 'bridge/server.mjs'), 'fixture-update');
+ f.state.failBootstrap = true;
+ await assert.rejects(f.installer.install(f.app), /bootstrap failed/);
+ assert.equal(readFileSync(join(f.support, 'bridge/server.mjs'), 'utf8'), 'fixture-new');
+ assert.equal(readFileSync(f.plist, 'utf8'), previousPlist);
+ assert.equal(readFileSync(f.marker, 'utf8'), previousMarker);
+ assert.ok(f.calls.some(([command, args, optional]) => command.endsWith('launchctl') && args[0] === 'bootstrap' && optional));
+});
+
+test('failed radar change rolls back the old option', async t => {
+ const f = fixture(t);
+ await f.installer.install(f.app);
+ f.state.failBootstrap = true;
+ assert.throws(() => f.installer.social('on'), /bootstrap failed/);
+ assert.equal(JSON.parse(readFileSync(f.marker, 'utf8')).social, false);
+});
+
+test('bridge removal retains app and Codex login, moves only managed files to Trash', async t => {
+ const f = fixture(t);
+ const login = join(f.config.homeDir, '.codex/auth.json');
+ mkdirSync(join(f.config.homeDir, '.codex'));
+ writeFileSync(login, 'synthetic-login');
+ await f.installer.install(f.app);
+ f.installer.uninstall(false);
+ assert.ok(existsSync(f.app));
+ assert.equal(readFileSync(login, 'utf8'), 'synthetic-login');
+ assert.ok(!existsSync(f.plist));
+ assert.ok(!existsSync(f.support));
+ const trash = join(f.config.homeDir, '.Trash');
+ assert.ok(existsSync(join(trash, readdirSync(trash)[0], 'Support/install.json')));
+});
+
+test('modified managed plist is protected from radar or removal actions', async t => {
+ const f = fixture(t);
+ await f.installer.install(f.app);
+ writeFileSync(f.plist, 'another service');
+ assert.throws(() => f.installer.uninstall(false), /does not match/);
+ assert.throws(() => f.installer.social('on'), /does not match/);
+ assert.equal(readFileSync(f.plist, 'utf8'), 'another service');
+});
+
+test('paths are safely XML escaped without invoking a shell', () => {
+ assert.equal(xml('A<&>"\''), 'A<&>"'');
+});