diff --git a/.gitattributes b/.gitattributes index 1d46598..9121ca4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,8 @@ * text=auto *.cmd text eol=crlf *.ps1 text eol=crlf +*.sh text eol=lf +*.command text eol=lf *.js text eol=lf *.mjs text eol=lf *.css text eol=lf diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 36476ea..effada8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,3 +27,24 @@ jobs: name: Codex-Native-Dock-Windows path: dist/Codex-Native-Dock-Windows-*.zip if-no-files-found: error + + macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Test shared source contracts + run: npm run test:node + - name: Check JavaScript and CDP guards + run: npm run check + - name: Check macOS shell scripts + run: npm run check:macos + - name: Build the one-click macOS archive + run: npm run package:macos + - uses: actions/upload-artifact@v4 + with: + name: Codex-Native-Dock-macOS + path: dist/Codex-Native-Dock-macOS-*.zip + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index cfb4d5a..73e4a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.2.0 - 2026-07-18 + +- Add a one-click macOS installer, Desktop launchers, live verification, and + reversible uninstall for Apple Silicon and Intel Macs. +- Validate the official Codex bundle signature and bind DevTools to loopback. +- Reuse Codex's signed Node.js runtime when compatible, with a SHA-256-verified + portable Node.js fallback. +- Build and test a permission-preserving macOS release archive in CI. + ## 0.1.0 - 2026-07-18 - Combine the local usage meter and lower-right quick controls into one diff --git a/Install-Codex-Native-Dock.command b/Install-Codex-Native-Dock.command new file mode 100755 index 0000000..ba30603 --- /dev/null +++ b/Install-Codex-Native-Dock.command @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd -P)" +exec "$ROOT/macos/scripts/install.sh" diff --git a/NOTICE.md b/NOTICE.md index 43992b6..733f9a7 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -5,5 +5,5 @@ with, endorsed by, or maintained by OpenAI. “OpenAI” and “Codex” are trademarks of their respective owner. The project injects a user-interface overlay through a loopback-only Chromium -DevTools endpoint. It does not modify `WindowsApps`, `app.asar`, or Codex -application files. +DevTools endpoint. It does not modify `WindowsApps`, the macOS app bundle, +`app.asar`, or Codex application files. diff --git a/README.md b/README.md index e649eb8..fe4bc4c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,9 @@ - ChatGPT 模式保留快捷控制,但不显示额度条(ChatGPT 额度不在这里伪造为 100%)。 - 账户额度从 Codex 已加载的本机账户状态读取;上下文 Token 从当前任务 JSONL 会话增量读取。数据不上传,也不扫描消息正文。 -## 一键安装(Windows 11) +## 一键安装 + +### Windows 11 1. 从 [Releases](https://github.com/Alert886/Codex-Native-Dock/releases/latest) 下载 Windows ZIP 并完整解压。 2. 双击 `Install-Codex-Native-Dock.cmd`。 @@ -25,9 +27,19 @@ 系统若没有 Node.js 22+,安装器会从 `nodejs.org` 下载便携版,并使用官方 `SHASUMS256.txt` 校验后再启用。 +### macOS + +1. 从 [Releases](https://github.com/Alert886/Codex-Native-Dock/releases/latest) 下载 macOS ZIP 并完整解压。 +2. 双击 `Install-Codex-Native-Dock.command`;若 macOS 首次阻止运行,请右键文件并选择“打开”。 +3. 安装器会验证 Codex 应用签名,必要时提示重启一次 Codex。 + +支持 Apple Silicon 与 Intel Mac。安装后桌面会生成启动和恢复入口;Codex 更新或普通启动后未显示工具栏时,双击桌面的 `Codex Native Dock.command` 即可恢复。 + +安装器优先复用 Codex 自带且签名有效的 Node.js;版本不满足要求时,才会下载并校验官方便携版。 + ## 恢复原生界面 -双击 `Restore-Codex-Native-Dock.cmd`。恢复只移除本项目的 DOM、后台进程、快捷方式和 `%LOCALAPPDATA%\CodexNativeDock`,不会修改 Codex 安装文件。 +Windows 双击 `Restore-Codex-Native-Dock.cmd`;macOS 双击桌面的 `Restore Codex Native Dock.command`。恢复只移除本项目的 DOM、后台进程、快捷方式和用户目录内的运行文件,不会修改 Codex 安装文件。 ## 开发与验证 @@ -51,8 +63,8 @@ powershell.exe -NoProfile -ExecutionPolicy RemoteSigned -File scripts\verify.ps1 ## 边界 -- 当前一键安装支持 Microsoft Store 版 Codex for Windows。 -- Codex 更新后,使用桌面快捷方式启动即可重新注入;无需修改或重打包 `app.asar`。 +- 当前一键安装支持 Microsoft Store 版 Codex for Windows,以及签名标识为 `com.openai.codex` 的官方 macOS 应用。 +- Codex 更新后,使用桌面快捷方式启动即可重新注入;无需修改或重打包应用文件。 - 本项目不是 OpenAI 官方产品,详见 [NOTICE.md](NOTICE.md)。 ## License diff --git a/Restore-Codex-Native-Dock.command b/Restore-Codex-Native-Dock.command new file mode 100755 index 0000000..abd3ce8 --- /dev/null +++ b/Restore-Codex-Native-Dock.command @@ -0,0 +1,4 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")" && pwd -P)" +exec "$ROOT/macos/scripts/restore.sh" --remove-files --restart-codex diff --git a/SECURITY.md b/SECURITY.md index 68e0033..fe5f64f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,7 +8,9 @@ The installer deliberately: - validates the browser and page WebSocket identities before connecting; - downloads Node.js only from `nodejs.org` and verifies its SHA-256 against the official checksum list; -- writes only below `%LOCALAPPDATA%\CodexNativeDock` plus two user shortcuts; -- never modifies `WindowsApps`, `app.asar`, or the Codex installation; +- writes only below `%LOCALAPPDATA%\CodexNativeDock` on Windows or + `~/Library/Application Support/CodexNativeDock` on macOS, plus user shortcuts; +- validates the official macOS app signature before using its bundled runtime; +- never modifies `WindowsApps`, the macOS app bundle, `app.asar`, or the Codex installation; - stops an injector process only when PID, executable, command line, script path, and process start time match the recorded state. diff --git a/macos/scripts/common.sh b/macos/scripts/common.sh new file mode 100755 index 0000000..fddb30e --- /dev/null +++ b/macos/scripts/common.sh @@ -0,0 +1,292 @@ +#!/bin/bash +set -euo pipefail + +if [ -z "${HOME:-}" ]; then + CURRENT_USER="$(/usr/bin/id -un)" + HOME="$(/usr/bin/dscl . -read "/Users/$CURRENT_USER" NFSHomeDirectory 2>/dev/null | /usr/bin/awk '{print $2}')" + [ -n "$HOME" ] || { printf 'Codex Native Dock: could not resolve the current home directory.\n' >&2; exit 1; } + export HOME +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd -P)" +INSTALL_ROOT="$HOME/Library/Application Support/CodexNativeDock" +ENGINE_ROOT="$INSTALL_ROOT/engine" +STATE_PATH="$INSTALL_ROOT/state.json" +RUNTIME_ROOT="$INSTALL_ROOT/runtime" +INJECTOR_LOG="$INSTALL_ROOT/injector.log" +INJECTOR_ERROR_LOG="$INSTALL_ROOT/injector-error.log" +APP_LOG="$INSTALL_ROOT/codex-launch.log" +APP_ERROR_LOG="$INSTALL_ROOT/codex-launch-error.log" +VERSION="0.2.0" +EXPECTED_CODEX_TEAM_ID="${CODEX_EXPECTED_TEAM_ID:-2DC432GLL2}" + +fail() { + printf 'Codex Native Dock: %s\n' "$*" >&2 + exit 1 +} + +ensure_macos() { + [ "$(/usr/bin/uname -s)" = "Darwin" ] || fail "This installer requires macOS." +} + +ensure_install_root() { + /bin/mkdir -p "$INSTALL_ROOT" + /bin/chmod 700 "$INSTALL_ROOT" +} + +path_is_within() { + case "$1" in "$2"|"$2"/*) return 0 ;; *) return 1 ;; esac +} + +discover_codex_app() { + local candidate identifier executable_name configured="${CODEX_APP_BUNDLE:-}" + CODEX_BUNDLE="" + for candidate in "$configured" \ + "/Applications/ChatGPT.app" "$HOME/Applications/ChatGPT.app" \ + "/Applications/Codex.app" "$HOME/Applications/Codex.app"; do + [ -n "$candidate" ] || continue + [ -f "$candidate/Contents/Info.plist" ] || continue + identifier="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$candidate/Contents/Info.plist" 2>/dev/null || true)" + if [ "$identifier" = "com.openai.codex" ]; then CODEX_BUNDLE="$candidate"; break; fi + done + if [ -z "$CODEX_BUNDLE" ]; then + candidate="$(/usr/bin/mdfind 'kMDItemCFBundleIdentifier == "com.openai.codex"' | /usr/bin/head -n 1)" + if [ -n "$candidate" ] && [ -f "$candidate/Contents/Info.plist" ]; then + identifier="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$candidate/Contents/Info.plist" 2>/dev/null || true)" + [ "$identifier" = "com.openai.codex" ] && CODEX_BUNDLE="$candidate" + fi + fi + [ -n "$CODEX_BUNDLE" ] || fail "The official Codex app (com.openai.codex) was not found." + executable_name="$(/usr/bin/plutil -extract CFBundleExecutable raw -o - "$CODEX_BUNDLE/Contents/Info.plist")" + CODEX_EXE="$CODEX_BUNDLE/Contents/MacOS/$executable_name" + CODEX_VERSION="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$CODEX_BUNDLE/Contents/Info.plist")" + [ -x "$CODEX_EXE" ] || fail "The Codex executable is missing: $CODEX_EXE" + export CODEX_BUNDLE CODEX_EXE CODEX_VERSION +} + +codesign_team_id() { + /usr/bin/codesign -dv --verbose=4 "$1" 2>&1 | /usr/bin/awk -F= '/^TeamIdentifier=/{print $2; exit}' +} + +validate_codex_signature() { + /usr/bin/codesign --verify --deep --strict "$CODEX_BUNDLE" >/dev/null 2>&1 \ + || fail "The Codex app signature is invalid. Reinstall the official app first." + CODEX_TEAM_ID="$(codesign_team_id "$CODEX_BUNDLE")" + [ "$CODEX_TEAM_ID" = "$EXPECTED_CODEX_TEAM_ID" ] \ + || fail "Unexpected Codex signing team: ${CODEX_TEAM_ID:-missing}." + export CODEX_TEAM_ID +} + +node_major() { + local value major + value="$("$1" --version 2>/dev/null || true)" + major="${value#v}"; major="${major%%.*}" + case "$major" in ''|*[!0-9]*) printf '0\n' ;; *) printf '%s\n' "$major" ;; esac +} + +install_portable_node() { + local machine_arch archive_arch base checksums name expected temporary archive extracted actual + machine_arch="$(/usr/bin/uname -m)" + case "$machine_arch" in arm64) archive_arch="arm64" ;; x86_64) archive_arch="x64" ;; *) fail "Unsupported Mac architecture: $machine_arch" ;; esac + base="https://nodejs.org/dist/latest-v22.x" + checksums="$(/usr/bin/curl --fail --location --silent --show-error --max-time 45 "$base/SHASUMS256.txt")" + name="$(printf '%s\n' "$checksums" | /usr/bin/awk -v arch="$archive_arch" '$2 ~ ("^node-v22\\.[0-9.]+-darwin-" arch "\\.tar\\.gz$") { print $2; exit }')" + expected="$(printf '%s\n' "$checksums" | /usr/bin/awk -v file="$name" '$2 == file { print $1; exit }')" + [ -n "$name" ] && [ -n "$expected" ] || fail "No official Node.js 22 archive was listed for this Mac." + temporary="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/codex-native-dock-node.XXXXXX")" + archive="$temporary/$name" + extracted="$temporary/extracted" + /bin/mkdir -p "$extracted" + /usr/bin/curl --fail --location --silent --show-error --max-time 240 "$base/$name" -o "$archive" + actual="$(/usr/bin/shasum -a 256 "$archive" | /usr/bin/awk '{print $1}')" + [ "$actual" = "$expected" ] || { /bin/rm -rf "$temporary"; fail "The Node.js SHA-256 checksum did not match nodejs.org."; } + /usr/bin/tar -xzf "$archive" -C "$extracted" + local node_root + node_root="$(/bin/ls -d "$extracted"/node-v22.*-darwin-"$archive_arch" 2>/dev/null | /usr/bin/head -n 1)" + [ -x "$node_root/bin/node" ] || { /bin/rm -rf "$temporary"; fail "The Node.js archive was incomplete."; } + ensure_install_root + /bin/rm -rf "$RUNTIME_ROOT.new" "$RUNTIME_ROOT.old" + /bin/mv "$node_root" "$RUNTIME_ROOT.new" + [ ! -e "$RUNTIME_ROOT" ] || /bin/mv "$RUNTIME_ROOT" "$RUNTIME_ROOT.old" + /bin/mv "$RUNTIME_ROOT.new" "$RUNTIME_ROOT" + /bin/rm -rf "$RUNTIME_ROOT.old" "$temporary" +} + +resolve_node() { + local candidate bundled + candidate="$(command -v node 2>/dev/null || true)" + if [ -n "$candidate" ] && [ "$(node_major "$candidate")" -ge 22 ]; then NODE="$candidate"; fi + bundled="$CODEX_BUNDLE/Contents/Resources/cua_node/bin/node" + if [ -z "${NODE:-}" ] && [ -x "$bundled" ] && [ "$(node_major "$bundled")" -ge 22 ]; then + /usr/bin/codesign --verify --strict "$bundled" >/dev/null 2>&1 || fail "The bundled Node.js signature is invalid." + [ "$(codesign_team_id "$bundled")" = "$CODEX_TEAM_ID" ] || fail "The bundled Node.js signer does not match Codex." + NODE="$bundled" + fi + if [ -z "${NODE:-}" ] && [ -x "$RUNTIME_ROOT/bin/node" ] && [ "$(node_major "$RUNTIME_ROOT/bin/node")" -ge 22 ]; then + NODE="$RUNTIME_ROOT/bin/node" + fi + if [ -z "${NODE:-}" ]; then install_portable_node; NODE="$RUNTIME_ROOT/bin/node"; fi + NODE_VERSION="$("$NODE" --version)" + export NODE NODE_VERSION +} + +codex_main_pids() { + local pid command_line + while read -r pid command_line; do + [ -n "$pid" ] || continue + case "$command_line" in "$CODEX_EXE"*) printf '%s\n' "$pid" ;; esac + done < <(/bin/ps -axo pid=,command=) +} + +codex_is_running() { [ -n "$(codex_main_pids)" ]; } + +stop_codex() { + local deadline pid + codex_is_running || return 0 + /usr/bin/osascript -e 'tell application id "com.openai.codex" to quit' >/dev/null 2>&1 || true + deadline=$((SECONDS + 12)) + while codex_is_running && [ "$SECONDS" -lt "$deadline" ]; do /bin/sleep 0.25; done + if codex_is_running; then + while IFS= read -r pid; do [ -z "$pid" ] || /bin/kill -TERM "$pid" 2>/dev/null || true; done < <(codex_main_pids) + fi + deadline=$((SECONDS + 5)) + while codex_is_running && [ "$SECONDS" -lt "$deadline" ]; do /bin/sleep 0.25; done + codex_is_running && fail "Codex could not be stopped safely." +} + +listener_pids() { /usr/sbin/lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | /usr/bin/sort -u || true; } + +pid_is_codex_descendant() { + local current="$1" command_line parent depth=0 + while [ "$current" -gt 1 ] 2>/dev/null && [ "$depth" -lt 32 ]; do + command_line="$(/bin/ps -p "$current" -o command= 2>/dev/null || true)" + case "$command_line" in "$CODEX_EXE"*) return 0 ;; esac + parent="$(/bin/ps -p "$current" -o ppid= 2>/dev/null | /usr/bin/awk '{$1=$1; print}')" + case "$parent" in ''|*[!0-9]*) return 1 ;; esac + [ "$parent" -ne "$current" ] || return 1 + current="$parent"; depth=$((depth + 1)) + done + return 1 +} + +port_belongs_to_codex() { + local port="$1" pid found=false + while IFS= read -r pid; do + [ -n "$pid" ] || continue + found=true + pid_is_codex_descendant "$pid" || return 1 + done < <(listener_pids "$port") + [ "$found" = true ] +} + +browser_identity() { + local port="$1" + port_belongs_to_codex "$port" || return 1 + "$NODE" --input-type=module - "$port" <<'NODE' +const port = Number(process.argv[2]); +try { + const version = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); + const socket = new URL(version.webSocketDebuggerUrl); + if (socket.protocol !== "ws:" || !["127.0.0.1", "localhost", "[::1]", "::1"].includes(socket.hostname)) process.exit(1); + if (Number(socket.port || 80) !== port || !socket.pathname.startsWith("/devtools/browser/")) process.exit(1); + const id = decodeURIComponent(socket.pathname.slice("/devtools/browser/".length)); + if (!/^[A-Za-z0-9._:-]{1,256}$/.test(id)) process.exit(1); + const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + if (!targets.some((item) => item?.type === "page" && ["app://-/index.html", "app://codex/"].includes(item.url))) process.exit(1); + process.stdout.write(id); +} catch { process.exit(1); } +NODE +} + +port_is_free() { [ -z "$(listener_pids "$1")" ]; } + +select_free_port() { + local candidate + for candidate in $(/usr/bin/jot - 9341 9355); do if port_is_free "$candidate"; then printf '%s\n' "$candidate"; return 0; fi; done + fail "No free loopback debugging port was found from 9341 through 9355." +} + +state_field() { + [ -f "$STATE_PATH" ] || return 1 + "$NODE" -e 'const fs=require("node:fs");const v=JSON.parse(fs.readFileSync(process.argv[1],"utf8"))[process.argv[2]];if(v!==undefined&&v!==null)process.stdout.write(String(v));' "$STATE_PATH" "$1" +} + +find_endpoint() { + local preferred="${1:-9341}" candidate identity saved="" + [ ! -f "$STATE_PATH" ] || saved="$(state_field port 2>/dev/null || true)" + for candidate in "$saved" "$preferred" $(/usr/bin/jot - 9335 9355); do + case "$candidate" in ''|*[!0-9]*) continue ;; esac + identity="$(browser_identity "$candidate" 2>/dev/null || true)" + if [ -n "$identity" ]; then printf '%s %s\n' "$candidate" "$identity"; return 0; fi + done + return 1 +} + +process_started_at() { /bin/ps -p "$1" -o lstart= 2>/dev/null | /usr/bin/awk '{$1=$1; print}'; } + +recorded_injector_matches() { + local pid="$1" started="$2" node="$3" injector="$4" port="$5" command actual + /bin/kill -0 "$pid" 2>/dev/null || return 1 + command="$(/bin/ps -p "$pid" -o command= 2>/dev/null || true)" + case "$command" in "$node"\ "$injector"\ --watch\ --port\ "$port"\ --browser-id\ *) ;; *) return 1 ;; esac + actual="$(process_started_at "$pid")" + [ -n "$actual" ] && [ "$actual" = "$started" ] +} + +stop_recorded_injector() { + [ -f "$STATE_PATH" ] || return 0 + local pid started node injector port deadline + pid="$(state_field injectorPid 2>/dev/null || true)" + started="$(state_field injectorStartedAt 2>/dev/null || true)" + node="$(state_field nodePath 2>/dev/null || true)" + injector="$(state_field injectorPath 2>/dev/null || true)" + port="$(state_field port 2>/dev/null || true)" + case "$pid" in ''|*[!0-9]*) fail "The saved injector identity is incomplete; refusing to signal an unknown process." ;; esac + /bin/kill -0 "$pid" 2>/dev/null || return 0 + recorded_injector_matches "$pid" "$started" "$node" "$injector" "$port" \ + || fail "The saved injector PID belongs to another process; it was not stopped." + /bin/kill -TERM "$pid" 2>/dev/null || true + deadline=$((SECONDS + 6)) + while /bin/kill -0 "$pid" 2>/dev/null && [ "$SECONDS" -lt "$deadline" ]; do /bin/sleep 0.2; done + if recorded_injector_matches "$pid" "$started" "$node" "$injector" "$port"; then /bin/kill -KILL "$pid" 2>/dev/null || true; fi +} + +launch_codex_with_cdp() { + local port="$1" + : > "$APP_LOG"; : > "$APP_ERROR_LOG" + /usr/bin/open -na "$CODEX_BUNDLE" --args --remote-debugging-address=127.0.0.1 --remote-debugging-port="$port" \ + >>"$APP_LOG" 2>>"$APP_ERROR_LOG" || true +} + +wait_for_endpoint() { + local port="$1" deadline=$((SECONDS + 45)) identity + while [ "$SECONDS" -lt "$deadline" ]; do + identity="$(browser_identity "$port" 2>/dev/null || true)" + if [ -n "$identity" ]; then printf '%s\n' "$identity"; return 0; fi + /bin/sleep 0.35 + done + return 1 +} + +launch_injector() { + local port="$1" browser_id="$2" injector="$ENGINE_ROOT/src/injector.mjs" pid + : > "$INJECTOR_LOG"; : > "$INJECTOR_ERROR_LOG" + /usr/bin/nohup "$NODE" "$injector" --watch --port "$port" --browser-id "$browser_id" \ + >>"$INJECTOR_LOG" 2>>"$INJECTOR_ERROR_LOG" & + pid="$!"; /bin/sleep 0.2 + /bin/kill -0 "$pid" 2>/dev/null || fail "The injector stopped during startup. See $INJECTOR_ERROR_LOG" + printf '%s\n' "$pid" +} + +write_state() { + local port="$1" browser_id="$2" pid="$3" started="$4" injector="$ENGINE_ROOT/src/injector.mjs" + "$NODE" - "$STATE_PATH" "$VERSION" "$port" "$browser_id" "$pid" "$started" "$injector" "$NODE" "$NODE_VERSION" "$CODEX_BUNDLE" "$CODEX_EXE" "$CODEX_VERSION" <<'NODE' +const fs = require("node:fs"); +const [file, version, port, browserId, pid, startedAt, injectorPath, nodePath, nodeVersion, codexBundle, codexExe, codexVersion] = process.argv.slice(2); +const state = {schemaVersion:1,platform:"macos",version,port:Number(port),browserId,injectorPid:Number(pid),injectorStartedAt:startedAt,injectorPath,nodePath,nodeVersion,codexBundle,codexExe,codexVersion,createdAt:new Date().toISOString()}; +const temporary = `${file}.${process.pid}.tmp`; +fs.writeFileSync(temporary, `${JSON.stringify(state,null,2)}\n`, {mode:0o600}); +fs.renameSync(temporary,file); +NODE +} diff --git a/macos/scripts/install.sh b/macos/scripts/install.sh new file mode 100755 index 0000000..f85b027 --- /dev/null +++ b/macos/scripts/install.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -Eeuo pipefail +. "$(cd "$(dirname "$0")" && pwd -P)/common.sh" + +PORT=9341 +IN_PLACE=false +while [ "$#" -gt 0 ]; do + case "$1" in + --port) PORT="${2:-}"; shift 2 ;; + --in-place) IN_PLACE=true; shift ;; + *) fail "Unknown installer argument: $1" ;; + esac +done +case "$PORT" in ''|*[!0-9]*) fail "Invalid port: $PORT" ;; esac +[ "$PORT" -ge 1024 ] && [ "$PORT" -le 65535 ] || fail "Port must be between 1024 and 65535." +ensure_macos + +deploy_engine() { + local temporary="$INSTALL_ROOT/engine.installing.$$" previous="$INSTALL_ROOT/engine.previous.$$" + [ -z "$(/usr/bin/find "$PROJECT_ROOT" -type l -print -quit)" ] || fail "The extracted package contains a symbolic link and was rejected." + ensure_install_root + /bin/rm -rf "$temporary" "$previous" + /bin/mkdir -p "$temporary/src" "$temporary/macos" + /usr/bin/rsync -a --exclude '.DS_Store' "$PROJECT_ROOT/src/" "$temporary/src/" + /usr/bin/rsync -a --exclude '.DS_Store' "$PROJECT_ROOT/macos/" "$temporary/macos/" + for file in README.md LICENSE NOTICE.md SECURITY.md Install-Codex-Native-Dock.command Restore-Codex-Native-Dock.command; do + [ ! -f "$PROJECT_ROOT/$file" ] || /bin/cp "$PROJECT_ROOT/$file" "$temporary/" + done + /bin/chmod 700 "$temporary"/macos/scripts/*.sh "$temporary"/*.command + [ ! -e "$ENGINE_ROOT" ] || /bin/mv "$ENGINE_ROOT" "$previous" + if ! /bin/mv "$temporary" "$ENGINE_ROOT"; then + [ ! -e "$previous" ] || /bin/mv "$previous" "$ENGINE_ROOT" + fail "Could not install files below $INSTALL_ROOT" + fi + /bin/rm -rf "$previous" +} + +if [ "$IN_PLACE" = false ] || [ "$PROJECT_ROOT" != "$ENGINE_ROOT" ]; then + deploy_engine + exec "$ENGINE_ROOT/macos/scripts/install.sh" --in-place --port "$PORT" +fi + +discover_codex_app +validate_codex_signature +resolve_node +"$NODE" --check "$ENGINE_ROOT/src/injector.mjs" +"$NODE" --check "$ENGINE_ROOT/src/renderer.js" +"$NODE" --check "$ENGINE_ROOT/src/usage-store.mjs" + +/bin/mkdir -p "$HOME/Desktop" +write_launcher() { + local target="$1" mode="$2" + if [ -e "$target" ] && ! /usr/bin/grep -q '^# Codex Native Dock launcher$' "$target" 2>/dev/null; then + fail "Refusing to overwrite an unrelated Desktop file: $target" + fi + if [ "$mode" = start ]; then + /usr/bin/printf '%s\n' '#!/bin/bash' '# Codex Native Dock launcher' 'set -e' \ + 'exec "$HOME/Library/Application Support/CodexNativeDock/engine/macos/scripts/start.sh" --prompt-restart' > "$target" + else + /usr/bin/printf '%s\n' '#!/bin/bash' '# Codex Native Dock launcher' 'set -e' \ + 'exec "$HOME/Library/Application Support/CodexNativeDock/engine/macos/scripts/restore.sh" --remove-files --restart-codex' > "$target" + fi + /bin/chmod 700 "$target" +} + +write_launcher "$HOME/Desktop/Codex Native Dock.command" start +write_launcher "$HOME/Desktop/Restore Codex Native Dock.command" restore +"$ENGINE_ROOT/macos/scripts/start.sh" --port "$PORT" --prompt-restart + +/usr/bin/osascript - <<'APPLESCRIPT' >/dev/null 2>&1 || true +display alert "Installation complete" message "Codex Native Dock is active. Use the new Desktop launcher whenever Codex is updated or opened without the dock." +APPLESCRIPT +printf 'Codex Native Dock %s installed successfully.\n' "$VERSION" diff --git a/macos/scripts/package.sh b/macos/scripts/package.sh new file mode 100755 index 0000000..723bd70 --- /dev/null +++ b/macos/scripts/package.sh @@ -0,0 +1,26 @@ +#!/bin/bash +set -Eeuo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd -P)" +VERSION="$(/usr/bin/sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$ROOT/package.json" | /usr/bin/head -n 1)" +[ -n "$VERSION" ] || { printf 'Could not read package version.\n' >&2; exit 1; } +OUTPUT="${1:-$ROOT/dist/Codex-Native-Dock-macOS-v$VERSION.zip}" +TEMPORARY="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/codex-native-dock-package.XXXXXX")" +STAGE="$TEMPORARY/Codex-Native-Dock-v$VERSION" +trap '/bin/rm -rf "$TEMPORARY"' EXIT +/bin/mkdir -p "$STAGE/src" "$STAGE/macos/scripts" "$(/usr/bin/dirname "$OUTPUT")" +/usr/bin/rsync -a "$ROOT/src/" "$STAGE/src/" +/usr/bin/rsync -a "$ROOT/macos/" "$STAGE/macos/" +for file in Install-Codex-Native-Dock.command Restore-Codex-Native-Dock.command README.md LICENSE NOTICE.md SECURITY.md; do + /bin/cp "$ROOT/$file" "$STAGE/" +done +/bin/chmod 755 "$STAGE"/*.command "$STAGE"/macos/scripts/*.sh +( + cd "$STAGE" + /usr/bin/find . -type f ! -name MANIFEST.sha256 | /usr/bin/sort | while IFS= read -r file; do + /usr/bin/shasum -a 256 "$file" + done > MANIFEST.sha256 +) +/usr/bin/xattr -cr "$STAGE" 2>/dev/null || true +/bin/rm -f "$OUTPUT" +COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --keepParent --norsrc --noextattr "$STAGE" "$OUTPUT" +printf 'Created %s\nSHA-256 %s\n' "$OUTPUT" "$(/usr/bin/shasum -a 256 "$OUTPUT" | /usr/bin/awk '{print $1}')" diff --git a/macos/scripts/restore.sh b/macos/scripts/restore.sh new file mode 100755 index 0000000..9dc5e71 --- /dev/null +++ b/macos/scripts/restore.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -Eeuo pipefail +. "$(cd "$(dirname "$0")" && pwd -P)/common.sh" + +REMOVE_FILES=false +RESTART_CODEX=false +while [ "$#" -gt 0 ]; do + case "$1" in + --remove-files) REMOVE_FILES=true; shift ;; + --restart-codex) RESTART_CODEX=true; shift ;; + *) fail "Unknown restore argument: $1" ;; + esac +done +ensure_macos +discover_codex_app +validate_codex_signature +resolve_node + +PORT="$(state_field port 2>/dev/null || true)" +BROWSER_ID="$(state_field browserId 2>/dev/null || true)" +stop_recorded_injector +if [ -n "$PORT" ] && [ -n "$BROWSER_ID" ] && [ -f "$ENGINE_ROOT/src/injector.mjs" ]; then + "$NODE" "$ENGINE_ROOT/src/injector.mjs" --remove --port "$PORT" --browser-id "$BROWSER_ID" >/dev/null 2>&1 || true +fi +/bin/rm -f "$STATE_PATH" + +WAS_RUNNING=false +codex_is_running && WAS_RUNNING=true +if [ "$RESTART_CODEX" = true ] && [ "$WAS_RUNNING" = true ]; then stop_codex; fi +/bin/rm -f "$HOME/Desktop/Codex Native Dock.command" "$HOME/Desktop/Restore Codex Native Dock.command" +if [ "$REMOVE_FILES" = true ]; then + path_is_within "$INSTALL_ROOT" "$HOME/Library/Application Support" \ + || fail "Refusing to remove an unexpected path." + [ "$(/usr/bin/basename "$INSTALL_ROOT")" = "CodexNativeDock" ] || fail "Refusing to remove an unexpected directory." + /bin/rm -rf "$INSTALL_ROOT" +fi +if [ "$RESTART_CODEX" = true ] && [ "$WAS_RUNNING" = true ]; then /usr/bin/open -na "$CODEX_BUNDLE"; fi +printf 'Codex Native Dock was removed. Codex itself was not modified.\n' diff --git a/macos/scripts/start.sh b/macos/scripts/start.sh new file mode 100755 index 0000000..09df535 --- /dev/null +++ b/macos/scripts/start.sh @@ -0,0 +1,56 @@ +#!/bin/bash +set -Eeuo pipefail +. "$(cd "$(dirname "$0")" && pwd -P)/common.sh" + +PORT=9341 +RESTART_EXISTING=false +PROMPT_RESTART=false +while [ "$#" -gt 0 ]; do + case "$1" in + --port) PORT="${2:-}"; shift 2 ;; + --restart-existing) RESTART_EXISTING=true; shift ;; + --prompt-restart) PROMPT_RESTART=true; shift ;; + *) fail "Unknown start argument: $1" ;; + esac +done +case "$PORT" in ''|*[!0-9]*) fail "Invalid port: $PORT" ;; esac +[ "$PORT" -ge 1024 ] && [ "$PORT" -le 65535 ] || fail "Port must be between 1024 and 65535." +ensure_macos +[ -f "$ENGINE_ROOT/src/injector.mjs" ] || fail "Codex Native Dock is not installed." +discover_codex_app +validate_codex_signature +resolve_node +ensure_install_root + +endpoint="$(find_endpoint "$PORT" 2>/dev/null || true)" +if [ -z "$endpoint" ]; then + if codex_is_running; then + if [ "$PROMPT_RESTART" = true ] && [ "$RESTART_EXISTING" = false ]; then + /usr/bin/osascript - <<'APPLESCRIPT' >/dev/null || fail "Startup was cancelled." +display dialog "Codex must restart once to enable the quick dock. Save any unsent input first." buttons {"Cancel", "Restart"} default button "Restart" cancel button "Cancel" with title "Codex Native Dock" +APPLESCRIPT + RESTART_EXISTING=true + fi + [ "$RESTART_EXISTING" = true ] || fail "Codex is open without a debugging endpoint. Close it first or approve the restart." + stop_codex + fi + PORT="$(select_free_port)" + launch_codex_with_cdp "$PORT" + BROWSER_ID="$(wait_for_endpoint "$PORT")" || fail "Codex did not expose a verified loopback endpoint. See $APP_ERROR_LOG" +else + PORT="${endpoint%% *}" + BROWSER_ID="${endpoint#* }" +fi + +stop_recorded_injector +/bin/rm -f "$STATE_PATH" +INJECTOR_PID="$(launch_injector "$PORT" "$BROWSER_ID")" +INJECTOR_STARTED_AT="$(process_started_at "$INJECTOR_PID")" +[ -n "$INJECTOR_STARTED_AT" ] || fail "Could not record the injector start time." +write_state "$PORT" "$BROWSER_ID" "$INJECTOR_PID" "$INJECTOR_STARTED_AT" +/bin/sleep 2 +if ! "$NODE" "$ENGINE_ROOT/src/injector.mjs" --verify --port "$PORT" --browser-id "$BROWSER_ID"; then + stop_recorded_injector + fail "Live verification failed. See $INJECTOR_ERROR_LOG" +fi +printf 'Codex Native Dock %s is running on loopback port %s.\n' "$VERSION" "$PORT" diff --git a/package.json b/package.json index 7d11502..625b284 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,16 @@ { "name": "codex-native-dock", - "version": "0.1.0", + "version": "0.2.0", "private": true, - "description": "A fixed Codex-native usage meter and quick control dock for the Codex Windows app.", + "description": "A fixed Codex-native usage meter and quick control dock for the Codex desktop app.", "type": "module", "scripts": { "test": "node --test tests/*.test.mjs && powershell.exe -NoProfile -ExecutionPolicy Bypass -File tests/run-tests.ps1", + "test:node": "node --test tests/*.test.mjs", "check": "node --check src/injector.mjs && node --check src/renderer.js && node --check src/usage-store.mjs && node src/injector.mjs --self-test", - "package:windows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1" + "check:macos": "bash -n Install-Codex-Native-Dock.command Restore-Codex-Native-Dock.command macos/scripts/*.sh", + "package:windows": "powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/package.ps1", + "package:macos": "bash macos/scripts/package.sh" }, "engines": { "node": ">=22" diff --git a/scripts/start.ps1 b/scripts/start.ps1 index cc3556b..a82e739 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -57,7 +57,7 @@ if ($process.HasExited) { $startedAt = (Get-Process -Id $process.Id).StartTime.ToUniversalTime().ToString('o') $state = [ordered]@{ schemaVersion = 1 - version = '0.1.0' + version = '0.2.0' port = [int]$endpoint.Port browserId = "$($endpoint.BrowserId)" injectorPid = $process.Id diff --git a/src/injector.mjs b/src/injector.mjs index fb66af4..ce23481 100644 --- a/src/injector.mjs +++ b/src/injector.mjs @@ -5,7 +5,7 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { readUsage } from "./usage-store.mjs"; -const VERSION = "0.1.0"; +const VERSION = "0.2.0"; const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]); const PAGE_URLS = new Set(["app://-/index.html", "app://codex/"]); const PAGE_ID = /^[A-Za-z0-9._:-]{1,256}$/; diff --git a/tests/macos-contract.test.mjs b/tests/macos-contract.test.mjs new file mode 100644 index 0000000..750de6b --- /dev/null +++ b/tests/macos-contract.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8"); +const common = read("macos/scripts/common.sh"); +const install = read("macos/scripts/install.sh"); +const start = read("macos/scripts/start.sh"); +const restore = read("macos/scripts/restore.sh"); +const pack = read("macos/scripts/package.sh"); + +test("macOS discovers and verifies the official Codex bundle", () => { + assert.match(common, /com\.openai\.codex/); + assert.match(common, /codesign --verify --deep --strict/); + assert.match(common, /EXPECTED_CODEX_TEAM_ID/); +}); + +test("macOS CDP remains loopback-only and process-owned", () => { + assert.match(common, /remote-debugging-address=127\.0\.0\.1/); + assert.match(common, /port_belongs_to_codex/); + assert.match(common, /devtools\/browser/); + assert.match(start, /browser_id|BROWSER_ID/); +}); + +test("macOS Node fallback uses official checksums", () => { + assert.match(common, /https:\/\/nodejs\.org\/dist\/latest-v22\.x/); + assert.match(common, /SHASUMS256\.txt/); + assert.match(common, /shasum -a 256/); +}); + +test("macOS install and restore stay outside the app bundle", () => { + assert.match(common, /Library\/Application Support\/CodexNativeDock/); + assert.match(install, /ENGINE_ROOT/); + assert.match(restore, /path_is_within/); + for (const source of [common, install, start, restore]) { + assert.doesNotMatch(source, /(?:cp|mv|rm).*CODEX_BUNDLE/); + assert.doesNotMatch(source, /app\.asar/); + } +}); + +test("macOS cleanup validates the recorded injector identity", () => { + assert.match(common, /recorded_injector_matches/); + assert.match(common, /injectorStartedAt/); + assert.match(common, /injectorPath/); + assert.match(common, /refusing to signal an unknown process/i); +}); + +test("macOS package preserves command permissions", () => { + assert.match(pack, /chmod 755/); + assert.match(pack, /ditto -c -k --keepParent/); + assert.match(pack, /MANIFEST\.sha256/); +}); diff --git a/tests/package-install-e2e.ps1 b/tests/package-install-e2e.ps1 index 428b074..8528062 100644 --- a/tests/package-install-e2e.ps1 +++ b/tests/package-install-e2e.ps1 @@ -1,6 +1,7 @@ $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot -$archive = Join-Path $root 'dist\Codex-Native-Dock-Windows-v0.1.0.zip' +$version = (Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json).version +$archive = Join-Path $root "dist\Codex-Native-Dock-Windows-v$version.zip" if (-not (Test-Path -LiteralPath $archive)) { throw 'Build the Windows archive before this test.' } $testRoot = Join-Path $env:TEMP ('Codex Native Dock package test ' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $testRoot | Out-Null