From d06cecc52280cfc4bcb90ad6445a2f41da4bc657 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 02:59:57 +0900 Subject: [PATCH 01/61] docs(devlog): plan macOS menu bar companion (Phase 0 roadmap) Nine numbered docs covering the roadmap for a maintainer-owned macOS menu bar app in app/, consolidating the two competing community PRs (#387 Swift/SwiftUI, #421 Tauri/React). - 000_plan: constraints, dependency-ordered phase map, accept criteria - 001_pr_survey: head-to-head of both PRs; stack decision is Swift/AppKit runtime with HTTP management-API transport, plus the salvage list - 002_api_surface: live payload inventory, the seconds-vs-milliseconds quota timestamp trap, and the default-provider 400 trap - 003_design_read: Design Read and dial lock (V2/M1/D7), inheriting the existing gui/src/styles.css tokens - 010-050: diff-level decade docs, one per implementation phase --- .../260725_macos_menubar_app/000_plan.md | 106 ++++++ .../260725_macos_menubar_app/001_pr_survey.md | 158 +++++++++ .../002_api_surface.md | 198 +++++++++++ .../003_design_read.md | 186 ++++++++++ .../010_phase1_core.md | 332 ++++++++++++++++++ .../260725_macos_menubar_app/020_phase2_ui.md | 248 +++++++++++++ .../030_phase3_actions.md | 168 +++++++++ .../040_phase4_release.md | 198 +++++++++++ .../050_phase5_handoff.md | 109 ++++++ 9 files changed, 1703 insertions(+) create mode 100644 devlog/_plan/260725_macos_menubar_app/000_plan.md create mode 100644 devlog/_plan/260725_macos_menubar_app/001_pr_survey.md create mode 100644 devlog/_plan/260725_macos_menubar_app/002_api_surface.md create mode 100644 devlog/_plan/260725_macos_menubar_app/003_design_read.md create mode 100644 devlog/_plan/260725_macos_menubar_app/010_phase1_core.md create mode 100644 devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md create mode 100644 devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md create mode 100644 devlog/_plan/260725_macos_menubar_app/040_phase4_release.md create mode 100644 devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md diff --git a/devlog/_plan/260725_macos_menubar_app/000_plan.md b/devlog/_plan/260725_macos_menubar_app/000_plan.md new file mode 100644 index 0000000000..722abb308a --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/000_plan.md @@ -0,0 +1,106 @@ +# 260725 — macOS menu bar companion app (`app/`) + +**Unit:** `devlog/_plan/260725_macos_menubar_app/` +**Branch:** `feat/macos-app` (worktree `/Users/jun/Developer/new/700_projects/opencodex-macos-app`, based on `origin/dev` @ `dbed8c15`) +**Work class:** C4 (new shippable surface + release/CI wiring) +**Mode:** HOTL multi-cycle PABCD under `cxc-loop`. This document is the Phase-0 roadmap lock. + +## Objective + +Ship one maintainer-owned macOS menu bar companion for OpenCodex, replacing the two +competing community PRs (#387 Swift/SwiftUI, #421 Tauri/React) with a single +implementation that lives in `app/`, builds a real distributable `.app` bundle, and +attaches release assets through the existing release workflow. + +## Why this unit exists + +Two contributors independently built a menu bar companion within 24 hours of each +other. They cannot both merge: they occupy different directories (`apps/macos-menu-bar/` +vs `menubar/`), use different runtimes (Swift Package Manager vs Tauri v2 + Rust + +React), and use different transports to reach the proxy (`ocx` CLI subprocess vs HTTP +management API). Merging either as-is would (a) leave the other contributor's work +stranded, and (b) commit the repository to a runtime choice that was never audited +against the release pipeline the project already has. + +The user's decision (2026-07-25) is to build the maintainer version, take the strongest +ideas from both, and close both PRs with credit. + +## Constraints + +| Constraint | Source | Consequence | +| --- | --- | --- | +| No `src/` proxy runtime changes | User scope | The app consumes only endpoints that already exist | +| No new management API endpoints | User scope | Any missing data must be derived from existing responses | +| Bun-native repo, no compile step for the proxy | `AGENTS.md` | The app cannot introduce a build step into the proxy's path | +| `bun run typecheck` / `test` / `privacy:scan` must stay green | `AGENTS.md` CI | `app/` must be excluded from the root `tsconfig` or be type-clean under it | +| Release flow is `scripts/release.ts` + `.github/workflows/release.yml` | `AGENTS.md` | macOS packaging attaches to the existing job graph, it does not fork it | +| Security-sensitive workflow edits require review | `AGENTS.md` | Workflow changes stay minimal, pinned, and least-privilege | +| Branch targets `dev` | `.github/workflows/enforce-pr-target.yml` | `feat/macos-app` is pushed, not PR'd, in this unit | + +## Evidence gathered at P (live, 2026-07-25) + +Local toolchain: + +```text +xcode-select -p -> /Library/Developer/CommandLineTools +swift --version -> Apple Swift 6.4, target arm64-apple-macosx27.0.0 +cargo -> present at ~/.cargo/bin/cargo +sw_vers -> macOS 27.0 (26A5378n) +``` + +Universal-build probe (decisive — see `001_pr_survey.md` §4): + +```text +swift build --arch arm64 --arch x86_64 -c release + -> ld: symbol(s) not found for architecture x86_64 + -> warning: The x86_64 architecture is deprecated for your deployment target (macOS 27.0) +swift build --arch arm64 -c release + -> Build complete! (10.39 sec) +``` + +Live proxy surface (`127.0.0.1:10100`, verified by `curl`): `/api/settings`, +`/api/startup-health`, `/api/usage`, `/api/provider-quotas`, `/api/providers`, +`/api/stop`. Full payload shapes in `002_api_surface.md`. + +## Work-phase map (dependency-ordered, PHASE-SPLIT-01) + +Ordering is build-order, not effort: the transport contract must exist before the UI +can render truth, the UI must exist before actions can report their result, and the +bundle must exist before packaging can wrap it. + +| Phase | Doc | Delivers | Independently verifiable by | +| --- | --- | --- | --- | +| 0 | `000`-`003` | Research, API inventory, design lock, this roadmap | Docs exist, audit passes | +| 1 | `010` | `app/` skeleton, proxy discovery, typed API client | `swift test` green, launchable bundle | +| 2 | `020` | Menu bar item + popover UI, all states | Screenshot of running app | +| 3 | `030` | Write actions on existing endpoints | Live action against running proxy | +| 4 | `040` | Universal build, packaging, CI/release wiring | `lipo -archs`, workflow syntax | +| 5 | `050` | Docs, PR closure, push | `gh pr view`, `git ls-remote` | + +## Scope boundary + +**IN:** `app/**`, `scripts/build-macos-app.sh`, `scripts/package-macos-release.sh`, +`.github/workflows/ci.yml`, `.github/workflows/release.yml`, `package.json` script +entries, `docs-site/` companion pages, this devlog unit. + +**OUT:** `src/**` (proxy runtime), new API endpoints, Windows/Linux companions, merging +to `dev`/`main`, the six Haydern provider PRs, `gui/**` beyond required asset reuse. + +## Accept criteria (mirrored into the goalplan) + +1. `app/` produces a launchable `.app` bundle from a repo script. +2. Proxy discovery honours `~/.opencodex/runtime-port.json` and falls back to 10100. +3. The popover renders health, usage/quota, providers, and activity from live data. +4. Loading / empty / error / proxy-unreachable states each render a next action. +5. Write actions call only pre-existing endpoints. +6. Release build is universal (arm64 + x86_64) **in CI**; local arm64-only is accepted + and documented (see `001` §4). +7. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. +8. No build artifacts or developer-absolute paths committed. +9. PRs #387 and #421 closed with English maintainer comments crediting both authors. +10. `feat/macos-app` pushed to origin. + +## Terminal outcomes + +`DONE` on all ten. `BLOCKED` only if no `.app` bundle can be produced after documented +attempts. `NEEDS_HUMAN` if a scope decision beyond the user's delegation appears. diff --git a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md new file mode 100644 index 0000000000..565fa6d856 --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md @@ -0,0 +1,158 @@ +# 001 — Survey: PR #387 vs PR #421, and the stack decision + +Research document. No diffs here (LEXICO-SPLIT-01); implementation lives in the decade docs. + +## 1. PR #387 — `feat: ship packaged macOS menu bar companion` (jaycho46) + +**Branch:** `feat/menubar-app` · **Directory:** `apps/macos-menu-bar/` · 16 commits · +1656/-32 + +Architecture (read from the branch, not from the PR body): + +```text +Package.swift swift-tools-version 5.9, .macOS(.v12) + OpenCodexMenuBarCore OcxClient, OcxLocator, StatusModels (library, tested) + OpenCodexMenuBar main.swift, MenuText, StatusBarIcon (executable) + Tests OpenCodexMenuBarCoreTests +``` + +**Transport: `ocx` CLI subprocess.** `OcxClient.fetchStatus` locates the `ocx` +executable via `OcxLocator`, runs `ocx status --json`, then brace-slices the stdout +(`output.firstIndex(of: "{")` … `lastIndex(of: "}")`) and decodes it. Write actions run +through `commandPlan(for:status:)`, which emits further `ocx` argument vectors. + +To make that transport work, the PR also **extends `src/cli/status.ts`** with +`proxy.health.version` and `proxy.health.uptimeSeconds`, and adds +`tests/cli-status-json.test.ts`. + +Packaging (the genuinely strong part): + +- `scripts/build-macos-app.sh` — assembles `OpenCodex.app` by hand: `Contents/MacOS`, + `Contents/Resources`, `Info.plist`, an `.iconset` built from `gui/public/favicon.png`, + and a refusal guard on unexpected bundle paths. +- `scripts/package-macos-release.sh` — `codesign --verify --deep --strict`, + `lipo -archs` assertion for both arches, `ditto -c -k --sequesterRsrc --keepParent`, + archive content assertion (`unzip -Z1` must contain the executable), `shasum -a 256`. +- `.github/workflows/release.yml` — new `package-macos` job on `macos-latest`, artifact + upload, and Release asset attachment. Also scopes Trusted Publishing OIDC to the + publish job (commit `fbc9c844`), which is an unrelated but correct hardening. +- `.github/workflows/ci.yml` — `test:macos` and `build:macos` steps gated on + `runner.os == 'macOS'`. + +Review history: no maintainer review. Its own author left 10 self-review comments and +CodeRabbit iterated ~14 rounds; the commit tail (`1454a925` bound CLI runs with a +timeout and concurrent pipe drain, `dcf4fea0` treated stale launchd services as +repairable, `0ebbb6a7` waited for pipe drain before reading buffers) shows real defect +repair, not cosmetic churn. + +## 2. PR #421 — `feat(menubar): redesign as macOS status widget` (genglintong) + +**Branch:** `feat/menubar-status-widget` · **Directory:** `menubar/` · 5 commits · +14532/-0 + +Architecture: + +```text +menubar/src-tauri/ Rust: tray.rs, keychain.rs, discover.rs, api.rs (~170 lines) +menubar/src/ React 19 + TS: App, sections/{Usage,Health,Status,Setup,Activity} +menubar/scripts/ build-app.sh, check-version.sh +``` + +**Transport: HTTP management API.** `discover.rs` reads +`~/.opencodex/runtime-port.json`; `api.rs` proxies WebView `invoke("api_request")` calls +through Rust `reqwest` so the API token stays out of JS memory, sourced from the macOS +Keychain. Zero proxy-side changes — it consumes only endpoints that already exist. + +Design: four-tab segmented widget (Usage / Health / Status / Activity), Apple-style +white theme, tabular-nums stats, `macOSPrivateApi: true` for a transparent rounded +popover with shadow. The submitted screenshot is the more polished of the two. + +Distribution: **none.** `.github/` is untouched — no CI job, no release job. Its own +Non-goals list says "DMG / Homebrew distribution (cargo build from source)". A user +would need `rustup` plus a full frontend toolchain to obtain the app. + +Blocking defect: `menubar/src-tauri/target/**` was committed. The Codex reviewer's P1 +notes that `.rustc_info.json` and sibling artifacts embed the contributor's +`/Users/glt/` home path and that `bun run privacy:scan` fails on the tree. Adding the +path to `.gitignore` does not remove it from history. + +## 3. Head-to-head + +| Axis | #387 (Swift) | #421 (Tauri) | +| --- | --- | --- | +| Runtime deps to build | Swift toolchain (Xcode CLT) | Rust + Node + Tauri CLI | +| Runtime deps to run | none (native binary) | none (bundled WebView) | +| Bundle size class | ~single-MB native | tens of MB (WebView shell + Rust) | +| Transport | `ocx` CLI subprocess | HTTP management API | +| Requires proxy source change | yes (`src/cli/status.ts`) | no | +| Distribution to users | zip + SHA-256 attached to Release | none, build from source | +| CI coverage | macOS test + build steps | none | +| Committed artifacts | none | `src-tauri/target/**` (privacy:scan FAIL) | +| UI polish (as submitted) | functional menu | higher — segmented tabs, tuned spacing | +| Data breadth | proxy status + control | usage, health, status, activity, quotas | + +## 4. Stack decision — Swift + AppKit, transport over HTTP + +**Decision: build in Swift (SwiftPM + AppKit), and talk to the proxy over the HTTP +management API.** This is a hybrid: #387's runtime and packaging discipline, #421's +transport and information architecture. + +Rationale, in order of weight: + +1. **Distribution is the whole point of the user's question.** A menu bar app that the + user must compile is not a shipped app. #387 already proves the packaging path end to + end; #421 explicitly declines it. Rebuilding Tauri packaging from scratch would mean + re-deriving what #387 already verified. +2. **HTTP beats CLI subprocess for a polling UI.** Spawning `ocx` every refresh cycle + costs a process launch plus Bun startup per tick, requires the brace-slicing hack to + survive incidental stdout, and — decisively — needs `src/cli/status.ts` to grow new + fields. The user put `src/` out of scope. The management API already returns richer + data (`/api/usage`, `/api/provider-quotas`) with no proxy change at all. +3. **Dependency weight.** Swift + AppKit ships zero third-party dependencies. Tauri adds + a Rust toolchain, a Cargo lockfile, generated ACL schemas, and a WebView runtime to a + repository whose entire premise is a single Bun process. +4. **`macOSPrivateApi: true` is a liability.** #421 enables it for rounded corners. + Private API usage is a documented App Store rejection vector and a notarization risk; + AppKit's `NSPopover` gives the same visual result through public API. + +### 4.1 The universal-binary finding (must be honoured by Phase 4) + +Probed live on this machine: + +```text +swift build --arch arm64 --arch x86_64 -c release + -> ld: symbol(s) not found for architecture x86_64 +swift build --arch arm64 -c release + -> Build complete! (10.39 sec) +``` + +Command Line Tools ships only current-architecture Swift compatibility libraries, and +macOS 27 additionally deprecates x86_64 for this deployment target. #387's build script +already detects this and refuses `UNIVERSAL=1` under CLT with a clear message — that +guard is correct and is inherited. + +**Consequence for the plan:** local verification is arm64-only and that is expected, not +a failure. The universal assertion belongs in CI, where `macos-latest` runners carry a +full Xcode. Phase 4 must therefore keep `UNIVERSAL` opt-in with the CLT guard, and the +`lipo` both-arch assertion must run in the CI job rather than gating local builds. + +## 5. What is salvaged from each PR + +From **#387 (jaycho46)** — packaging architecture: manual bundle assembly, the +unexpected-bundle-path refusal guard, `codesign --verify --deep --strict`, `lipo` +assertion, `ditto` archiving with archive-content verification, SHA-256 sidecar, the +`package-macos` release job shape, the CLT/universal guard, and the Gatekeeper +first-launch documentation angle. + +From **#421 (genglintong)** — product architecture: HTTP management-API transport, +`runtime-port.json` discovery with a 10100 fallback, auth token held outside the +rendering layer, the four-surface information architecture (usage / health / status / +activity), tabular-numeral stat treatment, and skipping auth entirely when the proxy has +no `apiKeys` configured. + +## 6. Rejected alternatives + +- **Merge #387, then re-skin later.** Rejected: it lands the `src/cli/status.ts` change + the user excluded, and the CLI transport would have to be replaced anyway. +- **Merge #421, then add packaging.** Rejected: the committed `target/` tree fails + `privacy:scan` and would need history rewriting, and the private-API dependency stays. +- **Ask the contributors to converge.** Rejected: the user asked for the maintainer + version now; a two-way contributor negotiation is slower and leaves both PRs open. diff --git a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md new file mode 100644 index 0000000000..19ffdccd34 --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md @@ -0,0 +1,198 @@ +# 002 — Management API surface the companion consumes + +Research document: what already exists, verified live against `127.0.0.1:10100` on +2026-07-25 and cross-read in `src/`. No proxy change is required by anything here. + +## 1. Discovery + +`src/config.ts:324` — `resolveRuntimePortPath()` returns `/runtime-port.json`, +where the config dir defaults to `~/.opencodex` (overridable by `OPENCODEX_HOME`). + +Live content: + +```json +{ "pid": 14582, "port": 10100 } +``` + +Resolution order the app implements: + +1. `OPENCODEX_HOME` if set, else `~/.opencodex`. +2. Read `runtime-port.json`; use `port` when it parses and is in `1..65535`. +3. Fall back to `10100`. +4. Host is always loopback (`127.0.0.1`). + +`pid` is present and could be liveness-checked, but the app treats a failed HTTP probe +as the authoritative "not running" signal — simpler, and it matches what the user sees. + +## 2. Authentication + +`src/server/auth-cors.ts:120` — `isApiAuthRequired(config)` returns +`!isLoopbackHostname(config.hostname)`. **On a loopback bind (the default), management +requests need no credential at all.** + +When required, `hasValidApiAuth` (`auth-cors.ts:161`) accepts any of: + +- `x-opencodex-api-key: ` +- `authorization: Bearer ` +- `x-api-key: ` + +validated against `OPENCODEX_API_AUTH_TOKEN` or `config.apiKeys[].key` with +`timingSafeEqual`. + +App behaviour: attempt unauthenticated first. On `401`, read the token from the macOS +Keychain and retry with `x-opencodex-api-key`. Never log the token, never write it to +`UserDefaults`, never include it in error strings surfaced to the UI. + +## 3. Read endpoints + +### `GET /api/settings` + +Bind/runtime configuration plus an embedded `startupHealth`. Live shape (truncated): + +```json +{ + "codexAutoStart": false, + "port": 10100, + "hostname": "127.0.0.1", + "streamMode": "auto", + "startupHealth": { "...": "see below" } +} +``` + +Used for: the port/hostname the app displays, and as the cheapest liveness probe. + +### `GET /api/startup-health` + +```json +{ + "routingKind": "opencodex-local", + "autostartEnabled": false, + "serviceInstalled": true, "serviceViable": true, "serviceEnabled": true, + "serviceRunning": true, "serviceStale": false, "serviceConflict": false, + "serviceSupported": true, + "shimInstalled": false, "shimHealthy": false, + "platform": "darwin", + "routingInjected": true, "localRoutingDependency": true, + "status": "at-risk", + "rebootSafe": false, + "protection": "none", + "shimCoverage": "none", + "recommendedCommand": "ocx service install", + "commands": { "installService": "...", "installShim": "...", "restoreNative": "..." } +} +``` + +`status` is the single field the menu bar icon derives its state from. Observed values +include `protected` and `at-risk`; the app must treat the field as an open string and +degrade unknown values to a neutral state rather than crashing. + +`recommendedCommand` is a **string to display**, never a command the app executes +silently. + +### `GET /api/usage` + +Accepts `?range=` (`24h`/`7d`/`30d`, live default `30d`) and `?surface=`. + +```json +{ + "range": "30d", "surface": "all", "since": 1782323333603, "generatedAt": 1784915333603, + "summary": { + "requests": 232507, "measuredRequests": 225380, "estimatedRequests": 14618, + "inputTokens": 33521662469, "outputTokens": 127401110, + "cachedInputTokens": 31920236280, "reasoningOutputTokens": 25395837, + "totalTokens": 36536664705, "coverageRatio": 0.969, "estimatedCostUsd": 34018.25 + }, + "days": [ { "date": "2026-06-28", "requests": 1746, "totalTokens": 0, "models": [] } ] +} +``` + +Note the magnitudes: request counts reach six figures, token counts reach 3.6e10, and +cost reaches five figures. **Every numeric in the UI must be abbreviated and use tabular +figures**; naive rendering destroys the layout. This is a hard design input, recorded in +`003`. + +`days[]` is the source for the activity sparkline. No separate activity endpoint exists. + +### `GET /api/provider-quotas` + +```json +{ + "generatedAt": 1784915336899, + "reports": [ + { "provider": "openai", "label": "OpenAI (Codex login)", "source": "chatgpt:wham", + "quota": { "weeklyPercent": 44, "weeklyResetAt": 1785258443, "resetCredits": 3 } }, + { "provider": "anthropic", "label": "Anthropic Claude", "source": "anthropic:oauth-usage", + "quota": { "weeklyPercent": 58, "weeklyResetAt": 1785265199718, + "customWindows": [ { "label": "5h", "percent": 1, "resetAt": 1784928599718 } ] } }, + { "provider": "xai", "label": "xAI Grok", "source": "xai:grok-billing", + "quota": { "monthlyPercent": 86.83, "monthlyResetAt": 1785542400000 } } + ] +} +``` + +Traps the app must handle: + +- The window key differs per provider: `weeklyPercent`, `monthlyPercent`, or only + `customWindows[]`. There is no single canonical percent field. +- `weeklyResetAt` is **seconds** for `openai` (`1785258443`) but **milliseconds** for + `anthropic` (`1785265199718`). Timestamps must be normalized by magnitude, not by + assuming a unit. +- `quota` may be absent entirely for a provider with no usage source. + +### `GET /api/providers` + +```json +[ { "name": "openai", "adapter": "openai-responses", + "baseUrl": "https://chatgpt.com/backend-api/codex", + "hasApiKey": false, "liveModels": true, "models": [], + "authMode": "forward", "disabled": false, "codexAccountMode": "pool" } ] +``` + +`hasApiKey` is a boolean presence flag — the key itself is never returned. `disabled` +drives the toggle in Phase 3. + +## 4. Write endpoints + +### `POST /api/stop` + +`src/server/management-api.ts:136`. Answers `200` first, then drains +(`src/lib/process-control.ts:77`). The app must therefore treat `200` as "stop accepted", +not "stopped", and re-probe until the port stops answering. + +### `PATCH /api/providers?name=` + +`src/server/management/provider-routes.ts:127`. Body must be a plain object. + +For the disabled toggle the body is exactly `{ "disabled": true|false }`: + +- `provider-routes.ts:177` — non-boolean `disabled` is `400`. +- `provider-routes.ts:178` — disabling `config.defaultProvider` is rejected `400` with + `"cannot disable the default provider; set another default first"`. **The app must + disable the toggle for the default provider and explain why, rather than firing a + request that is guaranteed to fail.** +- `provider-routes.ts:239` — a `disabled`-only patch skips the heavier merged-shape + validators, so the toggle stays a cheap, low-risk call. +- `codexAccountMode` is mutually exclusive with every other field + (`provider-routes.ts:139`) and is **out of scope** for this app. + +Unknown provider names return `404`. + +## 5. Endpoints deliberately not consumed + +`/api/oauth/*` (account operations are a Non-goal), `/api/update/*` (self-update is the +dashboard's job), `/api/debug/*` (verbose, privacy-sensitive), `/api/storage`, +`/api/combos`, `/api/models`, `/api/keys`. Adding them later does not require a proxy +change, so the surface stays extensible. + +## 6. Polling contract + +| Data | Endpoint | Interval | Rationale | +| --- | --- | --- | --- | +| Liveness + health | `/api/startup-health` | 5 s | Cheap, drives the icon | +| Usage summary | `/api/usage?range=24h` | 60 s | Aggregation is expensive | +| Quotas | `/api/provider-quotas` | 60 s | Upstream-rate-limited | +| Providers | `/api/providers` | on popover open | Changes rarely | + +Polling pauses entirely while the popover is closed except for the 5 s liveness tick, and +backs off to 30 s after three consecutive failures. This keeps an idle menu bar app from +behaving like a load generator against the user's own proxy. diff --git a/devlog/_plan/260725_macos_menubar_app/003_design_read.md b/devlog/_plan/260725_macos_menubar_app/003_design_read.md new file mode 100644 index 0000000000..9c282e47f8 --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/003_design_read.md @@ -0,0 +1,186 @@ +# 003 — Design Read + dial lock + +Design authority delegated by the user ("그냥 브랜치 너의 미감대로"). Produced under +`cxc-dev-uiux-design` before any UI code, per UX-CONCEPT-GEN-01. Implementation rules +are enforced from `cxc-dev-frontend`. + +## 1. Existing design system detection (MANDATORY, ran first) + +The repository already has a governing token system: `gui/src/styles.css`. It is not a +starter theme — it is deliberate, and the companion inherits it rather than inventing a +parallel aesthetic. + +```css +--bg: light-dark(#ffffff, #212121); +--surface: light-dark(#ffffff, #262626); +--raised: light-dark(#f4f4f4, #303030); +--border: light-dark(#e6e6e6, #3d3d3d); +--text: light-dark(#0d0d0d, #ececec); +--muted: light-dark(#6e6e6e, #a6a6a6); +--accent: light-dark(#0d0d0d, #ececec); /* ink, not a hue */ +--green: light-dark(#0a7d5c, #4ecb9d); +--amber: light-dark(#9a4a08, #fbbf24); +--red: light-dark(#b91c1c, #f87171); +--radius: 12px; --radius-sm: 8px; --radius-pill: 999px; +--text-micro: 10px; --text-caption: 11px; --text-label: 12px; --text-control: 13px; +``` + +Three properties of this system are load-bearing and are carried over verbatim: + +1. **The accent is ink, not a hue.** `--accent` is near-black in light mode and near-white + in dark. Colour is reserved for *state* (green/amber/red), never for decoration. This + is already the correct answer for a developer tool and it sidesteps the + purple-gradient tell without any further thought. +2. **`light-dark()` rather than a class toggle.** The OS decides. A menu bar app that + fought the system appearance would be immediately wrong on macOS. +3. **Small type ladder (10-13px).** Confirms the intended density is high. + +**Consequence:** this is a *derivation*, not a redesign. A separate palette would make +the companion look like a third-party utility rather than part of OpenCodex. + +## 2. Design Read + +```yaml +--- +name: opencodex-menubar +colors: + primary: "#0d0d0d" # ink accent, inverts to #ececec in dark + accent: "#0a7d5c" # state green only; amber #9a4a08, red #b91c1c + background: "#ffffff" # inverts to #212121 in dark +typography: + heading: { fontFamily: "SF Pro Text", fontSize: 12, weight: 600 } + body: { fontFamily: "SF Pro Text", fontSize: 11 } + numeric: { fontFamily: "SF Pro Text", feature: "tabular-nums", fontSize: 13 } +iconography: + system: "SF Symbols" + weight: "regular" + domain: "library-subset" +--- +``` + +Reading this as: **a glanceable operations readout for a local proxy the user already +runs**, in the visual language of the existing OpenCodex dashboard, compressed to a +340pt popover. + +The reference is not another menu bar app — it is an **instrument panel**: Activity +Monitor's CPU popover and Little Snitch's network monitor, where the whole point is that +one glance answers "is it fine?" and a second glance answers "what specifically". + +**Do's:** inherit the dashboard's ink-accent restraint; state colour only for state; +tabular numerals everywhere a number can change; one row = one fact; dense but not +cramped. + +**Don'ts:** no hero anything; no marketing copy; no gradients; no emoji; no segmented +tab bar that hides the answer behind a click; no colour that means nothing. + +### Font choice + +**SF Pro (via `NSFont.systemFont`), not the dashboard's OpenAI Sans.** The dashboard is a +web surface where a brand font is appropriate. A menu bar popover sits 4pt from macOS +chrome, and a non-system font there reads as a foreign object. SF Symbols are used for +iconography for the same reason — this is the one place where "use the platform default" +is the sophisticated choice rather than the lazy one, because the platform *is* the +context. + +## 3. Dial lock + +```text +DESIGN_VARIANCE: 2 +MOTION_INTENSITY: 1 +Product density profile: D7 (finance/ops class — high information density, restrained) +``` + +Reasoning: this is a repeated-glance operations surface for a developer tool. Per the +`cxc-dev-uiux-design` preset table, "Finance / ops" is `2 / 1 / D6-D7` and that is exactly +the right shape here — the user opens this to read numbers, not to be delighted. +MOTION_INTENSITY 1 means feedback-only: the popover's own open/close animation is +AppKit's, and the only in-app motion is a state-change crossfade on the status dot. +Scroll-driven motion is zero. Per FE-MOTION-HONESTY-01, declaring 1 obliges me to ship no +decorative motion, which is the intent. + +## 4. Information architecture + +PR #421 used four segmented tabs (Usage / Health / Status / Activity). **Rejected**, for a +specific reason: a menu bar popover is a glance surface, and tabs mean the answer to "is +it fine?" is one click away three times out of four. UX-LAZY-01 step 1 — can a correct +default remove this decision? Yes: show everything, ordered by urgency, in one scroll-free +column. + +```text +┌──────────────────────────────────────┐ +│ ● Running 127.0.0.1:10100 │ status line — the answer +│ protected · service │ qualifier, muted, 11px +├──────────────────────────────────────┤ +│ REQUESTS TOKENS COST │ micro labels, 10px, letterspaced +│ 1,746 12.4M $8.21 │ tabular-nums, 13px +│ ▁▂▃▅▂▁▃▇▄▂▁▃ │ 24h sparkline from usage.days[] +├──────────────────────────────────────┤ +│ OpenAI ▓▓▓▓▓░░░░░ 44% │ quota rows, one per provider +│ Anthropic ▓▓▓▓▓▓░░░░ 58% │ +│ xAI ▓▓▓▓▓▓▓▓▓░ 87% │ amber >80, red >95 +├──────────────────────────────────────┤ +│ Dashboard Restart ··· │ actions +└──────────────────────────────────────┘ +``` + +Vertical order is urgency order: liveness first (the reason the app exists), then +throughput, then quota pressure, then actions. Providers move to a disclosure row rather +than occupying primary space, since toggling one is rare compared to reading status. + +Target width 340pt. Height is content-driven, capped at 480pt with the provider list +scrolling if a user runs many providers. + +## 5. The one signature moment + +**The menu bar icon itself.** It is a template image so macOS inverts it correctly, and it +carries state without colour: + +| State | Glyph treatment | +| --- | --- | +| Running, protected | Solid mark | +| Running, at-risk | Solid mark + a single-pixel notch | +| Stopped | Outlined mark | +| Unreachable | Outlined mark at 40% opacity | + +Colour is deliberately not used in the menu bar. macOS menu bar template images are +monochrome by convention, and a coloured dot up there is the tell of an app that does not +respect the platform. The coloured status dot lives *inside* the popover, where it has a +label next to it and does not encode meaning by colour alone (WCAG 1.4.1). + +## 6. Anti-slop pre-registration + +Committed to before implementation, so Phase 2's audit can check them: + +- No emoji anywhere in the UI (STRICT). SF Symbols only. +- No gradients. Zero, not "one per viewport" — a 340pt utility popover has no room for + ambient decoration. +- No one-note theme: neutral surfaces, state colour only. +- No oversized display type: the largest text in the app is 13px numeric. +- No self-describing meta copy: no "Your proxy at a glance" style header. The window is + the product; it does not narrate itself. +- No fake data. If a value is unknown, the row shows an em dash, never a plausible zero. + `/api/usage` distinguishes `measuredRequests` from `estimatedRequests`, so an estimate + is marked as one. +- No colour-only meaning: every state colour is paired with a word or a glyph. + +## 7. Numeric formatting (hard requirement from `002`) + +Live data reaches `requests: 232507`, `totalTokens: 36536664705`, +`estimatedCostUsd: 34018.25`. Rules: + +- Counts: `1,746` → `12.4K` → `1.2M` (3 significant figures, SI suffix at 10 000). +- Tokens: always suffixed (`12.4M`, `36.5B`). +- Cost: `$8.21` below 1 000, `$34.0K` above. +- All numerics use `tabular-nums` so digits do not reflow while polling. +- Timestamps normalize by magnitude: values below `1e12` are seconds, at or above are + milliseconds (`002` §3 documents `openai` sending seconds and `anthropic` milliseconds + in the same array). + +## 8. Accessibility gates + +- Every icon-only control carries an `accessibilityLabel`. +- The popover is fully keyboard operable; Escape closes it. +- Quota bars expose their percentage as accessible text, not only as a filled width. +- `NSWorkspace.shared.accessibilityDisplayShouldReduceMotion` disables the status-dot + crossfade. +- Contrast is verified against both light and dark rendering, not assumed from tokens. diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md new file mode 100644 index 0000000000..d7aa998b06 --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md @@ -0,0 +1,332 @@ +# 010 — Phase 1: app skeleton, proxy discovery, management API client + +**Depends on:** nothing (foundation phase). +**Independently verifiable by:** `swift test --package-path app` green, and +`bash scripts/build-macos-app.sh` producing a launchable `OpenCodex.app`. + +## File change map + +| Path | Action | +| --- | --- | +| `app/Package.swift` | NEW | +| `app/Info.plist` | NEW | +| `app/Sources/MenuBarCore/Discovery.swift` | NEW | +| `app/Sources/MenuBarCore/ProxyModels.swift` | NEW | +| `app/Sources/MenuBarCore/ProxyClient.swift` | NEW | +| `app/Sources/MenuBarCore/Formatting.swift` | NEW | +| `app/Sources/MenuBarCore/Keychain.swift` | NEW | +| `app/Sources/MenuBarApp/main.swift` | NEW (placeholder app that launches; UI lands in 020) | +| `app/Tests/MenuBarCoreTests/DiscoveryTests.swift` | NEW | +| `app/Tests/MenuBarCoreTests/ModelDecodingTests.swift` | NEW | +| `app/Tests/MenuBarCoreTests/FormattingTests.swift` | NEW | +| `app/.gitignore` | NEW | +| `.gitignore` (root) | MODIFY — add `dist/macos/` | + +**Two-target split rationale:** `MenuBarCore` is a plain library with no AppKit +dependency, so it is testable under `swift test` on any runner. `MenuBarApp` holds +everything that needs a running `NSApplication`. PR #387 used the same split and it is +the right call. + +## `app/Package.swift` + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "OpenCodexMenuBar", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), + ], + targets: [ + .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), + .executableTarget(name: "MenuBarApp", dependencies: ["MenuBarCore"], path: "Sources/MenuBarApp"), + .testTarget(name: "MenuBarCoreTests", dependencies: ["MenuBarCore"], path: "Tests/MenuBarCoreTests"), + ], + swiftLanguageVersions: [.v5] +) +``` + +`.macOS(.v13)` rather than #387's `.v12`: Ventura is required for +`MenuBarExtra`-adjacent APIs and modern `NSPopover` behaviour, and macOS 12 is out of +Apple's security-update window. Zero third-party dependencies is a hard rule. + +## `app/Info.plist` + +```xml +LSUIElement +CFBundleIdentifiercom.opencodex.menubar +CFBundleNameOpenCodex +LSMinimumSystemVersion13.0 +NSHumanReadableCopyrightMIT — opencodex contributors +``` + +`LSUIElement` is what makes it a menu bar app: no Dock icon, no menu bar menus of its +own. `CFBundleShortVersionString` is injected by the build script from `package.json` so +the app version can never drift from the proxy release. + +## `Discovery.swift` + +Implements `002` §1. + +```swift +public struct ProxyEndpoint: Equatable, Sendable { + public let host: String // always loopback + public let port: Int + public var baseURL: URL { URL(string: "http://\(host):\(port)")! } +} + +public enum ProxyDiscovery { + public static let defaultPort = 10100 + + public static func configDirectory(environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { + if let override = environment["OPENCODEX_HOME"], !override.isEmpty { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + return home.appendingPathComponent(".opencodex") + } + + public static func resolve(configDirectory: URL) -> ProxyEndpoint { + let file = configDirectory.appendingPathComponent("runtime-port.json") + guard let data = try? Data(contentsOf: file), + let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), + (1...65535).contains(record.port) + else { return ProxyEndpoint(host: "127.0.0.1", port: defaultPort) } + return ProxyEndpoint(host: "127.0.0.1", port: record.port) + } +} + +struct RuntimePortRecord: Decodable { let pid: Int?; let port: Int } +``` + +Host is hard-coded loopback and never read from the file. A companion that could be +pointed at an arbitrary host by a file write is a needless attack surface; the config +file only supplies a port. + +`pid` is decoded but unused — `002` §1 records that a failed HTTP probe is the +authoritative liveness signal. + +## `ProxyModels.swift` + +Codable mirrors of the payloads in `002` §3. Every field that the proxy may omit is +optional; nothing is force-unwrapped. + +```swift +public struct StartupHealth: Decodable, Equatable, Sendable { + public let status: String? // "protected" | "at-risk" | unknown-tolerant + public let protection: String? + public let platform: String? + public let serviceRunning: Bool? + public let rebootSafe: Bool? + public let recommendedCommand: String? +} + +public struct UsageSummary: Decodable, Equatable, Sendable { + public let requests: Int? + public let measuredRequests: Int? + public let estimatedRequests: Int? + public let totalTokens: Int? + public let inputTokens: Int? + public let outputTokens: Int? + public let estimatedCostUsd: Double? + public let coverageRatio: Double? +} + +public struct UsageDay: Decodable, Equatable, Sendable { + public let date: String + public let requests: Int? + public let totalTokens: Int? +} + +public struct UsageReport: Decodable, Equatable, Sendable { + public let range: String? + public let generatedAt: Double? + public let summary: UsageSummary? + public let days: [UsageDay]? +} + +public struct QuotaWindow: Decodable, Equatable, Sendable { + public let label: String? + public let percent: Double? + public let resetAt: Double? +} + +public struct ProviderQuota: Decodable, Equatable, Sendable { + public let weeklyPercent: Double? + public let monthlyPercent: Double? + public let weeklyResetAt: Double? + public let monthlyResetAt: Double? + public let customWindows: [QuotaWindow]? + public let updatedAt: Double? +} + +public struct QuotaReport: Decodable, Equatable, Sendable { + public let provider: String + public let label: String? + public let source: String? + public let quota: ProviderQuota? +} + +public struct ProviderSummary: Decodable, Equatable, Sendable { + public let name: String + public let adapter: String? + public let authMode: String? + public let hasApiKey: Bool? + public let disabled: Bool? +} + +public struct ProxySettings: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let streamMode: String? +} +``` + +### The normalized quota view (the trap from `002` §3) + +```swift +public struct NormalizedQuota: Equatable, Sendable { + public let providerLabel: String + public let percent: Double? + public let windowLabel: String // "week" | "month" | customWindows[].label + public let resetAt: Date? +} + +public extension QuotaReport { + func normalized() -> NormalizedQuota { + if let p = quota?.weeklyPercent { + return .init(providerLabel: label ?? provider, percent: p, windowLabel: "week", + resetAt: Self.date(from: quota?.weeklyResetAt)) + } + if let p = quota?.monthlyPercent { + return .init(providerLabel: label ?? provider, percent: p, windowLabel: "month", + resetAt: Self.date(from: quota?.monthlyResetAt)) + } + if let w = quota?.customWindows?.first { + return .init(providerLabel: label ?? provider, percent: w.percent, + windowLabel: w.label ?? "window", resetAt: Self.date(from: w.resetAt)) + } + return .init(providerLabel: label ?? provider, percent: nil, windowLabel: "—", resetAt: nil) + } + + /// `002` §3: openai sends weeklyResetAt in SECONDS, anthropic in MILLISECONDS. + /// Disambiguate by magnitude — 1e12 is 2001 in ms and year 33658 in s. + static func date(from value: Double?) -> Date? { + guard let v = value, v > 0 else { return nil } + return Date(timeIntervalSince1970: v >= 1_000_000_000_000 ? v / 1000 : v) + } +} +``` + +## `ProxyClient.swift` + +```swift +public enum ProxyError: Error, Equatable { + case unreachable // connection refused → proxy not running + case unauthorized // 401 → needs a key + case http(Int) + case decoding +} + +public actor ProxyClient { + private let session: URLSession + private var endpoint: ProxyEndpoint + private var apiKey: String? + + public init(endpoint: ProxyEndpoint, session: URLSession = .shared) { ... } + + public func health() async throws -> StartupHealth + public func settings() async throws -> ProxySettings + public func usage(range: String = "24h") async throws -> UsageReport + public func quotas() async throws -> [QuotaReport] + public func providers() async throws -> [ProviderSummary] + + private func get(_ path: String) async throws -> T { + var request = URLRequest(url: endpoint.baseURL.appendingPathComponent(path)) + request.timeoutInterval = 4 + if let key = apiKey { request.setValue(key, forHTTPHeaderField: "x-opencodex-api-key") } + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ProxyError.decoding } + if http.statusCode == 401 { throw ProxyError.unauthorized } + guard (200..<300).contains(http.statusCode) else { throw ProxyError.http(http.statusCode) } + do { return try JSONDecoder().decode(T.self, from: data) } + catch { throw ProxyError.decoding } + } catch let urlError as URLError + where urlError.code == .cannotConnectToHost || urlError.code == .timedOut { + throw ProxyError.unreachable + } + } +} +``` + +`actor` rather than a `DispatchQueue`: the client owns mutable state (`endpoint`, +`apiKey`) touched from both the polling timer and UI actions, and the actor makes that +data-race-free by construction. + +`unauthorized` is a distinct case because it drives a distinct UI state — "add your API +key", not "the proxy is down". `002` §2 records that a loopback bind needs no credential, +so this path only fires for non-loopback setups. + +**Privacy rule:** `ProxyError` carries no response body. Bodies can echo config values, +and `privacy:scan` forbids logging them. + +## `Keychain.swift` + +Thin Security.framework wrapper: `read(account:)` / `write(_:account:)` / +`delete(account:)` against `kSecClassGenericPassword`, service +`com.opencodex.menubar.apikey`. The key is never written to `UserDefaults`, never +included in an error message, and never logged. Read lazily — only after a `401`. + +## `Formatting.swift` + +Implements `003` §7. + +```swift +public enum Format { + public static func count(_ value: Int?) -> String // 1,746 · 12.4K · 1.2M · 36.5B + public static func tokens(_ value: Int?) -> String // always SI-suffixed + public static func cost(_ value: Double?) -> String // $8.21 · $34.0K + public static func relative(_ date: Date?) -> String // "resets in 3d 4h" +} +``` + +Every function returns `"—"` for `nil` — never `"0"`. `003` §6 forbids fake data, and +"unknown" and "zero" are different facts. + +## Tests + +`DiscoveryTests`: valid record honoured · malformed JSON falls back to 10100 · missing +file falls back · out-of-range port (`0`, `70000`) falls back · `OPENCODEX_HOME` honoured +· host is loopback even when the file names another host. + +`ModelDecodingTests`: decode the **verbatim live payloads captured in `002`** (not +hand-written fixtures) for health, usage, quotas, providers · unknown `status` string +decodes without throwing · absent `quota` normalizes to `percent: nil` · openai seconds +and anthropic milliseconds both resolve to sane 2026 dates. + +`FormattingTests`: the `002` magnitudes (`232507`, `36536664705`, `34018.25`) render as +`232K`, `36.5B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. + +## `app/.gitignore` + +```gitignore +.build/ +.swiftpm/ +*.xcodeproj +DerivedData/ +``` + +Root `.gitignore` gains `dist/macos/`. This is the direct lesson from PR #421's committed +`src-tauri/target/` — the ignore rules land in the same commit as the first build script, +never afterwards. + +## Accept criteria + +1. `swift test --package-path app` green, with the `002` payloads as fixtures. +2. `swift build --package-path app -c release --arch arm64` succeeds. +3. A `.app` bundle launches and appears in the menu bar (placeholder UI is acceptable). +4. `git status` shows no `.build/` or `dist/` entries. +5. `bun run typecheck` and `bun run test` unaffected (no TS added). diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md new file mode 100644 index 0000000000..9dab327aff --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -0,0 +1,248 @@ +# 020 — Phase 2: menu bar surface and popover UI + +**Depends on:** `010` (client + models + formatting must exist). +**Independently verifiable by:** a screenshot of the running app read back with +`view_image`, plus state-coverage tests. + +Implements the locked direction in `003`. Dials: `DESIGN_VARIANCE 2`, +`MOTION_INTENSITY 1`, density `D7`. + +## File change map + +| Path | Action | +| --- | --- | +| `app/Sources/MenuBarApp/main.swift` | MODIFY — replace the 010 placeholder | +| `app/Sources/MenuBarApp/AppDelegate.swift` | NEW | +| `app/Sources/MenuBarApp/StatusItemController.swift` | NEW | +| `app/Sources/MenuBarApp/StatusIcon.swift` | NEW | +| `app/Sources/MenuBarApp/PopoverViewController.swift` | NEW | +| `app/Sources/MenuBarApp/Views/StatusHeaderView.swift` | NEW | +| `app/Sources/MenuBarApp/Views/MetricsRowView.swift` | NEW | +| `app/Sources/MenuBarApp/Views/SparklineView.swift` | NEW | +| `app/Sources/MenuBarApp/Views/QuotaRowView.swift` | NEW | +| `app/Sources/MenuBarApp/Views/ActionBarView.swift` | NEW | +| `app/Sources/MenuBarApp/Theme.swift` | NEW | +| `app/Sources/MenuBarCore/ProxySnapshot.swift` | NEW | +| `app/Sources/MenuBarCore/PollingCoordinator.swift` | NEW | +| `app/Tests/MenuBarCoreTests/SnapshotStateTests.swift` | NEW | + +**AppKit, not SwiftUI.** SwiftUI in an `NSPopover` still fights sizing and first-responder +behaviour, and this layout is a fixed-width column of rows — precisely what AppKit stack +views do without ceremony. Zero-dependency and predictable beats idiomatic-but-fussy for +a surface that must render identically every time. + +## `Theme.swift` — token derivation from `gui/src/styles.css` + +`003` §1 established that the dashboard tokens are inherited rather than reinvented. +Where AppKit provides a semantic colour that already tracks the OS appearance, it wins +over a hardcoded hex, because it also handles increased-contrast and vibrancy. + +```swift +enum Theme { + // Surfaces: AppKit semantics track light/dark AND accessibility settings. + static let background = NSColor.windowBackgroundColor + static let raised = NSColor.controlBackgroundColor + static let separator = NSColor.separatorColor + + // Text: mapped from --text / --muted / --faint. + static let text = NSColor.labelColor + static let muted = NSColor.secondaryLabelColor + static let faint = NSColor.tertiaryLabelColor + + // State colours: taken verbatim from styles.css so the companion and the + // dashboard agree on what "healthy" looks like. + static let green = NSColor(light: 0x0A7D5C, dark: 0x4ECB9D) + static let amber = NSColor(light: 0x9A4A08, dark: 0xFBBF24) + static let red = NSColor(light: 0xB91C1C, dark: 0xF87171) + + // Type ladder: --text-micro/caption/label/control. + static let micro = NSFont.systemFont(ofSize: 10, weight: .medium) + static let caption = NSFont.systemFont(ofSize: 11) + static let label = NSFont.systemFont(ofSize: 12, weight: .semibold) + static let numeric = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) + + static let gutter: CGFloat = 12 // --space-3 + static let rowGap: CGFloat = 8 // --space-2 + static let radius: CGFloat = 8 // --radius-sm + static let width: CGFloat = 340 +} +``` + +`monospacedDigitSystemFont` is the AppKit equivalent of `font-variant-numeric: +tabular-nums` and is required by `003` §7 — without it, polling makes digits jitter. + +`NSColor(light:dark:)` is a small `init(name:dynamicProvider:)` helper so state colours +follow the OS appearance the same way `light-dark()` does on the web. + +## `ProxySnapshot.swift` — the state machine + +One value type describes everything the UI can show, so every view is a pure function of +it and no view invents its own loading flag. + +```swift +public enum ProxyState: Equatable, Sendable { + case loading // first fetch in flight, nothing known yet + case running(StartupHealth) + case unreachable // connection refused → not running + case unauthorized // 401 → needs an API key + case degraded(String) // reachable but errored; message is proxy-free text +} + +public struct ProxySnapshot: Equatable, Sendable { + public var state: ProxyState = .loading + public var endpoint: ProxyEndpoint + public var usage: UsageReport? + public var quotas: [NormalizedQuota] = [] + public var providers: [ProviderSummary] = [] + public var lastUpdated: Date? + public var consecutiveFailures: Int = 0 +} +``` + +`003` §6 forbids fake data, so `usage` stays `nil` until it actually arrives; the metrics +row renders em dashes rather than zeros in the meantime. + +## `PollingCoordinator.swift` — implements `002` §6 + +```swift +public actor PollingCoordinator { + // 5s liveness always; 60s heavy data only while the popover is open. + private static let livenessInterval: TimeInterval = 5 + private static let heavyInterval: TimeInterval = 60 + private static let backoffInterval: TimeInterval = 30 // after 3 consecutive failures + + public func setPopoverOpen(_ open: Bool) + public func refreshNow() async + public var snapshots: AsyncStream { get } +} +``` + +Heavy endpoints (`/api/usage`, `/api/provider-quotas`) are skipped entirely while the +popover is closed, and `/api/providers` is fetched only on open. After three consecutive +failures the liveness tick backs off to 30 s so a stopped proxy does not get hammered. +A menu bar app that polls a local server every 5 s forever is a battery complaint waiting +to happen. + +## `StatusIcon.swift` — the signature moment (`003` §5) + +```swift +enum StatusGlyph { + static func image(for state: ProxyState) -> NSImage { + let image: NSImage + switch state { + case .running(let h) where h.status == "protected": image = solidMark() + case .running: image = solidMarkNotched() + case .loading, .degraded: image = outlinedMark() + case .unreachable, .unauthorized: image = outlinedMark(alpha: 0.4) + } + image.isTemplate = true // macOS inverts for light/dark menu bar + return image + } +} +``` + +Drawn as `NSImage(size:flipped:drawingHandler:)` vector paths at 18×18pt — no PNG assets +for the menu bar, so it stays crisp on every scale factor and inverts correctly as a +template image. No colour in the menu bar, per `003` §5. + +## `PopoverViewController.swift` — layout + +`NSStackView`, vertical, 340pt wide, `edgeInsets` of 12pt, spacing 8pt. Children in +urgency order per `003` §4: + +1. `StatusHeaderView` +2. separator +3. `MetricsRowView` + `SparklineView` +4. separator +5. `QuotaRowView` per provider +6. separator +7. `ActionBarView` + +Behaviour: `NSPopover.behavior = .transient` (click-away dismiss), `Escape` closes, +`animates = false` when reduce-motion is set. + +### `StatusHeaderView` + +```text +● Running 127.0.0.1:10100 + protected · service +``` + +Dot 8pt, `Theme.green/amber/red` by state, **always accompanied by the word** ("Running", +"Stopped", "Unreachable", "Needs API key") so meaning is never colour-only (`003` §8). +Endpoint right-aligned in `Theme.caption`/`muted`. Qualifier line renders +`health.protection` and `health.status`, and when `recommendedCommand` is present it is +shown as selectable text — displayed, never executed (`002` §3). + +### `MetricsRowView` + +Three columns from `/api/usage?range=24h`: REQUESTS, TOKENS, COST. Labels in +`Theme.micro` uppercase with 0.5pt tracking; values in `Theme.numeric`. All values +through `Format` (`010`), so `36536664705` becomes `36.5B` and `nil` becomes `—`. + +When `summary.estimatedRequests > 0`, the requests value carries a trailing `~` with an +`accessibilityLabel` explaining the estimate — `003` §6 requires estimates to be marked. + +### `SparklineView` + +24 bars from `usage.days` (or hours when `range=24h` returns hourly buckets). Pure +`NSBezierPath` fill in `Theme.faint`, 24pt tall, no axes, no labels, no gradient. Renders +nothing (not a flat line) when data is absent. + +### `QuotaRowView` + +```text +OpenAI ▓▓▓▓▓░░░░░ 44% +``` + +Provider label left, bar centre, percent right in `Theme.numeric`. Bar fill: `green` below +80, `amber` 80-95, `red` above 95. The percentage text is always present, so the colour is +redundant rather than load-bearing. `accessibilityValue` reads +`"44 percent of weekly quota, resets in 3d 4h"` from `NormalizedQuota` (`010`), which +already resolved the seconds/milliseconds trap. + +Rows with `percent == nil` render the label and an em dash — never a zero-width bar that +looks like "0% used". + +### `ActionBarView` + +`Dashboard` (opens `http://127.0.0.1:` in the browser) · `Restart` (wired in `030`) +· `···` overflow menu (Preferences, Quit). Buttons are `.recessed` bezel, 24pt tall, with +`accessibilityLabel` on the icon-only overflow. + +## State coverage (UX-STATE-01 — all four required) + +| State | Header | Body | Action | +| --- | --- | --- | --- | +| `loading` | "Checking…" neutral dot | skeleton rows, em dashes | none | +| `running` | "Running" + green | live metrics, sparkline, quotas | Dashboard · Restart | +| `unreachable` | "Stopped" + red | "The proxy is not running." | **Start proxy** | +| `unauthorized` | "Needs API key" + amber | "This proxy requires a key." | **Add key…** | +| `degraded` | "Degraded" + amber | last known values + staleness age | Retry | + +Every non-running state names its next action — `dev-uiux-design` UX-STATE-01 forbids +dead-ending the user. `degraded` deliberately keeps the last known values with an explicit +"as of 2m ago" rather than blanking the popover, since stale-but-labelled beats empty. + +## Tests (`SnapshotStateTests`) + +`ProxyError.unreachable` → `.unreachable` · `401` → `.unauthorized` · `500` → +`.degraded` · health with `status: "protected"` → `.running` and solid glyph · +unknown status string → `.running` with notched glyph, no crash · three failures raise +`consecutiveFailures` and trigger backoff · reduce-motion disables animation. + +## Visual verification (mandatory before this phase closes) + +Build, launch, open the popover, `screencapture` the region, read it back with +`view_image`, and check against `003` §6: no emoji, no gradient, no oversized type, no +colour-only meaning, numbers abbreviated and tabular, dark and light both legible. Fix +what the screenshot shows, then re-verify. Code review alone does not close this phase. + +## Accept criteria + +1. Menu bar icon renders as a template image and changes with state. +2. Popover renders live data from the running proxy at 340pt. +3. All five states reachable and each names a next action. +4. Screenshot inspected with `view_image` in both appearances. +5. Keyboard: popover opens, Tab reaches every control, Escape closes. +6. `swift test --package-path app` green. diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md new file mode 100644 index 0000000000..c25694bb4e --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -0,0 +1,168 @@ +# 030 — Phase 3: write actions on existing endpoints + +**Depends on:** `020` (the UI must exist to report a result into). +**Independently verifiable by:** a live restart and a live provider toggle against the +running proxy, with the observed response and the resulting UI state. + +Constraint from the user's scope: **no new proxy endpoints.** Everything here calls +routes inventoried in `002` §4. + +## File change map + +| Path | Action | +| --- | --- | +| `app/Sources/MenuBarCore/ProxyClient.swift` | MODIFY — add write methods | +| `app/Sources/MenuBarCore/ActionCoordinator.swift` | NEW | +| `app/Sources/MenuBarApp/Views/ActionBarView.swift` | MODIFY — wire Restart | +| `app/Sources/MenuBarApp/Views/ProviderListView.swift` | NEW — disclosure + toggles | +| `app/Sources/MenuBarApp/Views/ConfirmSheet.swift` | NEW | +| `app/Tests/MenuBarCoreTests/ActionTests.swift` | NEW | + +## `ProxyClient` additions + +```swift +public func stop() async throws { + var request = URLRequest(url: endpoint.baseURL.appendingPathComponent("api/stop")) + request.httpMethod = "POST" + request.timeoutInterval = 6 + if let key = apiKey { request.setValue(key, forHTTPHeaderField: "x-opencodex-api-key") } + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw ProxyError.http((response as? HTTPURLResponse)?.statusCode ?? -1) + } +} + +public func setProviderDisabled(_ name: String, disabled: Bool) async throws { + var components = URLComponents(url: endpoint.baseURL.appendingPathComponent("api/providers"), + resolvingAgainstBaseURL: false)! + components.queryItems = [URLQueryItem(name: "name", value: name)] + var request = URLRequest(url: components.url!) + request.httpMethod = "PATCH" + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try JSONEncoder().encode(["disabled": disabled]) + ... +} +``` + +The PATCH body is exactly `{"disabled": }` and nothing else. `002` §4 records +`provider-routes.ts:239`: a `disabled`-only patch skips the heavy merged-shape +validators. Adding any second field would silently change the request class. + +## `ActionCoordinator.swift` + +### Restart — the drain problem + +`002` §4 records that `/api/stop` answers `200` **before** draining +(`src/lib/process-control.ts:77`). Treating `200` as "stopped" would make the UI lie for +several seconds. + +```swift +public enum ActionOutcome: Equatable, Sendable { + case succeeded + case failed(String) // user-facing text, never a raw response body + case requiresManualStart // stop confirmed; the app cannot relaunch it +} + +public func restart() async -> ActionOutcome { + do { try await client.stop() } catch { return .failed("Could not reach the proxy to stop it.") } + + // Poll until the port stops answering, up to 10s, before claiming anything. + let deadline = Date().addingTimeInterval(10) + while Date() < deadline { + try? await Task.sleep(for: .milliseconds(500)) + if await !client.isReachable() { return await waitForRestart() } + } + return .failed("The proxy did not stop within 10 seconds.") +} +``` + +### The honesty problem with "Restart" + +The management API can stop the proxy. **It cannot start one** — there is no start +endpoint, and by scope we are not adding one. A button labelled "Restart" that can only +stop is exactly the "fake completion" tell `003` §6 bans. + +Two options were considered: + +1. Shell out to `ocx start` (what PR #387 does via `OcxClient.perform`). +2. Label the control truthfully and let the service supervisor do its job. + +**Decision: option 2 for the default path, with option 1 available only when a +service-managed proxy is detected.** `/api/startup-health` already reports +`serviceInstalled`, `serviceRunning`, and `serviceEnabled` (`002` §3). When +`serviceInstalled && serviceEnabled`, launchd restarts the proxy on its own, so "Restart" +is genuinely a restart and the app polls until it comes back. When no service is +installed, the button is labelled **"Stop proxy"** and the resulting state offers the +exact command to start it again. The app does not silently spawn processes the user did +not ask for. + +```swift +var restartLabel: String { health.serviceManaged ? "Restart" : "Stop proxy" } +``` + +### Provider toggle — the default-provider trap + +`002` §4 records `provider-routes.ts:178`: disabling `config.defaultProvider` returns +`400` with `"cannot disable the default provider; set another default first"`. + +Per `dev-uiux-design` UX-LAZY-01, firing a request guaranteed to fail is not acceptable. +The toggle is disabled up front with an explanatory tooltip: + +```swift +let isDefault = provider.name == settings.defaultProvider +toggle.isEnabled = !isDefault +toggle.toolTip = isDefault + ? "This is the default provider. Choose another default in the dashboard first." + : nil +``` + +`/api/settings` supplies `defaultProvider`, so no extra call is needed. + +Optimistic update with rollback: flip the switch immediately, send the PATCH, and revert +with an inline error on failure. Reverting is the required behaviour — leaving a switch +in a state the server rejected is the "fake state" tell. + +## Confirmation policy + +| Action | Confirmation | Why | +| --- | --- | --- | +| Stop / Restart proxy | **Yes** — sheet | Disruptive: kills in-flight requests | +| Provider disable | No — optimistic + undo | Cheap and reversible | +| Provider enable | No | Strictly additive | + +`dev-uiux-design` UX-LAZY-01 exempts destructive actions from magic defaults, and stopping +a proxy mid-request is destructive. Everything else stays frictionless. + +`ConfirmSheet` states the concrete consequence — "In-flight requests will be +interrupted." — not a generic "Are you sure?". + +## Security rules + +- Write requests carry the key in `x-opencodex-api-key`, read from the Keychain lazily + (`010`), and never in a URL query. +- No response body ever reaches a log, an error string, or the UI verbatim. Failures map + to a fixed set of human sentences. +- No shell execution on the default path. The service-managed restart path is the only + process interaction, and only when `startup-health` proves a supervisor exists. +- The app never writes to `~/.opencodex/config.json` directly; all mutation goes through + the management API so the proxy's own validation runs. + +## Tests (`ActionTests`) + +Stubbed `URLProtocol`: + +- `stop()` on `200` → `.succeeded` only after reachability actually drops. +- `stop()` where the port keeps answering → `.failed`, never a false success. +- `setProviderDisabled` sends `PATCH /api/providers?name=x` with body exactly + `{"disabled":true}`. +- A `400` response reverts the optimistic toggle. +- The default provider's toggle is disabled before any request is attempted. +- No error path leaks a response body into `ActionOutcome`. + +## Accept criteria + +1. Stop/Restart executed live against the running proxy, with the observed outcome. +2. Provider disable + re-enable executed live and reflected in `/api/providers`. +3. The default provider's toggle is inert and explains why. +4. Failure paths surface a human sentence, never a raw body. +5. `swift test --package-path app` green. diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md new file mode 100644 index 0000000000..271c83237b --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -0,0 +1,198 @@ +# 040 — Phase 4: universal build, release packaging, CI wiring + +**Depends on:** `010`-`030` (there must be an app worth packaging). +**Independently verifiable by:** `lipo -archs` on the packaged executable, archive +content assertion, and workflow syntax validation. + +This phase is the direct answer to the user's question — *"메뉴바는 못 넣는 거 아님? 앱을 +만들어야 되는 거 아님?"* The app is only real when a user can download and run it without a +toolchain. Packaging architecture is inherited from PR #387 (`001` §5); it was the +strongest part of either PR and is not re-derived. + +**Security note:** this phase edits `.github/workflows/release.yml`, which +`AGENTS.md` classifies as requiring explicit security review. Changes are therefore +minimal, additive, SHA-pinned, and least-privilege. No secret is introduced. + +## File change map + +| Path | Action | +| --- | --- | +| `scripts/build-macos-app.sh` | NEW | +| `scripts/package-macos-release.sh` | NEW | +| `package.json` | MODIFY — three script entries | +| `.github/workflows/ci.yml` | MODIFY — path filter + macOS steps | +| `.github/workflows/release.yml` | MODIFY — `package-macos` job + asset attach | +| `.gitignore` | MODIFY — `dist/macos/` (already added in `010`) | + +## `scripts/build-macos-app.sh` + +Assembles the bundle by hand. No Xcode project, so nothing to keep in sync. + +```bash +#!/usr/bin/env bash +set -euo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +package_dir="$repo_root/app" +output_root="${OUTPUT_DIR:-$repo_root/dist/macos}" +configuration="${CONFIGURATION:-release}" + +[[ "$(uname -s)" == "Darwin" ]] || { echo "build:macos requires macOS." >&2; exit 1; } + +# Refuse to write outside the intended output root (inherited from PR #387). +app_bundle="$output_root/OpenCodex.app" +case "$app_bundle" in "$output_root"/*.app) ;; *) echo "Refusing unexpected bundle path" >&2; exit 1;; esac + +swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) +if [[ "${UNIVERSAL:-0}" == "1" ]]; then + developer_dir="$(xcode-select -p 2>/dev/null || true)" + if [[ "$developer_dir" == *"CommandLineTools"* ]]; then + echo "UNIVERSAL=1 requires the full Xcode toolchain; Command Line Tools ships only" >&2 + echo "current-architecture Swift compatibility libraries." >&2 + echo "Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 + exit 1 + fi + swift_args+=(--arch arm64 --arch x86_64) +fi + +swift build "${swift_args[@]}" +bin_dir="$(swift build "${swift_args[@]}" --show-bin-path)" +``` + +**The CLT guard is not optional.** `001` §4.1 records the live probe on this machine: + +```text +swift build --arch arm64 --arch x86_64 -c release + -> ld: symbol(s) not found for architecture x86_64 +swift build --arch arm64 -c release + -> Build complete! (10.39 sec) +``` + +Without the guard, a contributor on Command Line Tools gets a linker error with no +explanation. PR #387 discovered this and its message is kept nearly verbatim. + +Staging, then atomic swap: + +```bash +staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" +staged_app="$staging_root/OpenCodex.app" +trap 'rm -rf "$staging_root"' EXIT + +mkdir -p "$staged_app/Contents/MacOS" "$staged_app/Contents/Resources" +cp "$bin_dir/OpenCodexMenuBar" "$staged_app/Contents/MacOS/OpenCodexMenuBar" + +# Version comes from package.json — the app can never claim a version the release did not ship. +version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" +plutil -replace CFBundleShortVersionString -string "$version" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$version" "$staged_app/Contents/Info.plist" + +# Icon: reuse the existing dashboard favicon, no new binary asset in the repo. +iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" + +# Ad-hoc sign so Gatekeeper has a stable identity; CI may re-sign with a real identity. +codesign --force --sign - --timestamp=none "$staged_app" + +rm -rf "$app_bundle" && mv "$staged_app" "$app_bundle" +``` + +Building into a temp dir and moving at the end means an interrupted build never leaves a +half-written `.app` that launches and misbehaves. + +## `scripts/package-macos-release.sh` + +Wraps the bundle for distribution. Every step is an assertion, not a hope. + +```bash +RELEASE_VERSION guard # package.json must equal the requested release version +UNIVERSAL=1 CONFIGURATION=release bash scripts/build-macos-app.sh +codesign --verify --deep --strict --verbose=2 "$app_bundle" +lipo -archs "$executable" # must contain arm64 AND x86_64 when UNIVERSAL=1 +ditto -c -k --sequesterRsrc --keepParent "$app_bundle" "$archive_path" +unzip -Z1 "$archive_path" | grep -Fqx 'OpenCodex.app/Contents/MacOS/OpenCodexMenuBar' +shasum -a 256 "$archive_name" > "$checksum_name" +``` + +Output: `OpenCodex--macos-universal.zip` + `.sha256`. + +`ditto` rather than `zip`: it preserves extended attributes and symlinks, so the unpacked +bundle stays launchable. Plain `zip` corrupts code signatures. The `unzip -Z1` assertion +catches the case where the archive is produced but empty. + +## `package.json` + +```json +"build:macos": "bash scripts/build-macos-app.sh", +"package:macos": "bash scripts/package-macos-release.sh", +"test:macos": "swift test --package-path app" +``` + +## `.github/workflows/ci.yml` + +Path filter gains `"app/**"` in both the `pull_request` and `push` blocks. New steps in +the existing cross-platform job, gated so Linux and Windows runners skip them: + +```yaml +- name: Test macOS menu bar app + if: runner.os == 'macOS' + run: bun run test:macos + +- name: Build macOS menu bar app + if: runner.os == 'macOS' + run: bun run build:macos +``` + +Placed after `privacy:scan` so a credential leak fails before a long Swift build runs. + +## `.github/workflows/release.yml` + +New job, mirroring #387's shape: + +```yaml +package-macos: + runs-on: macos-latest + timeout-minutes: 15 + outputs: + archive_name: ${{ steps.package.outputs.archive_name }} + checksum_name: ${{ steps.package.outputs.checksum_name }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - id: package + env: + RELEASE_VERSION: ${{ inputs.version }} + UNIVERSAL: "1" + run: bash scripts/package-macos-release.sh + - uses: actions/upload-artifact@ +``` + +The release job then downloads the artifact and attaches both files to the GitHub +Release. **`UNIVERSAL: "1"` is safe here specifically because `macos-latest` carries a +full Xcode**, which is the environment `001` §4.1 identified as the one that can produce +both slices. This is why the universal assertion lives in CI and not in the local gate. + +Constraints honoured: + +- Every action pinned to a full commit SHA (existing repo convention, and `AGENTS.md` + treats mutable third-party refs as a release blocker). +- `package-macos` needs no `id-token`, no `contents: write`, no secrets. +- The npm publish path is untouched; a macOS packaging failure must not be able to + corrupt an npm release. + +## Privacy and artifact hygiene + +`bun run privacy:scan` must pass. Concretely: + +- `app/.gitignore` excludes `.build/`, `.swiftpm/`, `DerivedData/` (landed in `010`). +- Root `.gitignore` excludes `dist/macos/`. +- `git ls-files app/ | grep -E '\.build/|DerivedData/'` must return empty. +- No absolute developer path appears in any committed file — this is the exact defect + that blocked PR #421 (`001` §2), and it is checked explicitly rather than assumed. + +## Accept criteria + +1. `bun run build:macos` produces a launchable `dist/macos/OpenCodex.app`. +2. `bun run package:macos` produces zip + `.sha256`, with the content assertion passing. +3. `lipo -archs` shows `arm64` locally; both arches asserted in CI. +4. `UNIVERSAL=1` under Command Line Tools fails with the explanatory message, not a + linker error. +5. Workflow YAML parses; all actions SHA-pinned. +6. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. +7. No build artifacts tracked by git. diff --git a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md new file mode 100644 index 0000000000..98245fb621 --- /dev/null +++ b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md @@ -0,0 +1,109 @@ +# 050 — Phase 5: docs, PR consolidation, push + +**Depends on:** `040` (nothing is documented or announced until it builds and packages). +**Independently verifiable by:** `gh pr view 387/421` showing `CLOSED` with the posted +comments, and `git ls-remote --heads origin feat/macos-app` matching local `HEAD`. + +## File change map + +| Path | Action | +| --- | --- | +| `docs-site/src/content/docs/guides/macos-menu-bar.md` | NEW (English source) | +| `docs-site/src/content/docs/ko/guides/macos-menu-bar.md` | NEW | +| `docs-site/src/content/docs/ja/guides/macos-menu-bar.md` | NEW | +| `docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md` | NEW | +| `docs-site/src/content/docs/ru/guides/macos-menu-bar.md` | NEW | +| `docs-site/astro.config.mjs` | MODIFY — sidebar entry | +| `README.md` | MODIFY — one line under features | +| `structure/00_overview.md` | MODIFY — `app/` in the layout map (SOT-SYNC-01) | +| `AGENTS.md` | MODIFY — one line in "Repository layout" | + +`AGENTS.md` describes `src/`, `gui/`, `docs-site/`, `structure/`, `scripts/`, `devlog/`. +A new top-level `app/` that is not listed there would be invisible to the next agent. + +## Documentation content + +The guide answers, in order: what it is, how to get it, the Gatekeeper first launch, +what each part of the popover means, and how to build from source. + +**Gatekeeper section is mandatory.** The release zip is ad-hoc signed, not notarized, so +the first launch shows *"OpenCodex.app cannot be opened because the developer cannot be +verified."* Without documentation this reads as a broken download. The guide gives the +right-click → Open path and the `xattr -d com.apple.quarantine` alternative, and states +plainly that notarization requires a paid Apple Developer identity the project does not +currently hold. PR #387 documented this across five locales and that instinct is correct. + +Translated locales must not contradict the English source (`AGENTS.md` docs-sync rule). + +## PR closure + +Both PRs are closed with an English maintainer comment (`AGENTS.md`: always review in +English), naming what was taken from each. Credit is specific, not ceremonial — both +authors shipped work that materially shaped this implementation. + +### To #387 (jaycho46) + +Names what was adopted: the Swift/SwiftPM runtime choice, the two-target core/app split, +manual bundle assembly with the unexpected-path refusal guard, `codesign --verify --deep +--strict`, the `lipo` universal assertion, `ditto` archiving with archive-content +verification, the SHA-256 sidecar, the `package-macos` release job shape, the +Command-Line-Tools universal guard, and the Gatekeeper documentation. + +States plainly what changed and why: the transport moved from `ocx status --json` +subprocess calls to the HTTP management API, because the CLI path required extending +`src/cli/status.ts` and the maintainer scope for this work excluded proxy runtime +changes — and because `/api/usage` and `/api/provider-quotas` already return richer data +with no proxy change at all. + +### To #421 (genglintong) + +Names what was adopted: HTTP management-API transport, `runtime-port.json` discovery with +the 10100 fallback, keeping the API token out of the rendering layer, skipping auth when +the proxy has no `apiKeys` configured, the usage/health/status/activity information set, +and tabular-numeral stat treatment. + +States plainly what changed and why: Tauri was not adopted because the branch shipped no +distribution path (`.github/` untouched, DMG/Homebrew listed as a non-goal), because +`src-tauri/target/**` was committed with developer-absolute paths that fail +`bun run privacy:scan`, and because `macOSPrivateApi: true` is a notarization and +App-Store-rejection risk that `NSPopover` avoids through public API. The four-tab layout +became a single scroll-free column so the primary question — "is it running?" — is +answered without a click. + +Both comments state that the work is not discarded, point at this devlog unit, and invite +review of the maintainer branch. + +## Push + +```bash +git push -u origin feat/macos-app +``` + +Push is pre-approved by the user for this branch only (`cxc-loop` LOOP-GIT-01: push is +ESCALATE by default; the user's instruction "커밋쌓고 두개 클로즈 하고 푸시" is the +approval, scoped to `feat/macos-app`). + +**No PR is opened.** `.github/workflows/enforce-pr-target.yml` rewrites any PR not +targeting `dev` to `[WRONG BRANCH]` draft status. Opening one against `dev` is the +maintainer's call after reviewing the branch, and the user asked for a branch, not a PR. + +## Commit sequence + +One commit per phase, so `git log` reads as the build order: + +```text +docs(devlog): plan macOS menu bar companion (Phase 0 roadmap) +feat(app): add macOS menu bar core — discovery, client, formatting +feat(app): add menu bar status item and popover UI +feat(app): wire proxy control and provider toggles +feat(release): build and package the macOS companion +docs(macos): document the companion and Gatekeeper first launch +``` + +## Accept criteria + +1. Guide published in five locales, linked from the sidebar, no locale contradictions. +2. `README.md`, `AGENTS.md`, `structure/00_overview.md` mention `app/`. +3. #387 and #421 `CLOSED` with the comments above. +4. `feat/macos-app` pushed; remote SHA equals local `HEAD`. +5. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green on the final tree. From c8ea549a2a3479c9272fae6963bb7c9b0133be75 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 03:12:41 +0900 Subject: [PATCH 02/61] docs(devlog): fold 13 audit blockers into the macOS app roadmap Adversarial Phase-0 review returned FAIL. Corrections, all verified against live source and the running proxy: - /api/stop calls stopServiceIfInstalled() before responding, so nothing restarts the proxy. The app now ships Stop proxy, never Restart, and never spawns a process. - /api/usage supports only 7d/30d/all; 24h silently degrades to 30d. The range is now a closed enum and the UI labels the range the response returned, not the one it requested. - defaultProvider lives on /api/config, not /api/settings. Added the model, the client method, and the test. - The bundle script now defines every path before use and copies Info.plist before plutil; it could not have run as previously written. - release.yml grants contents:write and id-token:write at workflow level, so the new jobs declare explicit least-privilege permissions. Added a separate attach-macos job so packaging can never block the npm publish, and pinned both new actions to full SHAs. - Re-surveyed PR #421 at head 049ef2ac: the committed src-tauri/target tree was already removed by the contributor. The closing comment must credit that fix rather than repeat a stale defect. - /api/logs exists for per-request activity; documented as a deliberate v1 exclusion instead of an implicit gap. - Phase 1 no longer claims verification via a Phase 4 script. - Added StartupHealth service fields, security-review acceptance evidence to Phase 4, and removed developer-absolute paths from tracked docs. --- .../260725_macos_menubar_app/000_plan.md | 29 ++++- .../260725_macos_menubar_app/001_pr_survey.md | 60 +++++++-- .../002_api_surface.md | 86 +++++++++--- .../010_phase1_core.md | 62 +++++++-- .../260725_macos_menubar_app/020_phase2_ui.md | 37 +++++- .../030_phase3_actions.md | 88 +++++++------ .../040_phase4_release.md | 123 +++++++++++++++--- .../050_phase5_handoff.md | 41 ++++-- 8 files changed, 414 insertions(+), 112 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/000_plan.md b/devlog/_plan/260725_macos_menubar_app/000_plan.md index 722abb308a..95dc9c90c0 100644 --- a/devlog/_plan/260725_macos_menubar_app/000_plan.md +++ b/devlog/_plan/260725_macos_menubar_app/000_plan.md @@ -1,7 +1,7 @@ # 260725 — macOS menu bar companion app (`app/`) **Unit:** `devlog/_plan/260725_macos_menubar_app/` -**Branch:** `feat/macos-app` (worktree `/Users/jun/Developer/new/700_projects/opencodex-macos-app`, based on `origin/dev` @ `dbed8c15`) +**Branch:** `feat/macos-app` (dedicated worktree `/opencodex-macos-app`, based on `origin/dev` @ `dbed8c15`) **Work class:** C4 (new shippable surface + release/CI wiring) **Mode:** HOTL multi-cycle PABCD under `cxc-loop`. This document is the Phase-0 roadmap lock. @@ -62,6 +62,15 @@ Live proxy surface (`127.0.0.1:10100`, verified by `curl`): `/api/settings`, `/api/startup-health`, `/api/usage`, `/api/provider-quotas`, `/api/providers`, `/api/stop`. Full payload shapes in `002_api_surface.md`. +Audit-corrected surface facts (see `002` for evidence): + +- `defaultProvider` is served by `/api/config`, **not** `/api/settings`. +- `/api/usage` supports only `7d` / `30d` / `all`; `24h` silently degrades to `30d`. +- `/api/stop` calls `stopServiceIfInstalled()` before responding, so nothing restarts the + proxy and no start endpoint exists. +- `/api/logs` exists and would serve per-request activity; it is deliberately excluded + from v1. + ## Work-phase map (dependency-ordered, PHASE-SPLIT-01) Ordering is build-order, not effort: the transport contract must exist before the UI @@ -71,12 +80,15 @@ bundle must exist before packaging can wrap it. | Phase | Doc | Delivers | Independently verifiable by | | --- | --- | --- | --- | | 0 | `000`-`003` | Research, API inventory, design lock, this roadmap | Docs exist, audit passes | -| 1 | `010` | `app/` skeleton, proxy discovery, typed API client | `swift test` green, launchable bundle | -| 2 | `020` | Menu bar item + popover UI, all states | Screenshot of running app | +| 1 | `010` | `app/` skeleton, proxy discovery, typed API client | `swift test` + `swift build` green | +| 2 | `020` | Menu bar item + popover UI, all states, first launchable bundle | Screenshot of running app | | 3 | `030` | Write actions on existing endpoints | Live action against running proxy | | 4 | `040` | Universal build, packaging, CI/release wiring | `lipo -archs`, workflow syntax | | 5 | `050` | Docs, PR closure, push | `gh pr view`, `git ls-remote` | +Phase 1 closes on the compiler and tests, not on a bundle: `scripts/build-macos-app.sh` +is a Phase-4 artifact, and a phase may not be verified by a later phase's output. + ## Scope boundary **IN:** `app/**`, `scripts/build-macos-app.sh`, `scripts/package-macos-release.sh`, @@ -90,14 +102,17 @@ to `dev`/`main`, the six Haydern provider PRs, `gui/**` beyond required asset re 1. `app/` produces a launchable `.app` bundle from a repo script. 2. Proxy discovery honours `~/.opencodex/runtime-port.json` and falls back to 10100. -3. The popover renders health, usage/quota, providers, and activity from live data. +3. The popover renders health, usage trend, quotas, and providers from live data. + ("Activity" is the day-granular usage trend; per-request logs are out of scope for v1.) 4. Loading / empty / error / proxy-unreachable states each render a next action. -5. Write actions call only pre-existing endpoints. +5. Write actions call only pre-existing endpoints, and the app never spawns a process. 6. Release build is universal (arm64 + x86_64) **in CI**; local arm64-only is accepted and documented (see `001` §4). 7. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. -8. No build artifacts or developer-absolute paths committed. -9. PRs #387 and #421 closed with English maintainer comments crediting both authors. +8. No build artifacts committed, and no developer-absolute home path in any tracked file + (including `devlog/`, which `privacy:scan` does not cover). +9. PRs #387 and #421 closed with English maintainer comments crediting both authors, each + written against the PR's head commit at the time of posting. 10. `feat/macos-app` pushed to origin. ## Terminal outcomes diff --git a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md index 565fa6d856..0b7bdfa900 100644 --- a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md +++ b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md @@ -47,6 +47,8 @@ repair, not cosmetic churn. ## 2. PR #421 — `feat(menubar): redesign as macOS status widget` (genglintong) **Branch:** `feat/menubar-status-widget` · **Directory:** `menubar/` · 5 commits · +14532/-0 +**Surveyed at head `049ef2ac`** (re-verified after the Phase-0 audit; an earlier draft of +this document described an older head and was factually wrong — see §2.1). Architecture: @@ -65,14 +67,29 @@ Design: four-tab segmented widget (Usage / Health / Status / Activity), Apple-st white theme, tabular-nums stats, `macOSPrivateApi: true` for a transparent rounded popover with shadow. The submitted screenshot is the more polished of the two. -Distribution: **none.** `.github/` is untouched — no CI job, no release job. Its own -Non-goals list says "DMG / Homebrew distribution (cargo build from source)". A user -would need `rustup` plus a full frontend toolchain to obtain the app. +Distribution: `menubar/scripts/build-app.sh` runs `cargo tauri build` and produces both +`OpenCodex Menubar.app` and a `.dmg`. **But `.github/` is untouched** — no CI job, no +release job, no artifact attached to any GitHub Release. A user still needs `rustup` plus +a frontend toolchain and must build from source. -Blocking defect: `menubar/src-tauri/target/**` was committed. The Codex reviewer's P1 -notes that `.rustc_info.json` and sibling artifacts embed the contributor's -`/Users/glt/` home path and that `bun run privacy:scan` fails on the tree. Adding the -path to `.gitignore` does not remove it from history. +### 2.1 Correction: the committed-artifacts defect is FIXED at the current head + +An earlier draft of this survey stated that `menubar/src-tauri/target/**` was committed +and that `bun run privacy:scan` fails on the tree. **That was true of the head the Codex +reviewer saw, and the contributor has since fixed it.** Verified directly against +`049ef2ac`: + +```text +gh pr view 421 --json files --jq '[.files[].path | select(test("src-tauri/target"))] | length' + -> 0 + +gh api repos/genglintong/opencodex/contents/menubar/src-tauri?ref=049ef2ac + -> .gitignore, Cargo.lock, Cargo.toml, build.rs, capabilities, gen, icons, src, tauri.conf.json +``` + +Commit `049ef2ac` is titled "fix(menubar): address all Codex review findings (5 P1 + 14 +P2)". The contributor responded to review properly and the tree is clean. Any closing +comment must say so; repeating the stale defect would be both wrong and unfair. ## 3. Head-to-head @@ -83,9 +100,9 @@ path to `.gitignore` does not remove it from history. | Bundle size class | ~single-MB native | tens of MB (WebView shell + Rust) | | Transport | `ocx` CLI subprocess | HTTP management API | | Requires proxy source change | yes (`src/cli/status.ts`) | no | -| Distribution to users | zip + SHA-256 attached to Release | none, build from source | +| Distribution to users | zip + SHA-256 attached to Release | `.app` + `.dmg`, build from source only | | CI coverage | macOS test + build steps | none | -| Committed artifacts | none | `src-tauri/target/**` (privacy:scan FAIL) | +| Committed artifacts | none | none (fixed at `049ef2ac`) | | UI polish (as submitted) | functional menu | higher — segmented tabs, tuned spacing | | Data breadth | proxy status + control | usage, health, status, activity, quotas | @@ -113,6 +130,18 @@ Rationale, in order of weight: Private API usage is a documented App Store rejection vector and a notarization risk; AppKit's `NSPopover` gives the same visual result through public API. +**What is explicitly NOT part of the rationale** (each was in an earlier draft and each +is now known to be wrong or unfair): + +- Not "committed build artifacts" — fixed at `049ef2ac` (§2.1). +- Not "no bundle at all" — `build-app.sh` produces both `.app` and `.dmg`. +- Not "packaging must be rebuilt from scratch" — the gap is repository CI/release + *attachment*, not the ability to produce a bundle locally. + +The rejection of Tauri rests on exactly three facts: no repository CI or release +attachment, a materially heavier build stack for a project whose premise is one Bun +process, and the private-API dependency. + ### 4.1 The universal-binary finding (must be honoured by Phase 4) Probed live on this machine: @@ -134,6 +163,16 @@ a failure. The universal assertion belongs in CI, where `macos-latest` runners c full Xcode. Phase 4 must therefore keep `UNIVERSAL` opt-in with the CLT guard, and the `lipo` both-arch assertion must run in the CI job rather than gating local builds. +### 4.2 What the HTTP transport decision costs, honestly + +Choosing HTTP over the CLI is not free. `/api/stop` stops launchd on purpose +(`src/server/management-api.ts:136-147`), and there is no start endpoint — so the app can +stop the proxy but can never start it. PR #387's CLI transport *could* run `ocx start`. + +This is accepted rather than worked around: the app ships **Stop proxy**, not Restart, and +shows the start command for the user to run. Spawning processes from a menu bar app to +paper over a missing endpoint is worse than being honest about the capability. See `030`. + ## 5. What is salvaged from each PR From **#387 (jaycho46)** — packaging architecture: manual bundle assembly, the @@ -148,6 +187,9 @@ rendering layer, the four-surface information architecture (usage / health / sta activity), tabular-numeral stat treatment, and skipping auth entirely when the proxy has no `apiKeys` configured. +The contributor's review-response discipline at `049ef2ac` also directly improved this +plan: the audit that caught this document's own stale claims used that head as evidence. + ## 6. Rejected alternatives - **Merge #387, then re-skin later.** Rejected: it lands the `src/cli/status.ts` change diff --git a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md index 19ffdccd34..efacac921c 100644 --- a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md +++ b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md @@ -47,20 +47,32 @@ Keychain and retry with `x-opencodex-api-key`. Never log the token, never write ### `GET /api/settings` -Bind/runtime configuration plus an embedded `startupHealth`. Live shape (truncated): +Bind/runtime configuration plus an embedded `startupHealth`. **Exact live key set** +(enumerated, because an earlier draft of this plan assumed a field that does not exist): -```json -{ - "codexAutoStart": false, - "port": 10100, - "hostname": "127.0.0.1", - "streamMode": "auto", - "startupHealth": { "...": "see below" } -} +```text +codexAutoStart · port · hostname · streamMode · startupHealth · codexRuntime ``` Used for: the port/hostname the app displays, and as the cheapest liveness probe. +**`defaultProvider` is NOT in this response.** It lives in `GET /api/config` (below). + +### `GET /api/config` + +The safe config DTO (`src/server/auth-cors.ts:287-337` builds it; secrets are stripped). +Live key set: + +```text +port · hostname · defaultProvider · codexAutoStart · websockets · providers +``` + +Live value: `"defaultProvider": "openai"`. + +This is the **only** source for `defaultProvider`, which Phase 3 needs to disable the +toggle on the provider that cannot be disabled (§4). `/api/providers` does not mark the +default. + ### `GET /api/startup-health` ```json @@ -91,7 +103,22 @@ silently. ### `GET /api/usage` -Accepts `?range=` (`24h`/`7d`/`30d`, live default `30d`) and `?surface=`. +Accepts `?range=` and `?surface=`. + +**Supported ranges are exactly `7d`, `30d`, and `all`** — `src/usage/summary.ts:95-98`: + +```ts +export function parseRange(input: string | null | undefined): UsageRange { + if (input === "7d" || input === "30d" || input === "all") return input; + return "30d"; +} +``` + +Unrecognized values silently fall back to `30d`. Verified live: requesting +`?range=24h` returned `"range": "30d"` with 30 daily buckets. **There is no 24-hour +contract and no hourly bucketing.** `rangeWindow()` (`summary.ts:105-108`) only ever +produces day-granular windows. Adding an hourly range would require a `src/` change, +which is out of scope, so the UI uses `7d` and labels it truthfully. ```json { @@ -111,7 +138,21 @@ cost reaches five figures. **Every numeric in the UI must be abbreviated and use figures**; naive rendering destroys the layout. This is a hard design input, recorded in `003`. -`days[]` is the source for the activity sparkline. No separate activity endpoint exists. +`days[]` is day-granular and is the source for the **usage trend** sparkline. It is not +"recent activity" — see `/api/logs` below for that distinction. + +### `GET /api/logs` + +`src/server/management/logs-usage-routes.ts:66-69` — returns recent request log entries +through `requestLogDto`, filterable by query params. Each entry carries request time, +model, provider, status, latency, and token counts. + +This is the real "recent activity" source, and PR #421 used it. **Decision: not consumed +in v1.** Per-request rows carry model names and timing for a user's actual traffic; a +menu bar popover that is always one click from view is the wrong surface for that, and +the dashboard already renders it with proper filtering. The popover shows aggregate +trend only. This is a deliberate exclusion, not an oversight, and the endpoint stays +available if the requirement changes. ### `GET /api/provider-quotas` @@ -155,9 +196,23 @@ drives the toggle in Phase 3. ### `POST /api/stop` -`src/server/management-api.ts:136`. Answers `200` first, then drains -(`src/lib/process-control.ts:77`). The app must therefore treat `200` as "stop accepted", -not "stopped", and re-probe until the port stops answering. +`src/server/management-api.ts:136-147`. The full body matters: + +```ts +stopServiceIfInstalled(); +const restore = restoreNativeCodex(); +setTimeout(async () => { await drainAndShutdown(...); process.exit(0); }, 200); +return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." }); +``` + +Two consequences, both load-bearing: + +1. **It answers `200` before draining.** The app treats `200` as "stop accepted", not + "stopped", and re-probes until the port stops answering. +2. **It calls `stopServiceIfInstalled()` first — deliberately stopping launchd so the + supervisor cannot respawn the proxy.** A service-managed proxy therefore stays down. + **There is no automatic restart, and no start endpoint exists.** Any UI that says + "Restart" would be lying. See `030` for the corrected action design. ### `PATCH /api/providers?name=` @@ -189,9 +244,10 @@ change, so the surface stays extensible. | Data | Endpoint | Interval | Rationale | | --- | --- | --- | --- | | Liveness + health | `/api/startup-health` | 5 s | Cheap, drives the icon | -| Usage summary | `/api/usage?range=24h` | 60 s | Aggregation is expensive | +| Usage summary | `/api/usage?range=7d` | 60 s | Aggregation is expensive; `7d` is a real range | | Quotas | `/api/provider-quotas` | 60 s | Upstream-rate-limited | | Providers | `/api/providers` | on popover open | Changes rarely | +| Config (`defaultProvider`) | `/api/config` | on popover open | Changes rarely | Polling pauses entirely while the popover is closed except for the 5 s liveness tick, and backs off to 30 s after three consecutive failures. This keeps an idle menu bar app from diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md index d7aa998b06..6ce3e634a8 100644 --- a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md @@ -1,8 +1,14 @@ # 010 — Phase 1: app skeleton, proxy discovery, management API client **Depends on:** nothing (foundation phase). -**Independently verifiable by:** `swift test --package-path app` green, and -`bash scripts/build-macos-app.sh` producing a launchable `OpenCodex.app`. +**Independently verifiable by:** `swift test --package-path app` green and +`swift build --package-path app -c release --arch arm64` succeeding. + +**Bundle scope note (audit correction):** an earlier draft closed this phase on a +`.app` produced by `scripts/build-macos-app.sh`, but that script is a Phase-4 +deliverable — a phase cannot be verified by a later phase's output. Phase 1 therefore +closes on the compiler and the test suite. The first launchable bundle is a Phase-2 +deliverable (it needs the UI to be worth launching), and Phase 4 hardens and packages it. ## File change map @@ -15,7 +21,7 @@ | `app/Sources/MenuBarCore/ProxyClient.swift` | NEW | | `app/Sources/MenuBarCore/Formatting.swift` | NEW | | `app/Sources/MenuBarCore/Keychain.swift` | NEW | -| `app/Sources/MenuBarApp/main.swift` | NEW (placeholder app that launches; UI lands in 020) | +| `app/Sources/MenuBarApp/main.swift` | NEW (minimal `NSApplication` entry; UI lands in 020) | | `app/Tests/MenuBarCoreTests/DiscoveryTests.swift` | NEW | | `app/Tests/MenuBarCoreTests/ModelDecodingTests.swift` | NEW | | `app/Tests/MenuBarCoreTests/FormattingTests.swift` | NEW | @@ -119,10 +125,21 @@ public struct StartupHealth: Decodable, Equatable, Sendable { public let protection: String? public let platform: String? public let serviceRunning: Bool? + public let serviceInstalled: Bool? + public let serviceEnabled: Bool? public let rebootSafe: Bool? public let recommendedCommand: String? } +/// `GET /api/config` — the ONLY source of `defaultProvider` (`002` §3). +/// `/api/settings` does not carry it; the live key set there is exactly +/// codexAutoStart · port · hostname · streamMode · startupHealth · codexRuntime. +public struct ProxyConfigSummary: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let defaultProvider: String? +} + public struct UsageSummary: Decodable, Equatable, Sendable { public let requests: Int? public let measuredRequests: Int? @@ -184,6 +201,10 @@ public struct ProxySettings: Decodable, Equatable, Sendable { } ``` +`serviceInstalled` and `serviceEnabled` are decoded because `020`'s status qualifier line +renders them. They deliberately do **not** drive a restart branch — `030` establishes +that `/api/stop` stops launchd on purpose and nothing restarts the proxy automatically. + ### The normalized quota view (the trap from `002` §3) ```swift @@ -239,7 +260,8 @@ public actor ProxyClient { public func health() async throws -> StartupHealth public func settings() async throws -> ProxySettings - public func usage(range: String = "24h") async throws -> UsageReport + public func config() async throws -> ProxyConfigSummary + public func usage(range: UsageRange = .sevenDays) async throws -> UsageReport public func quotas() async throws -> [QuotaReport] public func providers() async throws -> [ProviderSummary] @@ -270,6 +292,24 @@ data-race-free by construction. key", not "the proxy is down". `002` §2 records that a loopback bind needs no credential, so this path only fires for non-loopback setups. +### `UsageRange` is a closed enum, not a string + +`src/usage/summary.ts:95-98` accepts exactly `7d`, `30d`, `all` and silently falls back +to `30d` for anything else. A stringly-typed range would let a caller ask for `24h`, +receive 30 days of data, and label it wrongly — which is exactly what an earlier draft of +this plan specified. + +```swift +public enum UsageRange: String, Sendable { + case sevenDays = "7d" + case thirtyDays = "30d" + case all +} +``` + +The UI additionally renders the `range` value the response actually returned, never the +one it requested (`020`). + **Privacy rule:** `ProxyError` carries no response body. Bodies can echo config values, and `privacy:scan` forbids logging them. @@ -303,9 +343,10 @@ file falls back · out-of-range port (`0`, `70000`) falls back · `OPENCODEX_HOM · host is loopback even when the file names another host. `ModelDecodingTests`: decode the **verbatim live payloads captured in `002`** (not -hand-written fixtures) for health, usage, quotas, providers · unknown `status` string -decodes without throwing · absent `quota` normalizes to `percent: nil` · openai seconds -and anthropic milliseconds both resolve to sane 2026 dates. +hand-written fixtures) for health, usage, quotas, providers, config · unknown `status` +string decodes without throwing · absent `quota` normalizes to `percent: nil` · openai +seconds and anthropic milliseconds both resolve to sane 2026 dates · `ProxySettings` +decodes without a `defaultProvider` field and `ProxyConfigSummary` supplies it. `FormattingTests`: the `002` magnitudes (`232507`, `36536664705`, `34018.25`) render as `232K`, `36.5B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. @@ -327,6 +368,7 @@ never afterwards. 1. `swift test --package-path app` green, with the `002` payloads as fixtures. 2. `swift build --package-path app -c release --arch arm64` succeeds. -3. A `.app` bundle launches and appears in the menu bar (placeholder UI is acceptable). -4. `git status` shows no `.build/` or `dist/` entries. -5. `bun run typecheck` and `bun run test` unaffected (no TS added). +3. `UsageRange` admits only `7d`/`30d`/`all`; no call site can request `24h`. +4. `ProxyConfigSummary.defaultProvider` decodes from live `/api/config`. +5. `git status` shows no `.build/` or `dist/` entries. +6. `bun run typecheck` and `bun run test` unaffected (no TS added). diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index 9dab327aff..ae8283f101 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -176,18 +176,31 @@ shown as selectable text — displayed, never executed (`002` §3). ### `MetricsRowView` -Three columns from `/api/usage?range=24h`: REQUESTS, TOKENS, COST. Labels in +Three columns from `/api/usage?range=7d`: REQUESTS, TOKENS, COST. Labels in `Theme.micro` uppercase with 0.5pt tracking; values in `Theme.numeric`. All values through `Format` (`010`), so `36536664705` becomes `36.5B` and `nil` becomes `—`. +**The range label is rendered from the response, not the request.** `002` §3 records that +`parseRange` silently falls back to `30d` for any unrecognized value, so a UI that +labelled its own request would lie whenever the server disagreed. The section header +reads `LAST 7 DAYS` only when `response.range == "7d"`. + When `summary.estimatedRequests > 0`, the requests value carries a trailing `~` with an `accessibilityLabel` explaining the estimate — `003` §6 requires estimates to be marked. ### `SparklineView` -24 bars from `usage.days` (or hours when `range=24h` returns hourly buckets). Pure -`NSBezierPath` fill in `Theme.faint`, 24pt tall, no axes, no labels, no gradient. Renders -nothing (not a flat line) when data is absent. +**Usage trend, not "activity".** One bar per element of `usage.days`, which is +day-granular — `002` §3 records that `rangeWindow()` only ever produces daily buckets and +that hourly data does not exist without a `src/` change. With `range=7d` that is 7 bars. +The bar count follows `days.count`; it is never hardcoded. + +Pure `NSBezierPath` fill in `Theme.faint`, 24pt tall, no axes, no labels, no gradient. +Renders nothing (not a flat line) when data is absent. + +Recent per-request activity (`GET /api/logs?tail=N`) is deliberately out of scope for v1 — +`002` §3 records the reasoning: per-request rows expose model and timing detail for the +user's real traffic, and the dashboard already presents it with proper filtering. ### `QuotaRowView` @@ -206,7 +219,7 @@ looks like "0% used". ### `ActionBarView` -`Dashboard` (opens `http://127.0.0.1:` in the browser) · `Restart` (wired in `030`) +`Dashboard` (opens `http://127.0.0.1:` in the browser) · `Stop proxy` (wired in `030`) · `···` overflow menu (Preferences, Quit). Buttons are `.recessed` bezel, 24pt tall, with `accessibilityLabel` on the icon-only overflow. @@ -220,6 +233,14 @@ looks like "0% used". | `unauthorized` | "Needs API key" + amber | "This proxy requires a key." | **Add key…** | | `degraded` | "Degraded" + amber | last known values + staleness age | Retry | +Corrections from the Phase-0 audit, carried in from `030`: + +- The `running` action is **`Stop proxy`**, never `Restart`. `/api/stop` stops launchd on + purpose and no start endpoint exists. +- The `unreachable` action is **not** a button that starts anything. It displays the + command to run (`ocx start`, or `ocx service start` when a service is installed) as + selectable text, since the app never spawns processes. + Every non-running state names its next action — `dev-uiux-design` UX-STATE-01 forbids dead-ending the user. `degraded` deliberately keeps the last known values with an explicit "as of 2m ago" rather than blanking the popover, since stale-but-labelled beats empty. @@ -245,4 +266,8 @@ what the screenshot shows, then re-verify. Code review alone does not close this 3. All five states reachable and each names a next action. 4. Screenshot inspected with `view_image` in both appearances. 5. Keyboard: popover opens, Tab reaches every control, Escape closes. -6. `swift test --package-path app` green. +6. The metrics header renders the range the response returned, verified by forcing a + fallback (`?range=bogus` → server answers `30d` → header must read `LAST 30 DAYS`). +7. Sparkline bar count equals `days.count`, not a hardcoded 24. +8. A launchable `.app` bundle exists (first bundle milestone; `040` hardens it). +9. `swift test --package-path app` green. diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index c25694bb4e..798c612643 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -50,11 +50,29 @@ validators. Adding any second field would silently change the request class. ## `ActionCoordinator.swift` -### Restart — the drain problem +### There is no restart. There is only stop. -`002` §4 records that `/api/stop` answers `200` **before** draining -(`src/lib/process-control.ts:77`). Treating `200` as "stopped" would make the UI lie for -several seconds. +This was the single biggest correction from the Phase-0 audit, and it is worth stating +plainly because an earlier draft of this document got it wrong. + +`src/server/management-api.ts:136-147` — `/api/stop` calls `stopServiceIfInstalled()` +**before** responding. That call exists precisely so launchd cannot respawn the proxy. +So a service-managed proxy does not come back on its own, and there is no start endpoint +to call. A control labelled "Restart" would therefore be a lie in every configuration. + +**Decision: the app ships `Stop proxy`, never `Restart`.** After a successful stop, the +UI enters the `unreachable` state (`020`) whose next action shows the exact command to +start it again (`ocx start`, or `ocx service start` when a service is installed) as +selectable text. The app does not spawn processes the user did not ask for, and it does +not claim a capability the API does not have. + +This also removes the `serviceManaged` branch an earlier draft assumed, and with it the +`StartupHealth.serviceInstalled` / `serviceEnabled` fields that `010` never declared. + +### The drain problem + +`002` §4 also records that `/api/stop` answers `200` **before** draining. Treating `200` +as "stopped" would make the UI lie for several seconds. ```swift public enum ActionOutcome: Equatable, Sendable { @@ -63,42 +81,21 @@ public enum ActionOutcome: Equatable, Sendable { case requiresManualStart // stop confirmed; the app cannot relaunch it } -public func restart() async -> ActionOutcome { +public func stopProxy() async -> ActionOutcome { do { try await client.stop() } catch { return .failed("Could not reach the proxy to stop it.") } // Poll until the port stops answering, up to 10s, before claiming anything. let deadline = Date().addingTimeInterval(10) while Date() < deadline { try? await Task.sleep(for: .milliseconds(500)) - if await !client.isReachable() { return await waitForRestart() } + if await !client.isReachable() { return .requiresManualStart } } return .failed("The proxy did not stop within 10 seconds.") } ``` -### The honesty problem with "Restart" - -The management API can stop the proxy. **It cannot start one** — there is no start -endpoint, and by scope we are not adding one. A button labelled "Restart" that can only -stop is exactly the "fake completion" tell `003` §6 bans. - -Two options were considered: - -1. Shell out to `ocx start` (what PR #387 does via `OcxClient.perform`). -2. Label the control truthfully and let the service supervisor do its job. - -**Decision: option 2 for the default path, with option 1 available only when a -service-managed proxy is detected.** `/api/startup-health` already reports -`serviceInstalled`, `serviceRunning`, and `serviceEnabled` (`002` §3). When -`serviceInstalled && serviceEnabled`, launchd restarts the proxy on its own, so "Restart" -is genuinely a restart and the app polls until it comes back. When no service is -installed, the button is labelled **"Stop proxy"** and the resulting state offers the -exact command to start it again. The app does not silently spawn processes the user did -not ask for. - -```swift -var restartLabel: String { health.serviceManaged ? "Restart" : "Stop proxy" } -``` +`requiresManualStart` is the honest success case: the stop is confirmed, and the app +says so while telling the user how to bring it back. ### Provider toggle — the default-provider trap @@ -109,14 +106,18 @@ Per `dev-uiux-design` UX-LAZY-01, firing a request guaranteed to fail is not acc The toggle is disabled up front with an explanatory tooltip: ```swift -let isDefault = provider.name == settings.defaultProvider +let isDefault = provider.name == config.defaultProvider toggle.isEnabled = !isDefault toggle.toolTip = isDefault ? "This is the default provider. Choose another default in the dashboard first." : nil ``` -`/api/settings` supplies `defaultProvider`, so no extra call is needed. +**`defaultProvider` comes from `GET /api/config`, not `/api/settings`.** The audit +verified the live `/api/settings` key set is exactly `codexAutoStart`, `port`, +`hostname`, `streamMode`, `startupHealth`, `codexRuntime` — no `defaultProvider`. +`/api/config` returns it (`"defaultProvider": "openai"` live). `010` adds a +`ProxyConfigSummary` model and `config()` client method for this. Optimistic update with rollback: flip the switch immediately, send the PATCH, and revert with an inline error on failure. Reverting is the required behaviour — leaving a switch @@ -126,15 +127,15 @@ in a state the server rejected is the "fake state" tell. | Action | Confirmation | Why | | --- | --- | --- | -| Stop / Restart proxy | **Yes** — sheet | Disruptive: kills in-flight requests | +| Stop proxy | **Yes** — sheet | Disruptive: kills in-flight requests, and nothing restarts it | | Provider disable | No — optimistic + undo | Cheap and reversible | | Provider enable | No | Strictly additive | `dev-uiux-design` UX-LAZY-01 exempts destructive actions from magic defaults, and stopping a proxy mid-request is destructive. Everything else stays frictionless. -`ConfirmSheet` states the concrete consequence — "In-flight requests will be -interrupted." — not a generic "Are you sure?". +`ConfirmSheet` states the concrete consequence — "In-flight requests will be interrupted, +and OpenCodex will not restart on its own." — not a generic "Are you sure?". ## Security rules @@ -142,8 +143,9 @@ interrupted." — not a generic "Are you sure?". (`010`), and never in a URL query. - No response body ever reaches a log, an error string, or the UI verbatim. Failures map to a fixed set of human sentences. -- No shell execution on the default path. The service-managed restart path is the only - process interaction, and only when `startup-health` proves a supervisor exists. +- **No shell execution at all.** The app never spawns `ocx` or any other process; it only + displays the command for the user to run. This is stricter than PR #387, which shelled + out to the CLI, and it removes an entire class of injection and privilege concerns. - The app never writes to `~/.opencodex/config.json` directly; all mutation goes through the management API so the proxy's own validation runs. @@ -151,18 +153,22 @@ interrupted." — not a generic "Are you sure?". Stubbed `URLProtocol`: -- `stop()` on `200` → `.succeeded` only after reachability actually drops. +- `stop()` on `200` → `.requiresManualStart` only after reachability actually drops. - `stop()` where the port keeps answering → `.failed`, never a false success. - `setProviderDisabled` sends `PATCH /api/providers?name=x` with body exactly `{"disabled":true}`. - A `400` response reverts the optimistic toggle. -- The default provider's toggle is disabled before any request is attempted. +- The default provider (from `/api/config`) has its toggle disabled before any request is + attempted. +- No code path constructs a `Process` / `NSTask`. - No error path leaks a response body into `ActionOutcome`. ## Accept criteria -1. Stop/Restart executed live against the running proxy, with the observed outcome. +1. Stop executed live against the running proxy, with the observed outcome, and the + resulting `unreachable` state showing the manual start command. 2. Provider disable + re-enable executed live and reflected in `/api/providers`. -3. The default provider's toggle is inert and explains why. +3. The default provider's toggle is inert and explains why, using `/api/config`. 4. Failure paths surface a human sentence, never a raw body. -5. `swift test --package-path app` green. +5. No `Process` / `NSTask` usage anywhere in `app/`. +6. `swift test --package-path app` green. diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 271c83237b..5016f60287 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -1,6 +1,8 @@ # 040 — Phase 4: universal build, release packaging, CI wiring -**Depends on:** `010`-`030` (there must be an app worth packaging). +**Depends on:** `010`-`030` (there must be an app worth packaging). Phase 2 produces the +first launchable bundle via a minimal builder; this phase hardens it into a signed, +verified, distributable artifact. **Independently verifiable by:** `lipo -archs` on the packaged executable, archive content assertion, and workflow syntax validation. @@ -58,6 +60,12 @@ swift build "${swift_args[@]}" bin_dir="$(swift build "${swift_args[@]}" --show-bin-path)" ``` +**Every path is defined before use, and `output_root` exists before `mktemp` targets it.** +An earlier draft of this document called `mktemp` inside a directory it had not created, +used `$iconset` before defining it, and ran `plutil` against an `Info.plist` it never +copied — under `set -u` that script cannot run. The full sequence below is the executable +version. + **The CLT guard is not optional.** `001` §4.1 records the live probe on this machine: ```text @@ -73,12 +81,16 @@ explanation. PR #387 discovered this and its message is kept nearly verbatim. Staging, then atomic swap: ```bash +mkdir -p "$output_root" +output_root="$(cd "$output_root" && pwd)" staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" staged_app="$staging_root/OpenCodex.app" +iconset="$staging_root/OpenCodex.iconset" trap 'rm -rf "$staging_root"' EXIT mkdir -p "$staged_app/Contents/MacOS" "$staged_app/Contents/Resources" cp "$bin_dir/OpenCodexMenuBar" "$staged_app/Contents/MacOS/OpenCodexMenuBar" +cp "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" # Version comes from package.json — the app can never claim a version the release did not ship. version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" @@ -86,6 +98,13 @@ plutil -replace CFBundleShortVersionString -string "$version" "$staged_app/Conte plutil -replace CFBundleVersion -string "$version" "$staged_app/Contents/Info.plist" # Icon: reuse the existing dashboard favicon, no new binary asset in the repo. +icon_source="$repo_root/gui/public/favicon.png" +[[ -f "$icon_source" ]] || { echo "Missing icon source: $icon_source" >&2; exit 1; } +mkdir -p "$iconset" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$icon_source" --out "$iconset/icon_${size}x${size}.png" >/dev/null + sips -z "$((size*2))" "$((size*2))" "$icon_source" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null +done iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" # Ad-hoc sign so Gatekeeper has a stable identity; CI may re-sign with a real identity. @@ -144,37 +163,102 @@ Placed after `privacy:scan` so a credential leak fails before a long Swift build ## `.github/workflows/release.yml` -New job, mirroring #387's shape: +### Current state (read before editing) + +The workflow declares **workflow-level** permissions at lines 32-35: + +```yaml +permissions: + contents: write # create the GitHub Release + tag after npm publish + actions: read # verify the release commit passed Cross-platform CI + id-token: write # OIDC for Trusted Publishing + provenance +``` + +Workflow-level permissions are **inherited by every job**. A `package-macos` job added +without its own `permissions:` block would silently run with `contents: write` and +`id-token: write` — an OIDC-capable token in a job that builds third-party-toolchain +code. An earlier draft of this document claimed the job "needs no `id-token`, no +`contents: write`" while specifying no block that would achieve that. + +### Job graph + +Three jobs, with npm independence preserved by construction: + +```text +publish (existing) package-macos (new) + npm + GitHub Release build + zip + sha256 + \ / + \ / + attach-macos (new, needs: [publish, package-macos]) + upload assets to the existing Release +``` + +`publish` gains no `needs`, so a Swift or packaging failure **cannot** block or fail the +npm publish. `attach-macos` runs only when both succeed. If npm publishes but packaging +fails, the release is still valid and the asset is attached by re-running the workflow's +packaging path — documented in the guide as the retry procedure. + +### The jobs ```yaml package-macos: runs-on: macos-latest - timeout-minutes: 15 + timeout-minutes: 20 + permissions: + contents: read # explicit: drops the inherited write + id-token outputs: archive_name: ${{ steps.package.outputs.archive_name }} checksum_name: ${{ steps.package.outputs.checksum_name }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - id: package env: RELEASE_VERSION: ${{ inputs.version }} UNIVERSAL: "1" run: bash scripts/package-macos-release.sh - - uses: actions/upload-artifact@ + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: macos-release + path: dist/release/ + if-no-files-found: error + retention-days: 7 + +attach-macos: + runs-on: ubuntu-latest + needs: [publish, package-macos] + if: ${{ inputs.dry_run != true }} + timeout-minutes: 10 + permissions: + contents: write # only to attach assets to the existing Release + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: macos-release + path: dist/release + - name: Verify checksum before upload + run: cd dist/release && shasum -a 256 -c *.sha256 + - name: Attach to release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "v${{ inputs.version }}" dist/release/* --clobber ``` -The release job then downloads the artifact and attaches both files to the GitHub -Release. **`UNIVERSAL: "1"` is safe here specifically because `macos-latest` carries a -full Xcode**, which is the environment `001` §4.1 identified as the one that can produce -both slices. This is why the universal assertion lives in CI and not in the local gate. +`shasum -c` before upload means a corrupted artifact transfer cannot become a published +asset. `if: inputs.dry_run != true` keeps dry runs from touching a real Release. + +**`UNIVERSAL: "1"` is safe here specifically because `macos-latest` carries a full +Xcode**, the environment `001` §4.1 identified as the only one that can produce both +slices. This is why the universal assertion lives in CI and not in the local gate. Constraints honoured: -- Every action pinned to a full commit SHA (existing repo convention, and `AGENTS.md` - treats mutable third-party refs as a release blocker). -- `package-macos` needs no `id-token`, no `contents: write`, no secrets. -- The npm publish path is untouched; a macOS packaging failure must not be able to - corrupt an npm release. +- Every action pinned to a full commit SHA, including the two new ones above + (`AGENTS.md` treats mutable third-party refs as a release blocker). +- Each new job declares explicit least-privilege `permissions`, overriding inheritance. +- `persist-credentials: false` on the packaging checkout. +- The npm publish path gains no new dependency. ## Privacy and artifact hygiene @@ -193,6 +277,13 @@ Constraints honoured: 3. `lipo -archs` shows `arm64` locally; both arches asserted in CI. 4. `UNIVERSAL=1` under Command Line Tools fails with the explanatory message, not a linker error. -5. Workflow YAML parses; all actions SHA-pinned. -6. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. -7. No build artifacts tracked by git. +5. The build script runs end to end on a clean checkout under `set -euo pipefail`, with + every variable defined before use. +6. Workflow YAML parses; all actions SHA-pinned to a full commit SHA. +7. **Security review evidence recorded** before this phase closes (`MAINTAINERS.md` + requires it for release automation): the final workflow diff reviewed, effective + per-job permissions enumerated and confirmed least-privilege, every action pin + resolved to an immutable SHA, dry-run behaviour confirmed not to touch a Release, and + the npm-publish path confirmed to have gained no new failure dependency. +8. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. +9. No build artifacts tracked by git. diff --git a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md index 98245fb621..061e3be6d9 100644 --- a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md +++ b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md @@ -55,6 +55,10 @@ subprocess calls to the HTTP management API, because the CLI path required exten changes — and because `/api/usage` and `/api/provider-quotas` already return richer data with no proxy change at all. +Also states the cost of that choice honestly (`001` §4.2): the CLI transport could run +`ocx start`, and HTTP cannot. The maintainer app ships **Stop proxy** rather than pretend +to restart. + ### To #421 (genglintong) Names what was adopted: HTTP management-API transport, `runtime-port.json` discovery with @@ -62,17 +66,28 @@ the 10100 fallback, keeping the API token out of the rendering layer, skipping a the proxy has no `apiKeys` configured, the usage/health/status/activity information set, and tabular-numeral stat treatment. -States plainly what changed and why: Tauri was not adopted because the branch shipped no -distribution path (`.github/` untouched, DMG/Homebrew listed as a non-goal), because -`src-tauri/target/**` was committed with developer-absolute paths that fail -`bun run privacy:scan`, and because `macOSPrivateApi: true` is a notarization and -App-Store-rejection risk that `NSPopover` avoids through public API. The four-tab layout -became a single scroll-free column so the primary question — "is it running?" — is -answered without a click. +**Must be written against head `049ef2ac`, not the head the bots reviewed.** The +contributor's commit "address all Codex review findings (5 P1 + 14 P2)" removed the +committed `src-tauri/target/**` tree; `001` §2.1 verifies zero matching paths remain. The +comment explicitly acknowledges that fix. Repeating the stale defect would be factually +wrong and would misrepresent a contributor who responded to review properly. + +The three remaining reasons Tauri was not adopted, and nothing else: no repository CI or +release attachment (`.github/` untouched, so no user can download a build), a materially +heavier build stack for a project whose premise is a single Bun process, and +`macOSPrivateApi: true` — a notarization and App-Store-rejection risk that `NSPopover` +avoids through public API. + +The four-tab layout became a single scroll-free column so the primary question — "is it +running?" — is answered without a click. Both comments state that the work is not discarded, point at this devlog unit, and invite review of the maintainer branch. +**Pre-send check:** re-read both PR heads immediately before posting. A closing comment +that describes a stale head is the one failure mode that cannot be corrected after the +fact, because the PR is closed by the same action. + ## Push ```bash @@ -100,10 +115,20 @@ feat(release): build and package the macOS companion docs(macos): document the companion and Gatekeeper first launch ``` +## Devlog path hygiene + +`scripts/privacy-scan.ts` excludes `devlog/`, so these documents are **not** covered by +the credential scan. That is a reason for more care, not less: absolute developer paths +(`/Users//...`) must not appear in tracked docs. Use repo-relative paths, or +`` as a placeholder, and redact home directories when quoting evidence from +another contributor's machine. + ## Accept criteria 1. Guide published in five locales, linked from the sidebar, no locale contradictions. 2. `README.md`, `AGENTS.md`, `structure/00_overview.md` mention `app/`. -3. #387 and #421 `CLOSED` with the comments above. +3. #387 and #421 `CLOSED` with the comments above, each verified against the PR's head + commit at the moment of posting. 4. `feat/macos-app` pushed; remote SHA equals local `HEAD`. 5. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green on the final tree. +6. No absolute developer home path in any tracked file, including `devlog/`. From 3a17e396c3fb8e2a8eefe9d73291e1c0c4abd582 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 03:19:40 +0900 Subject: [PATCH 03/61] docs(devlog): fold round-2 audit blockers (design lock, bundle ownership, plist) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 adversarial review returned FAIL on 9 findings, most of them caused by round-1 edits that corrected prose without correcting the specs those documents actually lock. - 003 was never touched in round 1, so the design lock still mandated a 24h sparkline and a Restart button that 002/030 prove impossible. Wireframe now shows LAST 7 DAYS and Stop proxy. - release.yml's input is named dry-run, not dry_run. inputs.dry_run would resolve to null and the attach-macos guard would silently pass during a dry run — the exact failure that guard exists to prevent. - Bundle ownership was relocated, not resolved: Phase 2 claimed a launchable .app while the builder stayed in Phase 4. Phases 1-3 now verify through swift test/build/run; Phase 4 owns the bundle end to end. - app/Info.plist was missing CFBundleExecutable, CFBundlePackageType, and CFBundleIconFile, so the specified bundle would not have launched. - Re-read #421 at head 049ef2ac: menubar/src/api.ts:12-13 returns the token into renderer memory, so the PR's isolation claim does not hold. Removed that credit from both the survey and the planned closing comment, and removed the remaining stale rejection sentences. - Scoped the absolute-path criterion to files this unit touches; unrelated historical devlogs already contain such paths. - loading is now explicitly exempt from the next-action rule, and per-section empty states are defined with their own copy. - The range-fallback test injects a stubbed response, since the closed UsageRange enum makes the curl path unreachable from production code. --- .../260725_macos_menubar_app/000_plan.md | 17 ++++---- .../260725_macos_menubar_app/001_pr_survey.md | 31 +++++++++++---- .../003_design_read.md | 5 ++- .../010_phase1_core.md | 39 +++++++++++++++---- .../260725_macos_menubar_app/020_phase2_ui.md | 37 +++++++++++++++--- .../030_phase3_actions.md | 9 +++-- .../040_phase4_release.md | 14 ++++--- .../050_phase5_handoff.md | 18 ++++++--- 8 files changed, 125 insertions(+), 45 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/000_plan.md b/devlog/_plan/260725_macos_menubar_app/000_plan.md index 95dc9c90c0..6cea70fa89 100644 --- a/devlog/_plan/260725_macos_menubar_app/000_plan.md +++ b/devlog/_plan/260725_macos_menubar_app/000_plan.md @@ -81,13 +81,14 @@ bundle must exist before packaging can wrap it. | --- | --- | --- | --- | | 0 | `000`-`003` | Research, API inventory, design lock, this roadmap | Docs exist, audit passes | | 1 | `010` | `app/` skeleton, proxy discovery, typed API client | `swift test` + `swift build` green | -| 2 | `020` | Menu bar item + popover UI, all states, first launchable bundle | Screenshot of running app | +| 2 | `020` | Menu bar item + popover UI, all states | Screenshot via `swift run` | | 3 | `030` | Write actions on existing endpoints | Live action against running proxy | | 4 | `040` | Universal build, packaging, CI/release wiring | `lipo -archs`, workflow syntax | | 5 | `050` | Docs, PR closure, push | `gh pr view`, `git ls-remote` | -Phase 1 closes on the compiler and tests, not on a bundle: `scripts/build-macos-app.sh` -is a Phase-4 artifact, and a phase may not be verified by a later phase's output. +Phases 1-3 close on `swift test` / `swift build` / `swift run` — never on a bundle. +`scripts/build-macos-app.sh` and the first `.app` belong entirely to Phase 4, so no phase +is verified by a later phase's output. ## Scope boundary @@ -100,17 +101,19 @@ to `dev`/`main`, the six Haydern provider PRs, `gui/**` beyond required asset re ## Accept criteria (mirrored into the goalplan) -1. `app/` produces a launchable `.app` bundle from a repo script. +1. `app/` produces a launchable `.app` bundle from a repo script (Phase 4). 2. Proxy discovery honours `~/.opencodex/runtime-port.json` and falls back to 10100. 3. The popover renders health, usage trend, quotas, and providers from live data. ("Activity" is the day-granular usage trend; per-request logs are out of scope for v1.) -4. Loading / empty / error / proxy-unreachable states each render a next action. +4. Every state renders a meaningful surface; error, unauthorized, unreachable, and empty + states each name a next action. `loading` is exempt — there is nothing to act on yet. 5. Write actions call only pre-existing endpoints, and the app never spawns a process. 6. Release build is universal (arm64 + x86_64) **in CI**; local arm64-only is accepted and documented (see `001` §4). 7. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. -8. No build artifacts committed, and no developer-absolute home path in any tracked file - (including `devlog/`, which `privacy:scan` does not cover). +8. No build artifacts committed, and no developer-absolute home path in **any file this + unit adds or modifies** (including its `devlog/` docs, which `privacy:scan` excludes). + Pre-existing paths in unrelated historical devlogs are out of scope. 9. PRs #387 and #421 closed with English maintainer comments crediting both authors, each written against the PR's head commit at the time of posting. 10. `feat/macos-app` pushed to origin. diff --git a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md index 0b7bdfa900..0eb9e4ebc9 100644 --- a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md +++ b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md @@ -60,8 +60,20 @@ menubar/scripts/ build-app.sh, check-version.sh **Transport: HTTP management API.** `discover.rs` reads `~/.opencodex/runtime-port.json`; `api.rs` proxies WebView `invoke("api_request")` calls -through Rust `reqwest` so the API token stays out of JS memory, sourced from the macOS -Keychain. Zero proxy-side changes — it consumes only endpoints that already exist. +through Rust `reqwest`, with the key sourced from the macOS Keychain. Zero proxy-side +changes — it consumes only endpoints that already exist. + +The PR body claims the token "never crosses to WebView JS". **That is not what the code +does at head `049ef2ac`** — `menubar/src/api.ts:12-13` receives it directly: + +```ts +const discovery = await invoke<{ url: string; token: string | null; found: boolean }>("discover_proxy"); +proxyConfig = { url: discovery.url, token: discovery.token }; +``` + +The token is returned to the renderer and cached in module state. The Rust IPC layer is +still a reasonable shape, but the isolation claim does not hold, so this plan does not +credit it and does not repeat it in the closing comment. Design: four-tab segmented widget (Usage / Health / Status / Activity), Apple-style white theme, tabular-nums stats, `macOSPrivateApi: true` for a transparent rounded @@ -114,10 +126,10 @@ transport and information architecture. Rationale, in order of weight: -1. **Distribution is the whole point of the user's question.** A menu bar app that the - user must compile is not a shipped app. #387 already proves the packaging path end to - end; #421 explicitly declines it. Rebuilding Tauri packaging from scratch would mean - re-deriving what #387 already verified. +1. **Distribution is the whole point of the user's question.** #421 can build an `.app` + and a `.dmg` locally, but nothing in the repository builds or publishes one: `.github/` + is untouched, so no user can download a build. #387 already proves the full path — + packaged, checksummed, and attached to a GitHub Release. 2. **HTTP beats CLI subprocess for a polling UI.** Spawning `ocx` every refresh cycle costs a process launch plus Bun startup per tick, requires the brace-slicing hack to survive incidental stdout, and — decisively — needs `src/cli/status.ts` to grow new @@ -194,7 +206,10 @@ plan: the audit that caught this document's own stale claims used that head as e - **Merge #387, then re-skin later.** Rejected: it lands the `src/cli/status.ts` change the user excluded, and the CLI transport would have to be replaced anyway. -- **Merge #421, then add packaging.** Rejected: the committed `target/` tree fails - `privacy:scan` and would need history rewriting, and the private-API dependency stays. +- **Merge #421, then add packaging.** Rejected on the current head's remaining facts: + the Rust + Node + Tauri toolchain is a large addition to a single-Bun-process project, + and `macOSPrivateApi: true` keeps a notarization and App-Store-rejection risk that + `NSPopover` avoids. (The committed-artifact defect is fixed — §2.1 — and is explicitly + NOT a reason.) - **Ask the contributors to converge.** Rejected: the user asked for the maintainer version now; a two-way contributor negotiation is slower and leaves both PRs open. diff --git a/devlog/_plan/260725_macos_menubar_app/003_design_read.md b/devlog/_plan/260725_macos_menubar_app/003_design_read.md index 9c282e47f8..f3ec2d3385 100644 --- a/devlog/_plan/260725_macos_menubar_app/003_design_read.md +++ b/devlog/_plan/260725_macos_menubar_app/003_design_read.md @@ -111,15 +111,16 @@ column. │ ● Running 127.0.0.1:10100 │ status line — the answer │ protected · service │ qualifier, muted, 11px ├──────────────────────────────────────┤ +│ LAST 7 DAYS │ range echoed from the response │ REQUESTS TOKENS COST │ micro labels, 10px, letterspaced │ 1,746 12.4M $8.21 │ tabular-nums, 13px -│ ▁▂▃▅▂▁▃▇▄▂▁▃ │ 24h sparkline from usage.days[] +│ ▁▂▃▅▂▁▃ │ 7d usage trend from usage.days[] ├──────────────────────────────────────┤ │ OpenAI ▓▓▓▓▓░░░░░ 44% │ quota rows, one per provider │ Anthropic ▓▓▓▓▓▓░░░░ 58% │ │ xAI ▓▓▓▓▓▓▓▓▓░ 87% │ amber >80, red >95 ├──────────────────────────────────────┤ -│ Dashboard Restart ··· │ actions +│ Dashboard Stop proxy ··· │ actions └──────────────────────────────────────┘ ``` diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md index 6ce3e634a8..0a16ab786e 100644 --- a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md @@ -61,16 +61,39 @@ Apple's security-update window. Zero third-party dependencies is a hard rule. ## `app/Info.plist` ```xml -LSUIElement -CFBundleIdentifiercom.opencodex.menubar -CFBundleNameOpenCodex -LSMinimumSystemVersion13.0 -NSHumanReadableCopyrightMIT — opencodex contributors + + + + + CFBundleDevelopmentRegion en + CFBundleExecutable OpenCodexMenuBar + CFBundleIdentifier com.opencodex.menubar + CFBundleInfoDictionaryVersion 6.0 + CFBundleName OpenCodex + CFBundleDisplayName OpenCodex + CFBundlePackageType APPL + CFBundleIconFile OpenCodex + CFBundleShortVersionString 0.0.0 + CFBundleVersion 0.0.0 + LSUIElement + LSMinimumSystemVersion 13.0 + NSHumanReadableCopyright MIT — opencodex contributors + + ``` -`LSUIElement` is what makes it a menu bar app: no Dock icon, no menu bar menus of its -own. `CFBundleShortVersionString` is injected by the build script from `package.json` so -the app version can never drift from the proxy release. +Three keys are load-bearing and an earlier draft omitted all of them, which would have +produced a bundle macOS refuses to launch: + +- `CFBundleExecutable` must equal the binary name the builder copies into + `Contents/MacOS/` — `OpenCodexMenuBar`. +- `CFBundlePackageType` must be `APPL` for the bundle to be treated as an application. +- `CFBundleIconFile` is `OpenCodex` (no extension), matching the `OpenCodex.icns` the + builder writes into `Contents/Resources/`. + +`LSUIElement` is what makes it a menu bar app: no Dock icon, no menu bar menus of its own. +The two version strings are placeholders — the build script overwrites both from +`package.json` (`040`), so the app can never claim a version the release did not ship. ## `Discovery.swift` diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index ae8283f101..dc17ec7905 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -4,6 +4,12 @@ **Independently verifiable by:** a screenshot of the running app read back with `view_image`, plus state-coverage tests. +**No `.app` bundle in this phase.** Visual QA runs the Swift executable directly +(`swift run --package-path app OpenCodexMenuBar`), which registers a menu bar item and +opens the popover exactly like a bundled build. `scripts/build-macos-app.sh` and the first +`.app` are Phase-4 deliverables; an earlier draft moved the "first launchable bundle" here +without moving the builder that produces it. + Implements the locked direction in `003`. Dials: `DESIGN_VARIANCE 2`, `MOTION_INTENSITY 1`, density `D7`. @@ -228,8 +234,8 @@ looks like "0% used". | State | Header | Body | Action | | --- | --- | --- | --- | | `loading` | "Checking…" neutral dot | skeleton rows, em dashes | none | -| `running` | "Running" + green | live metrics, sparkline, quotas | Dashboard · Restart | -| `unreachable` | "Stopped" + red | "The proxy is not running." | **Start proxy** | +| `running` | "Running" + green | live metrics, usage trend, quotas | Dashboard · Stop proxy | +| `unreachable` | "Stopped" + red | "The proxy is not running." | start command as selectable text | | `unauthorized` | "Needs API key" + amber | "This proxy requires a key." | **Add key…** | | `degraded` | "Degraded" + amber | last known values + staleness age | Retry | @@ -241,6 +247,22 @@ Corrections from the Phase-0 audit, carried in from `030`: command to run (`ocx start`, or `ocx service start` when a service is installed) as selectable text, since the app never spawns processes. +### Empty states (per-section, distinct from `loading`) + +`loading` means "not known yet" and correctly offers no action. **Empty means "known, and +there is nothing"** — a different fact needing different copy. Each data section defines +its own: + +| Section | Empty condition | Copy | Action | +| --- | --- | --- | --- | +| Metrics | `summary` present, `requests == 0` | "No requests in the last 7 days." | Dashboard | +| Usage trend | `days` empty or all-zero | bars omitted entirely, no flat line | none | +| Quotas | `reports` empty | "No provider quota sources connected." | Dashboard | +| Providers | `providers` empty | "No providers configured." | Dashboard | + +A zero is rendered as `0` only when the server actually reported zero; unknown stays an em +dash (`003` §6). Conflating the two is the fake-data tell. + Every non-running state names its next action — `dev-uiux-design` UX-STATE-01 forbids dead-ending the user. `degraded` deliberately keeps the last known values with an explicit "as of 2m ago" rather than blanking the popover, since stale-but-labelled beats empty. @@ -263,11 +285,16 @@ what the screenshot shows, then re-verify. Code review alone does not close this 1. Menu bar icon renders as a template image and changes with state. 2. Popover renders live data from the running proxy at 340pt. -3. All five states reachable and each names a next action. +3. All five states reachable; each except `loading` names a next action. 4. Screenshot inspected with `view_image` in both appearances. 5. Keyboard: popover opens, Tab reaches every control, Escape closes. 6. The metrics header renders the range the response returned, verified by forcing a fallback (`?range=bogus` → server answers `30d` → header must read `LAST 30 DAYS`). + The `UsageRange` enum is closed, so production code cannot issue `?range=bogus`; the + test injects a stubbed response whose `range` differs from the requested value and + asserts the header follows the response. A direct `curl ?range=bogus` is kept only as + server-contract evidence in `002`. 7. Sparkline bar count equals `days.count`, not a hardcoded 24. -8. A launchable `.app` bundle exists (first bundle milestone; `040` hardens it). -9. `swift test --package-path app` green. +8. Each empty state above renders its defined copy, distinct from `loading`. +9. `swift run --package-path app OpenCodexMenuBar` shows the menu bar item and popover. +10. `swift test --package-path app` green. diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index 798c612643..8837c96afc 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -1,7 +1,7 @@ # 030 — Phase 3: write actions on existing endpoints **Depends on:** `020` (the UI must exist to report a result into). -**Independently verifiable by:** a live restart and a live provider toggle against the +**Independently verifiable by:** a live stop and a live provider toggle against the running proxy, with the observed response and the resulting UI state. Constraint from the user's scope: **no new proxy endpoints.** Everything here calls @@ -13,7 +13,7 @@ routes inventoried in `002` §4. | --- | --- | | `app/Sources/MenuBarCore/ProxyClient.swift` | MODIFY — add write methods | | `app/Sources/MenuBarCore/ActionCoordinator.swift` | NEW | -| `app/Sources/MenuBarApp/Views/ActionBarView.swift` | MODIFY — wire Restart | +| `app/Sources/MenuBarApp/Views/ActionBarView.swift` | MODIFY — wire Stop proxy | | `app/Sources/MenuBarApp/Views/ProviderListView.swift` | NEW — disclosure + toggles | | `app/Sources/MenuBarApp/Views/ConfirmSheet.swift` | NEW | | `app/Tests/MenuBarCoreTests/ActionTests.swift` | NEW | @@ -66,8 +66,9 @@ start it again (`ocx start`, or `ocx service start` when a service is installed) selectable text. The app does not spawn processes the user did not ask for, and it does not claim a capability the API does not have. -This also removes the `serviceManaged` branch an earlier draft assumed, and with it the -`StartupHealth.serviceInstalled` / `serviceEnabled` fields that `010` never declared. +This removes the `serviceManaged` computed branch an earlier draft assumed. The +`StartupHealth.serviceInstalled` / `serviceEnabled` fields are still decoded in `010` — +they render the status qualifier line in `020`, they just no longer gate an action. ### The drain problem diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 5016f60287..cd6fa2979a 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -1,8 +1,9 @@ # 040 — Phase 4: universal build, release packaging, CI wiring -**Depends on:** `010`-`030` (there must be an app worth packaging). Phase 2 produces the -first launchable bundle via a minimal builder; this phase hardens it into a signed, -verified, distributable artifact. +**Depends on:** `010`-`030` (there must be an app worth packaging). Phases 1-3 verify +themselves through `swift test` / `swift build` / `swift run`; **this phase owns the +bundle end to end** — `scripts/build-macos-app.sh` is introduced here and the first `.app` +is produced here. **Independently verifiable by:** `lipo -archs` on the packaged executable, archive content assertion, and workflow syntax validation. @@ -228,7 +229,7 @@ package-macos: attach-macos: runs-on: ubuntu-latest needs: [publish, package-macos] - if: ${{ inputs.dry_run != true }} + if: ${{ inputs.dry-run != true }} timeout-minutes: 10 permissions: contents: write # only to attach assets to the existing Release @@ -246,7 +247,10 @@ attach-macos: ``` `shasum -c` before upload means a corrupted artifact transfer cannot become a published -asset. `if: inputs.dry_run != true` keeps dry runs from touching a real Release. +asset. `if: ${{ inputs.dry-run != true }}` keeps dry runs from touching a real Release. The +input is named `dry-run` with a hyphen (`release.yml:22-26`); `inputs.dry_run` would +resolve to null and the guard would silently pass, which is the exact failure this line +exists to prevent. **`UNIVERSAL: "1"` is safe here specifically because `macos-latest` carries a full Xcode**, the environment `001` §4.1 identified as the only one that can produce both diff --git a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md index 061e3be6d9..0af7d65ae7 100644 --- a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md +++ b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md @@ -62,12 +62,18 @@ to restart. ### To #421 (genglintong) Names what was adopted: HTTP management-API transport, `runtime-port.json` discovery with -the 10100 fallback, keeping the API token out of the rendering layer, skipping auth when -the proxy has no `apiKeys` configured, the usage/health/status/activity information set, -and tabular-numeral stat treatment. - -**Must be written against head `049ef2ac`, not the head the bots reviewed.** The -contributor's commit "address all Codex review findings (5 P1 + 14 P2)" removed the +the 10100 fallback, Keychain-backed key storage, skipping auth when the proxy has no +`apiKeys` configured, the usage/health/status information set, and tabular-numeral stat +treatment. + +**Do not credit renderer-side token isolation.** `001` §2 shows `menubar/src/api.ts:12-13` +returning the token into renderer memory at head `049ef2ac`, so the PR body's claim does +not hold and repeating it would put a false statement in the record. + +**Must be written against head `049ef2ac`.** CodeRabbit's review is anchored to that same +head, so this is not a "bots reviewed an older tree" situation — the tree simply changed +after the Codex reviewer's P1. The contributor's commit titled "address all Codex review +findings (5 P1 + 14 P2)" removed the committed `src-tauri/target/**` tree; `001` §2.1 verifies zero matching paths remain. The comment explicitly acknowledges that fix. Repeating the stale defect would be factually wrong and would misrepresent a contributor who responded to review properly. From 7db2d3c64c66ab31223e6b83741654495c1b76f0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 03:22:04 +0900 Subject: [PATCH 04/61] docs(devlog): clear round-3 residual nits Round-3 audit returned GO-WITH-FIXES (blockers=0). Cleared all three: - 010 still called the first bundle a Phase-2 deliverable, contradicting 000/020/040. Phase 4 owns the bundle end to end. - 001's salvage list still credited #421 with renderer-side token isolation, contradicting its own verified analysis, and claimed all four surfaces were adopted when per-request activity was deliberately excluded. - 040 and 050 still scoped the absolute-path rule to every tracked file, which pre-existing historical devlogs already violate. Both now match 000's unit-scoped wording. --- .../_plan/260725_macos_menubar_app/001_pr_survey.md | 12 ++++++++---- .../260725_macos_menubar_app/010_phase1_core.md | 4 ++-- .../260725_macos_menubar_app/040_phase4_release.md | 6 ++++-- .../260725_macos_menubar_app/050_phase5_handoff.md | 3 ++- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md index 0eb9e4ebc9..538c5b04cf 100644 --- a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md +++ b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md @@ -194,10 +194,14 @@ assertion, `ditto` archiving with archive-content verification, SHA-256 sidecar, first-launch documentation angle. From **#421 (genglintong)** — product architecture: HTTP management-API transport, -`runtime-port.json` discovery with a 10100 fallback, auth token held outside the -rendering layer, the four-surface information architecture (usage / health / status / -activity), tabular-numeral stat treatment, and skipping auth entirely when the proxy has -no `apiKeys` configured. +`runtime-port.json` discovery with a 10100 fallback, Keychain-backed key storage, the +usage / health / status information set, tabular-numeral stat treatment, and skipping auth +entirely when the proxy has no `apiKeys` configured. + +Two things from that branch are deliberately NOT carried over: renderer-side token +isolation (§2 shows the token does reach renderer memory at `049ef2ac`, so there is +nothing to adopt), and the per-request activity surface (`002` §3 records why it is +excluded from v1). The contributor's review-response discipline at `049ef2ac` also directly improved this plan: the audit that caught this document's own stale claims used that head as evidence. diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md index 0a16ab786e..2efc8be2b5 100644 --- a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md @@ -7,8 +7,8 @@ **Bundle scope note (audit correction):** an earlier draft closed this phase on a `.app` produced by `scripts/build-macos-app.sh`, but that script is a Phase-4 deliverable — a phase cannot be verified by a later phase's output. Phase 1 therefore -closes on the compiler and the test suite. The first launchable bundle is a Phase-2 -deliverable (it needs the UI to be worth launching), and Phase 4 hardens and packages it. +closes on the compiler and the test suite. Phase 2 does its visual QA with `swift run`, +and **Phase 4 owns the bundle end to end**: the builder, the first `.app`, and packaging. ## File change map diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index cd6fa2979a..59d8d6abd7 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -271,8 +271,10 @@ Constraints honoured: - `app/.gitignore` excludes `.build/`, `.swiftpm/`, `DerivedData/` (landed in `010`). - Root `.gitignore` excludes `dist/macos/`. - `git ls-files app/ | grep -E '\.build/|DerivedData/'` must return empty. -- No absolute developer path appears in any committed file — this is the exact defect - that blocked PR #421 (`001` §2), and it is checked explicitly rather than assumed. +- No absolute developer path appears in **any file this unit adds or modifies** — checked + explicitly rather than assumed. Pre-existing paths in unrelated historical devlogs are + out of scope (`000` criterion 8). This mirrors the artifact defect the Codex reviewer + originally raised on PR #421, which that contributor has since fixed (`001` §2.1). ## Accept criteria diff --git a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md index 0af7d65ae7..49dad1b4c0 100644 --- a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md +++ b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md @@ -137,4 +137,5 @@ another contributor's machine. commit at the moment of posting. 4. `feat/macos-app` pushed; remote SHA equals local `HEAD`. 5. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green on the final tree. -6. No absolute developer home path in any tracked file, including `devlog/`. +6. No absolute developer home path in any file this unit adds or modifies, including its + `devlog/` docs. Pre-existing paths in unrelated historical devlogs are out of scope. From 2a8bc1385ac83abb79e3689de7eeaa5d553d5336 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 03:40:26 +0900 Subject: [PATCH 05/61] =?UTF-8?q?feat(app):=20add=20macOS=20menu=20bar=20c?= =?UTF-8?q?ore=20=E2=80=94=20discovery,=20client,=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the macOS companion (010_phase1_core.md). Zero third-party dependencies; AppKit and Foundation only. - Discovery resolves the proxy from OPENCODEX_HOME/runtime-port.json with a 10100 fallback. The host is pinned to loopback and never read from the record, so a file write cannot redirect the app at another host. - ProxyModels mirror the live payloads. QuotaReport.normalized() absorbs two real traps: the window key differs per provider (weekly/monthly/custom), and weeklyResetAt arrives in seconds from openai but milliseconds from anthropic within the same array, so timestamps are disambiguated by magnitude. - UsageRange is a closed enum because the server silently degrades an unrecognized range to 30d; UsageReport.rangeLabel is derived from the response so the UI can never label 30 days of data as something else. - ProxyClient is an actor. ProxyError carries human sentences only, never a response body, since bodies can echo configuration. - Format renders an em dash for unknown and a real zero for zero; the live proxy reports 3.6e10 tokens, so everything is abbreviated. Testing is an executable target rather than a .testTarget: Command Line Tools resolves neither XCTest (module not found) nor the swift-testing runtime (Testing.framework fails to dlopen). Requiring full Xcode to run these tests would exclude most contributors. 31 cases pass via `swift run --package-path app MenuBarCoreTests`. Verified live against the running proxy: endpoint discovery, health (at-risk, service-managed), defaultProvider=openai from /api/config, 7-day usage (44.5K requests, 7.34B tokens, $6.15K), and four provider quotas with correctly resolved reset windows. --- .gitignore | 1 + app/.gitignore | 4 + app/Info.plist | 32 +++ app/Package.swift | 28 +++ app/Sources/MenuBarApp/main.swift | 38 +++ app/Sources/MenuBarCore/Discovery.swift | 72 ++++++ app/Sources/MenuBarCore/Formatting.swift | 89 ++++++++ app/Sources/MenuBarCore/Keychain.swift | 52 +++++ app/Sources/MenuBarCore/ProxyClient.swift | 168 ++++++++++++++ app/Sources/MenuBarCore/ProxyModels.swift | 216 ++++++++++++++++++ .../MenuBarCoreTests/DiscoverySuite.swift | 82 +++++++ .../MenuBarCoreTests/FormattingSuite.swift | 61 +++++ app/Sources/MenuBarCoreTests/Harness.swift | 99 ++++++++ .../MenuBarCoreTests/ModelDecodingSuite.swift | 174 ++++++++++++++ app/Sources/MenuBarCoreTests/main.swift | 12 + .../010_phase1_core.md | 38 ++- 16 files changed, 1162 insertions(+), 4 deletions(-) create mode 100644 app/.gitignore create mode 100644 app/Info.plist create mode 100644 app/Package.swift create mode 100644 app/Sources/MenuBarApp/main.swift create mode 100644 app/Sources/MenuBarCore/Discovery.swift create mode 100644 app/Sources/MenuBarCore/Formatting.swift create mode 100644 app/Sources/MenuBarCore/Keychain.swift create mode 100644 app/Sources/MenuBarCore/ProxyClient.swift create mode 100644 app/Sources/MenuBarCore/ProxyModels.swift create mode 100644 app/Sources/MenuBarCoreTests/DiscoverySuite.swift create mode 100644 app/Sources/MenuBarCoreTests/FormattingSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/Harness.swift create mode 100644 app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/main.swift diff --git a/.gitignore b/.gitignore index 1973231f05..895d9894ea 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,4 @@ go/ # Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. native/**/target/ +dist/macos/ diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000000..4629e801bf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj +DerivedData/ diff --git a/app/Info.plist b/app/Info.plist new file mode 100644 index 0000000000..058e1680e9 --- /dev/null +++ b/app/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + OpenCodexMenuBar + CFBundleIdentifier + com.opencodex.menubar + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + OpenCodex + CFBundleDisplayName + OpenCodex + CFBundlePackageType + APPL + CFBundleIconFile + OpenCodex + CFBundleShortVersionString + 0.0.0 + CFBundleVersion + 0.0.0 + LSUIElement + + LSMinimumSystemVersion + 13.0 + NSHumanReadableCopyright + MIT — opencodex contributors + + diff --git a/app/Package.swift b/app/Package.swift new file mode 100644 index 0000000000..3371694ddf --- /dev/null +++ b/app/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "OpenCodexMenuBar", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), + .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), + ], + targets: [ + .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), + .executableTarget( + name: "MenuBarApp", + dependencies: ["MenuBarCore"], + path: "Sources/MenuBarApp" + ), + // An executable rather than a .testTarget: Xcode Command Line Tools ships + // neither a usable XCTest module nor the swift-testing runtime, so a test bundle + // cannot run without a full Xcode install. See Sources/MenuBarCoreTests/Harness.swift. + .executableTarget( + name: "MenuBarCoreTests", + dependencies: ["MenuBarCore"], + path: "Sources/MenuBarCoreTests" + ), + ], + swiftLanguageVersions: [.v5] +) diff --git a/app/Sources/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift new file mode 100644 index 0000000000..aa518aacf7 --- /dev/null +++ b/app/Sources/MenuBarApp/main.swift @@ -0,0 +1,38 @@ +import AppKit +import MenuBarCore + +// Phase 1 entry point: registers a status item so the executable is launchable and +// verifiable via `swift run`. The popover UI lands in Phase 2 (020). + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) + +let delegate = AppDelegate() +app.delegate = delegate +app.run() + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem? + + func applicationDidFinishLaunching(_ notification: Notification) { + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.title = "ocx" + item.button?.toolTip = "OpenCodex" + + let endpoint = ProxyDiscovery.resolve() + let menu = NSMenu() + menu.addItem( + withTitle: "Proxy: \(endpoint.display)", + action: nil, + keyEquivalent: "" + ) + menu.addItem(.separator()) + menu.addItem( + withTitle: "Quit OpenCodex", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + item.menu = menu + statusItem = item + } +} diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift new file mode 100644 index 0000000000..e972d2c7ce --- /dev/null +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -0,0 +1,72 @@ +import Foundation + +/// A loopback endpoint for the local OpenCodex proxy. +/// +/// The host is deliberately fixed to loopback and never read from disk: the port record +/// is a convenience, not a redirection mechanism. +public struct ProxyEndpoint: Equatable, Sendable { + public static let loopbackHost = "127.0.0.1" + + public let host: String + public let port: Int + + public init(port: Int) { + self.host = Self.loopbackHost + self.port = port + } + + public var baseURL: URL { + // Safe: host is a fixed literal and port is range-checked at construction sites. + URL(string: "http://\(host):\(port)")! + } + + public var display: String { "\(host):\(port)" } +} + +struct RuntimePortRecord: Decodable { + let pid: Int? + let port: Int +} + +/// Resolves where the proxy is listening, mirroring `resolveRuntimePortPath()` in +/// `src/config.ts`. +public enum ProxyDiscovery { + public static let defaultPort = 10100 + public static let validPorts = 1...65535 + + /// `OPENCODEX_HOME` when set and non-empty, else `~/.opencodex`. + public static func configDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespaces), + !override.isEmpty { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + return home.appendingPathComponent(".opencodex", isDirectory: true) + } + + /// Reads `runtime-port.json`, falling back to the default port on any problem. + /// + /// Every failure mode — missing file, malformed JSON, out-of-range port — resolves to + /// the default rather than throwing. A menu bar app that refuses to start because a + /// cache file is unreadable would be worse than one that probes the usual port. + public static func resolve(configDirectory directory: URL) -> ProxyEndpoint { + let file = directory.appendingPathComponent("runtime-port.json") + guard + let data = try? Data(contentsOf: file), + let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), + validPorts.contains(record.port) + else { + return ProxyEndpoint(port: defaultPort) + } + return ProxyEndpoint(port: record.port) + } + + public static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> ProxyEndpoint { + resolve(configDirectory: configDirectory(environment: environment, home: home)) + } +} diff --git a/app/Sources/MenuBarCore/Formatting.swift b/app/Sources/MenuBarCore/Formatting.swift new file mode 100644 index 0000000000..63a3b8d430 --- /dev/null +++ b/app/Sources/MenuBarCore/Formatting.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Number and date presentation for a 340pt popover. +/// +/// Live data reaches `requests: 232507`, `totalTokens: 36536664705`, and +/// `estimatedCostUsd: 34018.25`. Rendering those verbatim destroys the layout, so every +/// value is abbreviated and every unknown is an em dash — never a plausible-looking zero. +public enum Format { + public static let unknown = "—" + + private static let grouping: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + f.maximumFractionDigits = 0 + return f + }() + + /// Counts: grouped below 10 000, then SI-suffixed with 3 significant figures. + public static func count(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 10_000 { + return grouping.string(from: NSNumber(value: value)) ?? String(value) + } + return abbreviate(Double(value)) + } + + /// Tokens are always suffixed — they are never small enough to be worth grouping. + public static func tokens(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 1_000 { return String(value) } + return abbreviate(Double(value)) + } + + public static func cost(_ value: Double?) -> String { + guard let value else { return unknown } + if value < 1_000 { + return String(format: "$%.2f", value) + } + return "$" + abbreviate(value) + } + + public static func percent(_ value: Double?) -> String { + guard let value else { return unknown } + return "\(Int(value.rounded()))%" + } + + /// "resets in 3d 4h" / "resets in 12m". Past dates read as "expired". + public static func resetsIn(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "expired" } + + let totalMinutes = Int(interval / 60) + let days = totalMinutes / 1440 + let hours = (totalMinutes % 1440) / 60 + let minutes = totalMinutes % 60 + + if days > 0 { return hours > 0 ? "\(days)d \(hours)h" : "\(days)d" } + if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" } + return "\(max(minutes, 1))m" + } + + /// "2m ago" for staleness labels on the degraded state. + public static func age(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let seconds = Int(now.timeIntervalSince(date)) + if seconds < 60 { return "just now" } + if seconds < 3600 { return "\(seconds / 60)m ago" } + if seconds < 86_400 { return "\(seconds / 3600)h ago" } + return "\(seconds / 86_400)d ago" + } + + private static func abbreviate(_ value: Double) -> String { + let units: [(threshold: Double, suffix: String)] = [ + (1_000_000_000_000, "T"), + (1_000_000_000, "B"), + (1_000_000, "M"), + (1_000, "K"), + ] + for unit in units where value >= unit.threshold { + let scaled = value / unit.threshold + // 3 significant figures: 36.5B, 1.20M, 232K. + let decimals = scaled >= 100 ? 0 : (scaled >= 10 ? 1 : 2) + return String(format: "%.\(decimals)f%@", scaled, unit.suffix) + } + return String(format: "%.0f", value) + } +} diff --git a/app/Sources/MenuBarCore/Keychain.swift b/app/Sources/MenuBarCore/Keychain.swift new file mode 100644 index 0000000000..7d2aac1a85 --- /dev/null +++ b/app/Sources/MenuBarCore/Keychain.swift @@ -0,0 +1,52 @@ +import Foundation +import Security + +/// Generic-password storage for the optional management API key. +/// +/// The key is read lazily — only after a 401 — and is never written to UserDefaults, +/// never logged, and never included in an error surfaced to the UI. +public enum Keychain { + public static let service = "com.opencodex.menubar.apikey" + public static let defaultAccount = "default" + + public static func read(account: String = defaultAccount) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8), + !value.isEmpty + else { return nil } + return value + } + + @discardableResult + public static func write(_ value: String, account: String = defaultAccount) -> Bool { + delete(account: account) + let attributes: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecValueData as String: Data(value.utf8), + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, + ] + return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess + } + + @discardableResult + public static func delete(account: String = defaultAccount) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let status = SecItemDelete(query as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift new file mode 100644 index 0000000000..fc5fab5de9 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -0,0 +1,168 @@ +import Foundation + +public enum ProxyError: Error, Equatable { + /// Connection refused or timed out — the proxy is not running. + case unreachable + /// 401 — a non-loopback bind that requires a credential. + case unauthorized + case http(Int) + case decoding + + /// Human sentences only. Response bodies can echo configuration values, so they + /// never reach the UI or a log. + public var userMessage: String { + switch self { + case .unreachable: return "The proxy is not running." + case .unauthorized: return "This proxy requires an API key." + case .http(let code): return "The proxy returned an unexpected status (\(code))." + case .decoding: return "The proxy returned a response this app could not read." + } + } +} + +/// HTTP client for the OpenCodex management API. +/// +/// An actor because the endpoint and key are mutated from both the polling loop and user +/// actions; the isolation makes that data-race-free by construction rather than by +/// convention. +public actor ProxyClient { + private let session: URLSession + private var endpoint: ProxyEndpoint + private var apiKey: String? + + public init(endpoint: ProxyEndpoint, session: URLSession? = nil) { + self.endpoint = endpoint + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 4 + config.waitsForConnectivity = false + self.session = URLSession(configuration: config) + } + } + + public var currentEndpoint: ProxyEndpoint { endpoint } + + public func updateEndpoint(_ endpoint: ProxyEndpoint) { self.endpoint = endpoint } + + public func setAPIKey(_ key: String?) { self.apiKey = key } + + // MARK: - Reads + + public func health() async throws -> StartupHealth { try await get("api/startup-health") } + public func settings() async throws -> ProxySettings { try await get("api/settings") } + public func config() async throws -> ProxyConfigSummary { try await get("api/config") } + public func providers() async throws -> [ProviderSummary] { try await get("api/providers") } + + public func usage(range: UsageRange = .sevenDays) async throws -> UsageReport { + try await get("api/usage", query: [URLQueryItem(name: "range", value: range.rawValue)]) + } + + public func quotas() async throws -> [QuotaReport] { + let envelope: QuotaEnvelope = try await get("api/provider-quotas") + return envelope.reports ?? [] + } + + /// Cheapest possible liveness probe. + public func isReachable() async -> Bool { + do { + _ = try await settings() + return true + } catch ProxyError.unauthorized { + // Answering 401 still proves something is listening. + return true + } catch { + return false + } + } + + // MARK: - Writes + + /// `POST /api/stop`. Returns once the proxy has accepted the request. + /// + /// The proxy answers 200 *before* draining, and it stops the launchd service first so + /// nothing respawns it. Callers must poll `isReachable()` rather than treat this + /// return as "stopped". + public func stop() async throws { + _ = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) + } + + /// `PATCH /api/providers?name=` with a body of exactly `{"disabled": }`. + /// + /// A disabled-only patch skips the proxy's heavier merged-shape validators, so adding + /// any second field would silently change the request class. + public func setProviderDisabled(_ name: String, disabled: Bool) async throws { + _ = try await send( + method: "PATCH", + path: "api/providers", + query: [URLQueryItem(name: "name", value: name)], + body: ProviderDisabledPatch(disabled: disabled) + ) + } + + // MARK: - Transport + + private func get(_ path: String, query: [URLQueryItem] = []) async throws -> T { + let data = try await send(method: "GET", path: path, query: query, body: nil as EmptyBody?) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ProxyError.decoding + } + } + + private func send( + method: String, + path: String, + query: [URLQueryItem] = [], + body: Body? + ) async throws -> Data { + guard var components = URLComponents( + url: endpoint.baseURL.appendingPathComponent(path), + resolvingAgainstBaseURL: false + ) else { throw ProxyError.decoding } + if !query.isEmpty { components.queryItems = query } + guard let url = components.url else { throw ProxyError.decoding } + + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = method == "GET" ? 4 : 6 + if let apiKey { request.setValue(apiKey, forHTTPHeaderField: "x-opencodex-api-key") } + if let body { + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try? JSONEncoder().encode(body) + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ProxyError.decoding } + if http.statusCode == 401 { throw ProxyError.unauthorized } + guard (200..<300).contains(http.statusCode) else { + throw ProxyError.http(http.statusCode) + } + return data + } catch let error as ProxyError { + throw error + } catch let error as URLError { + switch error.code { + case .cannotConnectToHost, .timedOut, .networkConnectionLost, + .cannotFindHost, .notConnectedToInternet: + throw ProxyError.unreachable + default: + throw ProxyError.unreachable + } + } + } +} + +private struct QuotaEnvelope: Decodable { + let generatedAt: Double? + let reports: [QuotaReport]? +} + +private struct ProviderDisabledPatch: Encodable { + let disabled: Bool +} + +private struct EmptyBody: Encodable {} diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift new file mode 100644 index 0000000000..54ff652aa4 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -0,0 +1,216 @@ +import Foundation + +// Codable mirrors of the management API payloads inventoried in +// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. +// +// Every field the proxy may omit is optional. The proxy is a fast-moving local service; +// a companion that fails to decode because one field moved is worse than one that shows +// an em dash. + +/// `GET /api/startup-health` +public struct StartupHealth: Decodable, Equatable, Sendable { + public let status: String? + public let protection: String? + public let platform: String? + public let routingKind: String? + public let serviceRunning: Bool? + public let serviceInstalled: Bool? + public let serviceEnabled: Bool? + public let rebootSafe: Bool? + public let recommendedCommand: String? + + public init( + status: String? = nil, + protection: String? = nil, + platform: String? = nil, + routingKind: String? = nil, + serviceRunning: Bool? = nil, + serviceInstalled: Bool? = nil, + serviceEnabled: Bool? = nil, + rebootSafe: Bool? = nil, + recommendedCommand: String? = nil + ) { + self.status = status + self.protection = protection + self.platform = platform + self.routingKind = routingKind + self.serviceRunning = serviceRunning + self.serviceInstalled = serviceInstalled + self.serviceEnabled = serviceEnabled + self.rebootSafe = rebootSafe + self.recommendedCommand = recommendedCommand + } + + /// `status` is treated as an open string: unknown values degrade to a neutral state + /// rather than crashing or being coerced into "healthy". + public var isProtected: Bool { status == "protected" } + + /// True when a supervisor owns the process lifecycle. Used only for the qualifier + /// line — it deliberately does not gate any action, because `/api/stop` stops the + /// service on purpose and nothing restarts the proxy automatically. + public var isServiceManaged: Bool { + (serviceInstalled ?? false) && (serviceEnabled ?? false) + } + + /// The command to show the user when the proxy is not running. + public var manualStartCommand: String { + isServiceManaged ? "ocx service start" : "ocx start" + } +} + +/// `GET /api/settings`. Note the absence of `defaultProvider` — it lives on +/// `/api/config`, verified against the live key set. +public struct ProxySettings: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let streamMode: String? + public let codexAutoStart: Bool? +} + +/// `GET /api/config` — the only source of `defaultProvider`. +public struct ProxyConfigSummary: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let defaultProvider: String? +} + +/// Ranges accepted by `parseRange()` in `src/usage/summary.ts`. +/// +/// Closed on purpose: the server silently degrades anything else to `30d`, so a +/// stringly-typed range would let a caller ask for `24h`, receive thirty days of data, +/// and label it wrongly. +public enum UsageRange: String, Sendable, CaseIterable { + case sevenDays = "7d" + case thirtyDays = "30d" + case all +} + +public struct UsageSummary: Decodable, Equatable, Sendable { + public let requests: Int? + public let measuredRequests: Int? + public let estimatedRequests: Int? + public let totalTokens: Int? + public let inputTokens: Int? + public let outputTokens: Int? + public let estimatedCostUsd: Double? + public let coverageRatio: Double? + + public var hasEstimates: Bool { (estimatedRequests ?? 0) > 0 } +} + +public struct UsageDay: Decodable, Equatable, Sendable { + public let date: String + public let requests: Int? + public let totalTokens: Int? +} + +public struct UsageReport: Decodable, Equatable, Sendable { + public let range: String? + public let surface: String? + public let generatedAt: Double? + public let summary: UsageSummary? + public let days: [UsageDay]? + + /// The range the server actually applied, which is not always the one requested. + public var effectiveRange: UsageRange? { + range.flatMap(UsageRange.init(rawValue:)) + } + + /// Header text driven by the response, never by the request. + public var rangeLabel: String { + switch effectiveRange { + case .sevenDays: return "LAST 7 DAYS" + case .thirtyDays: return "LAST 30 DAYS" + case .all: return "ALL TIME" + case nil: return "USAGE" + } + } + + public var isEmpty: Bool { + guard let summary else { return true } + return (summary.requests ?? 0) == 0 + } +} + +public struct QuotaWindow: Decodable, Equatable, Sendable { + public let label: String? + public let percent: Double? + public let resetAt: Double? +} + +public struct ProviderQuota: Decodable, Equatable, Sendable { + public let weeklyPercent: Double? + public let monthlyPercent: Double? + public let weeklyResetAt: Double? + public let monthlyResetAt: Double? + public let customWindows: [QuotaWindow]? + public let updatedAt: Double? +} + +public struct QuotaReport: Decodable, Equatable, Sendable { + public let provider: String + public let label: String? + public let source: String? + public let quota: ProviderQuota? +} + +/// A provider-agnostic view of quota, since the window key differs per provider. +public struct NormalizedQuota: Equatable, Sendable { + public let provider: String + public let providerLabel: String + public let percent: Double? + public let windowLabel: String + public let resetAt: Date? + + public var hasPercent: Bool { percent != nil } +} + +public extension QuotaReport { + /// Timestamps in this payload are not uniform: the live proxy returns + /// `weeklyResetAt` in seconds for `openai` and in milliseconds for `anthropic`, + /// within the same array. Disambiguate by magnitude — 1e12 is 2001 read as + /// milliseconds and year 33658 read as seconds, so the boundary is unambiguous for + /// any timestamp this app will ever see. + static func date(from value: Double?) -> Date? { + guard let value, value > 0 else { return nil } + let seconds = value >= 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + func normalized() -> NormalizedQuota { + let name = label ?? provider + if let percent = quota?.weeklyPercent { + return NormalizedQuota( + provider: provider, providerLabel: name, percent: percent, + windowLabel: "week", resetAt: Self.date(from: quota?.weeklyResetAt) + ) + } + if let percent = quota?.monthlyPercent { + return NormalizedQuota( + provider: provider, providerLabel: name, percent: percent, + windowLabel: "month", resetAt: Self.date(from: quota?.monthlyResetAt) + ) + } + if let window = quota?.customWindows?.first { + return NormalizedQuota( + provider: provider, providerLabel: name, percent: window.percent, + windowLabel: window.label ?? "window", resetAt: Self.date(from: window.resetAt) + ) + } + return NormalizedQuota( + provider: provider, providerLabel: name, percent: nil, + windowLabel: "—", resetAt: nil + ) + } +} + +/// `GET /api/providers`. `hasApiKey` is a presence flag; the key never leaves the proxy. +public struct ProviderSummary: Decodable, Equatable, Sendable { + public let name: String + public let adapter: String? + public let authMode: String? + public let hasApiKey: Bool? + public let disabled: Bool? + + public var isEnabled: Bool { !(disabled ?? false) } +} diff --git a/app/Sources/MenuBarCoreTests/DiscoverySuite.swift b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift new file mode 100644 index 0000000000..e8aa2acf1b --- /dev/null +++ b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift @@ -0,0 +1,82 @@ +import Foundation +import MenuBarCore + +enum DiscoverySuite { + static func run(_ t: TestRunner) { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ocx-discovery-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + func writeRecord(_ contents: String) throws { + try contents.write( + to: root.appendingPathComponent("runtime-port.json"), + atomically: true, + encoding: .utf8 + ) + } + + t.test("discovery: honours a valid record") { + try writeRecord(#"{"pid": 14582, "port": 10100}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10100) + } + + t.test("discovery: honours a non-default port") { + try writeRecord(#"{"pid": 1, "port": 18080}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 18080) + } + + t.test("discovery: a record without pid still resolves") { + try writeRecord(#"{"port": 10250}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10250) + } + + t.test("discovery: malformed JSON falls back to the default port") { + try writeRecord("{not json at all") + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, ProxyDiscovery.defaultPort) + } + + t.test("discovery: out-of-range ports fall back to the default") { + for invalid in ["0", "70000", "-1"] { + try writeRecord(#"{"port": \#(invalid)}"#) + t.equal( + ProxyDiscovery.resolve(configDirectory: root).port, + ProxyDiscovery.defaultPort, + "port \(invalid)" + ) + } + } + + t.test("discovery: a missing file falls back to the default port") { + let empty = root.appendingPathComponent("empty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + t.equal(ProxyDiscovery.resolve(configDirectory: empty).port, ProxyDiscovery.defaultPort) + } + + // The record may carry a hostname, but the app must never follow it: the port + // file is a convenience, not a redirection mechanism. + t.test("discovery: host stays loopback even when the record names another host") { + try writeRecord(#"{"pid": 1, "port": 10100, "hostname": "10.0.0.5"}"#) + let endpoint = ProxyDiscovery.resolve(configDirectory: root) + t.equal(endpoint.host, "127.0.0.1") + t.equal(endpoint.baseURL.absoluteString, "http://127.0.0.1:10100") + } + + t.test("discovery: OPENCODEX_HOME overrides the default directory") { + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": root.path], + home: URL(fileURLWithPath: "/nonexistent") + ) + t.equal(resolved.path, root.path) + } + + t.test("discovery: a blank OPENCODEX_HOME falls back to the home directory") { + let home = URL(fileURLWithPath: "/Users/example") + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": " "], + home: home + ) + t.equal(resolved.path, home.appendingPathComponent(".opencodex").path) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/FormattingSuite.swift b/app/Sources/MenuBarCoreTests/FormattingSuite.swift new file mode 100644 index 0000000000..8ceeeb6dab --- /dev/null +++ b/app/Sources/MenuBarCoreTests/FormattingSuite.swift @@ -0,0 +1,61 @@ +import Foundation +import MenuBarCore + +/// Magnitudes are taken from the live proxy capture in 002_api_surface.md. +enum FormattingSuite { + static func run(_ t: TestRunner) { + t.test("format: counts group below 10k and suffix above") { + t.equal(Format.count(0), "0") + t.equal(Format.count(1_746), "1,746") + t.equal(Format.count(9_999), "9,999") + t.equal(Format.count(232_507), "233K") + t.equal(Format.count(1_200_000), "1.20M") + } + + t.test("format: tokens are suffixed at scale") { + t.equal(Format.tokens(999), "999") + t.equal(Format.tokens(12_400_000), "12.4M") + t.equal(Format.tokens(36_536_664_705), "36.5B") + } + + t.test("format: cost switches to a suffix above one thousand") { + t.equal(Format.cost(8.21), "$8.21") + t.equal(Format.cost(999.99), "$999.99") + t.equal(Format.cost(34_018.25204647066), "$34.0K") + } + + // Unknown and zero are different facts. Rendering nil as "0" is the fake-data + // tell that 003 section 6 bans. + t.test("format: nil renders an em dash while zero renders zero") { + t.equal(Format.count(nil), "—") + t.equal(Format.tokens(nil), "—") + t.equal(Format.cost(nil), "—") + t.equal(Format.percent(nil), "—") + t.equal(Format.count(0), "0") + t.equal(Format.cost(0), "$0.00") + } + + t.test("format: percent rounds") { + t.equal(Format.percent(44), "44%") + t.equal(Format.percent(86.82666666666667), "87%") + t.equal(Format.percent(9.976811594202898), "10%") + } + + t.test("format: reset countdowns are coarse") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.resetsIn(now.addingTimeInterval(60 * 30), now: now), "30m") + t.equal(Format.resetsIn(now.addingTimeInterval(3600 * 5), now: now), "5h") + t.equal(Format.resetsIn(now.addingTimeInterval(86_400 * 3 + 3600 * 4), now: now), "3d 4h") + t.equal(Format.resetsIn(now.addingTimeInterval(-60), now: now), "expired") + t.equal(Format.resetsIn(nil), "—") + } + + t.test("format: staleness ages read naturally") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.age(now.addingTimeInterval(-10), now: now), "just now") + t.equal(Format.age(now.addingTimeInterval(-120), now: now), "2m ago") + t.equal(Format.age(now.addingTimeInterval(-7200), now: now), "2h ago") + t.equal(Format.age(nil), "—") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/Harness.swift b/app/Sources/MenuBarCoreTests/Harness.swift new file mode 100644 index 0000000000..9b9e119824 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/Harness.swift @@ -0,0 +1,99 @@ +import Foundation + +/// A dependency-free assertion harness. +/// +/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line +/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles +/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to +/// run the unit tests of a menu bar companion would put the tests out of reach for most +/// contributors and for any CI runner without Xcode selected. +/// +/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that +/// both a human and CI can read. If the package ever gains a full-Xcode requirement for +/// other reasons, migrating these cases to swift-testing is mechanical. +public struct TestFailure { + let test: String + let message: String + let file: String + let line: Int +} + +public final class TestRunner { + private(set) var passed = 0 + private(set) var failures: [TestFailure] = [] + private var current = "" + + public init() {} + + public func test(_ name: String, _ body: () throws -> Void) { + current = name + do { + try body() + passed += 1 + print("ok — \(name)") + } catch { + failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) + print("FAIL — \(name): threw \(error)") + } + } + + public func expect( + _ condition: Bool, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + guard !condition else { return } + let failure = TestFailure(test: current, message: message(), file: file, line: line) + failures.append(failure) + print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") + } + + public func equal( + _ actual: T, + _ expected: T, + _ label: String = "", + file: String = #file, + line: Int = #line + ) { + expect( + actual == expected, + "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", + file: file, + line: line + ) + } + + public func notNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) -> T? { + expect(value != nil, "\(label) should not be nil", file: file, line: line) + return value + } + + public func isNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) { + expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) + } + + /// Prints the summary and returns the process exit code. + public func summarize() -> Int32 { + print("") + if failures.isEmpty { + print("\(passed) passed, 0 failed") + return 0 + } + print("\(passed) passed, \(failures.count) FAILED") + for failure in failures { + print(" - \(failure.test): \(failure.message)") + } + return 1 + } +} diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift new file mode 100644 index 0000000000..c534aad5ad --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -0,0 +1,174 @@ +import Foundation +import MenuBarCore + +/// Fixtures are verbatim captures from the live proxy on 2026-07-25, recorded in +/// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. Hand-written fixtures would +/// only prove the models decode themselves. +enum ModelDecodingSuite { + private static func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + private struct Envelope: Decodable { let reports: [QuotaReport]? } + + private static let liveHealth = """ + {"routingKind":"opencodex-local","autostartEnabled":false,"serviceInstalled":true, + "serviceViable":true,"serviceEnabled":true,"serviceRunning":true,"serviceStale":false, + "serviceConflict":false,"serviceSupported":true,"shimInstalled":false, + "shimHealthy":false,"platform":"darwin","diagnosticStale":true,"routingInjected":true, + "localRoutingDependency":true,"status":"at-risk","rebootSafe":false,"protection":"none", + "shimCoverage":"none","recommendedCommand":"ocx service install", + "commands":{"installService":"ocx service install","installShim":"ocx codex-shim install", + "restoreNative":"ocx restore"}} + """ + + private static let liveQuotas = """ + {"generatedAt":1784915336899,"reports":[ + {"provider":"openai","label":"OpenAI (Codex login)","source":"chatgpt:wham", + "quota":{"updatedAt":1784915090763,"weeklyPercent":44,"weeklyResetAt":1785258443, + "resetCredits":3}}, + {"provider":"anthropic","label":"Anthropic Claude","source":"anthropic:oauth-usage", + "quota":{"weeklyPercent":58,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"5h","percent":1,"resetAt":1784928599718}]}}, + {"provider":"xai","label":"xAI Grok","source":"xai:grok-billing", + "quota":{"monthlyPercent":86.82666666666667,"monthlyResetAt":1785542400000}}]} + """ + + static func run(_ t: TestRunner) { + t.test("health: decodes the live startup-health payload") { + let health = try decode(StartupHealth.self, liveHealth) + t.equal(health.status, "at-risk") + t.equal(health.platform, "darwin") + t.equal(health.recommendedCommand, "ocx service install") + t.equal(health.isProtected, false) + t.equal(health.isServiceManaged, true) + t.equal(health.manualStartCommand, "ocx service start") + } + + t.test("health: an unknown status string decodes without throwing") { + let health = try decode(StartupHealth.self, #"{"status":"some-future-state"}"#) + t.equal(health.status, "some-future-state") + t.equal(health.isProtected, false) + } + + t.test("health: without service fields it is not service-managed") { + let health = try decode(StartupHealth.self, #"{"status":"protected"}"#) + t.equal(health.isProtected, true) + t.equal(health.isServiceManaged, false) + t.equal(health.manualStartCommand, "ocx start") + } + + // The live /api/settings key set contains no defaultProvider. Decoding must + // succeed anyway — an earlier plan draft expected the field here and was wrong. + t.test("settings: decodes without a defaultProvider field") { + let json = """ + {"codexAutoStart":false,"port":10100,"hostname":"127.0.0.1","streamMode":"auto", + "startupHealth":{"status":"protected"},"codexRuntime":{}} + """ + let settings = try decode(ProxySettings.self, json) + t.equal(settings.port, 10100) + t.equal(settings.hostname, "127.0.0.1") + t.equal(settings.streamMode, "auto") + } + + t.test("config: supplies defaultProvider") { + let json = """ + {"port":10100,"hostname":"127.0.0.1","defaultProvider":"openai", + "codexAutoStart":false,"websockets":{},"providers":{}} + """ + t.equal(try decode(ProxyConfigSummary.self, json).defaultProvider, "openai") + } + + t.test("usage: decodes the live summary at real magnitudes") { + let json = """ + {"range":"30d","surface":"all","since":1782323333603,"generatedAt":1784915333603, + "summary":{"requests":232507,"measuredRequests":225380,"estimatedRequests":14618, + "inputTokens":33521662469,"outputTokens":127401110,"totalTokens":36536664705, + "coverageRatio":0.969347159440361,"estimatedCostUsd":34018.25204647066}, + "days":[{"date":"2026-06-28","requests":1746,"totalTokens":0,"models":[]}]} + """ + let report = try decode(UsageReport.self, json) + t.equal(report.summary?.requests, 232_507) + t.equal(report.summary?.totalTokens, 36_536_664_705) + t.equal(report.effectiveRange, .thirtyDays) + t.equal(report.rangeLabel, "LAST 30 DAYS") + t.equal(report.summary?.hasEstimates, true) + t.equal(report.isEmpty, false) + } + + // The server silently degrades an unrecognized range to 30d, so the label must + // follow the response and never the request. + t.test("usage: an unknown range degrades to a neutral label") { + let report = try decode(UsageReport.self, #"{"range":"24h"}"#) + t.isNil(report.effectiveRange, "effectiveRange for 24h") + t.equal(report.rangeLabel, "USAGE") + } + + t.test("usage: zero requests reads as empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"requests":0}}"#) + t.equal(report.isEmpty, true) + } + + t.test("usage: the range enum is closed") { + t.isNil(UsageRange(rawValue: "24h"), "UsageRange(24h)") + t.equal(UsageRange.allCases.map(\.rawValue), ["7d", "30d", "all"]) + } + + // The decisive trap: openai sends weeklyResetAt in SECONDS (1785258443) while + // anthropic sends MILLISECONDS (1785265199718) in the same array. + t.test("quotas: mixed second and millisecond timestamps both resolve to 2026") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + t.equal(reports.count, 3) + let calendar = Calendar(identifier: .gregorian) + for report in reports { + let normalized = report.normalized() + guard let date = t.notNil(normalized.resetAt, "\(report.provider) resetAt") else { continue } + t.equal(calendar.component(.year, from: date), 2026, "\(report.provider) year") + } + } + + t.test("quotas: normalization picks the right window per provider") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + let byProvider = Dictionary(uniqueKeysWithValues: reports.map { ($0.provider, $0.normalized()) }) + t.equal(byProvider["openai"]?.windowLabel, "week") + t.equal(byProvider["openai"]?.percent, 44) + t.equal(byProvider["anthropic"]?.windowLabel, "week") + t.equal(byProvider["xai"]?.windowLabel, "month") + t.equal(byProvider["xai"]?.providerLabel, "xAI Grok") + } + + t.test("quotas: a custom-window-only quota uses its own label") { + let json = """ + {"provider":"p","quota":{"customWindows":[{"label":"5h","percent":12,"resetAt":1784928599718}]}} + """ + let normalized = try decode(QuotaReport.self, json).normalized() + t.equal(normalized.windowLabel, "5h") + t.equal(normalized.percent, 12) + } + + t.test("quotas: an absent quota normalizes to a nil percent") { + let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized() + t.isNil(normalized.percent, "percent") + t.equal(normalized.hasPercent, false) + t.isNil(normalized.resetAt, "resetAt") + } + + t.test("providers: decodes the live list") { + let json = """ + [{"name":"openai","adapter":"openai-responses","hasApiKey":false, + "authMode":"forward","disabled":false,"codexAccountMode":"pool"}, + {"name":"anthropic","adapter":"anthropic","hasApiKey":false, + "authMode":"oauth","disabled":true}] + """ + let providers = try decode([ProviderSummary].self, json) + t.equal(providers.count, 2) + t.equal(providers[0].name, "openai") + t.equal(providers[0].isEnabled, true) + t.equal(providers[1].isEnabled, false) + } + + t.test("providers: a provider without a disabled field is enabled") { + t.equal(try decode(ProviderSummary.self, #"{"name":"custom"}"#).isEnabled, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift new file mode 100644 index 0000000000..1befdc3c4e --- /dev/null +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -0,0 +1,12 @@ +import Foundation + +// Entry point for `swift run --package-path app MenuBarCoreTests`. +// See Harness.swift for why this is an executable rather than an XCTest bundle. + +let runner = TestRunner() + +DiscoverySuite.run(runner) +ModelDecodingSuite.run(runner) +FormattingSuite.run(runner) + +exit(runner.summarize()) diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md index 2efc8be2b5..d80a3981fa 100644 --- a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md @@ -22,12 +22,39 @@ and **Phase 4 owns the bundle end to end**: the builder, the first `.app`, and p | `app/Sources/MenuBarCore/Formatting.swift` | NEW | | `app/Sources/MenuBarCore/Keychain.swift` | NEW | | `app/Sources/MenuBarApp/main.swift` | NEW (minimal `NSApplication` entry; UI lands in 020) | -| `app/Tests/MenuBarCoreTests/DiscoveryTests.swift` | NEW | -| `app/Tests/MenuBarCoreTests/ModelDecodingTests.swift` | NEW | -| `app/Tests/MenuBarCoreTests/FormattingTests.swift` | NEW | +| `app/Sources/MenuBarCoreTests/Harness.swift` | NEW | +| `app/Sources/MenuBarCoreTests/DiscoverySuite.swift` | NEW | +| `app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift` | NEW | +| `app/Sources/MenuBarCoreTests/FormattingSuite.swift` | NEW | +| `app/Sources/MenuBarCoreTests/main.swift` | NEW | | `app/.gitignore` | NEW | | `.gitignore` (root) | MODIFY — add `dist/macos/` | +### Build-time amendment: the test target is an executable, not a `.testTarget` + +Planned as `swift test`. That does not work on this toolchain, and the failure is +environmental rather than incidental — verified during Phase 1 implementation: + +```text +import XCTest + -> error: unable to resolve module dependency: 'XCTest' + +import Testing (swift-testing) + -> compiles, then at run time: + Library not loaded: @rpath/Testing.framework/Versions/A/Testing +``` + +Xcode Command Line Tools ships neither a usable XCTest module nor the swift-testing +runtime; both require a full Xcode install. Requiring Xcode to run the unit tests of a +menu bar companion would put them out of reach of most contributors and of any CI runner +that has not selected Xcode — the same class of constraint `001` §4.1 already found for +universal builds. + +**Resolution:** a ~90-line dependency-free harness (`Harness.swift`) plus an executable +target. Tests run with `swift run --package-path app MenuBarCoreTests`, exit non-zero on +failure, and print one line per case. Migration to swift-testing is mechanical if the +package ever requires full Xcode for other reasons. + **Two-target split rationale:** `MenuBarCore` is a plain library with no AppKit dependency, so it is testable under `swift test` on any runner. `MenuBarApp` holds everything that needs a running `NSApplication`. PR #387 used the same split and it is @@ -389,9 +416,12 @@ never afterwards. ## Accept criteria -1. `swift test --package-path app` green, with the `002` payloads as fixtures. +1. `swift run --package-path app MenuBarCoreTests` green, with the `002` payloads as + fixtures (see the build-time amendment above). 2. `swift build --package-path app -c release --arch arm64` succeeds. 3. `UsageRange` admits only `7d`/`30d`/`all`; no call site can request `24h`. 4. `ProxyConfigSummary.defaultProvider` decodes from live `/api/config`. 5. `git status` shows no `.build/` or `dist/` entries. 6. `bun run typecheck` and `bun run test` unaffected (no TS added). +7. A live probe against the running proxy resolves the endpoint and decodes health, + config, usage, quotas, and providers — fixtures alone do not prove the transport. From 36cdb137cf518246de931c619bdf8a82587178a8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 03:58:56 +0900 Subject: [PATCH 06/61] fix(app): fold code-review blockers into the menu bar core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of c7fbf57c returned FAIL on 10 findings. All verified against the live proxy or Apple docs before folding. - Info.plist: add NSAllowsLocalNetworking. macOS 14 stopped allowing IP loads under ATS, so the packaged bundle could not reach 127.0.0.1 at all while swift run stayed green — the app's primary function, broken only in the artifact users would actually download. - ProxyClient: wire the lazy Keychain retry that the plan required and the code never implemented. A CredentialStore protocol is injected, the key loads once, and exactly one retry follows a 401 so a stale key cannot spin. - Keychain: set kSecUseDataProtectionKeychain on every query, without which kSecAttrAccessible is ignored on macOS; tighten to ThisDeviceOnly; update before add so a failed add cannot destroy a working key. - ProxyModels: live kimi reports fiveHourPercent alongside weeklyPercent, and cursor and google-antigravity each carry two customWindows. Added the five-hour fields and normalizedWindows() returning every window; normalized() keeps an explicit longest-horizon precedence. - isEmptyOrUnknown preserves three states so an omitted request count cannot render as "No requests". - Cancellation now propagates instead of reading as a stopped proxy, and unrelated transport failures get their own .transport case. - ProxyEndpoint is failable; baseURL is built once instead of force-unwrapped. - Format promotes at rollover: 999_999 renders 1.00M, not 1000K. - TransportSuite adds 14 cases over status mapping, the 401 retry path, cancellation, request shape, and body redaction. 51 pass, 0 fail. - Harness no longer counts a case as passed when it recorded a failure. Live re-verification across all six providers: Kimi 5h+week, Cursor's three windows, and correct primary-window selection for each. --- app/Info.plist | 10 + app/Sources/MenuBarCore/Discovery.swift | 26 +- app/Sources/MenuBarCore/Formatting.swift | 26 +- app/Sources/MenuBarCore/Keychain.swift | 50 ++-- app/Sources/MenuBarCore/ProxyClient.swift | 60 +++- app/Sources/MenuBarCore/ProxyModels.swift | 72 +++-- .../MenuBarCoreTests/FormattingSuite.swift | 16 + app/Sources/MenuBarCoreTests/Harness.swift | 10 +- .../MenuBarCoreTests/ModelDecodingSuite.swift | 52 ++++ .../MenuBarCoreTests/TransportSuite.swift | 273 ++++++++++++++++++ app/Sources/MenuBarCoreTests/main.swift | 1 + .../010_phase1_core.md | 23 +- .../260725_macos_menubar_app/020_phase2_ui.md | 4 +- .../030_phase3_actions.md | 4 +- .../040_phase4_release.md | 2 +- 15 files changed, 566 insertions(+), 63 deletions(-) create mode 100644 app/Sources/MenuBarCoreTests/TransportSuite.swift diff --git a/app/Info.plist b/app/Info.plist index 058e1680e9..3d52873063 100644 --- a/app/Info.plist +++ b/app/Info.plist @@ -26,6 +26,16 @@ LSMinimumSystemVersion 13.0 + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSHumanReadableCopyright MIT — opencodex contributors diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift index e972d2c7ce..412a5ff5c0 100644 --- a/app/Sources/MenuBarCore/Discovery.swift +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -6,19 +6,27 @@ import Foundation /// is a convenience, not a redirection mechanism. public struct ProxyEndpoint: Equatable, Sendable { public static let loopbackHost = "127.0.0.1" + public static let validPorts = 1...65535 public let host: String public let port: Int + private let resolvedURL: URL - public init(port: Int) { + /// Fails rather than traps on an out-of-range port. `baseURL` is built once here, so + /// no accessor can crash later on a value that was never a valid URL. + public init?(port: Int) { + guard Self.validPorts.contains(port), + let url = URL(string: "http://\(Self.loopbackHost):\(port)") + else { return nil } self.host = Self.loopbackHost self.port = port + self.resolvedURL = url } - public var baseURL: URL { - // Safe: host is a fixed literal and port is range-checked at construction sites. - URL(string: "http://\(host):\(port)")! - } + /// The default endpoint, which is known-valid by construction. + public static let `default` = ProxyEndpoint(port: ProxyDiscovery.defaultPort)! + + public var baseURL: URL { resolvedURL } public var display: String { "\(host):\(port)" } } @@ -32,7 +40,7 @@ struct RuntimePortRecord: Decodable { /// `src/config.ts`. public enum ProxyDiscovery { public static let defaultPort = 10100 - public static let validPorts = 1...65535 + public static var validPorts: ClosedRange { ProxyEndpoint.validPorts } /// `OPENCODEX_HOME` when set and non-empty, else `~/.opencodex`. public static func configDirectory( @@ -56,11 +64,11 @@ public enum ProxyDiscovery { guard let data = try? Data(contentsOf: file), let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), - validPorts.contains(record.port) + let endpoint = ProxyEndpoint(port: record.port) else { - return ProxyEndpoint(port: defaultPort) + return .default } - return ProxyEndpoint(port: record.port) + return endpoint } public static func resolve( diff --git a/app/Sources/MenuBarCore/Formatting.swift b/app/Sources/MenuBarCore/Formatting.swift index 63a3b8d430..f273fc8711 100644 --- a/app/Sources/MenuBarCore/Formatting.swift +++ b/app/Sources/MenuBarCore/Formatting.swift @@ -78,12 +78,28 @@ public enum Format { (1_000_000, "M"), (1_000, "K"), ] - for unit in units where value >= unit.threshold { - let scaled = value / unit.threshold - // 3 significant figures: 36.5B, 1.20M, 232K. - let decimals = scaled >= 100 ? 0 : (scaled >= 10 ? 1 : 2) - return String(format: "%.\(decimals)f%@", scaled, unit.suffix) + // Ascending, so promotion is a simple step to the next entry. + let ascending = units.reversed().map { $0 } + + for (index, unit) in ascending.enumerated() where value < (unit.threshold * 1000) { + let rendered = render(value / unit.threshold, suffix: unit.suffix) + // Rounding can push a value across its own boundary: 999_999 scales to + // 999.999K, which would render "1000K" instead of promoting to "1.00M". + guard rendered.hasPrefix("1000"), index + 1 < ascending.count else { return rendered } + let larger = ascending[index + 1] + return render(value / larger.threshold, suffix: larger.suffix) + } + + // Beyond the largest unit, stay in that unit rather than inventing a suffix. + if let largest = ascending.last, value >= largest.threshold { + return render(value / largest.threshold, suffix: largest.suffix) } return String(format: "%.0f", value) } + + /// 3 significant figures: 36.5B, 1.20M, 233K. + private static func render(_ scaled: Double, suffix: String) -> String { + let decimals = scaled >= 100 ? 0 : (scaled >= 10 ? 1 : 2) + return String(format: "%.\(decimals)f%@", scaled, suffix) + } } diff --git a/app/Sources/MenuBarCore/Keychain.swift b/app/Sources/MenuBarCore/Keychain.swift index 7d2aac1a85..66cc0774b4 100644 --- a/app/Sources/MenuBarCore/Keychain.swift +++ b/app/Sources/MenuBarCore/Keychain.swift @@ -5,18 +5,29 @@ import Security /// /// The key is read lazily — only after a 401 — and is never written to UserDefaults, /// never logged, and never included in an error surfaced to the UI. +/// +/// Every query sets `kSecUseDataProtectionKeychain`. Without it, `kSecAttrAccessible` is +/// ignored on macOS (it applies only to data-protection or synchronizable items), so the +/// declared accessibility class would be decorative. Setting it on *all* operations also +/// matters for correctness: a data-protection item is invisible to a query that omits +/// the flag, so a mixed set of queries would fail to find or delete its own items. public enum Keychain { public static let service = "com.opencodex.menubar.apikey" public static let defaultAccount = "default" - public static func read(account: String = defaultAccount) -> String? { - let query: [String: Any] = [ + private static func baseQuery(account: String) -> [String: Any] { + [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseDataProtectionKeychain as String: true, ] + } + + public static func read(account: String = defaultAccount) -> String? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data, @@ -28,25 +39,28 @@ public enum Keychain { @discardableResult public static func write(_ value: String, account: String = defaultAccount) -> Bool { - delete(account: account) - let attributes: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecValueData as String: Data(value.utf8), - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock, - ] + let data = Data(value.utf8) + + // Update first, add only when absent. Deleting first would destroy a working key + // whenever the subsequent add failed. + let updateStatus = SecItemUpdate( + baseQuery(account: account) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return true } + guard updateStatus == errSecItemNotFound else { return false } + + var attributes = baseQuery(account: account) + attributes[kSecValueData as String] = data + // ThisDeviceOnly: the key is a local proxy credential with no reason to migrate + // to another machine via backup or transfer. + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess } @discardableResult public static func delete(account: String = defaultAccount) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let status = SecItemDelete(query as CFDictionary) + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) return status == errSecSuccess || status == errSecItemNotFound } } diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index fc5fab5de9..360ca44d53 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -7,6 +7,8 @@ public enum ProxyError: Error, Equatable { case unauthorized case http(Int) case decoding + /// A transport failure that is not evidence the proxy is down (TLS, policy, DNS). + case transport /// Human sentences only. Response bodies can echo configuration values, so they /// never reach the UI or a log. @@ -16,10 +18,22 @@ public enum ProxyError: Error, Equatable { case .unauthorized: return "This proxy requires an API key." case .http(let code): return "The proxy returned an unexpected status (\(code))." case .decoding: return "The proxy returned a response this app could not read." + case .transport: return "The connection to the proxy failed." } } } +/// Supplies the optional management API key. Injected so tests never touch the real +/// Keychain and so the app can swap the source without touching transport code. +public protocol CredentialStore: Sendable { + func loadAPIKey() -> String? +} + +public struct KeychainCredentialStore: CredentialStore { + public init() {} + public func loadAPIKey() -> String? { Keychain.read() } +} + /// HTTP client for the OpenCodex management API. /// /// An actor because the endpoint and key are mutated from both the polling loop and user @@ -27,11 +41,19 @@ public enum ProxyError: Error, Equatable { /// convention. public actor ProxyClient { private let session: URLSession + private let credentials: CredentialStore private var endpoint: ProxyEndpoint private var apiKey: String? - - public init(endpoint: ProxyEndpoint, session: URLSession? = nil) { + /// Ensures the lazy credential load happens at most once per client. + private var didAttemptCredentialLoad = false + + public init( + endpoint: ProxyEndpoint, + session: URLSession? = nil, + credentials: CredentialStore = KeychainCredentialStore() + ) { self.endpoint = endpoint + self.credentials = credentials if let session { self.session = session } else { @@ -46,7 +68,11 @@ public actor ProxyClient { public func updateEndpoint(_ endpoint: ProxyEndpoint) { self.endpoint = endpoint } - public func setAPIKey(_ key: String?) { self.apiKey = key } + public func setAPIKey(_ key: String?) { + self.apiKey = key + // An explicitly supplied key replaces the lazy path entirely. + self.didAttemptCredentialLoad = true + } // MARK: - Reads @@ -117,6 +143,28 @@ public actor ProxyClient { path: String, query: [URLQueryItem] = [], body: Body? + ) async throws -> Data { + do { + return try await perform(method: method, path: path, query: query, body: body) + } catch ProxyError.unauthorized { + // A loopback proxy needs no credential, so a 401 means this install is bound + // to a non-loopback host. Load the stored key once and retry exactly once — + // never a loop, so a stale key cannot spin. + guard !didAttemptCredentialLoad else { throw ProxyError.unauthorized } + didAttemptCredentialLoad = true + guard let stored = credentials.loadAPIKey(), !stored.isEmpty else { + throw ProxyError.unauthorized + } + apiKey = stored + return try await perform(method: method, path: path, query: query, body: body) + } + } + + private func perform( + method: String, + path: String, + query: [URLQueryItem], + body: Body? ) async throws -> Data { guard var components = URLComponents( url: endpoint.baseURL.appendingPathComponent(path), @@ -146,11 +194,15 @@ public actor ProxyClient { throw error } catch let error as URLError { switch error.code { + case .cancelled: + // Propagate cancellation rather than reporting a stopped proxy: the + // polling coordinator cancels in-flight work whenever the popover closes. + throw CancellationError() case .cannotConnectToHost, .timedOut, .networkConnectionLost, .cannotFindHost, .notConnectedToInternet: throw ProxyError.unreachable default: - throw ProxyError.unreachable + throw ProxyError.transport } } } diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index 54ff652aa4..0bf1b83257 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -127,8 +127,15 @@ public struct UsageReport: Decodable, Equatable, Sendable { } public var isEmpty: Bool { - guard let summary else { return true } - return (summary.requests ?? 0) == 0 + isEmptyOrUnknown == true + } + + /// Three states, not two: `nil` means the proxy did not report a request count, and + /// `true` means it explicitly reported zero. Collapsing those would let the UI print + /// "No requests" for data it simply does not have. + public var isEmptyOrUnknown: Bool? { + guard let requests = summary?.requests else { return nil } + return requests == 0 } } @@ -141,8 +148,10 @@ public struct QuotaWindow: Decodable, Equatable, Sendable { public struct ProviderQuota: Decodable, Equatable, Sendable { public let weeklyPercent: Double? public let monthlyPercent: Double? + public let fiveHourPercent: Double? public let weeklyResetAt: Double? public let monthlyResetAt: Double? + public let fiveHourResetAt: Double? public let customWindows: [QuotaWindow]? public let updatedAt: Double? } @@ -177,27 +186,52 @@ public extension QuotaReport { return Date(timeIntervalSince1970: seconds) } - func normalized() -> NormalizedQuota { + /// Every window the provider reported, in display order. + /// + /// The live proxy is not uniform: `openai` and `xai` report a single named window, + /// `kimi` reports both `weeklyPercent` and `fiveHourPercent`, and `cursor` and + /// `google-antigravity` carry two `customWindows` each. Returning only one window + /// would silently hide real quota pressure. + func normalizedWindows() -> [NormalizedQuota] { let name = label ?? provider - if let percent = quota?.weeklyPercent { - return NormalizedQuota( - provider: provider, providerLabel: name, percent: percent, - windowLabel: "week", resetAt: Self.date(from: quota?.weeklyResetAt) - ) - } - if let percent = quota?.monthlyPercent { - return NormalizedQuota( + var windows: [NormalizedQuota] = [] + + func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?) { + guard percent != nil || resetAt != nil else { return } + windows.append(NormalizedQuota( provider: provider, providerLabel: name, percent: percent, - windowLabel: "month", resetAt: Self.date(from: quota?.monthlyResetAt) - ) + windowLabel: windowLabel, resetAt: Self.date(from: resetAt) + )) } - if let window = quota?.customWindows?.first { - return NormalizedQuota( - provider: provider, providerLabel: name, percent: window.percent, - windowLabel: window.label ?? "window", resetAt: Self.date(from: window.resetAt) - ) + + append(quota?.fiveHourPercent, "5h", quota?.fiveHourResetAt) + append(quota?.weeklyPercent, "week", quota?.weeklyResetAt) + append(quota?.monthlyPercent, "month", quota?.monthlyResetAt) + + for window in quota?.customWindows ?? [] { + append(window.percent, window.label ?? "window", window.resetAt) } - return NormalizedQuota( + + return windows + } + + /// The single window that best represents overall pressure, for the compact row. + /// + /// Precedence is longest-horizon-first (month, then week, then shorter windows): + /// a monthly cap is the one that actually stops work, while a 5h window recovers on + /// its own. Providers with no numeric window normalize to a nil percent so the UI + /// renders an em dash rather than a misleading zero. + func normalized() -> NormalizedQuota { + let name = label ?? provider + let windows = normalizedWindows() + + let preferred = + windows.first { $0.windowLabel == "month" && $0.hasPercent } + ?? windows.first { $0.windowLabel == "week" && $0.hasPercent } + ?? windows.first { $0.hasPercent } + ?? windows.first + + return preferred ?? NormalizedQuota( provider: provider, providerLabel: name, percent: nil, windowLabel: "—", resetAt: nil ) diff --git a/app/Sources/MenuBarCoreTests/FormattingSuite.swift b/app/Sources/MenuBarCoreTests/FormattingSuite.swift index 8ceeeb6dab..26f066515e 100644 --- a/app/Sources/MenuBarCoreTests/FormattingSuite.swift +++ b/app/Sources/MenuBarCoreTests/FormattingSuite.swift @@ -12,6 +12,22 @@ enum FormattingSuite { t.equal(Format.count(1_200_000), "1.20M") } + // Rounding can push a value across its own unit boundary: 999_999 scales to + // 999.999K and must promote to 1.00M rather than render "1000K". + t.test("format: values promote at suffix rollover boundaries") { + t.equal(Format.count(999_999), "1.00M") + t.equal(Format.count(999_499), "999K") + t.equal(Format.tokens(999_999_999), "1.00B") + t.equal(Format.tokens(999_999_999_999), "1.00T") + t.equal(Format.cost(999_999), "$1.00M") + } + + t.test("format: exact unit thresholds render as the new unit") { + t.equal(Format.tokens(1_000), "1.00K") + t.equal(Format.tokens(1_000_000), "1.00M") + t.equal(Format.tokens(1_000_000_000), "1.00B") + } + t.test("format: tokens are suffixed at scale") { t.equal(Format.tokens(999), "999") t.equal(Format.tokens(12_400_000), "12.4M") diff --git a/app/Sources/MenuBarCoreTests/Harness.swift b/app/Sources/MenuBarCoreTests/Harness.swift index 9b9e119824..0deb1d6ae4 100644 --- a/app/Sources/MenuBarCoreTests/Harness.swift +++ b/app/Sources/MenuBarCoreTests/Harness.swift @@ -27,13 +27,19 @@ public final class TestRunner { public func test(_ name: String, _ body: () throws -> Void) { current = name + let failuresBefore = failures.count do { try body() - passed += 1 - print("ok — \(name)") } catch { failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) print("FAIL — \(name): threw \(error)") + return + } + // A case that recorded an expectation failure is not a pass, even though its + // body returned normally. + if failures.count == failuresBefore { + passed += 1 + print("ok — \(name)") } } diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index c534aad5ad..6d865cba6c 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -107,6 +107,15 @@ enum ModelDecodingSuite { t.test("usage: zero requests reads as empty") { let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"requests":0}}"#) t.equal(report.isEmpty, true) + t.equal(report.isEmptyOrUnknown, true) + } + + // Unknown and zero are different facts: an omitted count must not render as + // "No requests". + t.test("usage: an omitted request count is unknown, not empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"totalTokens":5}}"#) + t.isNil(report.isEmptyOrUnknown, "isEmptyOrUnknown for an omitted count") + t.equal(report.isEmpty, false, "isEmpty must not claim empty for unknown") } t.test("usage: the range enum is closed") { @@ -146,6 +155,49 @@ enum ModelDecodingSuite { t.equal(normalized.percent, 12) } + // Live kimi reports weeklyPercent AND fiveHourPercent; live cursor and + // google-antigravity each carry two customWindows. Returning one window would + // hide real quota pressure. + t.test("quotas: kimi exposes both its five-hour and weekly windows") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":22, + "fiveHourResetAt":1784928599718,"weeklyPercent":61,"weeklyResetAt":1785265199718}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 2) + t.equal(windows.map(\.windowLabel), ["5h", "week"]) + // The compact row prefers the longer horizon. + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 61) + } + + t.test("quotas: multiple custom windows are all retained") { + let json = """ + {"provider":"cursor","label":"Cursor","quota":{"monthlyPercent":10, + "monthlyResetAt":1785256304000, + "customWindows":[{"label":"First-party models","percent":4,"resetAt":1785256304000}, + {"label":"API usage","percent":1,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 3) + t.equal(windows.map(\.windowLabel), ["month", "First-party models", "API usage"]) + t.equal(report.normalized().windowLabel, "month") + } + + t.test("quotas: a provider with only custom windows still normalizes") { + let json = """ + {"provider":"google-antigravity","label":"Google","quota":{ + "customWindows":[{"label":"Gem","percent":30,"resetAt":1785256304000}, + {"label":"Cla","percent":12,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalizedWindows().count, 2) + t.equal(report.normalized().windowLabel, "Gem") + t.equal(report.normalized().percent, 30) + } + t.test("quotas: an absent quota normalizes to a nil percent") { let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized() t.isNil(normalized.percent, "percent") diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift new file mode 100644 index 0000000000..ea0a2335c4 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -0,0 +1,273 @@ +import Foundation +import MenuBarCore + +/// Stubs the network so status mapping, the 401 retry, cancellation, request shape, and +/// body privacy are covered without a live proxy. +final class StubProtocol: URLProtocol, @unchecked Sendable { + struct Response { + var status: Int + var body: String + var urlError: URLError.Code? + } + + nonisolated(unsafe) static var queue: [Response] = [] + nonisolated(unsafe) static var recorded: [URLRequest] = [] + private static let lock = NSLock() + + static func reset(_ responses: [Response]) { + lock.lock(); defer { lock.unlock() } + queue = responses + recorded = [] + } + + static func record(_ request: URLRequest) { + lock.lock(); defer { lock.unlock() } + recorded.append(request) + } + + static func next() -> Response? { + lock.lock(); defer { lock.unlock() } + return queue.isEmpty ? nil : queue.removeFirst() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.record(request) + guard let response = Self.next() else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + return + } + if let code = response.urlError { + client?.urlProtocol(self, didFailWithError: URLError(code)) + return + } + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: nil + )! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(response.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private struct StubCredentials: CredentialStore { + let key: String? + let counter: Counter + + final class Counter: @unchecked Sendable { + private(set) var loads = 0 + private let lock = NSLock() + func bump() { lock.lock(); loads += 1; lock.unlock() } + } + + func loadAPIKey() -> String? { + counter.bump() + return key + } +} + +enum TransportSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = ResultBox() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class ResultBox: @unchecked Sendable { var value: T? } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("transport: a 200 decodes into the model") { + StubProtocol.reset([.init(status: 200, body: #"{"status":"protected"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let result: String? = sync { + try? await client.health().status + } + t.equal(result, "protected") + } + + t.test("transport: a 500 maps to .http and never carries the body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG-VALUE", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .http(500)) + let message = error?.userMessage ?? "" + t.expect(!message.contains("SECRET"), "error message must not echo the body: \(message)") + } + + t.test("transport: malformed JSON maps to .decoding") { + StubProtocol.reset([.init(status: 200, body: "{not json", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .decoding) + } + + t.test("transport: connection refused maps to .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unreachable) + } + + // A policy failure is not evidence the proxy is down; conflating them would put + // the UI in "Stopped" for a running proxy. + t.test("transport: an unrelated URLError maps to .transport, not .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .appTransportSecurityRequiresSecureConnection)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .transport) + } + + t.test("transport: cancellation propagates instead of reading as a stopped proxy") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cancelled)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let wasCancellation: Bool = sync { + do { _ = try await client.health(); return false } + catch is CancellationError { return true } + catch { return false } + } + t.equal(wasCancellation, true) + } + + t.test("auth: a 401 with a stored key retries once and succeeds") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + let status: String? = sync { try? await client.health().status } + t.equal(status, "protected") + t.equal(counter.loads, 1, "credential loaded exactly once") + t.equal(StubProtocol.recorded.count, 2, "one retry") + let retry = StubProtocol.recorded.last + t.equal(retry?.value(forHTTPHeaderField: "x-opencodex-api-key"), "test-key") + } + + t.test("auth: a 401 with no stored key surfaces .unauthorized without retrying") { + StubProtocol.reset([.init(status: 401, body: "", urlError: nil)]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: counter)) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 1, "no retry without a key") + } + + // A stale stored key must not spin: one retry, then surface the failure. + t.test("auth: repeated 401s retry exactly once, never looping") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "stale", counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 2, "exactly one retry") + } + + t.test("requests: usage sends the enum range as a query item") { + StubProtocol.reset([.init(status: 200, body: #"{"range":"7d"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { try? await client.usage(range: .sevenDays) } + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("range=7d"), "expected range=7d in \(url)") + t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") + } + + t.test("requests: the provider patch sends exactly {\"disabled\":true}") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { + try? await client.setProviderDisabled("anthropic", disabled: true) + } + let request = StubProtocol.recorded.first + t.equal(request?.httpMethod, "PATCH") + let url = request?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + + // URLProtocol strips httpBody into a stream, so assert on the encoder directly. + let encoded = String( + data: try JSONEncoder().encode(["disabled": true]), + encoding: .utf8 + ) + t.equal(encoded, #"{"disabled":true}"#) + } + + t.test("liveness: a 401 still proves something is listening") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "k", counter: .init())) + t.equal(sync { await client.isReachable() }, true) + } + + t.test("liveness: connection refused reads as not reachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + t.equal(sync { await client.isReachable() }, false) + } + + t.test("endpoint: an out-of-range port cannot be constructed") { + t.isNil(ProxyEndpoint(port: 0), "port 0") + t.isNil(ProxyEndpoint(port: -1), "port -1") + t.isNil(ProxyEndpoint(port: 70_000), "port 70000") + t.equal(ProxyEndpoint(port: 10_100)?.baseURL.absoluteString, "http://127.0.0.1:10100") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift index 1befdc3c4e..25430f2749 100644 --- a/app/Sources/MenuBarCoreTests/main.swift +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -8,5 +8,6 @@ let runner = TestRunner() DiscoverySuite.run(runner) ModelDecodingSuite.run(runner) FormattingSuite.run(runner) +TransportSuite.run(runner) exit(runner.summarize()) diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md index d80a3981fa..bdaa82b4ff 100644 --- a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md @@ -1,7 +1,7 @@ # 010 — Phase 1: app skeleton, proxy discovery, management API client **Depends on:** nothing (foundation phase). -**Independently verifiable by:** `swift test --package-path app` green and +**Independently verifiable by:** `swift run --package-path app MenuBarCoreTests` green and `swift build --package-path app -c release --arch arm64` succeeding. **Bundle scope note (audit correction):** an earlier draft closed this phase on a @@ -414,6 +414,27 @@ Root `.gitignore` gains `dist/macos/`. This is the direct lesson from PR #421's `src-tauri/target/` — the ignore rules land in the same commit as the first build script, never afterwards. +## Code-review corrections (round 1, folded before B closed) + +An adversarial review of the first implementation returned FAIL on 10 findings. Each was +verified against the live proxy or Apple documentation before being folded: + +| Finding | Correction | +| --- | --- | +| ATS blocks loopback IP loads on macOS 14+, so the *packaged* app could not reach the proxy at all while `swift run` stayed green | `Info.plist` gains `NSAppTransportSecurity` / `NSAllowsLocalNetworking` | +| The lazy Keychain retry after 401 was never wired; `Keychain` was dead production code | `CredentialStore` protocol injected into `ProxyClient`; one load, exactly one retry, no loop | +| `kSecAttrAccessible` is ignored on macOS without `kSecUseDataProtectionKeychain` | Flag set on every query; class tightened to `…ThisDeviceOnly`; `write` now updates-then-adds so a failed add cannot destroy a valid key | +| Live `kimi` reports `fiveHourPercent`/`fiveHourResetAt`; `cursor` and `google-antigravity` each carry two `customWindows`. `normalized()` discarded all but one | Added the five-hour fields and `normalizedWindows()` returning every window; `normalized()` keeps an explicit longest-horizon precedence for the compact row | +| `(requests ?? 0) == 0` turned unknown into "no usage" | `isEmptyOrUnknown: Bool?` preserves three states | +| Every non-connectivity `URLError` — including `.cancelled` — mapped to `.unreachable` | `.cancelled` propagates as `CancellationError`; other failures map to a new `.transport` case | +| `ProxyEndpoint.baseURL` force-unwrapped a URL the initializer never validated | Failable initializer; the URL is built once and stored | +| Rounding produced `1000K` instead of promoting to `1.00M` | Promotion on rollover, with boundary tests at, below, and above every unit | +| No tests covered transport, auth, or privacy | `TransportSuite`: 14 cases over status mapping, 401 retry, cancellation, request shape, and body redaction | +| The executable-test amendment was not propagated | `020`, `030`, `040` now all reference `swift run --package-path app MenuBarCoreTests` | + +Live re-verification after the fixes covered all six providers, including Kimi's 5h+week +pair and Cursor's three windows. + ## Accept criteria 1. `swift run --package-path app MenuBarCoreTests` green, with the `002` payloads as diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index dc17ec7905..8124cc6a41 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -30,7 +30,7 @@ Implements the locked direction in `003`. Dials: `DESIGN_VARIANCE 2`, | `app/Sources/MenuBarApp/Theme.swift` | NEW | | `app/Sources/MenuBarCore/ProxySnapshot.swift` | NEW | | `app/Sources/MenuBarCore/PollingCoordinator.swift` | NEW | -| `app/Tests/MenuBarCoreTests/SnapshotStateTests.swift` | NEW | +| `app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift` | NEW | **AppKit, not SwiftUI.** SwiftUI in an `NSPopover` still fights sizing and first-responder behaviour, and this layout is a fixed-width column of rows — precisely what AppKit stack @@ -297,4 +297,4 @@ what the screenshot shows, then re-verify. Code review alone does not close this 7. Sparkline bar count equals `days.count`, not a hardcoded 24. 8. Each empty state above renders its defined copy, distinct from `loading`. 9. `swift run --package-path app OpenCodexMenuBar` shows the menu bar item and popover. -10. `swift test --package-path app` green. +10. `swift run --package-path app MenuBarCoreTests` green (see `010` build-time amendment). diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index 8837c96afc..a922f6f545 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -16,7 +16,7 @@ routes inventoried in `002` §4. | `app/Sources/MenuBarApp/Views/ActionBarView.swift` | MODIFY — wire Stop proxy | | `app/Sources/MenuBarApp/Views/ProviderListView.swift` | NEW — disclosure + toggles | | `app/Sources/MenuBarApp/Views/ConfirmSheet.swift` | NEW | -| `app/Tests/MenuBarCoreTests/ActionTests.swift` | NEW | +| `app/Sources/MenuBarCoreTests/ActionSuite.swift` | NEW | ## `ProxyClient` additions @@ -172,4 +172,4 @@ Stubbed `URLProtocol`: 3. The default provider's toggle is inert and explains why, using `/api/config`. 4. Failure paths surface a human sentence, never a raw body. 5. No `Process` / `NSTask` usage anywhere in `app/`. -6. `swift test --package-path app` green. +6. `swift run --package-path app MenuBarCoreTests` green. diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 59d8d6abd7..183204e046 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -142,7 +142,7 @@ catches the case where the archive is produced but empty. ```json "build:macos": "bash scripts/build-macos-app.sh", "package:macos": "bash scripts/package-macos-release.sh", -"test:macos": "swift test --package-path app" +"test:macos": "swift run --package-path app MenuBarCoreTests" ``` ## `.github/workflows/ci.yml` From a740ec8863ca823fdeff87bcc239bd6d070faad2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 04:08:33 +0900 Subject: [PATCH 07/61] fix(app): per-request 401 retry and pressure-based quota selection Round-2 code review found two real defects, both semantic rather than syntactic, and both proven with gated probes. - Concurrent initial 401s produced a false authorization failure. The actor suspends across each request, so two calls can both receive 401; the first loaded the key and retried while the second saw the global didAttemptCredentialLoad flag and threw .unauthorized even though a usable key now existed. Retry eligibility is now decided per request against the key that request actually sent, so a caller that started before the load still retries with it, and a caller that already used the current key does not loop. Actor isolation prevented data races here but not reentrancy. - The compact quota row preferred the longest horizon, which could hide the window actually blocking the user: 99% of a five-hour limit alongside 10% monthly rendered as a green 10%. Selection is now highest reported usage, with ties breaking toward the longer horizon since that one does not recover on its own. Live proof: Cursor's row moved from month=10% to API usage=42%, which the previous logic concealed. Regressions: a gated concurrent-401 case asserting two successes, exactly one credential load, and four total requests; plus pressure-selection cases for higher-short-window, tie-break, and unmeasured-window inputs. 51 -> 55 cases, all passing. Live re-verified across all six providers. --- app/Sources/MenuBarCore/ProxyClient.swift | 40 ++++++++++++++----- app/Sources/MenuBarCore/ProxyModels.swift | 36 ++++++++++++----- .../MenuBarCoreTests/ModelDecodingSuite.swift | 32 +++++++++++++++ .../MenuBarCoreTests/TransportSuite.swift | 26 ++++++++++++ .../010_phase1_core.md | 11 +++++ 5 files changed, 126 insertions(+), 19 deletions(-) diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 360ca44d53..0937c4f347 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -144,27 +144,47 @@ public actor ProxyClient { query: [URLQueryItem] = [], body: Body? ) async throws -> Data { + let keyAtStart = apiKey do { return try await perform(method: method, path: path, query: query, body: body) } catch ProxyError.unauthorized { // A loopback proxy needs no credential, so a 401 means this install is bound - // to a non-loopback host. Load the stored key once and retry exactly once — - // never a loop, so a stale key cannot spin. - guard !didAttemptCredentialLoad else { throw ProxyError.unauthorized } - didAttemptCredentialLoad = true - guard let stored = credentials.loadAPIKey(), !stored.isEmpty else { + // to a non-loopback host. + // + // Reentrancy matters here: the actor suspends across the request, so several + // calls can be in flight and all receive 401. Retry eligibility is therefore + // decided per request, against the key THAT request actually sent — not + // against a single global "already tried" flag. A concurrent caller that + // started before the key was loaded must still get to retry with it. + guard let key = try await credentialForRetry(after: keyAtStart) else { throw ProxyError.unauthorized } - apiKey = stored - return try await perform(method: method, path: path, query: query, body: body) + return try await perform(method: method, path: path, query: query, body: body, key: key) } } + /// The key to retry with, or `nil` when this request already used the current + /// credential (so retrying would repeat an identical, failing call). + private func credentialForRetry(after keyAtStart: String?) async throws -> String? { + // Another in-flight call already loaded a key this request did not use. + if let current = apiKey, current != keyAtStart { return current } + // This request already carried the newest key: a stale credential, not a + // missing one. Never loop. + if apiKey != nil, apiKey == keyAtStart { return nil } + + guard !didAttemptCredentialLoad else { return nil } + didAttemptCredentialLoad = true + guard let stored = credentials.loadAPIKey(), !stored.isEmpty else { return nil } + apiKey = stored + return stored + } + private func perform( method: String, path: String, query: [URLQueryItem], - body: Body? + body: Body?, + key: String? = nil ) async throws -> Data { guard var components = URLComponents( url: endpoint.baseURL.appendingPathComponent(path), @@ -176,7 +196,9 @@ public actor ProxyClient { var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = method == "GET" ? 4 : 6 - if let apiKey { request.setValue(apiKey, forHTTPHeaderField: "x-opencodex-api-key") } + if let credential = key ?? apiKey { + request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") + } if let body { request.setValue("application/json", forHTTPHeaderField: "content-type") request.httpBody = try? JSONEncoder().encode(body) diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index 0bf1b83257..765a2b1bb8 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -215,21 +215,37 @@ public extension QuotaReport { return windows } - /// The single window that best represents overall pressure, for the compact row. + /// The single window that best represents current pressure, for the compact row. /// - /// Precedence is longest-horizon-first (month, then week, then shorter windows): - /// a monthly cap is the one that actually stops work, while a 5h window recovers on - /// its own. Providers with no numeric window normalize to a nil percent so the UI - /// renders an em dash rather than a misleading zero. + /// Selection is **highest reported usage**, not longest horizon. Every window can + /// stop work: a provider at 99% of a five-hour limit and 10% of its monthly limit is + /// blocked right now, and showing the monthly 10% would paint that row green while + /// the user cannot make a request. Ties break toward the longer horizon, since that + /// is the one that will not recover on its own. + /// + /// Providers with no numeric window normalize to a nil percent so the UI renders an + /// em dash rather than a misleading zero. func normalized() -> NormalizedQuota { let name = label ?? provider let windows = normalizedWindows() - let preferred = - windows.first { $0.windowLabel == "month" && $0.hasPercent } - ?? windows.first { $0.windowLabel == "week" && $0.hasPercent } - ?? windows.first { $0.hasPercent } - ?? windows.first + // Longer horizons rank higher only as a tie-breaker. + func horizonRank(_ label: String) -> Int { + switch label { + case "month": return 3 + case "week": return 2 + case "5h": return 1 + default: return 0 + } + } + + let measured = windows.filter(\.hasPercent) + let preferred = measured.max { lhs, rhs in + let left = lhs.percent ?? 0 + let right = rhs.percent ?? 0 + if left != right { return left < right } + return horizonRank(lhs.windowLabel) < horizonRank(rhs.windowLabel) + } ?? windows.first return preferred ?? NormalizedQuota( provider: provider, providerLabel: name, percent: nil, diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index 6d865cba6c..5368100a2f 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -198,6 +198,38 @@ enum ModelDecodingSuite { t.equal(report.normalized().percent, 30) } + // Every window can stop work. A provider at 99% of a five-hour limit is blocked + // right now even if its monthly usage is 10%; picking the longer horizon would + // paint that row green while the user cannot make a request. + t.test("quotas: the compact row shows the window under the most pressure") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "5h") + t.equal(report.normalized().percent, 99) + t.equal(report.normalizedWindows().count, 2) + } + + t.test("quotas: equal pressure breaks toward the longer horizon") { + let json = """ + {"provider":"p","quota":{"fiveHourPercent":50,"fiveHourResetAt":1784928599718, + "weeklyPercent":50,"weeklyResetAt":1785265199718}} + """ + t.equal(try decode(QuotaReport.self, json).normalized().windowLabel, "week") + } + + t.test("quotas: a window reporting only a reset time does not outrank a measured one") { + let json = """ + {"provider":"p","quota":{"weeklyPercent":12,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"unmeasured","resetAt":1785265199718}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 12) + } + t.test("quotas: an absent quota normalizes to a nil percent") { let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized() t.isNil(normalized.percent, "percent") diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index ea0a2335c4..fe37c6aa19 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -269,5 +269,31 @@ enum TransportSuite { t.isNil(ProxyEndpoint(port: 70_000), "port 70000") t.equal(ProxyEndpoint(port: 10_100)?.baseURL.absoluteString, "http://127.0.0.1:10100") } + + // The actor suspends across each request, so several calls can be in flight and + // all receive 401. A single global "already tried" flag made the second caller + // fail even though the first had just loaded a usable key. + t.test("auth: concurrent initial 401s both succeed once a key is loaded") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + + let outcomes: [String] = sync { + async let first = try? await client.health().status + async let second = try? await client.health().status + let results = await [first, second] + return results.map { $0 ?? "error" } + } + + t.equal(outcomes.filter { $0 == "protected" }.count, 2, "both calls should succeed") + t.equal(counter.loads, 1, "credentials loaded exactly once") + t.equal(StubProtocol.recorded.count, 4, "two initial calls plus two retries") + } } } diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md index bdaa82b4ff..f6f8c854cb 100644 --- a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md @@ -435,6 +435,17 @@ verified against the live proxy or Apple documentation before being folded: Live re-verification after the fixes covered all six providers, including Kimi's 5h+week pair and Cursor's three windows. +### Round 2 (two blockers, both reentrancy/semantics rather than syntax) + +| Finding | Correction | +| --- | --- | +| Concurrent initial 401s: the actor suspends across each request, so two calls could both get 401; the first loaded a key and retried while the second saw the global `didAttemptCredentialLoad` flag and failed with `.unauthorized` despite a usable key now existing | Retry eligibility is decided **per request**, against the key that request actually sent. A caller that started before the load still retries with the newly available key; a caller that already used the current key does not loop | +| `normalized()` preferred the longest horizon, so a provider at 99% of a five-hour limit and 10% monthly rendered as a green 10% row while the user was actually blocked | The compact row now selects the **highest reported usage**, with ties breaking toward the longer horizon. Live proof: Cursor's compact row moved from `month=10%` to `API usage=42%` | + +Regression tests added for both: a gated concurrent-401 case asserting two successes, +one credential load, and four total requests; and pressure-selection cases covering +higher-short-window, tie-break, and unmeasured-window inputs. 51 -> 55 cases. + ## Accept criteria 1. `swift run --package-path app MenuBarCoreTests` green, with the `002` payloads as From bb2d8be69b7f2e85464fc61a39195054d39bf069 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 04:28:04 +0900 Subject: [PATCH 08/61] feat(app): add menu bar status item and popover UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (020_phase2_ui.md). AppKit rather than SwiftUI: this is a fixed-width column of rows, which stack views do without fighting NSPopover sizing. - ProxySnapshot is the single source the views render from, so no view invents its own loading flag. Five states, and every one carries a word beside its dot so meaning is never colour-only. - PollingCoordinator implements the 002 contract: 5s liveness always, heavy aggregation only while the popover is open, 30s backoff after three consecutive failures. Cancellation is not treated as a failure. - Theme derives from gui/src/styles.css but prefers AppKit semantic colours where they exist, since those also track increased-contrast and vibrancy. Numerics use monospaced digits so polling does not make digits jitter. - The menu bar glyph is a vector template image and carries state through fill and a notch, not colour: a coloured dot in the menu bar is the tell of an app that ignores the platform. - Quota rows show which window each percentage belongs to. Without it, 42% of Cursor's API-usage window and 42% of a month look identical. - A nil percent draws no bar at all, because a zero-width bar reads as "0% used" — a different fact from unknown. Visual verification drove three fixes that code review would not have caught: the sparkline rendered as wide slabs that read as a progress bar rather than a chart; the trend was centred and floated away from the columns it belongs to; and hidden sections left a large empty void because the view kept its initial 260pt instead of sizing to content. Screenshots of running, stopped, unauthorized, degraded, and empty were inspected in the real window server. UI moved into a MenuBarUI library so the visual-QA probe can build the same surface — an executable target cannot be imported. 55 -> 64 test cases. --- app/Package.swift | 7 +- app/Sources/MenuBarApp/main.swift | 33 +- .../MenuBarCore/PollingCoordinator.swift | 108 +++++++ app/Sources/MenuBarCore/ProxySnapshot.swift | 135 ++++++++ .../MenuBarCoreTests/SnapshotStateSuite.swift | 106 ++++++ app/Sources/MenuBarCoreTests/main.swift | 1 + app/Sources/MenuBarUI/AppDelegate.swift | 115 +++++++ .../MenuBarUI/PopoverViewController.swift | 197 ++++++++++++ app/Sources/MenuBarUI/StatusIcon.swift | 64 ++++ app/Sources/MenuBarUI/Theme.swift | 81 +++++ app/Sources/MenuBarUI/Views.swift | 303 ++++++++++++++++++ app/Sources/UIProbe/main.swift | 94 ++++++ 12 files changed, 1213 insertions(+), 31 deletions(-) create mode 100644 app/Sources/MenuBarCore/PollingCoordinator.swift create mode 100644 app/Sources/MenuBarCore/ProxySnapshot.swift create mode 100644 app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift create mode 100644 app/Sources/MenuBarUI/AppDelegate.swift create mode 100644 app/Sources/MenuBarUI/PopoverViewController.swift create mode 100644 app/Sources/MenuBarUI/StatusIcon.swift create mode 100644 app/Sources/MenuBarUI/Theme.swift create mode 100644 app/Sources/MenuBarUI/Views.swift create mode 100644 app/Sources/UIProbe/main.swift diff --git a/app/Package.swift b/app/Package.swift index 3371694ddf..0ea30d59a1 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -7,12 +7,16 @@ let package = Package( products: [ .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), + .executable(name: "UIProbe", targets: ["UIProbe"]), ], targets: [ .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), + // AppKit views live in a library so both the app and the visual-QA probe can + // build the same surface. An executable target cannot be imported. + .target(name: "MenuBarUI", dependencies: ["MenuBarCore"], path: "Sources/MenuBarUI"), .executableTarget( name: "MenuBarApp", - dependencies: ["MenuBarCore"], + dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/MenuBarApp" ), // An executable rather than a .testTarget: Xcode Command Line Tools ships @@ -23,6 +27,7 @@ let package = Package( dependencies: ["MenuBarCore"], path: "Sources/MenuBarCoreTests" ), + .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), ], swiftLanguageVersions: [.v5] ) diff --git a/app/Sources/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift index aa518aacf7..710ac8c51b 100644 --- a/app/Sources/MenuBarApp/main.swift +++ b/app/Sources/MenuBarApp/main.swift @@ -1,38 +1,11 @@ import AppKit -import MenuBarCore - -// Phase 1 entry point: registers a status item so the executable is launchable and -// verifiable via `swift run`. The popover UI lands in Phase 2 (020). +import MenuBarUI let app = NSApplication.shared +// .accessory keeps it out of the Dock; LSUIElement in Info.plist does the same for the +// packaged bundle, and this covers `swift run` during development. app.setActivationPolicy(.accessory) let delegate = AppDelegate() app.delegate = delegate app.run() - -final class AppDelegate: NSObject, NSApplicationDelegate { - private var statusItem: NSStatusItem? - - func applicationDidFinishLaunching(_ notification: Notification) { - let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - item.button?.title = "ocx" - item.button?.toolTip = "OpenCodex" - - let endpoint = ProxyDiscovery.resolve() - let menu = NSMenu() - menu.addItem( - withTitle: "Proxy: \(endpoint.display)", - action: nil, - keyEquivalent: "" - ) - menu.addItem(.separator()) - menu.addItem( - withTitle: "Quit OpenCodex", - action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q" - ) - item.menu = menu - statusItem = item - } -} diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift new file mode 100644 index 0000000000..9480384400 --- /dev/null +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Owns the refresh schedule and turns transport results into a `ProxySnapshot`. +/// +/// Polling is deliberately conservative. A menu bar app that hits a local server every +/// five seconds forever is a battery complaint waiting to happen, so heavy aggregation +/// endpoints are fetched only while the popover is open, and repeated failures back the +/// liveness tick off rather than hammering a proxy the user has stopped on purpose. +public actor PollingCoordinator { + public static let livenessInterval: TimeInterval = 5 + public static let heavyInterval: TimeInterval = 60 + public static let backoffInterval: TimeInterval = 30 + public static let backoffAfterFailures = 3 + + private let client: ProxyClient + private var snapshot: ProxySnapshot + private var popoverOpen = false + private var lastHeavyRefresh: Date? + private var observers: [UUID: @Sendable (ProxySnapshot) -> Void] = [:] + + public init(client: ProxyClient, endpoint: ProxyEndpoint) { + self.client = client + self.snapshot = ProxySnapshot(endpoint: endpoint) + } + + public var current: ProxySnapshot { snapshot } + + /// Interval until the next liveness tick, widened once failures pile up. + public var currentInterval: TimeInterval { + snapshot.consecutiveFailures >= Self.backoffAfterFailures + ? Self.backoffInterval + : Self.livenessInterval + } + + @discardableResult + public func observe(_ handler: @escaping @Sendable (ProxySnapshot) -> Void) -> UUID { + let token = UUID() + observers[token] = handler + handler(snapshot) + return token + } + + public func removeObserver(_ token: UUID) { observers[token] = nil } + + public func setPopoverOpen(_ open: Bool) async { + popoverOpen = open + if open { await refresh(includeHeavy: true) } + } + + /// One refresh cycle. Heavy endpoints are skipped unless the popover is open and the + /// heavy interval has elapsed. + public func refresh(includeHeavy: Bool = false) async { + do { + let health = try await client.health() + snapshot.state = .running(health) + snapshot.lastKnownStartCommand = health.manualStartCommand + snapshot.consecutiveFailures = 0 + snapshot.lastUpdated = Date() + } catch is CancellationError { + // The popover closed mid-flight. Not a proxy failure; leave state untouched. + return + } catch let error as ProxyError { + apply(error) + publish() + return + } catch { + apply(.transport) + publish() + return + } + + let heavyDue = includeHeavy || lastHeavyRefresh.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true + + if popoverOpen && heavyDue { + await refreshHeavy() + lastHeavyRefresh = Date() + } + + publish() + } + + private func refreshHeavy() async { + // Each read is independent: one failing endpoint must not blank the others. + if let usage = try? await client.usage(range: .sevenDays) { snapshot.usage = usage } + if let quotas = try? await client.quotas() { snapshot.quotas = quotas } + if let providers = try? await client.providers() { snapshot.providers = providers } + if let config = try? await client.config() { snapshot.defaultProvider = config.defaultProvider } + } + + private func apply(_ error: ProxyError) { + snapshot.consecutiveFailures += 1 + switch error { + case .unreachable: + snapshot.state = .unreachable + case .unauthorized: + snapshot.state = .unauthorized + case .http, .decoding, .transport: + snapshot.state = .degraded(error.userMessage) + } + } + + private func publish() { + let value = snapshot + for handler in observers.values { handler(value) } + } +} diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift new file mode 100644 index 0000000000..60bf608905 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Everything the UI can show, as one value. +/// +/// Views are pure functions of this snapshot, so no view invents its own loading flag or +/// decides independently whether data is missing. +public enum ProxyState: Equatable, Sendable { + /// First fetch in flight; nothing is known yet. + case loading + case running(StartupHealth) + /// Connection refused — the proxy is not running. + case unreachable + /// 401 with no usable credential. + case unauthorized + /// Reachable but erroring. The message is proxy-free human text. + case degraded(String) + + public var isRunning: Bool { + if case .running = self { return true } + return false + } + + /// Short label shown beside the status dot. Colour is never the only carrier of + /// meaning, so every state has a word. + public var title: String { + switch self { + case .loading: return "Checking…" + case .running: return "Running" + case .unreachable: return "Stopped" + case .unauthorized: return "Needs API key" + case .degraded: return "Degraded" + } + } + + public enum Tone: Sendable { case neutral, good, warning, bad } + + public var tone: Tone { + switch self { + case .loading: return .neutral + case .running(let health): return health.isProtected ? .good : .warning + case .unreachable: return .bad + case .unauthorized: return .warning + case .degraded: return .warning + } + } + + /// Secondary line under the title. + public var detail: String? { + switch self { + case .loading: + return nil + case .running(let health): + let parts = [health.status, health.protection] + .compactMap { $0 } + .filter { !$0.isEmpty && $0 != "none" } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + case .unreachable: + return "The proxy is not running." + case .unauthorized: + return "This proxy requires an API key." + case .degraded(let message): + return message + } + } +} + +/// What the user should do next. `loading` deliberately has none — there is nothing to +/// act on yet — but every other non-running state names one. +public enum NextAction: Equatable, Sendable { + case none + /// A command to run, shown as selectable text. The app never spawns processes. + case runCommand(String) + case addAPIKey + case retry +} + +public struct ProxySnapshot: Equatable, Sendable { + public var state: ProxyState + public var endpoint: ProxyEndpoint + public var usage: UsageReport? + public var quotas: [QuotaReport] + public var providers: [ProviderSummary] + public var defaultProvider: String? + public var lastUpdated: Date? + public var consecutiveFailures: Int + /// Remembered from the last successful health read, so a stopped proxy can still + /// tell the user the right start command for their install. + public var lastKnownStartCommand: String? + + public init( + state: ProxyState = .loading, + endpoint: ProxyEndpoint, + usage: UsageReport? = nil, + quotas: [QuotaReport] = [], + providers: [ProviderSummary] = [], + defaultProvider: String? = nil, + lastUpdated: Date? = nil, + consecutiveFailures: Int = 0, + lastKnownStartCommand: String? = nil + ) { + self.state = state + self.endpoint = endpoint + self.usage = usage + self.quotas = quotas + self.providers = providers + self.defaultProvider = defaultProvider + self.lastUpdated = lastUpdated + self.consecutiveFailures = consecutiveFailures + self.lastKnownStartCommand = lastKnownStartCommand + } + + public var nextAction: NextAction { + switch state { + case .loading: return .none + case .running: return .none + case .unreachable: + return .runCommand(lastKnownStartCommand ?? "ocx start") + case .unauthorized: return .addAPIKey + case .degraded: return .retry + } + } + + /// One normalized row per provider for the compact quota list. + public var quotaRows: [NormalizedQuota] { + quotas.map { $0.normalized() } + } + + /// Whether the metrics section should render its empty copy. `nil` means unknown, + /// which renders em dashes instead. + public var usageIsEmpty: Bool? { usage?.isEmptyOrUnknown } + + public func canToggle(_ provider: ProviderSummary) -> Bool { + provider.name != defaultProvider + } +} diff --git a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift new file mode 100644 index 0000000000..b9cb5d8146 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift @@ -0,0 +1,106 @@ +import Foundation +import MenuBarCore + +enum SnapshotStateSuite { + private static func health(_ status: String?, service: Bool = false) -> StartupHealth { + StartupHealth( + status: status, + protection: service ? "service" : "none", + serviceInstalled: service, + serviceEnabled: service + ) + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("state: every state has a word, so colour is never the only signal") { + let states: [ProxyState] = [ + .loading, .running(health("protected")), .unreachable, + .unauthorized, .degraded("boom"), + ] + for state in states { + t.expect(!state.title.isEmpty, "state \(state) must have a title") + } + t.equal(ProxyState.unreachable.title, "Stopped") + t.equal(ProxyState.unauthorized.title, "Needs API key") + } + + t.test("state: an unprotected but running proxy reads as a warning, not healthy") { + t.equal(ProxyState.running(health("protected")).tone, .good) + t.equal(ProxyState.running(health("at-risk")).tone, .warning) + t.equal(ProxyState.unreachable.tone, .bad) + } + + // loading is the one state with nothing to act on; every other non-running + // state must name a next step rather than dead-ending the user. + t.test("actions: loading has none, and every other non-running state names one") { + let loading = ProxySnapshot(state: .loading, endpoint: endpoint) + t.equal(loading.nextAction, NextAction.none) + + let unauthorized = ProxySnapshot(state: .unauthorized, endpoint: endpoint) + t.equal(unauthorized.nextAction, NextAction.addAPIKey) + + let degraded = ProxySnapshot(state: .degraded("x"), endpoint: endpoint) + t.equal(degraded.nextAction, NextAction.retry) + } + + t.test("actions: a stopped proxy offers the start command for its own install") { + let plain = ProxySnapshot(state: .unreachable, endpoint: endpoint) + t.equal(plain.nextAction, NextAction.runCommand("ocx start")) + + let managed = ProxySnapshot( + state: .unreachable, endpoint: endpoint, + lastKnownStartCommand: "ocx service start" + ) + t.equal(managed.nextAction, NextAction.runCommand("ocx service start")) + } + + t.test("state: the running detail line drops empty and 'none' qualifiers") { + let protectedDetail = ProxyState.running(health("protected", service: true)).detail + t.equal(protectedDetail, "protected · service") + // protection "none" is noise, not information. + t.equal(ProxyState.running(health("at-risk")).detail, "at-risk") + } + + t.test("snapshot: quota rows normalize one row per provider") { + let json = """ + [{"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}}] + """ + let quotas = try JSONDecoder().decode([QuotaReport].self, from: Data(json.utf8)) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, quotas: quotas) + t.equal(snapshot.quotaRows.count, 1) + t.equal(snapshot.quotaRows[0].windowLabel, "5h") + } + + t.test("snapshot: the default provider cannot be toggled") { + let providers = try JSONDecoder().decode( + [ProviderSummary].self, + from: Data(#"[{"name":"openai"},{"name":"anthropic"}]"#.utf8) + ) + let snapshot = ProxySnapshot( + state: .running(health("protected")), endpoint: endpoint, + providers: providers, defaultProvider: "openai" + ) + t.equal(snapshot.canToggle(providers[0]), false, "default provider") + t.equal(snapshot.canToggle(providers[1]), true, "non-default provider") + } + + t.test("snapshot: an omitted usage count stays unknown rather than empty") { + let usage = try JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"7d","summary":{"totalTokens":5}}"#.utf8) + ) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, usage: usage) + t.isNil(snapshot.usageIsEmpty, "usageIsEmpty for an omitted count") + } + + t.test("polling: the interval backs off only after repeated failures") { + t.equal(PollingCoordinator.livenessInterval, 5) + t.equal(PollingCoordinator.heavyInterval, 60) + t.equal(PollingCoordinator.backoffInterval, 30) + t.equal(PollingCoordinator.backoffAfterFailures, 3) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift index 25430f2749..f7e793c7ec 100644 --- a/app/Sources/MenuBarCoreTests/main.swift +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -9,5 +9,6 @@ DiscoverySuite.run(runner) ModelDecodingSuite.run(runner) FormattingSuite.run(runner) TransportSuite.run(runner) +SnapshotStateSuite.run(runner) exit(runner.summarize()) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift new file mode 100644 index 0000000000..69a631d8d6 --- /dev/null +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -0,0 +1,115 @@ +import AppKit +import MenuBarCore + +public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { + private var statusItem: NSStatusItem? + private let popover = NSPopover() + private let controller = PopoverViewController() + private var coordinator: PollingCoordinator? + private var client: ProxyClient? + private var endpoint = ProxyEndpoint.default + private var pollTask: Task? + + public override init() { super.init() } + + public func applicationDidFinishLaunching(_ notification: Notification) { + endpoint = ProxyDiscovery.resolve() + let client = ProxyClient(endpoint: endpoint) + self.client = client + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + self.coordinator = coordinator + + let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + item.button?.image = StatusIcon.image(for: .loading) + item.button?.imagePosition = .imageOnly + item.button?.target = self + item.button?.action = #selector(togglePopover) + item.button?.setAccessibilityLabel("OpenCodex proxy status") + statusItem = item + + controller.onDashboard = { [weak self] in self?.openDashboard() } + controller.onStop = { [weak self] in self?.stopProxy() } + controller.onRefresh = { [weak self] in self?.refreshNow() } + controller.onQuit = { NSApp.terminate(nil) } + + popover.contentViewController = controller + popover.behavior = .transient + popover.delegate = self + // MOTION_INTENSITY 1: no decorative animation, and none at all under reduce-motion. + popover.animates = !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + + // The observer closure is `@Sendable` and crosses actor boundaries, so it must + // not capture the delegate. It hops to the main actor and looks the delegate up + // there instead. + Task { + await coordinator.observe { snapshot in + Task { @MainActor in + (NSApp.delegate as? AppDelegate)?.render(snapshot) + } + } + await MainActor.run { (NSApp.delegate as? AppDelegate)?.startPolling() } + } + } + + public func applicationWillTerminate(_ notification: Notification) { + pollTask?.cancel() + } + + // MARK: - Polling + + @MainActor + fileprivate func startPolling() { + guard let coordinator else { return } + pollTask?.cancel() + pollTask = Task { + while !Task.isCancelled { + await coordinator.refresh() + let interval = await coordinator.currentInterval + try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000)) + } + } + } + + private func refreshNow() { + Task { [coordinator] in await coordinator?.refresh(includeHeavy: true) } + } + + @MainActor + fileprivate func render(_ snapshot: ProxySnapshot) { + statusItem?.button?.image = StatusIcon.image(for: snapshot.state) + statusItem?.button?.toolTip = "OpenCodex — \(snapshot.state.title) (\(snapshot.endpoint.display))" + controller.apply(snapshot) + } + + // MARK: - Actions + + @objc private func togglePopover() { + guard let button = statusItem?.button else { return } + if popover.isShown { + popover.performClose(nil) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + public func popoverDidShow(_ notification: Notification) { + Task { [coordinator] in await coordinator?.setPopoverOpen(true) } + } + + public func popoverDidClose(_ notification: Notification) { + Task { [coordinator] in await coordinator?.setPopoverOpen(false) } + } + + private func openDashboard() { + NSWorkspace.shared.open(endpoint.baseURL) + } + + /// Wired fully in Phase 3; the confirmation sheet and result handling land there. + private func stopProxy() { + Task { [client, coordinator] in + try? await client?.stop() + await coordinator?.refresh() + } + } +} diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift new file mode 100644 index 0000000000..a9c4dd1ece --- /dev/null +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -0,0 +1,197 @@ +import AppKit +import MenuBarCore + +/// The popover body: one scroll-free column ordered by urgency. +/// +/// Deliberately not a tab bar. A menu bar popover is a glance surface, and tabs would put +/// the answer to "is it fine?" one click away three times out of four. +public final class PopoverViewController: NSViewController { + public override init(nibName: NSNib.Name?, bundle: Bundle?) { super.init(nibName: nibName, bundle: bundle) } + public required init?(coder: NSCoder) { nil } + + private let header = StatusHeaderView() + private let metrics = MetricsView() + private let sparkline = SparklineView() + private let quotaStack = NSStackView() + private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) + private let actionLabel = makeLabel("", font: Theme.caption, color: Theme.muted) + private let commandField = NSTextField(labelWithString: "") + private let dashboardButton = NSButton() + private let stopButton = NSButton() + private let overflowButton = NSButton() + private let metricsSeparator = makeSeparator() + private let quotaSeparator = makeSeparator() + + public var onDashboard: (() -> Void)? + public var onStop: (() -> Void)? + public var onQuit: (() -> Void)? + + private var snapshot: ProxySnapshot? + + public override func loadView() { + let root = NSView(frame: NSRect(x: 0, y: 0, width: Theme.width, height: 260)) + + quotaStack.orientation = .vertical + quotaStack.alignment = .leading + quotaStack.spacing = Theme.tightGap + + configureButtons() + commandField.font = Theme.numericSmall + commandField.textColor = Theme.text + commandField.isSelectable = true + commandField.isBordered = false + commandField.backgroundColor = .clear + + let actions = makeRow([dashboardButton, stopButton, NSView(), overflowButton]) + actions.alignment = .centerY + + let column = NSStackView(views: [ + header, + makeSeparator(), + metrics, + sparkline, + metricsSeparator, + quotaStack, + quotaEmpty, + quotaSeparator, + actionLabel, + commandField, + actions, + ]) + column.orientation = .vertical + column.alignment = .leading + column.spacing = Theme.rowGap + column.edgeInsets = NSEdgeInsets( + top: Theme.gutter, left: Theme.gutter, + bottom: Theme.gutter, right: Theme.gutter + ) + column.translatesAutoresizingMaskIntoConstraints = false + root.addSubview(column) + + NSLayoutConstraint.activate([ + column.topAnchor.constraint(equalTo: root.topAnchor), + column.leadingAnchor.constraint(equalTo: root.leadingAnchor), + column.trailingAnchor.constraint(equalTo: root.trailingAnchor), + column.bottomAnchor.constraint(equalTo: root.bottomAnchor), + root.widthAnchor.constraint(equalToConstant: Theme.width), + ]) + + // Let the column drive the height so hidden sections actually shrink the + // popover. Without this the view keeps its initial 260pt and a stopped proxy + // renders a large empty void under the status line. + column.setHuggingPriority(.required, for: .vertical) + column.setContentCompressionResistancePriority(.required, for: .vertical) + + for view in [header, metrics, sparkline, quotaStack, actions] { + view.translatesAutoresizingMaskIntoConstraints = false + view.widthAnchor.constraint(equalTo: column.widthAnchor, constant: -Theme.gutter * 2).isActive = true + } + + view = root + } + + private func configureButtons() { + for (button, title) in [(dashboardButton, "Dashboard"), (stopButton, "Stop proxy")] { + button.title = title + button.bezelStyle = .rounded + button.controlSize = .small + button.font = Theme.caption + button.target = self + } + dashboardButton.action = #selector(dashboardTapped) + stopButton.action = #selector(stopTapped) + + overflowButton.title = "···" + overflowButton.bezelStyle = .rounded + overflowButton.controlSize = .small + overflowButton.font = Theme.caption + overflowButton.target = self + overflowButton.action = #selector(overflowTapped) + overflowButton.setAccessibilityLabel("More actions") + } + + public func apply(_ snapshot: ProxySnapshot) { + self.snapshot = snapshot + header.apply(snapshot) + metrics.apply(snapshot) + sparkline.apply(snapshot) + applyQuotas(snapshot) + applyAction(snapshot) + + // Metrics and quotas are meaningless when the proxy is not answering. + let live = snapshot.state.isRunning + metrics.isHidden = !live + metricsSeparator.isHidden = !live + quotaSeparator.isHidden = !live + if !live { + sparkline.isHidden = true + quotaStack.isHidden = true + quotaEmpty.isHidden = true + } + stopButton.isEnabled = live + + view.layoutSubtreeIfNeeded() + preferredContentSize = NSSize(width: Theme.width, height: view.fittingSize.height) + } + + private func applyQuotas(_ snapshot: ProxySnapshot) { + for view in quotaStack.arrangedSubviews { quotaStack.removeArrangedSubview(view); view.removeFromSuperview() } + let rows = snapshot.quotaRows + quotaStack.isHidden = rows.isEmpty + quotaEmpty.isHidden = !(rows.isEmpty && snapshot.state.isRunning) + for quota in rows { + let row = QuotaRowView(quota: quota) + row.translatesAutoresizingMaskIntoConstraints = false + quotaStack.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: quotaStack.widthAnchor).isActive = true + } + } + + private func applyAction(_ snapshot: ProxySnapshot) { + switch snapshot.nextAction { + case .none: + actionLabel.isHidden = true + commandField.isHidden = true + case .runCommand(let command): + actionLabel.isHidden = false + actionLabel.stringValue = "Start it again with:" + commandField.isHidden = false + commandField.stringValue = command + commandField.setAccessibilityLabel("Start command: \(command)") + case .addAPIKey: + actionLabel.isHidden = false + actionLabel.stringValue = "Add an API key in the dashboard to continue." + commandField.isHidden = true + case .retry: + actionLabel.isHidden = false + let age = Format.age(snapshot.lastUpdated) + actionLabel.stringValue = "Showing data from \(age). Retrying automatically." + commandField.isHidden = true + } + } + + @objc private func dashboardTapped() { onDashboard?() } + @objc private func stopTapped() { onStop?() } + + @objc private func overflowTapped() { + let menu = NSMenu() + menu.addItem(withTitle: "Refresh", action: #selector(refreshTapped), keyEquivalent: "r").target = self + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit OpenCodex", action: #selector(quitTapped), keyEquivalent: "q").target = self + menu.popUp(positioning: nil, at: NSPoint(x: 0, y: overflowButton.bounds.height + 4), in: overflowButton) + } + + @objc private func refreshTapped() { onRefresh?() } + @objc private func quitTapped() { onQuit?() } + + public var onRefresh: (() -> Void)? + + public override func keyDown(with event: NSEvent) { + // Escape closes, per the keyboard contract. + if event.keyCode == 53 { + view.window?.close() + return + } + super.keyDown(with: event) + } +} diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift new file mode 100644 index 0000000000..436f3e30e7 --- /dev/null +++ b/app/Sources/MenuBarUI/StatusIcon.swift @@ -0,0 +1,64 @@ +import AppKit +import MenuBarCore + +/// The menu bar glyph. +/// +/// Drawn as vector paths rather than shipped as PNGs, so it stays crisp at every scale +/// factor and inverts correctly as a template image. +/// +/// Colour is deliberately absent here. macOS menu bar items are monochrome by +/// convention, and a coloured dot up there is the tell of an app that does not respect +/// the platform. State is carried by fill and by a notch instead. The coloured dot lives +/// inside the popover, where it sits beside a word and so never encodes meaning by +/// colour alone. +enum StatusIcon { + static let size = NSSize(width: 17, height: 17) + + static func image(for state: ProxyState) -> NSImage { + switch state { + case .running(let health) where health.isProtected: + return mark(filled: true, notched: false, alpha: 1) + case .running: + return mark(filled: true, notched: true, alpha: 1) + case .loading, .degraded: + return mark(filled: false, notched: false, alpha: 1) + case .unreachable, .unauthorized: + return mark(filled: false, notched: false, alpha: 0.4) + } + } + + /// A rounded square bracket pair — the OpenCodex mark reduced to menu bar scale. + private static func mark(filled: Bool, notched: Bool, alpha: CGFloat) -> NSImage { + let image = NSImage(size: size, flipped: false) { rect in + let inset = rect.insetBy(dx: 2.5, dy: 2.5) + let path = NSBezierPath(roundedRect: inset, xRadius: 4, yRadius: 4) + path.lineWidth = 1.6 + + NSColor.black.withAlphaComponent(alpha).setStroke() + NSColor.black.withAlphaComponent(alpha).setFill() + + if filled { + path.fill() + } else { + path.stroke() + } + + if notched { + // A single carved notch marks "running but unprotected" without colour. + let notch = NSBezierPath() + let midY = inset.midY + notch.move(to: NSPoint(x: inset.maxX - 3.5, y: midY)) + notch.line(to: NSPoint(x: inset.maxX + 0.5, y: midY)) + notch.lineWidth = 2.4 + NSColor.clear.setStroke() + // Erase rather than draw: the notch must read as a gap in the mark. + NSGraphicsContext.current?.compositingOperation = .clear + notch.stroke() + NSGraphicsContext.current?.compositingOperation = .sourceOver + } + return true + } + image.isTemplate = true + return image + } +} diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift new file mode 100644 index 0000000000..516f806f1c --- /dev/null +++ b/app/Sources/MenuBarUI/Theme.swift @@ -0,0 +1,81 @@ +import AppKit + +/// Tokens derived from `gui/src/styles.css` so the companion and the dashboard agree on +/// what "healthy" looks like. +/// +/// Where AppKit already has a semantic colour, it wins over a hardcoded hex: it tracks +/// light/dark *and* the increased-contrast and vibrancy accessibility settings, which a +/// literal cannot. +enum Theme { + // Surfaces + static let separator = NSColor.separatorColor + static let raised = NSColor.controlBackgroundColor + + // Text: --text / --muted / --faint + static let text = NSColor.labelColor + static let muted = NSColor.secondaryLabelColor + static let faint = NSColor.tertiaryLabelColor + + // State colours, taken verbatim from styles.css. + static let green = dynamic(light: 0x0A7D5C, dark: 0x4ECB9D) + static let amber = dynamic(light: 0x9A4A08, dark: 0xFBBF24) + static let red = dynamic(light: 0xB91C1C, dark: 0xF87171) + + // Type ladder: --text-micro / --text-caption / --text-label / --text-control. + static let micro = NSFont.systemFont(ofSize: 10, weight: .medium) + static let caption = NSFont.systemFont(ofSize: 11) + static let label = NSFont.systemFont(ofSize: 12, weight: .semibold) + /// Monospaced digits are the AppKit equivalent of `font-variant-numeric: tabular-nums`. + /// Without this, polling makes every digit jitter. + static let numeric = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) + static let numericSmall = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .regular) + + // Geometry: --space-* and --radius-sm. + static let gutter: CGFloat = 12 + static let rowGap: CGFloat = 8 + static let tightGap: CGFloat = 4 + static let radius: CGFloat = 8 + static let width: CGFloat = 340 + + static func color(for tone: ProxyToneBridge) -> NSColor { + switch tone { + case .neutral: return muted + case .good: return green + case .warning: return amber + case .bad: return red + } + } + + /// Quota fill: green under 80, amber to 95, red above. The percentage is always + /// printed beside the bar, so colour is reinforcement rather than the only signal. + static func quotaColor(percent: Double?) -> NSColor { + guard let percent else { return faint } + if percent > 95 { return red } + if percent >= 80 { return amber } + return green + } + + /// `light-dark()` equivalent: resolves per appearance instead of at creation time. + private static func dynamic(light: Int, dark: Int) -> NSColor { + NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return NSColor(hex: isDark ? dark : light) + } + } +} + +/// Mirrors `ProxyState.Tone` without importing AppKit into the core module. +enum ProxyToneBridge { + case neutral, good, warning, bad +} + +extension NSColor { + convenience init(hex: Int) { + self.init( + srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, + green: CGFloat((hex >> 8) & 0xFF) / 255, + blue: CGFloat(hex & 0xFF) / 255, + alpha: 1 + ) + } +} diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift new file mode 100644 index 0000000000..b258122619 --- /dev/null +++ b/app/Sources/MenuBarUI/Views.swift @@ -0,0 +1,303 @@ +import AppKit +import MenuBarCore + +// MARK: - Shared helpers + +func makeLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField { + let field = NSTextField(labelWithString: text) + field.font = font + field.textColor = color + field.lineBreakMode = .byTruncatingTail + return field +} + +func makeRow(_ views: [NSView], spacing: CGFloat = Theme.rowGap) -> NSStackView { + let stack = NSStackView(views: views) + stack.orientation = .horizontal + stack.spacing = spacing + stack.alignment = .firstBaseline + return stack +} + +func makeSeparator() -> NSView { + let line = NSView() + line.wantsLayer = true + line.layer?.backgroundColor = Theme.separator.cgColor + line.translatesAutoresizingMaskIntoConstraints = false + line.heightAnchor.constraint(equalToConstant: 1).isActive = true + return line +} + +// MARK: - Status header + +/// `● Running 127.0.0.1:10100` +/// +/// The dot never travels alone: the word beside it carries the same meaning, so the UI +/// stays readable without colour perception (WCAG 1.4.1). +final class StatusHeaderView: NSView { + private let dot = StatusDotView() + private let title = makeLabel("", font: Theme.label, color: Theme.text) + private let endpoint = makeLabel("", font: Theme.caption, color: Theme.muted) + private let detail = makeLabel("", font: Theme.caption, color: Theme.muted) + + init() { + super.init(frame: .zero) + let top = makeRow([dot, title, NSView(), endpoint], spacing: Theme.rowGap) + top.alignment = .centerY + top.distribution = .fill + endpoint.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let stack = NSStackView(views: [top, detail]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 2 + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + let state = snapshot.state + title.stringValue = state.title + endpoint.stringValue = snapshot.endpoint.display + dot.tone = bridge(state.tone) + + if let text = state.detail { + detail.stringValue = text + detail.isHidden = false + } else { + detail.isHidden = true + } + + setAccessibilityLabel("Proxy \(state.title) at \(snapshot.endpoint.display)") + } + + private func bridge(_ tone: ProxyState.Tone) -> ProxyToneBridge { + switch tone { + case .neutral: return .neutral + case .good: return .good + case .warning: return .warning + case .bad: return .bad + } + } +} + +final class StatusDotView: NSView { + var tone: ProxyToneBridge = .neutral { + didSet { needsDisplay = true } + } + + override var intrinsicContentSize: NSSize { NSSize(width: 8, height: 8) } + + override func draw(_ dirtyRect: NSRect) { + let rect = NSRect(x: 0, y: (bounds.height - 8) / 2, width: 8, height: 8) + Theme.color(for: tone).setFill() + NSBezierPath(ovalIn: rect).fill() + } +} + +// MARK: - Metrics + +/// Three columns plus a range header that echoes the response, never the request. +final class MetricsView: NSView { + private let rangeLabel = makeLabel("USAGE", font: Theme.micro, color: Theme.faint) + private let columns: [(caption: NSTextField, value: NSTextField)] + private let emptyLabel = makeLabel("", font: Theme.caption, color: Theme.muted) + private let stack: NSStackView + private let columnsRow: NSStackView + + init() { + let captions = ["REQUESTS", "TOKENS", "COST"] + columns = captions.map { caption in + (makeLabel(caption, font: Theme.micro, color: Theme.faint), + makeLabel(Format.unknown, font: Theme.numeric, color: Theme.text)) + } + + let columnViews: [NSView] = columns.map { pair in + let column = NSStackView(views: [pair.caption, pair.value]) + column.orientation = .vertical + column.alignment = .leading + column.spacing = 1 + return column + } + columnsRow = NSStackView(views: columnViews) + columnsRow.orientation = .horizontal + columnsRow.distribution = .fillEqually + columnsRow.alignment = .top + + stack = NSStackView(views: [rangeLabel, columnsRow, emptyLabel]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = Theme.tightGap + + super.init(frame: .zero) + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), + stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + let usage = snapshot.usage + rangeLabel.stringValue = usage?.rangeLabel ?? "USAGE" + + // Three states: known-empty gets copy, unknown gets em dashes, data gets values. + switch snapshot.usageIsEmpty { + case .some(true): + columnsRow.isHidden = true + emptyLabel.isHidden = false + emptyLabel.stringValue = "No requests in this period." + default: + columnsRow.isHidden = false + emptyLabel.isHidden = true + let summary = usage?.summary + let requests = Format.count(summary?.requests) + columns[0].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests + columns[1].value.stringValue = Format.tokens(summary?.totalTokens) + columns[2].value.stringValue = Format.cost(summary?.estimatedCostUsd) + columns[0].value.setAccessibilityLabel( + (summary?.hasEstimates ?? false) + ? "\(requests) requests, partly estimated" + : "\(requests) requests" + ) + } + } +} + +/// Day-granular usage trend. Not "activity" — per-request logs are a different surface. +final class SparklineView: NSView { + private var values: [Int] = [] + private let caption = makeLabel("", font: Theme.micro, color: Theme.faint) + + override var intrinsicContentSize: NSSize { NSSize(width: NSView.noIntrinsicMetric, height: 22) } + + func apply(_ snapshot: ProxySnapshot) { + values = (snapshot.usage?.days ?? []).map { $0.requests ?? 0 } + isHidden = values.isEmpty || values.allSatisfy { $0 == 0 } + setAccessibilityLabel("Usage trend over \(values.count) days") + needsDisplay = true + } + + override func draw(_ dirtyRect: NSRect) { + guard !values.isEmpty, let peak = values.max(), peak > 0 else { return } + // Narrow bars with generous gaps read as a chart; wide slabs read as a progress + // bar. Cap the width so a short series does not stretch into blocks. + let count = CGFloat(values.count) + let gap: CGFloat = 4 + let available = bounds.width - gap * (count - 1) + let barWidth = min(14, max(2, available / count)) + // Left-aligned so the trend sits under the metric columns it belongs to. + // Centering it would float the chart away from its own labels. + let originX: CGFloat = 0 + + for (index, value) in values.enumerated() { + // A floor of 2pt keeps a low-but-nonzero day visible; a true zero draws + // nothing, so "quiet" and "none" stay distinguishable. + let ratio = CGFloat(value) / CGFloat(peak) + guard value > 0 else { continue } + let height = max(2, bounds.height * ratio) + let rect = NSRect( + x: originX + CGFloat(index) * (barWidth + gap), + y: 0, + width: barWidth, + height: height + ) + // The most recent day is the one being asked about, so it carries full + // weight while history recedes. + let isLatest = index == values.count - 1 + (isLatest ? Theme.muted : Theme.faint).setFill() + NSBezierPath(roundedRect: rect, xRadius: 1.5, yRadius: 1.5).fill() + } + } +} + +// MARK: - Quotas + +/// `OpenAI ▓▓▓▓▓░░░░░ 44%` +final class QuotaRowView: NSView { + init(quota: NormalizedQuota) { + super.init(frame: .zero) + + let name = makeLabel(quota.providerLabel, font: Theme.caption, color: Theme.text) + name.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + name.lineBreakMode = .byTruncatingTail + + // Which window a number belongs to is not decoration: 42% of an API-usage window + // and 42% of a month mean very different things. + let window = makeLabel( + quota.hasPercent ? quota.windowLabel : "", + font: Theme.micro, color: Theme.faint + ) + + let labels = NSStackView(views: [name, window]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 0 + + let bar = QuotaBarView() + bar.percent = quota.percent + + let value = makeLabel(Format.percent(quota.percent), font: Theme.numericSmall, color: Theme.muted) + value.alignment = .right + + let row = NSStackView(views: [labels, bar, value]) + row.orientation = .horizontal + row.spacing = Theme.rowGap + row.alignment = .centerY + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: topAnchor), + row.leadingAnchor.constraint(equalTo: leadingAnchor), + row.trailingAnchor.constraint(equalTo: trailingAnchor), + row.bottomAnchor.constraint(equalTo: bottomAnchor), + labels.widthAnchor.constraint(equalToConstant: 132), + value.widthAnchor.constraint(equalToConstant: 36), + ]) + + // The percentage is spoken, not merely drawn as a filled width. + let reset = Format.resetsIn(quota.resetAt) + setAccessibilityLabel( + quota.hasPercent + ? "\(quota.providerLabel): \(Format.percent(quota.percent)) of \(quota.windowLabel) quota, resets in \(reset)" + : "\(quota.providerLabel): quota unknown" + ) + } + + required init?(coder: NSCoder) { nil } +} + +final class QuotaBarView: NSView { + var percent: Double? + + override var intrinsicContentSize: NSSize { NSSize(width: 110, height: 6) } + + override func draw(_ dirtyRect: NSRect) { + let track = NSRect(x: 0, y: (bounds.height - 6) / 2, width: bounds.width, height: 6) + Theme.raised.setFill() + NSBezierPath(roundedRect: track, xRadius: 3, yRadius: 3).fill() + + // A nil percent draws no fill at all — a zero-width bar would read as "0% used", + // which is a different fact from "unknown". + guard let percent else { return } + let clamped = max(0, min(100, percent)) + guard clamped > 0 else { return } + let fill = NSRect(x: 0, y: track.origin.y, width: track.width * CGFloat(clamped / 100), height: 6) + Theme.quotaColor(percent: percent).setFill() + NSBezierPath(roundedRect: fill, xRadius: 3, yRadius: 3).fill() + } +} diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift new file mode 100644 index 0000000000..0d254e84af --- /dev/null +++ b/app/Sources/UIProbe/main.swift @@ -0,0 +1,94 @@ +// Visual-QA harness (not shipped). +// +// Renders the popover in a plain window and screenshots it through the window server, so +// every UI state can be inspected without depending on free menu bar space. Set +// PROBE_STATE to live | stopped | unauthorized | loading | degraded | empty and +// PROBE_TAG to name the output file. +// +// Capturing via `screencapture -l ` rather than cacheDisplay(in:to:) is +// deliberate: the bitmap-rep path skips text rendering and produced a screenshot with no +// labels at all. + +import AppKit +import MenuBarCore +import MenuBarUI + +// Renders the popover in a plain window and screenshots it, so the UI can be inspected +// without depending on menu bar space being available. +final class ProbeDelegate: NSObject, NSApplicationDelegate { + let controller = PopoverViewController() + var window: NSWindow? + + func applicationDidFinishLaunching(_ n: Notification) { + let endpoint = ProxyDiscovery.resolve() + let client = ProxyClient(endpoint: endpoint) + let coordinator = PollingCoordinator(client: client, endpoint: endpoint) + + let w = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 300), + styleMask: [.titled], backing: .buffered, defer: false) + w.title = "OpenCodex popover probe" + w.contentViewController = controller + w.center() + w.makeKeyAndOrderFront(nil) + window = w + NSApp.activate(ignoringOtherApps: true) + + Task { + var snap: ProxySnapshot + let mode = ProcessInfo.processInfo.environment["PROBE_STATE"] ?? "live" + switch mode { + case "stopped": + snap = ProxySnapshot(state: .unreachable, endpoint: endpoint, + lastKnownStartCommand: "ocx service start") + case "unauthorized": + snap = ProxySnapshot(state: .unauthorized, endpoint: endpoint) + case "loading": + snap = ProxySnapshot(state: .loading, endpoint: endpoint) + case "degraded": + snap = ProxySnapshot(state: .degraded("The proxy returned an unexpected status (503)."), + endpoint: endpoint, lastUpdated: Date().addingTimeInterval(-120)) + case "empty": + let usage = try? JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"7d","summary":{"requests":0},"days":[]}"#.utf8)) + snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), + endpoint: endpoint, usage: usage, quotas: [], providers: []) + default: + await coordinator.setPopoverOpen(true) + snap = await coordinator.current + } + await MainActor.run { + self.controller.apply(snap) + self.controller.view.layoutSubtreeIfNeeded() + // Match the real popover: size to content instead of a fixed frame. + let h = self.controller.preferredContentSize.height + if h > 0, let w = self.window { + w.setContentSize(NSSize(width: 340, height: h)) + } + } + try? await Task.sleep(nanoseconds: 1_200_000_000) + await MainActor.run { self.capture() } + } + } + + @MainActor func capture() { + guard let w = window else { return } + let tag = ProcessInfo.processInfo.environment["PROBE_TAG"] ?? "light" + // Capture the real window through the window server: caching the view's bitmap + // rep skips text rendering, which produced a screenshot with no labels at all. + let id = CGWindowID(w.windowNumber) + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + task.arguments = ["-x", "-o", "-l", String(id), "/tmp/popover-\(tag).png"] + try? task.run() + task.waitUntilExit() + NSApp.terminate(nil) + } +} + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +let d = ProbeDelegate() +app.delegate = d +app.run() From 18c2b3990159fbb0773faa18bc3b2395ebbdcde3 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 04:46:01 +0900 Subject: [PATCH 09/61] =?UTF-8?q?fix(app):=20fold=20UI=20review=20blockers?= =?UTF-8?q?=20=E2=80=94=20states,=20keyboard,=20polling,=20glyph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review rendered every state and returned FAIL on 9 findings. - Stop proxy now confirms first. It interrupts in-flight requests and stops launchd, so firing it on a single click was wrong. - Escape did not work at all: an accessory app never takes key focus, so keyDown never arrived. Now activates on open, sets a first responder, and installs a scoped key monitor that is removed on close. - Loading, unauthorized, and degraded were specified but not built. Loading shows skeleton rows with disabled chrome; unauthorized has a real Add key button; degraded keeps its last-known data with an explicit age plus Retry, because stale-but-labelled beats a blank panel. - Polling split into on-open reads (providers, config) and interval-gated aggregation (usage, quotas). Previously every open forced aggregation while background ticks fetched on-open data — exactly backwards. - Refreshes can no longer overlap or outlive a close: one in-flight cycle, a generation counter that discards superseded results, and freshness advanced only when the aggregation actually completed. - The at-risk notch never rendered. Stroking with .clear under a .clear composite silently did nothing, so a protected and an at-risk proxy showed an identical glyph — the state signal was invisible. Carved with even-odd winding and verified against a rendered glyph sheet. - recommendedCommand was decoded but never displayed; the live proxy has been advising ocx service install this whole time. Now shown as selectable text, alongside a provider summary line. - Popover height is capped at 480pt with a scrolling body, and scrollers appear only on real overflow. - PollingSuite replaces a test that asserted four constants: gating, cadence, backoff, recovery, degraded retention, and observer delivery. 64 -> 73. UIProbe captures via CGWindowListCreateImage so nothing under app/ constructs a Process, per the 030 security rule. --- app/Package.swift | 2 + app/Sources/IconProbe/main.swift | 32 ++ .../MenuBarCore/PollingCoordinator.swift | 86 ++++- app/Sources/MenuBarCore/ProxySnapshot.swift | 32 +- .../MenuBarCoreTests/PollingSuite.swift | 197 ++++++++++++ app/Sources/MenuBarCoreTests/main.swift | 1 + app/Sources/MenuBarUI/AppDelegate.swift | 52 ++- .../MenuBarUI/PopoverViewController.swift | 296 +++++++++++++----- app/Sources/MenuBarUI/StatusIcon.swift | 47 +-- app/Sources/UIProbe/main.swift | 18 +- .../260725_macos_menubar_app/020_phase2_ui.md | 20 ++ 11 files changed, 658 insertions(+), 125 deletions(-) create mode 100644 app/Sources/IconProbe/main.swift create mode 100644 app/Sources/MenuBarCoreTests/PollingSuite.swift diff --git a/app/Package.swift b/app/Package.swift index 0ea30d59a1..128c7a6343 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -8,6 +8,7 @@ let package = Package( .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), .executable(name: "UIProbe", targets: ["UIProbe"]), + .executable(name: "IconProbe", targets: ["IconProbe"]), ], targets: [ .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), @@ -28,6 +29,7 @@ let package = Package( path: "Sources/MenuBarCoreTests" ), .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), + .executableTarget(name: "IconProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/IconProbe"), ], swiftLanguageVersions: [.v5] ) diff --git a/app/Sources/IconProbe/main.swift b/app/Sources/IconProbe/main.swift new file mode 100644 index 0000000000..f9a359114d --- /dev/null +++ b/app/Sources/IconProbe/main.swift @@ -0,0 +1,32 @@ +// Renders every menu bar glyph state to one sheet so the state signal can be verified +// visually. The notch previously did not render at all, which made protected and +// at-risk indistinguishable. +import AppKit +import MenuBarCore +import MenuBarUI + +let states: [(String, ProxyState)] = [ + ("protected", .running(StartupHealth(status: "protected"))), + ("at-risk", .running(StartupHealth(status: "at-risk"))), + ("loading", .loading), + ("stopped", .unreachable), +] + +let scale: CGFloat = 6 +let cell = NSSize(width: 17 * scale, height: 17 * scale) +let sheet = NSImage(size: NSSize(width: cell.width * CGFloat(states.count), height: cell.height)) +sheet.lockFocus() +NSColor.white.setFill() +NSRect(origin: .zero, size: sheet.size).fill() +for (i, entry) in states.enumerated() { + let img = StatusIcon.image(for: entry.1) + let rect = NSRect(x: CGFloat(i) * cell.width, y: 0, width: cell.width, height: cell.height) + NSGraphicsContext.current?.imageInterpolation = .none + img.draw(in: rect.insetBy(dx: 8, dy: 8)) +} +sheet.unlockFocus() +if let tiff = sheet.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: "/tmp/glyphs.png")) +} +print("wrote /tmp/glyphs.png:", states.map(\.0).joined(separator: ", ")) diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 9480384400..51e032f50e 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -17,6 +17,10 @@ public actor PollingCoordinator { private var popoverOpen = false private var lastHeavyRefresh: Date? private var observers: [UUID: @Sendable (ProxySnapshot) -> Void] = [:] + /// Rises on every close and on every new refresh, so results from a superseded or + /// abandoned cycle can be discarded instead of overwriting fresher state. + private var generation = 0 + private var refreshInFlight = false public init(client: ProxyClient, endpoint: ProxyEndpoint) { self.client = client @@ -44,49 +48,103 @@ public actor PollingCoordinator { public func setPopoverOpen(_ open: Bool) async { popoverOpen = open - if open { await refresh(includeHeavy: true) } + if open { + await refresh(includeHeavy: true) + } else { + // Abandon in-flight heavy work: its results are no longer visible and + // must not land as if they were current. + generation &+= 1 + } } - /// One refresh cycle. Heavy endpoints are skipped unless the popover is open and the - /// heavy interval has elapsed. + /// One refresh cycle. + /// + /// `includeHeavy` marks a popover-open refresh: on-open reads (providers, config) + /// always run, while the expensive aggregation reads (usage, quotas) still respect + /// the 60s interval so reopening the popover repeatedly does not hammer the proxy. public func refresh(includeHeavy: Bool = false) async { + // Overlapping cycles publish interleaved state and double the request rate. + guard !refreshInFlight else { return } + refreshInFlight = true + generation &+= 1 + let cycle = generation + defer { refreshInFlight = false } + do { let health = try await client.health() + guard cycle == generation else { return } snapshot.state = .running(health) snapshot.lastKnownStartCommand = health.manualStartCommand + snapshot.recommendedCommand = health.recommendedCommand snapshot.consecutiveFailures = 0 snapshot.lastUpdated = Date() } catch is CancellationError { // The popover closed mid-flight. Not a proxy failure; leave state untouched. return } catch let error as ProxyError { + guard cycle == generation else { return } apply(error) publish() return } catch { + guard cycle == generation else { return } apply(.transport) publish() return } - let heavyDue = includeHeavy || lastHeavyRefresh.map { - Date().timeIntervalSince($0) >= Self.heavyInterval - } ?? true + if popoverOpen { + // Cheap, changes rarely, and only meaningful while the popover is visible. + await refreshOnOpen(cycle: cycle) - if popoverOpen && heavyDue { - await refreshHeavy() - lastHeavyRefresh = Date() + let aggregationDue = lastHeavyRefresh.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true + if includeHeavy && aggregationDue || (!includeHeavy && aggregationDue) { + let completed = await refreshAggregation(cycle: cycle) + // Only a fully successful aggregation counts as fresh; otherwise the + // next cycle retries instead of waiting out a 60s window on stale data. + if completed { lastHeavyRefresh = Date() } + } } + guard cycle == generation else { return } publish() } - private func refreshHeavy() async { + /// Reads that are only meaningful while the popover is open. + private func refreshOnOpen(cycle: Int) async { + if let providers = try? await client.providers(), cycle == generation { + snapshot.providers = providers + snapshot.providersLoaded = true + } + if let config = try? await client.config(), cycle == generation { + snapshot.defaultProvider = config.defaultProvider + } + } + + /// The expensive aggregation reads. Returns whether every read landed, so a partial + /// failure does not masquerade as a completed refresh. + private func refreshAggregation(cycle: Int) async -> Bool { + var complete = true + // Each read is independent: one failing endpoint must not blank the others. - if let usage = try? await client.usage(range: .sevenDays) { snapshot.usage = usage } - if let quotas = try? await client.quotas() { snapshot.quotas = quotas } - if let providers = try? await client.providers() { snapshot.providers = providers } - if let config = try? await client.config() { snapshot.defaultProvider = config.defaultProvider } + if let usage = try? await client.usage(range: .sevenDays) { + guard cycle == generation else { return false } + snapshot.usage = usage + } else { + complete = false + } + + if let quotas = try? await client.quotas() { + guard cycle == generation else { return false } + snapshot.quotas = quotas + snapshot.quotasLoaded = true + } else { + complete = false + } + + return complete } private func apply(_ error: ProxyError) { diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index 60bf608905..03b57ba276 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -86,6 +86,13 @@ public struct ProxySnapshot: Equatable, Sendable { /// Remembered from the last successful health read, so a stopped proxy can still /// tell the user the right start command for their install. public var lastKnownStartCommand: String? + /// The proxy's own remediation hint (for example `ocx service install`). Displayed + /// as selectable text, never executed. + public var recommendedCommand: String? + /// Whether a section has actually been read, so "not fetched yet" and "the proxy + /// reported none" render differently. + public var providersLoaded: Bool + public var quotasLoaded: Bool public init( state: ProxyState = .loading, @@ -96,7 +103,10 @@ public struct ProxySnapshot: Equatable, Sendable { defaultProvider: String? = nil, lastUpdated: Date? = nil, consecutiveFailures: Int = 0, - lastKnownStartCommand: String? = nil + lastKnownStartCommand: String? = nil, + recommendedCommand: String? = nil, + providersLoaded: Bool = false, + quotasLoaded: Bool = false ) { self.state = state self.endpoint = endpoint @@ -107,8 +117,28 @@ public struct ProxySnapshot: Equatable, Sendable { self.lastUpdated = lastUpdated self.consecutiveFailures = consecutiveFailures self.lastKnownStartCommand = lastKnownStartCommand + self.recommendedCommand = recommendedCommand + self.providersLoaded = providersLoaded + self.quotasLoaded = quotasLoaded } + /// Whether the data sections are worth rendering at all. + /// + /// `degraded` keeps them: the plan requires stale-but-labelled over blank, because a + /// user who can still see last-known numbers with an explicit age is better served + /// than one staring at an empty panel. + public var showsData: Bool { + switch state { + case .running: return true + case .degraded: return lastUpdated != nil + case .loading, .unreachable, .unauthorized: return false + } + } + + /// True once the proxy has been read at least once, so `loading` can show skeletons + /// rather than empty copy. + public var hasEverLoaded: Bool { lastUpdated != nil } + public var nextAction: NextAction { switch state { case .loading: return .none diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift new file mode 100644 index 0000000000..449e4e80e0 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -0,0 +1,197 @@ +import Foundation +import MenuBarCore + +/// Exercises the polling contract against a stubbed transport instead of asserting that +/// four constants still hold the values they were declared with. +enum PollingSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + + private static let healthOK = #"{"status":"protected","serviceInstalled":true,"serviceEnabled":true}"# + private static let usageOK = #"{"range":"7d","summary":{"requests":10},"days":[{"date":"d","requests":10}]}"# + private static let quotasOK = #"{"reports":[{"provider":"p","quota":{"weeklyPercent":5}}]}"# + private static let providersOK = #"[{"name":"openai"}]"# + private static let configOK = #"{"defaultProvider":"openai"}"# + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + func makeCoordinator() -> PollingCoordinator { + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: NoCredentials()) + return PollingCoordinator(client: client, endpoint: endpoint) + } + + // The whole point of gating: a closed popover must not trigger aggregation. + t.test("polling: a closed popover fetches only liveness") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + sync { await coordinator.refresh() } + t.equal(paths(), ["/api/startup-health"]) + } + + t.test("polling: opening the popover fetches on-open and aggregation reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + return await coordinator.current + } + t.expect(paths().contains("/api/providers"), "providers fetched on open") + t.expect(paths().contains("/api/usage"), "usage fetched on open") + t.expect(paths().contains("/api/provider-quotas"), "quotas fetched on open") + t.equal(snapshot.providersLoaded, true) + t.equal(snapshot.quotasLoaded, true) + t.equal(snapshot.defaultProvider, "openai") + } + + // Reopening within the aggregation window should refresh cheap reads only. + t.test("polling: a second open reuses aggregation but refreshes on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.setPopoverOpen(false) + await coordinator.setPopoverOpen(true) + } + let usageCalls = paths().filter { $0 == "/api/usage" }.count + let providerCalls = paths().filter { $0 == "/api/providers" }.count + t.equal(usageCalls, 1, "aggregation respects its interval") + t.equal(providerCalls, 2, "on-open reads run every open") + } + + t.test("polling: a refused proxy becomes unreachable and counts a failure") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + t.equal(snapshot.consecutiveFailures, 1) + t.equal(snapshot.showsData, false) + } + + t.test("polling: repeated failures widen the interval to the backoff value") { + StubProtocol.reset(Array(repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 4)) + let coordinator = makeCoordinator() + let interval = sync { () -> TimeInterval in + for _ in 0..<3 { await coordinator.refresh() } + return await coordinator.currentInterval + } + t.equal(interval, PollingCoordinator.backoffInterval) + } + + t.test("polling: a recovered proxy resets the failure count and interval") { + StubProtocol.reset([ + .init(status: 0, body: "", urlError: .cannotConnectToHost), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.consecutiveFailures, 0) + t.equal(snapshot.state.isRunning, true) + } + + // A degraded proxy keeps its last-known numbers with an explicit age, rather + // than blanking the panel. + t.test("polling: a 500 degrades while retaining previously loaded data") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + return await coordinator.current + } + if case .degraded = snapshot.state { + t.expect(true, "degraded") + } else { + t.expect(false, "expected degraded, got \(snapshot.state)") + } + t.equal(snapshot.showsData, true, "stale-but-labelled beats blank") + _ = t.notNil(snapshot.usage, "usage retained") + } + + t.test("polling: the recommended command is carried into the snapshot") { + StubProtocol.reset([ + .init(status: 200, + body: #"{"status":"at-risk","recommendedCommand":"ocx service install"}"#, + urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.recommendedCommand, "ocx service install") + } + + t.test("polling: observers receive the snapshot on registration and on change") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + let counter = Counter() + sync { + await coordinator.observe { _ in counter.bump() } + await coordinator.refresh() + } + t.expect(counter.count >= 2, "expected at least 2 notifications, got \(counter.count)") + } + } + + private struct NoCredentials: CredentialStore { + func loadAPIKey() -> String? { nil } + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var count: Int { lock.lock(); defer { lock.unlock() }; return value } + func bump() { lock.lock(); value += 1; lock.unlock() } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift index f7e793c7ec..df707b3191 100644 --- a/app/Sources/MenuBarCoreTests/main.swift +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -10,5 +10,6 @@ ModelDecodingSuite.run(runner) FormattingSuite.run(runner) TransportSuite.run(runner) SnapshotStateSuite.run(runner) +PollingSuite.run(runner) exit(runner.summarize()) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 69a631d8d6..ad060df7af 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -9,6 +9,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega private var client: ProxyClient? private var endpoint = ProxyEndpoint.default private var pollTask: Task? + /// Scoped Escape handling: an accessory app's popover does not reliably receive key + /// events through the responder chain, so the monitor is installed on open and + /// removed on close rather than left running for the process lifetime. + private var escapeMonitor: Any? public override init() { super.init() } @@ -30,6 +34,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega controller.onDashboard = { [weak self] in self?.openDashboard() } controller.onStop = { [weak self] in self?.stopProxy() } controller.onRefresh = { [weak self] in self?.refreshNow() } + controller.onPrimaryAction = { [weak self] in self?.primaryAction() } controller.onQuit = { NSApp.terminate(nil) } popover.contentViewController = controller @@ -53,6 +58,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega public func applicationWillTerminate(_ notification: Notification) { pollTask?.cancel() + removeEscapeMonitor() } // MARK: - Polling @@ -88,25 +94,67 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega if popover.isShown { popover.performClose(nil) } else { + // An accessory app is not active by default, so its popover would never + // take key focus and the keyboard path would silently not work. + NSApp.activate(ignoringOtherApps: true) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - popover.contentViewController?.view.window?.makeKey() + if let window = popover.contentViewController?.view.window { + window.makeKeyAndOrderFront(nil) + window.makeFirstResponder(popover.contentViewController?.view) + } } } public func popoverDidShow(_ notification: Notification) { + installEscapeMonitor() Task { [coordinator] in await coordinator?.setPopoverOpen(true) } } public func popoverDidClose(_ notification: Notification) { + removeEscapeMonitor() Task { [coordinator] in await coordinator?.setPopoverOpen(false) } } + private func installEscapeMonitor() { + removeEscapeMonitor() + escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + guard event.keyCode == 53 else { return event } // Escape + self?.popover.performClose(nil) + return nil + } + } + + private func removeEscapeMonitor() { + if let monitor = escapeMonitor { NSEvent.removeMonitor(monitor) } + escapeMonitor = nil + } + private func openDashboard() { NSWorkspace.shared.open(endpoint.baseURL) } - /// Wired fully in Phase 3; the confirmation sheet and result handling land there. + /// The state-specific call to action. Both current cases route the user to the + /// place they can actually resolve the problem. + private func primaryAction() { + openDashboard() + refreshNow() + } + + /// Stopping is destructive: it interrupts in-flight requests and stops the launchd + /// service, so nothing restarts the proxy. It always confirms first. Drain polling + /// and failure reporting land in Phase 3. private func stopProxy() { + let alert = NSAlert() + alert.messageText = "Stop the OpenCodex proxy?" + alert.informativeText = + "In-flight requests will be interrupted, and OpenCodex will not restart on its own." + alert.alertStyle = .warning + alert.addButton(withTitle: "Stop proxy") + alert.addButton(withTitle: "Cancel") + + NSApp.activate(ignoringOtherApps: true) + guard alert.runModal() == .alertFirstButtonReturn else { return } + Task { [client, coordinator] in try? await client?.stop() await coordinator?.refresh() diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index a9c4dd1ece..3ab42f8718 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -1,63 +1,90 @@ import AppKit import MenuBarCore -/// The popover body: one scroll-free column ordered by urgency. +/// The popover body: one column ordered by urgency. /// /// Deliberately not a tab bar. A menu bar popover is a glance surface, and tabs would put /// the answer to "is it fine?" one click away three times out of four. +/// +/// Fixed header and action row with a scrolling middle: the quota and provider sections +/// grow with the user's configuration, and an uncapped popover would eventually run off +/// the screen. public final class PopoverViewController: NSViewController { - public override init(nibName: NSNib.Name?, bundle: Bundle?) { super.init(nibName: nibName, bundle: bundle) } + public override init(nibName: NSNib.Name?, bundle: Bundle?) { + super.init(nibName: nibName, bundle: bundle) + } + public required init?(coder: NSCoder) { nil } + /// The popover never grows past this; the variable middle scrolls instead. + private static let maxHeight: CGFloat = 480 + + // Fixed chrome private let header = StatusHeaderView() + private let dashboardButton = NSButton() + private let stopButton = NSButton() + private let overflowButton = NSButton() + /// State-specific call to action: "Add key…" or "Retry". + private let primaryButton = NSButton() + + // Scrolling body + private let scrollView = NSScrollView() + private let body = NSStackView() private let metrics = MetricsView() private let sparkline = SparklineView() private let quotaStack = NSStackView() private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) - private let actionLabel = makeLabel("", font: Theme.caption, color: Theme.muted) + private let providerSummary = makeLabel("", font: Theme.caption, color: Theme.muted) + private let skeleton = SkeletonView() + private let guidanceLabel: NSTextField = { + let field = makeLabel("", font: Theme.caption, color: Theme.muted) + // Guidance is a sentence, not a stat: let it wrap instead of truncating away + // the half that explains what to do. + field.lineBreakMode = .byWordWrapping + field.maximumNumberOfLines = 3 + field.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 + return field + }() private let commandField = NSTextField(labelWithString: "") - private let dashboardButton = NSButton() - private let stopButton = NSButton() - private let overflowButton = NSButton() private let metricsSeparator = makeSeparator() private let quotaSeparator = makeSeparator() public var onDashboard: (() -> Void)? public var onStop: (() -> Void)? public var onQuit: (() -> Void)? + public var onRefresh: (() -> Void)? + /// Invoked by the state-specific primary button. + public var onPrimaryAction: (() -> Void)? private var snapshot: ProxySnapshot? + private var scrollHeight: NSLayoutConstraint? public override func loadView() { - let root = NSView(frame: NSRect(x: 0, y: 0, width: Theme.width, height: 260)) + configureControls() - quotaStack.orientation = .vertical - quotaStack.alignment = .leading - quotaStack.spacing = Theme.tightGap + body.orientation = .vertical + body.alignment = .leading + body.spacing = Theme.rowGap + body.setViews( + [skeleton, metrics, sparkline, metricsSeparator, quotaStack, quotaEmpty, + providerSummary, quotaSeparator, guidanceLabel, commandField], + in: .top + ) + body.translatesAutoresizingMaskIntoConstraints = false - configureButtons() - commandField.font = Theme.numericSmall - commandField.textColor = Theme.text - commandField.isSelectable = true - commandField.isBordered = false - commandField.backgroundColor = .clear + scrollView.documentView = body + scrollView.hasVerticalScroller = true + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + scrollView.borderType = .noBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false - let actions = makeRow([dashboardButton, stopButton, NSView(), overflowButton]) + let actions = NSStackView(views: [dashboardButton, stopButton, primaryButton, NSView(), overflowButton]) + actions.orientation = .horizontal + actions.spacing = Theme.rowGap actions.alignment = .centerY - let column = NSStackView(views: [ - header, - makeSeparator(), - metrics, - sparkline, - metricsSeparator, - quotaStack, - quotaEmpty, - quotaSeparator, - actionLabel, - commandField, - actions, - ]) + let column = NSStackView(views: [header, makeSeparator(), scrollView, actions]) column.orientation = .vertical column.alignment = .leading column.spacing = Theme.rowGap @@ -66,31 +93,31 @@ public final class PopoverViewController: NSViewController { bottom: Theme.gutter, right: Theme.gutter ) column.translatesAutoresizingMaskIntoConstraints = false + + let root = NSView(frame: NSRect(x: 0, y: 0, width: Theme.width, height: 300)) root.addSubview(column) + let contentWidth = Theme.width - Theme.gutter * 2 NSLayoutConstraint.activate([ column.topAnchor.constraint(equalTo: root.topAnchor), column.leadingAnchor.constraint(equalTo: root.leadingAnchor), column.trailingAnchor.constraint(equalTo: root.trailingAnchor), column.bottomAnchor.constraint(equalTo: root.bottomAnchor), root.widthAnchor.constraint(equalToConstant: Theme.width), + header.widthAnchor.constraint(equalToConstant: contentWidth), + actions.widthAnchor.constraint(equalToConstant: contentWidth), + scrollView.widthAnchor.constraint(equalToConstant: contentWidth), + body.widthAnchor.constraint(equalToConstant: contentWidth), ]) - // Let the column drive the height so hidden sections actually shrink the - // popover. Without this the view keeps its initial 260pt and a stopped proxy - // renders a large empty void under the status line. - column.setHuggingPriority(.required, for: .vertical) - column.setContentCompressionResistancePriority(.required, for: .vertical) - - for view in [header, metrics, sparkline, quotaStack, actions] { - view.translatesAutoresizingMaskIntoConstraints = false - view.widthAnchor.constraint(equalTo: column.widthAnchor, constant: -Theme.gutter * 2).isActive = true - } + let heightConstraint = scrollView.heightAnchor.constraint(equalToConstant: 120) + heightConstraint.isActive = true + scrollHeight = heightConstraint view = root } - private func configureButtons() { + private func configureControls() { for (button, title) in [(dashboardButton, "Dashboard"), (stopButton, "Stop proxy")] { button.title = title button.bezelStyle = .rounded @@ -101,6 +128,13 @@ public final class PopoverViewController: NSViewController { dashboardButton.action = #selector(dashboardTapped) stopButton.action = #selector(stopTapped) + primaryButton.bezelStyle = .rounded + primaryButton.controlSize = .small + primaryButton.font = Theme.caption + primaryButton.target = self + primaryButton.action = #selector(primaryTapped) + primaryButton.isHidden = true + overflowButton.title = "···" overflowButton.bezelStyle = .rounded overflowButton.controlSize = .small @@ -108,37 +142,58 @@ public final class PopoverViewController: NSViewController { overflowButton.target = self overflowButton.action = #selector(overflowTapped) overflowButton.setAccessibilityLabel("More actions") + + quotaStack.orientation = .vertical + quotaStack.alignment = .leading + quotaStack.spacing = Theme.tightGap + + commandField.font = Theme.numericSmall + commandField.textColor = Theme.text + commandField.isSelectable = true + commandField.isBordered = false + commandField.drawsBackground = false } public func apply(_ snapshot: ProxySnapshot) { self.snapshot = snapshot header.apply(snapshot) - metrics.apply(snapshot) - sparkline.apply(snapshot) - applyQuotas(snapshot) - applyAction(snapshot) - - // Metrics and quotas are meaningless when the proxy is not answering. - let live = snapshot.state.isRunning - metrics.isHidden = !live - metricsSeparator.isHidden = !live - quotaSeparator.isHidden = !live - if !live { + + let showsData = snapshot.showsData + let isLoading = !snapshot.hasEverLoaded && snapshot.state == .loading + + // Loading shows structure, not empty copy: the shape of the answer is already + // known, only the values are missing. + skeleton.isHidden = !isLoading + + metrics.isHidden = !showsData + metricsSeparator.isHidden = !showsData + quotaSeparator.isHidden = !showsData + if showsData { + metrics.apply(snapshot) + sparkline.apply(snapshot) + applyQuotas(snapshot) + applyProviders(snapshot) + } else { sparkline.isHidden = true quotaStack.isHidden = true quotaEmpty.isHidden = true + providerSummary.isHidden = true } - stopButton.isEnabled = live - view.layoutSubtreeIfNeeded() - preferredContentSize = NSSize(width: Theme.width, height: view.fittingSize.height) + applyGuidance(snapshot) + applyActions(snapshot, isLoading: isLoading) + resize() } private func applyQuotas(_ snapshot: ProxySnapshot) { - for view in quotaStack.arrangedSubviews { quotaStack.removeArrangedSubview(view); view.removeFromSuperview() } + for view in quotaStack.arrangedSubviews { + quotaStack.removeArrangedSubview(view) + view.removeFromSuperview() + } let rows = snapshot.quotaRows quotaStack.isHidden = rows.isEmpty - quotaEmpty.isHidden = !(rows.isEmpty && snapshot.state.isRunning) + // "Not fetched yet" and "the proxy reported none" are different facts. + quotaEmpty.isHidden = !(rows.isEmpty && snapshot.quotasLoaded) for quota in rows { let row = QuotaRowView(quota: quota) row.translatesAutoresizingMaskIntoConstraints = false @@ -147,51 +202,128 @@ public final class PopoverViewController: NSViewController { } } - private func applyAction(_ snapshot: ProxySnapshot) { + private func applyProviders(_ snapshot: ProxySnapshot) { + guard snapshot.providersLoaded else { + providerSummary.isHidden = true + return + } + providerSummary.isHidden = false + if snapshot.providers.isEmpty { + providerSummary.stringValue = "No providers configured." + } else { + let enabled = snapshot.providers.filter(\.isEnabled).count + providerSummary.stringValue = "\(enabled) of \(snapshot.providers.count) providers enabled" + } + } + + /// Guidance text plus any command the user should run. Commands are shown as + /// selectable text; the app never executes them. + private func applyGuidance(_ snapshot: ProxySnapshot) { + var guidance: String? + var command: String? + switch snapshot.nextAction { case .none: - actionLabel.isHidden = true - commandField.isHidden = true - case .runCommand(let command): - actionLabel.isHidden = false - actionLabel.stringValue = "Start it again with:" - commandField.isHidden = false - commandField.stringValue = command - commandField.setAccessibilityLabel("Start command: \(command)") + // A running-but-at-risk proxy still has advice worth surfacing. + if case .running = snapshot.state, let recommended = snapshot.recommendedCommand { + guidance = "Recommended:" + command = recommended + } + case .runCommand(let value): + guidance = "Start it again with:" + command = value case .addAPIKey: - actionLabel.isHidden = false - actionLabel.stringValue = "Add an API key in the dashboard to continue." - commandField.isHidden = true + guidance = "This proxy is bound to a non-loopback address and needs a key." case .retry: - actionLabel.isHidden = false - let age = Format.age(snapshot.lastUpdated) - actionLabel.stringValue = "Showing data from \(age). Retrying automatically." - commandField.isHidden = true + guidance = "Showing data from \(Format.age(snapshot.lastUpdated)). Retrying automatically." + } + + guidanceLabel.isHidden = guidance == nil + guidanceLabel.stringValue = guidance ?? "" + commandField.isHidden = command == nil + commandField.stringValue = command ?? "" + if let command { + commandField.setAccessibilityLabel("Command to run: \(command)") } } + private func applyActions(_ snapshot: ProxySnapshot, isLoading: Bool) { + // Nothing is actionable before the first read completes. + dashboardButton.isEnabled = !isLoading + overflowButton.isEnabled = !isLoading + stopButton.isEnabled = snapshot.state.isRunning + stopButton.isHidden = !snapshot.state.isRunning + + switch snapshot.nextAction { + case .addAPIKey: + primaryButton.isHidden = false + primaryButton.title = "Add key…" + primaryButton.keyEquivalent = "\r" + case .retry: + primaryButton.isHidden = false + primaryButton.title = "Retry" + primaryButton.keyEquivalent = "\r" + case .none, .runCommand: + primaryButton.isHidden = true + primaryButton.keyEquivalent = "" + } + } + + private func resize() { + view.layoutSubtreeIfNeeded() + let bodyHeight = ceil(body.fittingSize.height) + // Chrome is the header, separator, action row, and insets. + let chrome = ceil(header.fittingSize.height) + Theme.gutter * 2 + Theme.rowGap * 3 + 28 + let natural = chrome + bodyHeight + let capped = min(Self.maxHeight, natural) + // Scrollers appear only when the content genuinely overflows; a scroll bar on a + // three-line loading state reads as a broken layout. + let overflowing = natural > Self.maxHeight + scrollView.hasVerticalScroller = overflowing + scrollHeight?.constant = max(0, capped - chrome) + preferredContentSize = NSSize(width: Theme.width, height: max(96, capped)) + } + + // MARK: - Actions + @objc private func dashboardTapped() { onDashboard?() } @objc private func stopTapped() { onStop?() } + @objc private func primaryTapped() { onPrimaryAction?() } + @objc private func refreshTapped() { onRefresh?() } + @objc private func quitTapped() { onQuit?() } @objc private func overflowTapped() { let menu = NSMenu() menu.addItem(withTitle: "Refresh", action: #selector(refreshTapped), keyEquivalent: "r").target = self + menu.addItem(withTitle: "Open dashboard", action: #selector(dashboardTapped), keyEquivalent: "").target = self menu.addItem(.separator()) menu.addItem(withTitle: "Quit OpenCodex", action: #selector(quitTapped), keyEquivalent: "q").target = self menu.popUp(positioning: nil, at: NSPoint(x: 0, y: overflowButton.bounds.height + 4), in: overflowButton) } - @objc private func refreshTapped() { onRefresh?() } - @objc private func quitTapped() { onQuit?() } + /// AppKit routes Escape here for the whole responder chain, which `keyDown` does not + /// reliably receive inside a popover. + public override func cancelOperation(_ sender: Any?) { + view.window?.performClose(nil) + } +} - public var onRefresh: (() -> Void)? +/// Loading structure: grey bars where values will appear, so the first paint shows the +/// shape of the answer instead of empty space or a spinner. +final class SkeletonView: NSView { + override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: 84) + } - public override func keyDown(with event: NSEvent) { - // Escape closes, per the keyboard contract. - if event.keyCode == 53 { - view.window?.close() - return + override func draw(_ dirtyRect: NSRect) { + Theme.raised.setFill() + let widths: [CGFloat] = [72, 0, 96, 140, 120, 110] + var y = bounds.maxY - 12 + for width in widths { + guard width > 0 else { y -= 8; continue } + let rect = NSRect(x: 0, y: y, width: width, height: 9) + NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill() + y -= 15 } - super.keyDown(with: event) } } diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift index 436f3e30e7..e7eb2951b1 100644 --- a/app/Sources/MenuBarUI/StatusIcon.swift +++ b/app/Sources/MenuBarUI/StatusIcon.swift @@ -11,10 +11,10 @@ import MenuBarCore /// the platform. State is carried by fill and by a notch instead. The coloured dot lives /// inside the popover, where it sits beside a word and so never encodes meaning by /// colour alone. -enum StatusIcon { - static let size = NSSize(width: 17, height: 17) +public enum StatusIcon { + public static let size = NSSize(width: 17, height: 17) - static func image(for state: ProxyState) -> NSImage { + public static func image(for state: ProxyState) -> NSImage { switch state { case .running(let health) where health.isProtected: return mark(filled: true, notched: false, alpha: 1) @@ -27,12 +27,34 @@ enum StatusIcon { } } - /// A rounded square bracket pair — the OpenCodex mark reduced to menu bar scale. + /// A rounded mark reduced to menu bar scale. + /// + /// The notch is carved out of the geometry with an even-odd path rather than by + /// compositing. An earlier version stroked with `.clear` and `.clear` composite mode, + /// which silently did nothing — the rendered at-risk glyph was indistinguishable from + /// the protected one, so the state signal was invisible. private static func mark(filled: Bool, notched: Bool, alpha: CGFloat) -> NSImage { let image = NSImage(size: size, flipped: false) { rect in let inset = rect.insetBy(dx: 2.5, dy: 2.5) let path = NSBezierPath(roundedRect: inset, xRadius: 4, yRadius: 4) - path.lineWidth = 1.6 + + if notched { + // A slot carved out of the trailing edge, kept fully inside the mark so + // the silhouette stays clean. Even-odd winding turns the subpath into a + // hole rather than a second filled shape. + let notch = NSBezierPath( + roundedRect: NSRect( + x: inset.maxX - 4.2, + y: inset.midY - 1.1, + width: 3.0, + height: 2.2 + ), + xRadius: 1.1, + yRadius: 1.1 + ) + path.append(notch) + path.windingRule = .evenOdd + } NSColor.black.withAlphaComponent(alpha).setStroke() NSColor.black.withAlphaComponent(alpha).setFill() @@ -40,22 +62,9 @@ enum StatusIcon { if filled { path.fill() } else { + path.lineWidth = 1.6 path.stroke() } - - if notched { - // A single carved notch marks "running but unprotected" without colour. - let notch = NSBezierPath() - let midY = inset.midY - notch.move(to: NSPoint(x: inset.maxX - 3.5, y: midY)) - notch.line(to: NSPoint(x: inset.maxX + 0.5, y: midY)) - notch.lineWidth = 2.4 - NSColor.clear.setStroke() - // Erase rather than draw: the notch must read as a gap in the mark. - NSGraphicsContext.current?.compositingOperation = .clear - notch.stroke() - NSGraphicsContext.current?.compositingOperation = .sourceOver - } return true } image.isTemplate = true diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index 0d254e84af..6a9b59e009 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -75,14 +75,18 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { @MainActor func capture() { guard let w = window else { return } let tag = ProcessInfo.processInfo.environment["PROBE_TAG"] ?? "light" - // Capture the real window through the window server: caching the view's bitmap - // rep skips text rendering, which produced a screenshot with no labels at all. + // CGWindowListCreateImage rather than shelling out to screencapture: nothing + // under app/ may construct a Process (030 security rule). The bitmap-rep path + // is not an option either — it skips text rendering entirely. let id = CGWindowID(w.windowNumber) - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") - task.arguments = ["-x", "-o", "-l", String(id), "/tmp/popover-\(tag).png"] - try? task.run() - task.waitUntilExit() + if let cg = CGWindowListCreateImage( + .null, .optionIncludingWindow, id, [.boundsIgnoreFraming, .bestResolution] + ) { + let rep = NSBitmapImageRep(cgImage: cg) + if let png = rep.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: "/tmp/popover-\(tag).png")) + } + } NSApp.terminate(nil) } } diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index 8124cc6a41..bb23b798d2 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -281,6 +281,26 @@ Build, launch, open the popover, `screencapture` the region, read it back with colour-only meaning, numbers abbreviated and tabular, dark and light both legible. Fix what the screenshot shows, then re-verify. Code review alone does not close this phase. +## Code-review corrections (folded before B closed) + +An adversarial review that rendered every state returned FAIL on 9 findings. Each was +reproduced visually or with a stub before being folded: + +| Finding | Correction | +| --- | --- | +| `Stop proxy` fired an unconfirmed destructive stop | Confirmation sheet naming the concrete consequence, since `/api/stop` also stops launchd | +| Escape did not close the popover; the accessory app never took key focus | `NSApp.activate` on open, explicit first responder, plus a scoped local key monitor installed on open and removed on close | +| Loading, unauthorized, and degraded were not really implemented | Skeleton rows and disabled chrome while loading; an actual `Add key…` button; `Retry` plus a staleness age for degraded, which now retains its last-known data | +| Popover open forced aggregation every time, while periodic refreshes fetched on-open data | Split into on-open reads (providers, config) and interval-gated aggregation (usage, quotas) | +| Overlapping refreshes could interleave, outlive a close, and mark stale data fresh | One in-flight cycle, a generation counter that discards superseded results, close bumps the generation, and only a fully successful aggregation advances the freshness timestamp | +| The at-risk notch did not render at all, so protected and at-risk looked identical | Notch carved with even-odd winding instead of a `.clear` composite that silently did nothing; verified with a rendered glyph sheet | +| `recommendedCommand` was decoded but never shown, and providers had no section | Recommended command shown as selectable text; a provider summary line with its own empty copy | +| Popover height was uncapped with no scroll region | Fixed header and actions with a scrolling body, capped at 480pt, scrollers only when content actually overflows | +| Polling tests asserted four constants and nothing else | `PollingSuite`: gating, cadence, backoff, recovery, degraded retention, and observer delivery against a stubbed transport | + +Also folded: `UIProbe` now captures with `CGWindowListCreateImage` rather than `Process`, +so nothing under `app/` constructs a subprocess (`030` security rule). + ## Accept criteria 1. Menu bar icon renders as a template image and changes with state. From d9f91b3437fcaad5d6685edce5d1887d955fac29 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:00:44 +0900 Subject: [PATCH 10/61] fix(app): make Escape work, top-anchor overflow, split data freshness Round-2 UI review found 6 defects, all reproduced before folding. - Escape genuinely did not work. Activating before presentation leaves an accessory app's popover without key focus, so no key event ever arrived. Activation now happens on the next main-loop turn after show(relativeTo:). Verified by synthesizing keycode 53 into the app's own event queue: popover shown true before, false after. - Overflowing content opened scrolled to the bottom, hiding the status line and metrics that the urgency order exists to surface. NSScrollView is bottom-origin by default; a flipped clip view fixes it. - Close-then-immediate-reopen dropped the reopen's refresh: the old cycle exited on its generation guard while the new one had already been rejected by the in-flight lock. Refreshes now queue and drain on every exit path. - Closing mid-sequence still paid for later requests, and a partial aggregation failure re-fetched its healthy sibling every 5 seconds because the rate limit keyed on success. Now every request re-checks the cycle, and aggregation is limited by attempt. - Retry opened a browser. Add key and Retry now have separate callbacks. - Degraded quoted an age derived from the last health probe, so it could claim to be showing data it never loaded. healthUpdated and usageUpdated are now separate, showsData requires actually-loaded sections, and the guidance quotes the data age. The overflow menu ships Refresh, Open dashboard, and Quit rather than the sketched Preferences: there is no preferences surface, and a menu item that opens nothing is worse than its absence. Spec amended to match. --- .../MenuBarCore/PollingCoordinator.swift | 69 ++++++++++++++----- app/Sources/MenuBarCore/ProxySnapshot.swift | 18 ++++- app/Sources/MenuBarUI/AppDelegate.swift | 23 +++---- .../MenuBarUI/PopoverViewController.swift | 26 +++++-- app/Sources/UIProbe/main.swift | 14 +++- .../260725_macos_menubar_app/020_phase2_ui.md | 17 ++++- 6 files changed, 129 insertions(+), 38 deletions(-) diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 51e032f50e..e7fca8ac83 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -21,6 +21,13 @@ public actor PollingCoordinator { /// abandoned cycle can be discarded instead of overwriting fresher state. private var generation = 0 private var refreshInFlight = false + /// A refresh requested while another was in flight. Without this, closing and + /// immediately reopening the popover dropped the reopen's refresh entirely: the old + /// cycle exited on its generation guard and the new one had already been rejected. + private var pendingOpenRefresh = false + /// Attempt time, distinct from success time: a persistently failing endpoint must + /// not turn its healthy sibling into a 5-second poller. + private var lastAggregationAttempt: Date? public init(client: ProxyClient, endpoint: ProxyEndpoint) { self.client = client @@ -64,7 +71,10 @@ public actor PollingCoordinator { /// the 60s interval so reopening the popover repeatedly does not hammer the proxy. public func refresh(includeHeavy: Bool = false) async { // Overlapping cycles publish interleaved state and double the request rate. - guard !refreshInFlight else { return } + guard !refreshInFlight else { + if includeHeavy { pendingOpenRefresh = true } + return + } refreshInFlight = true generation &+= 1 let cycle = generation @@ -78,18 +88,21 @@ public actor PollingCoordinator { snapshot.recommendedCommand = health.recommendedCommand snapshot.consecutiveFailures = 0 snapshot.lastUpdated = Date() + snapshot.healthUpdated = Date() } catch is CancellationError { // The popover closed mid-flight. Not a proxy failure; leave state untouched. + refreshInFlight = false + await drainPendingRefresh() return } catch let error as ProxyError { - guard cycle == generation else { return } - apply(error) - publish() + if cycle == generation { apply(error); publish() } + refreshInFlight = false + await drainPendingRefresh() return } catch { - guard cycle == generation else { return } - apply(.transport) - publish() + if cycle == generation { apply(.transport); publish() } + refreshInFlight = false + await drainPendingRefresh() return } @@ -97,47 +110,69 @@ public actor PollingCoordinator { // Cheap, changes rarely, and only meaningful while the popover is visible. await refreshOnOpen(cycle: cycle) - let aggregationDue = lastHeavyRefresh.map { + // Rate-limit on ATTEMPT, not success: gating on success alone meant one + // persistently failing endpoint re-fetched its healthy sibling every 5s. + let aggregationDue = lastAggregationAttempt.map { Date().timeIntervalSince($0) >= Self.heavyInterval } ?? true - if includeHeavy && aggregationDue || (!includeHeavy && aggregationDue) { + if aggregationDue { + lastAggregationAttempt = Date() let completed = await refreshAggregation(cycle: cycle) - // Only a fully successful aggregation counts as fresh; otherwise the - // next cycle retries instead of waiting out a 60s window on stale data. if completed { lastHeavyRefresh = Date() } } } - guard cycle == generation else { return } - publish() + if cycle == generation { publish() } + refreshInFlight = false + await drainPendingRefresh() + } + + /// Runs a refresh that arrived while another cycle held the lock. + private func drainPendingRefresh() async { + guard pendingOpenRefresh, popoverOpen else { + pendingOpenRefresh = false + return + } + pendingOpenRefresh = false + await refresh(includeHeavy: true) } /// Reads that are only meaningful while the popover is open. private func refreshOnOpen(cycle: Int) async { - if let providers = try? await client.providers(), cycle == generation { + guard isCurrent(cycle) else { return } + if let providers = try? await client.providers(), isCurrent(cycle) { snapshot.providers = providers snapshot.providersLoaded = true } - if let config = try? await client.config(), cycle == generation { + // Re-check before each subsequent request: closing mid-flight should stop the + // sequence, not merely discard its results after paying for them. + guard isCurrent(cycle) else { return } + if let config = try? await client.config(), isCurrent(cycle) { snapshot.defaultProvider = config.defaultProvider } } + /// Still the newest cycle, and still worth doing. + private func isCurrent(_ cycle: Int) -> Bool { cycle == generation && popoverOpen } + /// The expensive aggregation reads. Returns whether every read landed, so a partial /// failure does not masquerade as a completed refresh. private func refreshAggregation(cycle: Int) async -> Bool { + guard isCurrent(cycle) else { return false } var complete = true // Each read is independent: one failing endpoint must not blank the others. if let usage = try? await client.usage(range: .sevenDays) { - guard cycle == generation else { return false } + guard isCurrent(cycle) else { return false } snapshot.usage = usage + snapshot.usageUpdated = Date() } else { complete = false } + guard isCurrent(cycle) else { return false } if let quotas = try? await client.quotas() { - guard cycle == generation else { return false } + guard isCurrent(cycle) else { return false } snapshot.quotas = quotas snapshot.quotasLoaded = true } else { diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index 03b57ba276..1e1a22fa30 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -93,6 +93,11 @@ public struct ProxySnapshot: Equatable, Sendable { /// reported none" render differently. public var providersLoaded: Bool public var quotasLoaded: Bool + /// When health last succeeded, versus when the aggregation data last succeeded. + /// Conflating them let a degraded state claim "showing data from 5s ago" while + /// holding no metrics at all. + public var healthUpdated: Date? + public var usageUpdated: Date? public init( state: ProxyState = .loading, @@ -106,7 +111,9 @@ public struct ProxySnapshot: Equatable, Sendable { lastKnownStartCommand: String? = nil, recommendedCommand: String? = nil, providersLoaded: Bool = false, - quotasLoaded: Bool = false + quotasLoaded: Bool = false, + healthUpdated: Date? = nil, + usageUpdated: Date? = nil ) { self.state = state self.endpoint = endpoint @@ -120,6 +127,8 @@ public struct ProxySnapshot: Equatable, Sendable { self.recommendedCommand = recommendedCommand self.providersLoaded = providersLoaded self.quotasLoaded = quotasLoaded + self.healthUpdated = healthUpdated + self.usageUpdated = usageUpdated } /// Whether the data sections are worth rendering at all. @@ -130,11 +139,16 @@ public struct ProxySnapshot: Equatable, Sendable { public var showsData: Bool { switch state { case .running: return true - case .degraded: return lastUpdated != nil + // Only claim stale data when data was actually loaded. Health succeeding while + // the popover was closed is not the same as having metrics to show. + case .degraded: return usage != nil || quotasLoaded case .loading, .unreachable, .unauthorized: return false } } + /// Age of the DATA, not of the last health probe. + public var dataAge: Date? { usageUpdated } + /// True once the proxy has been read at least once, so `loading` can show skeletons /// rather than empty copy. public var hasEverLoaded: Bool { lastUpdated != nil } diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index ad060df7af..f1b00c6ef8 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -34,7 +34,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega controller.onDashboard = { [weak self] in self?.openDashboard() } controller.onStop = { [weak self] in self?.stopProxy() } controller.onRefresh = { [weak self] in self?.refreshNow() } - controller.onPrimaryAction = { [weak self] in self?.primaryAction() } + controller.onAddKey = { [weak self] in self?.openDashboard() } + controller.onRetry = { [weak self] in self?.refreshNow() } controller.onQuit = { NSApp.terminate(nil) } popover.contentViewController = controller @@ -94,13 +95,16 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega if popover.isShown { popover.performClose(nil) } else { - // An accessory app is not active by default, so its popover would never - // take key focus and the keyboard path would silently not work. - NSApp.activate(ignoringOtherApps: true) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - if let window = popover.contentViewController?.view.window { + // Activation has to happen AFTER presentation and on a later main-loop turn: + // an accessory process is inactive by default, and activating before the + // popover window exists leaves it without key focus, so no key event — + // including Escape — ever reaches it. + DispatchQueue.main.async { [weak self] in + guard let self, let window = self.popover.contentViewController?.view.window else { return } + NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) - window.makeFirstResponder(popover.contentViewController?.view) + window.makeFirstResponder(self.controller.view) } } } @@ -133,13 +137,6 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega NSWorkspace.shared.open(endpoint.baseURL) } - /// The state-specific call to action. Both current cases route the user to the - /// place they can actually resolve the problem. - private func primaryAction() { - openDashboard() - refreshNow() - } - /// Stopping is destructive: it interrupts in-flight requests and stops the launchd /// service, so nothing restarts the proxy. It always confirms first. Drain polling /// and failure reporting land in Phase 3. diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index 3ab42f8718..4f6f2efbf0 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -53,8 +53,10 @@ public final class PopoverViewController: NSViewController { public var onStop: (() -> Void)? public var onQuit: (() -> Void)? public var onRefresh: (() -> Void)? - /// Invoked by the state-specific primary button. - public var onPrimaryAction: (() -> Void)? + /// Distinct callbacks: "Retry" must retry in place, while "Add key…" navigates to + /// the dashboard. Routing both through one handler made Retry open a browser. + public var onAddKey: (() -> Void)? + public var onRetry: (() -> Void)? private var snapshot: ProxySnapshot? private var scrollHeight: NSLayoutConstraint? @@ -72,6 +74,10 @@ public final class PopoverViewController: NSViewController { ) body.translatesAutoresizingMaskIntoConstraints = false + // A flipped clip view puts the scroll origin at the TOP. Without this, content + // that overflows opens scrolled to the bottom, hiding the status and metrics the + // urgency order exists to surface first. + scrollView.contentView = FlippedClipView() scrollView.documentView = body scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true @@ -235,7 +241,8 @@ public final class PopoverViewController: NSViewController { case .addAPIKey: guidance = "This proxy is bound to a non-loopback address and needs a key." case .retry: - guidance = "Showing data from \(Format.age(snapshot.lastUpdated)). Retrying automatically." + guidance = snapshot.dataAge.map { "Showing data from \(Format.age($0)). Retrying automatically." } + ?? "Retrying automatically." } guidanceLabel.isHidden = guidance == nil @@ -288,7 +295,13 @@ public final class PopoverViewController: NSViewController { @objc private func dashboardTapped() { onDashboard?() } @objc private func stopTapped() { onStop?() } - @objc private func primaryTapped() { onPrimaryAction?() } + @objc private func primaryTapped() { + switch snapshot?.nextAction { + case .addAPIKey: onAddKey?() + case .retry: onRetry?() + default: break + } + } @objc private func refreshTapped() { onRefresh?() } @objc private func quitTapped() { onQuit?() } @@ -308,6 +321,11 @@ public final class PopoverViewController: NSViewController { } } +/// Top-anchored clip view. AppKit scroll views are bottom-origin by default. +final class FlippedClipView: NSClipView { + override var isFlipped: Bool { true } +} + /// Loading structure: grey bars where values will appear, so the first paint shows the /// shape of the answer instead of empty space or a spinner. final class SkeletonView: NSView { diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index 6a9b59e009..41e360ea26 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -48,12 +48,24 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { case "degraded": snap = ProxySnapshot(state: .degraded("The proxy returned an unexpected status (503)."), endpoint: endpoint, lastUpdated: Date().addingTimeInterval(-120)) + case "overflow": + let many = (1...24).map { i in + #"{"provider":"p\#(i)","label":"Provider \#(i)","quota":{"weeklyPercent":\#(i * 3)}}"# + }.joined(separator: ",") + let quotas = (try? JSONDecoder().decode([QuotaReport].self, from: Data("[\(many)]".utf8))) ?? [] + let usage = try? JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"7d","summary":{"requests":100},"days":[{"date":"d","requests":100}]}"#.utf8)) + snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), + endpoint: endpoint, usage: usage, quotas: quotas, + quotasLoaded: true) case "empty": let usage = try? JSONDecoder().decode( UsageReport.self, from: Data(#"{"range":"7d","summary":{"requests":0},"days":[]}"#.utf8)) snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, quotas: [], providers: []) + endpoint: endpoint, usage: usage, quotas: [], providers: [], + providersLoaded: true, quotasLoaded: true) default: await coordinator.setPopoverOpen(true) snap = await coordinator.current diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index bb23b798d2..d56c0e3c9f 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -255,7 +255,7 @@ its own: | Section | Empty condition | Copy | Action | | --- | --- | --- | --- | -| Metrics | `summary` present, `requests == 0` | "No requests in the last 7 days." | Dashboard | +| Metrics | `summary` present, `requests == 0` | "No requests in this period." | Dashboard | | Usage trend | `days` empty or all-zero | bars omitted entirely, no flat line | none | | Quotas | `reports` empty | "No provider quota sources connected." | Dashboard | | Providers | `providers` empty | "No providers configured." | Dashboard | @@ -301,6 +301,21 @@ reproduced visually or with a stub before being folded: Also folded: `UIProbe` now captures with `CGWindowListCreateImage` rather than `Process`, so nothing under `app/` constructs a subprocess (`030` security rule). +### Round 2 (6 findings) + +| Finding | Correction | +| --- | --- | +| Escape still did not close the popover — activating before presentation left an accessory app without key focus | Activate on the next main-loop turn *after* `show(relativeTo:)`, then set key window and first responder. Verified by synthesizing keycode 53 into the app's own queue: shown `true` before, `false` after | +| Overflowing content opened scrolled to the bottom, hiding the status and metrics | `FlippedClipView` so the scroll origin is top-anchored | +| Close-then-immediate-reopen could drop the reopen's refresh entirely | `pendingOpenRefresh` queued while a cycle holds the lock, drained on every exit path | +| Closing mid-sequence still issued later requests, and a partial aggregation failure re-fetched its healthy sibling every 5s | `isCurrent(cycle)` re-checked before each request; aggregation rate-limited on ATTEMPT, not success | +| "Retry" opened a browser | Separate `onAddKey` and `onRetry` callbacks; Retry only refreshes | +| Degraded claimed a data age derived from the last *health* probe | `healthUpdated` and `usageUpdated` split; `showsData` requires real loaded sections, and the guidance quotes `dataAge` | + +The overflow menu ships `Refresh`, `Open dashboard`, and `Quit` rather than the +originally sketched `Preferences`: there is no preferences surface to open yet, and a +menu item that opens nothing is worse than its absence. + ## Accept criteria 1. Menu bar icon renders as a template image and changes with state. From 33f67d0d16800c38a7990f4ef4192422a096c5d1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:13:14 +0900 Subject: [PATCH 11/61] fix(app): replace NSPopover with a key-capable panel so Escape works Three rounds of Escape fixes failed because the premise was wrong, not the implementation. Probing the real delegate from an accessory process: popover window in NSApp.windows : absent canBecomeKey : false after NSApp.activate : appActive=true, isKey=false after NSRunningApplication : appActive=true, isKey=false after raising the window level : appActive=true, isKey=false macOS does not route key events to a window that cannot become key, so no activation strategy could ever have delivered Escape. PopoverPanel measures shown=1 canBecomeKey=1 isKey=1, and Escape closes it. The panel keeps the parts of the popover contract that matter: transient dismissal on outside click, dismissal on losing key focus, and nonactivatingPanel so opening does not steal focus from the user's editor. Also fixed: - The success path's generation guard returned without draining a queued reopen, so close-then-reopen still dropped its refresh. Every exit path now clears the lock and drains. - On-open reads ran on every 5s liveness tick, turning two rarely-changing endpoints into pollers. Now gated on an actual open or manual refresh. - An already-invalid cycle could consume the aggregation window and make a legitimate reopen skip usage and quotas for 60 seconds. - Removed lastHeavyRefresh and healthUpdated, written but never read. Four new polling tests: tick-while-open, closed-popover, partial aggregation failure, and degraded-without-data. 73 -> 77. --- .../MenuBarCore/PollingCoordinator.swift | 18 +-- app/Sources/MenuBarCore/ProxySnapshot.swift | 9 +- .../MenuBarCoreTests/PollingSuite.swift | 64 +++++++++++ app/Sources/MenuBarUI/AppDelegate.swift | 62 +++++------ app/Sources/MenuBarUI/PopoverPanel.swift | 104 ++++++++++++++++++ .../260725_macos_menubar_app/020_phase2_ui.md | 37 +++++++ 6 files changed, 249 insertions(+), 45 deletions(-) create mode 100644 app/Sources/MenuBarUI/PopoverPanel.swift diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index e7fca8ac83..2cfc3ba2d4 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -15,7 +15,6 @@ public actor PollingCoordinator { private let client: ProxyClient private var snapshot: ProxySnapshot private var popoverOpen = false - private var lastHeavyRefresh: Date? private var observers: [UUID: @Sendable (ProxySnapshot) -> Void] = [:] /// Rises on every close and on every new refresh, so results from a superseded or /// abandoned cycle can be discarded instead of overwriting fresher state. @@ -82,13 +81,16 @@ public actor PollingCoordinator { do { let health = try await client.health() - guard cycle == generation else { return } + guard cycle == generation else { + refreshInFlight = false + await drainPendingRefresh() + return + } snapshot.state = .running(health) snapshot.lastKnownStartCommand = health.manualStartCommand snapshot.recommendedCommand = health.recommendedCommand snapshot.consecutiveFailures = 0 snapshot.lastUpdated = Date() - snapshot.healthUpdated = Date() } catch is CancellationError { // The popover closed mid-flight. Not a proxy failure; leave state untouched. refreshInFlight = false @@ -107,18 +109,18 @@ public actor PollingCoordinator { } if popoverOpen { - // Cheap, changes rarely, and only meaningful while the popover is visible. - await refreshOnOpen(cycle: cycle) + // Only on an actual open or manual refresh. Running these on every liveness + // tick turned two rarely-changing endpoints into 5-second pollers. + if includeHeavy { await refreshOnOpen(cycle: cycle) } // Rate-limit on ATTEMPT, not success: gating on success alone meant one // persistently failing endpoint re-fetched its healthy sibling every 5s. let aggregationDue = lastAggregationAttempt.map { Date().timeIntervalSince($0) >= Self.heavyInterval } ?? true - if aggregationDue { + if aggregationDue, isCurrent(cycle) { lastAggregationAttempt = Date() - let completed = await refreshAggregation(cycle: cycle) - if completed { lastHeavyRefresh = Date() } + _ = await refreshAggregation(cycle: cycle) } } diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index 1e1a22fa30..d8867d814c 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -93,10 +93,9 @@ public struct ProxySnapshot: Equatable, Sendable { /// reported none" render differently. public var providersLoaded: Bool public var quotasLoaded: Bool - /// When health last succeeded, versus when the aggregation data last succeeded. - /// Conflating them let a degraded state claim "showing data from 5s ago" while - /// holding no metrics at all. - public var healthUpdated: Date? + /// When the aggregation data last succeeded, which is NOT when health last + /// succeeded. Conflating them let a degraded state claim "showing data from 5s ago" + /// while holding no metrics at all. public var usageUpdated: Date? public init( @@ -112,7 +111,6 @@ public struct ProxySnapshot: Equatable, Sendable { recommendedCommand: String? = nil, providersLoaded: Bool = false, quotasLoaded: Bool = false, - healthUpdated: Date? = nil, usageUpdated: Date? = nil ) { self.state = state @@ -127,7 +125,6 @@ public struct ProxySnapshot: Equatable, Sendable { self.recommendedCommand = recommendedCommand self.providersLoaded = providersLoaded self.quotasLoaded = quotasLoaded - self.healthUpdated = healthUpdated self.usageUpdated = usageUpdated } diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index 449e4e80e0..b89e0da7d4 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -182,6 +182,70 @@ enum PollingSuite { } t.expect(counter.count >= 2, "expected at least 2 notifications, got \(counter.count)") } + + // On-open reads are cheap but not free: running them on every liveness tick + // turned two rarely-changing endpoints into 5-second pollers. + t.test("polling: a background tick while open does not refetch on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() // ordinary liveness tick + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 1, "providers fetched once") + t.equal(paths().filter { $0 == "/api/config" }.count, 1, "config fetched once") + t.equal(paths().filter { $0 == "/api/startup-health" }.count, 2, "health fetched twice") + } + + t.test("polling: a closed popover skips on-open reads entirely") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.refresh() + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 0) + t.equal(paths().filter { $0 == "/api/usage" }.count, 0) + } + + // A failing quota endpoint must not drag its healthy sibling into the 5s tick. + t.test("polling: a partial aggregation failure still consumes the interval") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), // quotas fail + .init(status: 200, body: healthOK, urlError: nil), // next tick + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/usage" }.count, 1, "usage not refetched after a sibling failure") + } + + t.test("polling: degraded without any loaded data does not claim to show data") { + StubProtocol.reset([.init(status: 500, body: "", urlError: nil)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.showsData, false, "no data was ever loaded") + t.isNil(snapshot.dataAge, "dataAge") + } } private struct NoCredentials: CredentialStore { diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index f1b00c6ef8..a3acf33b46 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -1,17 +1,24 @@ import AppKit import MenuBarCore -public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { +public final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? - private let popover = NSPopover() + /// A key-capable panel rather than `NSPopover`. + /// + /// This is the single most-tested decision in this file. `NSPopover` from an + /// accessory (`LSUIElement`) process creates a window that never appears in + /// `NSApp.windows` and reports `canBecomeKey == false`, so macOS will not route key + /// events to it no matter how the process is activated — Escape and the Tab path + /// simply never arrive. A `nonactivatingPanel` that overrides `canBecomeKey` + /// measures as `canBecomeKey=1 isKey=1` under the same conditions. + private let panel = PopoverPanel() private let controller = PopoverViewController() private var coordinator: PollingCoordinator? private var client: ProxyClient? private var endpoint = ProxyEndpoint.default private var pollTask: Task? - /// Scoped Escape handling: an accessory app's popover does not reliably receive key - /// events through the responder chain, so the monitor is installed on open and - /// removed on close rather than left running for the process lifetime. + /// Fallback Escape handling for the case where the panel is visible but another + /// process holds focus. Installed on open, removed on close. private var escapeMonitor: Any? public override init() { super.init() } @@ -38,11 +45,8 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega controller.onRetry = { [weak self] in self?.refreshNow() } controller.onQuit = { NSApp.terminate(nil) } - popover.contentViewController = controller - popover.behavior = .transient - popover.delegate = self - // MOTION_INTENSITY 1: no decorative animation, and none at all under reduce-motion. - popover.animates = !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion + panel.contentViewController = controller + panel.onDismiss = { [weak self] in self?.handlePanelClosed() } // The observer closure is `@Sendable` and crosses actor boundaries, so it must // not capture the delegate. It hops to the main actor and looks the delegate up @@ -90,44 +94,40 @@ public final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelega // MARK: - Actions + /// Testing hook: drives the exact presentation path a status-item click uses, so a + /// harness can verify key focus and Escape without Accessibility permission. + public func debugTogglePanel() { togglePopover() } + @objc private func togglePopover() { guard let button = statusItem?.button else { return } - if popover.isShown { - popover.performClose(nil) + if panel.isShown { + panel.dismiss() } else { - popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) - // Activation has to happen AFTER presentation and on a later main-loop turn: - // an accessory process is inactive by default, and activating before the - // popover window exists leaves it without key focus, so no key event — - // including Escape — ever reaches it. - DispatchQueue.main.async { [weak self] in - guard let self, let window = self.popover.contentViewController?.view.window else { return } - NSApp.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - window.makeFirstResponder(self.controller.view) - } + panel.present(from: button) + installEscapeMonitor() + Task { [coordinator] in await coordinator?.setPopoverOpen(true) } } } - public func popoverDidShow(_ notification: Notification) { - installEscapeMonitor() - Task { [coordinator] in await coordinator?.setPopoverOpen(true) } - } - - public func popoverDidClose(_ notification: Notification) { + /// Called by the panel whenever it closes, however it was dismissed. + private func handlePanelClosed() { removeEscapeMonitor() Task { [coordinator] in await coordinator?.setPopoverOpen(false) } } + /// The panel is key-capable, so `cancelOperation(_:)` handles Escape in the normal + /// case. This local monitor is belt-and-braces for the window where the panel is up + /// but focus sits elsewhere in this process, such as the confirmation sheet. private func installEscapeMonitor() { removeEscapeMonitor() escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard event.keyCode == 53 else { return event } // Escape - self?.popover.performClose(nil) + guard event.keyCode == 53, self?.panel.isShown == true else { return event } + self?.panel.dismiss() return nil } } + private func removeEscapeMonitor() { if let monitor = escapeMonitor { NSEvent.removeMonitor(monitor) } escapeMonitor = nil diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift new file mode 100644 index 0000000000..00683205c9 --- /dev/null +++ b/app/Sources/MenuBarUI/PopoverPanel.swift @@ -0,0 +1,104 @@ +import AppKit + +/// The popover surface. +/// +/// Deliberately a panel rather than `NSPopover`. Measured on macOS 27 from an accessory +/// (`LSUIElement`) process: the window `NSPopover` creates never appears in +/// `NSApp.windows` and reports `canBecomeKey == false`, so the OS refuses to route key +/// events to it — Escape and Tab never arrive regardless of how the process is +/// activated. The same probe against this panel reports `canBecomeKey=1 isKey=1`. +/// +/// `nonactivatingPanel` keeps the click-through feel of a menu bar popover: opening it +/// does not steal focus from the user's editor. +public final class PopoverPanel: NSPanel { + /// Invoked whenever the panel closes, however it was dismissed. + public var onDismiss: (() -> Void)? + + private var clickOutsideMonitor: Any? + + public init() { + super.init( + contentRect: NSRect(x: 0, y: 0, width: 340, height: 300), + styleMask: [.nonactivatingPanel, .fullSizeContentView, .borderless], + backing: .buffered, + defer: false + ) + isFloatingPanel = true + level = .statusBar + hidesOnDeactivate = false + becomesKeyOnlyIfNeeded = false + isOpaque = false + backgroundColor = .clear + hasShadow = true + isMovable = false + animationBehavior = .utilityWindow + + contentView?.wantsLayer = true + } + + public override var canBecomeKey: Bool { true } + /// Never main: this is chrome, not a document window. + public override var canBecomeMain: Bool { false } + + public var isShown: Bool { isVisible } + + /// Presents under a status item button, clamped to the visible screen. + public func present(from button: NSStatusBarButton) { + guard let buttonWindow = button.window else { return } + layoutContent() + + let size = contentViewController?.preferredContentSize ?? frame.size + setContentSize(size) + + let buttonRect = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil)) + var origin = NSPoint( + x: buttonRect.midX - size.width / 2, + y: buttonRect.minY - size.height - 6 + ) + + if let screen = buttonWindow.screen ?? NSScreen.main { + let visible = screen.visibleFrame + origin.x = min(max(origin.x, visible.minX + 8), visible.maxX - size.width - 8) + origin.y = max(origin.y, visible.minY + 8) + } + + setFrameOrigin(origin) + makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + installClickOutsideMonitor() + } + + public func dismiss() { + removeClickOutsideMonitor() + orderOut(nil) + onDismiss?() + } + + /// Transient behaviour: clicking anywhere else dismisses, matching what a menu bar + /// popover trained the user to expect. + private func installClickOutsideMonitor() { + removeClickOutsideMonitor() + clickOutsideMonitor = NSEvent.addGlobalMonitorForEvents( + matching: [.leftMouseDown, .rightMouseDown] + ) { [weak self] _ in + self?.dismiss() + } + } + + private func removeClickOutsideMonitor() { + if let monitor = clickOutsideMonitor { NSEvent.removeMonitor(monitor) } + clickOutsideMonitor = nil + } + + public override func cancelOperation(_ sender: Any?) { dismiss() } + + public override func resignKey() { + super.resignKey() + // Losing key focus means the user moved on. Do not linger like a stuck overlay. + if isVisible { dismiss() } + } + + private func layoutContent() { + contentViewController?.view.layoutSubtreeIfNeeded() + } +} diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index d56c0e3c9f..455b1de3d8 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -316,6 +316,43 @@ The overflow menu ships `Refresh`, `Open dashboard`, and `Quit` rather than the originally sketched `Preferences`: there is no preferences surface to open yet, and a menu item that opens nothing is worse than its absence. +### Round 3 (3 findings) — and the amendment that resolved Escape + +**`NSPopover` is replaced by a key-capable `NSPanel` (`PopoverPanel`).** This is a spec +amendment, and it was forced by measurement rather than preference. Three rounds of +Escape fixes failed because the premise was wrong. Probing the real delegate from an +accessory process showed: + +```text +popover window in NSApp.windows : absent +canBecomeKey : false +after NSApp.activate : appActive=true, isKey=false +after NSRunningApplication : appActive=true, isKey=false +after raising window level : appActive=true, isKey=false +``` + +macOS will not route key events to a window that cannot become key, so no activation +strategy could have worked. The same probe against `PopoverPanel`: + +```text +shown=1 canBecomeKey=1 isKey=1 appActive=1 +afterEscape shown=0 RESULT=ESCAPE CLOSES PANEL +``` + +`PopoverPanel` keeps the popover contract that matters — transient dismissal on outside +click, dismissal on losing key focus, `nonactivatingPanel` so opening does not steal +focus from the user's editor — while actually being able to receive a keystroke. + +| Other finding | Correction | +| --- | --- | +| The success path's generation guard returned without draining a queued reopen | Every exit path now clears the lock and drains | +| On-open reads ran on every 5s liveness tick | Gated on `includeHeavy`, so they run only on a real open or manual refresh | +| An already-invalid cycle could consume the aggregation window | `isCurrent(cycle)` required before `lastAggregationAttempt` is set | + +Also removed `lastHeavyRefresh` and `healthUpdated`, which were written but never read. +Four new polling tests cover the tick-while-open, closed-popover, partial-failure, and +degraded-without-data cases. 73 -> 77. + ## Accept criteria 1. Menu bar icon renders as a template image and changes with state. From ced828b032cf7b3e4e995a93285f6c72878d8038 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:21:45 +0900 Subject: [PATCH 12/61] fix(app): give the panel a real surface and keep it alive behind the alert Round-4 review found two defects introduced by the NSPopover -> NSPanel amendment. Both are things NSPopover had been providing for free. - The borderless panel had no background at all. isOpaque=false with a clear backgroundColor composited the whole dashboard onto whatever application was underneath: labels collided with the app behind it, and contrast depended on that app's colours. Content is now wrapped in an NSVisualEffectView with .popover material, rounded and clipped. - Presenting the Stop confirmation made the alert key, which tripped resignKey() and dismissed the panel behind it. A user who chose Cancel was returned to nothing. isPresentingModal now suspends resign-key dismissal; Cancel restores key focus and Confirm dismisses deliberately. UIProbe missed the first defect because it rendered the controller inside an ordinary NSWindow, which supplies its own background. It now presents through the real PopoverPanel over a loud backdrop, so a missing surface cannot hide. That is twice in this phase that the harness rather than the code was concealing a defect. Also: dismiss() is idempotent against a late monitor callback, debugTogglePanel() is #if DEBUG only, and applicationWillTerminate dismisses the panel. --- app/Sources/MenuBarUI/AppDelegate.swift | 16 ++++++- app/Sources/MenuBarUI/PopoverPanel.swift | 47 ++++++++++++++++++- app/Sources/UIProbe/main.swift | 35 +++++++++++--- .../260725_macos_menubar_app/020_phase2_ui.md | 19 ++++++++ 4 files changed, 107 insertions(+), 10 deletions(-) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index a3acf33b46..8c9afe4e04 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -64,6 +64,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { public func applicationWillTerminate(_ notification: Notification) { pollTask?.cancel() removeEscapeMonitor() + panel.dismiss() } // MARK: - Polling @@ -94,9 +95,12 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Actions + #if DEBUG /// Testing hook: drives the exact presentation path a status-item click uses, so a /// harness can verify key focus and Escape without Accessibility permission. + /// Debug-only — it is not part of the shipped surface. public func debugTogglePanel() { togglePopover() } + #endif @objc private func togglePopover() { guard let button = statusItem?.button else { return } @@ -149,8 +153,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { alert.addButton(withTitle: "Stop proxy") alert.addButton(withTitle: "Cancel") + // The alert takes key focus, which would otherwise trip resignKey and dismiss + // the panel behind it — leaving a user who chose Cancel with nothing. + panel.isPresentingModal = true NSApp.activate(ignoringOtherApps: true) - guard alert.runModal() == .alertFirstButtonReturn else { return } + let confirmed = alert.runModal() == .alertFirstButtonReturn + panel.isPresentingModal = false + + guard confirmed else { + panel.makeKeyAndOrderFront(nil) + return + } + panel.dismiss() Task { [client, coordinator] in try? await client?.stop() diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift index 00683205c9..c5fcc2ea0f 100644 --- a/app/Sources/MenuBarUI/PopoverPanel.swift +++ b/app/Sources/MenuBarUI/PopoverPanel.swift @@ -32,10 +32,49 @@ public final class PopoverPanel: NSPanel { hasShadow = true isMovable = false animationBehavior = .utilityWindow + } - contentView?.wantsLayer = true + /// Wraps the content in a real popover material. + /// + /// A borderless panel has NO background of its own: without this the dashboard + /// composites straight onto whatever application is underneath, so labels collide + /// with the app behind it and contrast depends on that app's colours. `NSPopover` + /// supplies this surface automatically; a panel must build it. + public override var contentViewController: NSViewController? { + didSet { + guard let content = contentViewController?.view else { return } + let effect = NSVisualEffectView() + effect.material = .popover + effect.blendingMode = .behindWindow + effect.state = .active + effect.wantsLayer = true + effect.layer?.cornerRadius = 10 + effect.layer?.masksToBounds = true + effect.translatesAutoresizingMaskIntoConstraints = false + + let host = NSView() + host.addSubview(effect) + effect.addSubview(content) + content.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + effect.topAnchor.constraint(equalTo: host.topAnchor), + effect.leadingAnchor.constraint(equalTo: host.leadingAnchor), + effect.trailingAnchor.constraint(equalTo: host.trailingAnchor), + effect.bottomAnchor.constraint(equalTo: host.bottomAnchor), + content.topAnchor.constraint(equalTo: effect.topAnchor), + content.leadingAnchor.constraint(equalTo: effect.leadingAnchor), + content.trailingAnchor.constraint(equalTo: effect.trailingAnchor), + content.bottomAnchor.constraint(equalTo: effect.bottomAnchor), + ]) + contentView = host + } } + /// Suspends resign-key dismissal, so presenting a modal sheet does not tear the + /// panel down behind it and strand a user who chose Cancel. + public var isPresentingModal = false + public override var canBecomeKey: Bool { true } /// Never main: this is chrome, not a document window. public override var canBecomeMain: Bool { false } @@ -69,6 +108,8 @@ public final class PopoverPanel: NSPanel { } public func dismiss() { + // Idempotent: a late monitor callback must not re-run teardown. + guard isVisible else { return } removeClickOutsideMonitor() orderOut(nil) onDismiss?() @@ -94,7 +135,9 @@ public final class PopoverPanel: NSPanel { public override func resignKey() { super.resignKey() - // Losing key focus means the user moved on. Do not linger like a stuck overlay. + // Losing key focus means the user moved on — unless we put the focus elsewhere + // ourselves by presenting a confirmation. + guard !isPresentingModal else { return } if isVisible { dismiss() } } diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index 41e360ea26..e9ad954646 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -24,14 +24,34 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { let client = ProxyClient(endpoint: endpoint) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) - let w = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 340, height: 300), + // A loud backdrop first: if the panel has no surface of its own, this shows + // straight through and the defect is unmissable. + let backdrop = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 520, height: 620), styleMask: [.titled], backing: .buffered, defer: false) - w.title = "OpenCodex popover probe" - w.contentViewController = controller - w.center() - w.makeKeyAndOrderFront(nil) - window = w + backdrop.title = "backdrop" + let strip = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 620)) + strip.wantsLayer = true + strip.layer?.backgroundColor = NSColor.systemRed.cgColor + for i in 0..<14 { + let bar = NSView(frame: NSRect(x: 0, y: CGFloat(i) * 44, width: 520, height: 22)) + bar.wantsLayer = true + bar.layer?.backgroundColor = NSColor.systemYellow.cgColor + strip.addSubview(bar) + } + backdrop.contentView = strip + backdrop.center() + backdrop.makeKeyAndOrderFront(nil) + + // Present through the real panel so its surface (or absence of one) is captured. + let realPanel = PopoverPanel() + realPanel.contentViewController = controller + controller.view.layoutSubtreeIfNeeded() + let size = controller.preferredContentSize + realPanel.setContentSize(NSSize(width: 340, height: max(size.height, 200))) + realPanel.setFrameOrigin(NSPoint(x: backdrop.frame.midX - 170, y: backdrop.frame.midY - 150)) + realPanel.makeKeyAndOrderFront(nil) + window = realPanel NSApp.activate(ignoringOtherApps: true) Task { @@ -77,6 +97,7 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { let h = self.controller.preferredContentSize.height if h > 0, let w = self.window { w.setContentSize(NSSize(width: 340, height: h)) + w.setFrameOrigin(NSPoint(x: w.frame.origin.x, y: w.frame.origin.y)) } } try? await Task.sleep(nanoseconds: 1_200_000_000) diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index 455b1de3d8..8c43168f18 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -353,6 +353,25 @@ Also removed `lastHeavyRefresh` and `healthUpdated`, which were written but neve Four new polling tests cover the tick-while-open, closed-popover, partial-failure, and degraded-without-data cases. 73 -> 77. +### Round 4 (2 findings) — the cost of the panel amendment + +Replacing `NSPopover` removed two things it had been providing for free: + +| Finding | Correction | +| --- | --- | +| The borderless panel had **no surface at all**: `isOpaque = false` plus a clear background composited the dashboard straight onto whatever app was underneath, so labels collided with the app behind and contrast depended on it | Content is wrapped in an `NSVisualEffectView` with `.popover` material, rounded and clipped — the surface `NSPopover` supplies automatically | +| Presenting the Stop confirmation made the alert key, which tripped `resignKey()` and tore the panel down behind it — a user who chose Cancel was left with nothing | `isPresentingModal` suspends resign-key dismissal; Cancel restores key focus, Confirm dismisses deliberately | + +**Why the probe missed the first one:** `UIProbe` rendered the controller inside an +ordinary `NSWindow`, which supplies its own background. The probe now presents through +the real `PopoverPanel` over a deliberately loud backdrop, so a missing surface is +impossible to miss. This is the second time in this phase that the harness, not the +code, was the thing hiding a defect. + +Also folded: `dismiss()` is now idempotent against a late monitor callback, +`debugTogglePanel()` is `#if DEBUG` only, and `applicationWillTerminate` dismisses the +panel for lifecycle symmetry. + ## Accept criteria 1. Menu bar icon renders as a template image and changes with state. From 4416915b8e4453579df98fed199b6372252a3d82 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:31:54 +0900 Subject: [PATCH 13/61] fix(app): let the alert own Escape, and fix measured contrast failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review found two defects, both verified by measurement. - Escape during the Stop confirmation dismissed the panel and consumed the event, leaving the alert stranded with no keyboard way to cancel. The monitor now returns the event unchanged while isPresentingModal, so NSAlert handles Escape as Cancel. - Theme.faint used tertiaryLabelColor, which measured 2.01:1 in light and 2.39:1 in dark against the popover material — far under the 4.5:1 required for text. AppKit's tertiary tier is meant for disabled affordances, but it was carrying the range heading, metric captions, and quota window labels: information the user has to read. Replaced with calibrated tokens plus a separate graphMark token held to the 3:1 non-text threshold. Re-measured from the rendered PNG: 7.27:1 light, 4.98:1 dark. Contrast is now measured rather than assumed from token names, and UIProbe can force an appearance without touching system settings. --- app/Sources/MenuBarUI/AppDelegate.swift | 7 +++++- app/Sources/MenuBarUI/Theme.swift | 11 ++++++++- app/Sources/MenuBarUI/Views.swift | 2 +- app/Sources/UIProbe/main.swift | 24 ++++++++++++------- .../260725_macos_menubar_app/020_phase2_ui.md | 15 ++++++++++++ 5 files changed, 48 insertions(+), 11 deletions(-) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 8c9afe4e04..04c1438842 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -125,7 +125,12 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private func installEscapeMonitor() { removeEscapeMonitor() escapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - guard event.keyCode == 53, self?.panel.isShown == true else { return event } + // While a confirmation is up, Escape belongs to the alert: consuming it + // here dismissed the panel and stranded the alert with no way to cancel. + guard event.keyCode == 53, + self?.panel.isShown == true, + self?.panel.isPresentingModal == false + else { return event } self?.panel.dismiss() return nil } diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift index 516f806f1c..a09df9304b 100644 --- a/app/Sources/MenuBarUI/Theme.swift +++ b/app/Sources/MenuBarUI/Theme.swift @@ -12,9 +12,18 @@ enum Theme { static let raised = NSColor.controlBackgroundColor // Text: --text / --muted / --faint + // + // `tertiaryLabelColor` measured 2.01:1 in light and 2.39:1 in dark against the + // popover material — well under the 4.5:1 required for normal text. AppKit's + // tertiary tier is intended for disabled affordances, not for information the user + // has to read, and every label using this tier here (range heading, metric captions, + // quota window labels) carries real meaning. Calibrated tokens replace it. static let text = NSColor.labelColor static let muted = NSColor.secondaryLabelColor - static let faint = NSColor.tertiaryLabelColor + /// Small supporting text that must still be legible: 10-11pt captions and labels. + static let faint = dynamic(light: 0x55534F, dark: 0xB8B6B3) + /// Graphical marks only, held to the 3:1 non-text threshold. + static let graphMark = dynamic(light: 0x8A8782, dark: 0x94918C) // State colours, taken verbatim from styles.css. static let green = dynamic(light: 0x0A7D5C, dark: 0x4ECB9D) diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index b258122619..016d92c74f 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -218,7 +218,7 @@ final class SparklineView: NSView { // The most recent day is the one being asked about, so it carries full // weight while history recedes. let isLatest = index == values.count - 1 - (isLatest ? Theme.muted : Theme.faint).setFill() + (isLatest ? Theme.muted : Theme.graphMark).setFill() NSBezierPath(roundedRect: rect, xRadius: 1.5, yRadius: 1.5).fill() } } diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index e9ad954646..417666d1a5 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -1,13 +1,18 @@ // Visual-QA harness (not shipped). // -// Renders the popover in a plain window and screenshots it through the window server, so -// every UI state can be inspected without depending on free menu bar space. Set -// PROBE_STATE to live | stopped | unauthorized | loading | degraded | empty and -// PROBE_TAG to name the output file. +// Presents the real PopoverPanel over a deliberately loud backdrop and captures it with +// CGWindowListCreateImage, so every UI state can be inspected without depending on free +// menu bar space. // -// Capturing via `screencapture -l ` rather than cacheDisplay(in:to:) is -// deliberate: the bitmap-rep path skips text rendering and produced a screenshot with no -// labels at all. +// Two harness decisions are load-bearing, both learned the hard way: +// * Present through the REAL panel. An earlier version used a plain NSWindow, which +// supplied its own background and hid the fact that the panel had none at all. +// * Capture through the window server. cacheDisplay(in:to:) skips text rendering and +// produced screenshots with no labels. +// +// PROBE_STATE: live | stopped | unauthorized | loading | degraded | empty | overflow +// PROBE_TAG: output filename suffix +// PROBE_APPEARANCE: light | dark (forces appearance without touching system settings) import AppKit import MenuBarCore @@ -20,6 +25,10 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { var window: NSWindow? func applicationDidFinishLaunching(_ n: Notification) { + // Force an appearance for contrast measurement without touching system settings. + if let name = ProcessInfo.processInfo.environment["PROBE_APPEARANCE"] { + NSApp.appearance = NSAppearance(named: name == "dark" ? .darkAqua : .aqua) + } let endpoint = ProxyDiscovery.resolve() let client = ProxyClient(endpoint: endpoint) let coordinator = PollingCoordinator(client: client, endpoint: endpoint) @@ -97,7 +106,6 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { let h = self.controller.preferredContentSize.height if h > 0, let w = self.window { w.setContentSize(NSSize(width: 340, height: h)) - w.setFrameOrigin(NSPoint(x: w.frame.origin.x, y: w.frame.origin.y)) } } try? await Task.sleep(nanoseconds: 1_200_000_000) diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index 8c43168f18..e935856391 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -372,6 +372,21 @@ Also folded: `dismiss()` is now idempotent against a late monitor callback, `debugTogglePanel()` is `#if DEBUG` only, and `applicationWillTerminate` dismisses the panel for lifecycle symmetry. +### Round 5 (2 findings) + +| Finding | Correction | +| --- | --- | +| Escape during the Stop confirmation dismissed the panel and left the alert stranded with no way to cancel | The Escape monitor now returns the event unchanged while `isPresentingModal`, so `NSAlert` handles it as Cancel | +| `tertiaryLabelColor` measured **2.01:1** in light and **2.39:1** in dark against the popover material, far under the 4.5:1 needed for text | Calibrated `faint` and a separate `graphMark` token. Re-measured on the rendered panel: **7.27:1** light, **4.98:1** dark | + +The contrast finding is worth naming precisely: AppKit's tertiary tier is intended for +disabled affordances, and it was being used for the range heading, metric captions, and +quota window labels — all information the user actually has to read. "It is a system +semantic colour" is not the same as "it is legible on this material." + +Contrast is now measured from the rendered PNG rather than assumed from tokens, and the +probe can force an appearance (`PROBE_APPEARANCE=dark`) without touching system settings. + ## Accept criteria 1. Menu bar icon renders as a template image and changes with state. From 5bfeb18276f03d6c932eca47c75bf594a7a01413 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:38:32 +0900 Subject: [PATCH 14/61] fix(app): recalibrate all four text tiers against the rendered material MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My round-5 contrast correction was itself wrong. The sampling took the darkest pixel in a band, which is primary text, not faint — so 7.27:1 and 4.98:1 described a token that was never in question while the actual faint tier sat at 2.87:1 in dark and the sparkline marks at 1.85:1. Corrected method: count pixels matching each exact token value in the rendered PNG, so one tier cannot be measured by accidentally sampling another. Measured against light (220,219,218) and dark (103,102,102): text 12.59 / 5.72 (>= 4.5) muted 7.86 / 5.11 (>= 4.5) faint 5.48 / 4.89 (>= 4.5) graphMark 3.58 / 3.79 (>= 3.0, non-text) All four pass and text > muted > faint holds in both appearances. The light inversion the reviewer found — faint outranking muted — is gone. The dark material is the binding constraint: pure white measures only 5.81:1 against it, so three text tiers have to fit inside a 1.3-point band. That is why the dark values cluster, and why AppKit's semantic tiers cannot be used here without silently reintroducing the failure. --- app/Sources/MenuBarUI/Theme.swift | 17 ++++++++---- app/Sources/UIProbe/main.swift | 4 +-- .../260725_macos_menubar_app/020_phase2_ui.md | 26 +++++++++++++++++-- 3 files changed, 38 insertions(+), 9 deletions(-) diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift index a09df9304b..d41c2efb0d 100644 --- a/app/Sources/MenuBarUI/Theme.swift +++ b/app/Sources/MenuBarUI/Theme.swift @@ -18,12 +18,19 @@ enum Theme { // tertiary tier is intended for disabled affordances, not for information the user // has to read, and every label using this tier here (range heading, metric captions, // quota window labels) carries real meaning. Calibrated tokens replace it. - static let text = NSColor.labelColor - static let muted = NSColor.secondaryLabelColor + /// All three text tiers are calibrated against the RENDERED popover material, not + /// picked from AppKit's semantic palette. Measured backgrounds: light (220,219,218), + /// dark (102,101,101). + /// + /// The dark material constrains this hard — pure white measures only 5.81:1 against + /// it — so the tiers are packed into the band that remains while keeping every text + /// tier above 4.5:1 and preserving `text > muted > faint` in both appearances. + static let text = dynamic(light: 0x1A1A1A, dark: 0xFFFFFF) + static let muted = dynamic(light: 0x3D3D3D, dark: 0xF2F2F2) /// Small supporting text that must still be legible: 10-11pt captions and labels. - static let faint = dynamic(light: 0x55534F, dark: 0xB8B6B3) - /// Graphical marks only, held to the 3:1 non-text threshold. - static let graphMark = dynamic(light: 0x8A8782, dark: 0x94918C) + static let faint = dynamic(light: 0x545454, dark: 0xEDEDED) + /// Graphical marks only, held to the 3:1 non-text threshold rather than 4.5:1. + static let graphMark = dynamic(light: 0x707070, dark: 0xD2D2D2) // State colours, taken verbatim from styles.css. static let green = dynamic(light: 0x0A7D5C, dark: 0x4ECB9D) diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index 417666d1a5..33e323e7a3 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -18,8 +18,8 @@ import AppKit import MenuBarCore import MenuBarUI -// Renders the popover in a plain window and screenshots it, so the UI can be inspected -// without depending on menu bar space being available. +// Presents the real PopoverPanel over a contrasting backdrop and captures it through the +// window server, so the UI can be inspected without depending on menu bar space. final class ProbeDelegate: NSObject, NSApplicationDelegate { let controller = PopoverViewController() var window: NSWindow? diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index e935856391..6b2ef1d9bd 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -377,14 +377,36 @@ panel for lifecycle symmetry. | Finding | Correction | | --- | --- | | Escape during the Stop confirmation dismissed the panel and left the alert stranded with no way to cancel | The Escape monitor now returns the event unchanged while `isPresentingModal`, so `NSAlert` handles it as Cancel | -| `tertiaryLabelColor` measured **2.01:1** in light and **2.39:1** in dark against the popover material, far under the 4.5:1 needed for text | Calibrated `faint` and a separate `graphMark` token. Re-measured on the rendered panel: **7.27:1** light, **4.98:1** dark | +| `tertiaryLabelColor` measured **2.01:1** in light and **2.39:1** in dark against the popover material, far under the 4.5:1 needed for text | All four tiers recalibrated against the rendered material — see the table below | The contrast finding is worth naming precisely: AppKit's tertiary tier is intended for disabled affordances, and it was being used for the range heading, metric captions, and quota window labels — all information the user actually has to read. "It is a system semantic colour" is not the same as "it is legible on this material." -Contrast is now measured from the rendered PNG rather than assumed from tokens, and the +### Round 6: the contrast numbers, measured properly + +My first correction was itself wrong: the sampling picked the darkest pixel in a band, +which is primary `text`, not `faint`. Corrected method — count pixels matching each exact +token value in the rendered PNG, so a tier cannot be measured by sampling a different one. + +Backgrounds as rendered: light `(220,219,218)`, dark `(103,102,102)`. + +| Token | Light | Dark | Threshold | +| --- | ---: | ---: | ---: | +| `text` | 12.59:1 | 5.72:1 | 4.5 | +| `muted` | 7.86:1 | 5.11:1 | 4.5 | +| `faint` | 5.48:1 | 4.89:1 | 4.5 | +| `graphMark` | 3.58:1 | 3.79:1 | 3.0 (non-text) | + +Every tier passes and `text > muted > faint` holds in both appearances. + +The dark material is the binding constraint: **pure white measures only 5.81:1 against +it**, so the three text tiers have to fit inside a 1.3-point band. That is why the dark +values cluster — there is no room for the airy separation the light palette allows, and +choosing AppKit's semantic tiers instead would silently reintroduce the failure. + +Contrast is measured from the rendered PNG rather than assumed from token names, and the probe can force an appearance (`PROBE_APPEARANCE=dark`) without touching system settings. ## Accept criteria From 3e5b70587179f3a8c0c21e06e9080ce84ddcc358 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:42:12 +0900 Subject: [PATCH 15/61] docs(app): note the material pixel variation and the semantic-colour exception Round-7 review passed. Two documentation nits from it: - The dark popover material is not perfectly flat: the dominant pixel is (102,101,101) while adjacent pixels read (103,102,102). The contrast table uses the lighter value (the stricter test) and the 5.81:1 ceiling comes from the darker one. Both are now named. - Theme's header claimed AppKit semantic colours always win, which is true for surfaces but is now a deliberate exception for the text tiers. --- app/Sources/MenuBarUI/Theme.swift | 10 ++++++++-- devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift index d41c2efb0d..b1e8b4241f 100644 --- a/app/Sources/MenuBarUI/Theme.swift +++ b/app/Sources/MenuBarUI/Theme.swift @@ -3,9 +3,15 @@ import AppKit /// Tokens derived from `gui/src/styles.css` so the companion and the dashboard agree on /// what "healthy" looks like. /// -/// Where AppKit already has a semantic colour, it wins over a hardcoded hex: it tracks -/// light/dark *and* the increased-contrast and vibrancy accessibility settings, which a +/// For SURFACES, AppKit's semantic colours win over a hardcoded hex: they track +/// light/dark plus the increased-contrast and vibrancy accessibility settings, which a /// literal cannot. +/// +/// The TEXT tiers are a deliberate exception. Measured against the popover material, +/// `tertiaryLabelColor` renders at 2.01:1 in light and 2.39:1 in dark — it is designed +/// for disabled affordances, not for information the user has to read. All four text and +/// mark tokens below are therefore calibrated against the rendered material and verified +/// numerically rather than trusted by name. enum Theme { // Surfaces static let separator = NSColor.separatorColor diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md index 6b2ef1d9bd..28fbf34614 100644 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md @@ -390,7 +390,10 @@ My first correction was itself wrong: the sampling picked the darkest pixel in a which is primary `text`, not `faint`. Corrected method — count pixels matching each exact token value in the rendered PNG, so a tier cannot be measured by sampling a different one. -Backgrounds as rendered: light `(220,219,218)`, dark `(103,102,102)`. +Backgrounds as rendered: light `(220,219,218)`, dark `(103,102,102)`. The dark material +is not perfectly flat — the dominant pixel is `(102,101,101)` and adjacent pixels read +`(103,102,102)`. The table below uses the lighter of the two, which is the stricter test; +the 5.81:1 ceiling quoted afterwards is measured against `(102,101,101)`. | Token | Light | Dark | Threshold | | --- | ---: | ---: | ---: | From 988db7e9840412b152c55e8200ecfa4c217717b9 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:47:45 +0900 Subject: [PATCH 16/61] feat(app): wire proxy control and provider toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 (030_phase3_actions.md). The client write methods and the confirmation sheet already landed in Phase 2 — a Stop button could not ship without them — so this phase adds what was actually missing: outcome reporting, the provider toggle UI, and result feedback. - ActionCoordinator reports what happened rather than what was requested. /api/stop answers before it drains and stops launchd on the way, so a 200 means accepted, not stopped: the coordinator polls until the port stops answering and reports requiresManualStart with the command for that install. A proxy still answering after 10s is a failure, not a success. - Provider toggles are optimistic with revert on rejection. The default provider's switch is inert and explains why, since the proxy answers 400 for that case and firing a request that cannot succeed is worse than not offering it. - A result banner reports every write outcome and clears itself, guarded by a token so an older timer cannot clear a newer result. - No failure path quotes a response body; bodies can echo configuration. The stop timeout test needed an injectable clock, not just a no-op sleeper: the loop is bounded by a deadline, so skipping the sleep without advancing time meant it never expired and the test reported success. Recorded in 030 along with the stub's drain-to-refused fallback, which can make an under-queued test pass for the wrong reason. Live-verified against the running proxy: anthropic disabled and re-enabled with the proxy confirming each state, and the default-provider guard refusing before any request. Proxy state restored afterwards, 10 of 10 enabled. 77 -> 87 tests. --- .../MenuBarCore/ActionCoordinator.swift | 90 +++++++++ .../MenuBarCoreTests/ActionSuite.swift | 171 ++++++++++++++++++ app/Sources/MenuBarCoreTests/main.swift | 1 + app/Sources/MenuBarUI/AppDelegate.swift | 57 +++++- .../MenuBarUI/PopoverViewController.swift | 54 ++++-- app/Sources/MenuBarUI/ProviderListView.swift | 170 +++++++++++++++++ app/Sources/UIProbe/main.swift | 21 +++ .../030_phase3_actions.md | 45 ++++- 8 files changed, 586 insertions(+), 23 deletions(-) create mode 100644 app/Sources/MenuBarCore/ActionCoordinator.swift create mode 100644 app/Sources/MenuBarCoreTests/ActionSuite.swift create mode 100644 app/Sources/MenuBarUI/ProviderListView.swift diff --git a/app/Sources/MenuBarCore/ActionCoordinator.swift b/app/Sources/MenuBarCore/ActionCoordinator.swift new file mode 100644 index 0000000000..e076df3ad1 --- /dev/null +++ b/app/Sources/MenuBarCore/ActionCoordinator.swift @@ -0,0 +1,90 @@ +import Foundation + +/// The result of a write action, in terms the UI can render directly. +public enum ActionOutcome: Equatable, Sendable { + case succeeded + /// The stop was confirmed, but nothing will restart the proxy — the user has to. + case requiresManualStart(String) + /// A human sentence. Never a response body: bodies can echo configuration. + case failed(String) +} + +/// Executes write actions and reports what actually happened. +/// +/// Split from the UI because the interesting behaviour is timing, not presentation: +/// `/api/stop` answers before it drains, so "the request returned 200" and "the proxy +/// stopped" are different facts and only the second one is worth telling the user. +public actor ActionCoordinator { + /// How long to wait for the port to stop answering before giving up. + public static let stopTimeout: TimeInterval = 10 + public static let pollInterval: TimeInterval = 0.5 + + private let client: ProxyClient + private let sleeper: @Sendable (TimeInterval) async -> Void + /// Injected so tests can advance time without waiting for it. A no-op sleeper alone + /// is not enough: the loop is bounded by a deadline, so the clock has to move too. + private let now: @Sendable () -> Date + + public init( + client: ProxyClient, + sleeper: @escaping @Sendable (TimeInterval) async -> Void = { seconds in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.client = client + self.sleeper = sleeper + self.now = now + } + + /// Stops the proxy and waits until it is actually gone. + /// + /// `/api/stop` calls `stopServiceIfInstalled()` and returns before draining, so a + /// 200 means "accepted", not "stopped". Reporting success on the response alone + /// would make the UI claim a state the system has not reached yet. + public func stop(startCommand: String) async -> ActionOutcome { + do { + try await client.stop() + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("Could not reach the proxy to stop it.") + } + + let deadline = now().addingTimeInterval(Self.stopTimeout) + while now() < deadline { + await sleeper(Self.pollInterval) + if await !client.isReachable() { + return .requiresManualStart(startCommand) + } + } + return .failed("The proxy accepted the stop but was still responding after \(Int(Self.stopTimeout)) seconds.") + } + + /// Enables or disables a provider. + /// + /// The default provider is rejected before any request is sent: the proxy answers + /// 400 for that case, and firing a request that cannot succeed is worse than not + /// offering it. + public func setProvider( + _ name: String, + disabled: Bool, + defaultProvider: String? + ) async -> ActionOutcome { + if disabled, name == defaultProvider { + return .failed("\(name) is the default provider. Choose another default in the dashboard first.") + } + do { + try await client.setProviderDisabled(name, disabled: disabled) + return .succeeded + } catch ProxyError.http(400) { + // The proxy validates more than we can predict; surface its refusal without + // quoting its body. + return .failed("The proxy refused that change. Adjust it in the dashboard.") + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("That change could not be applied.") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift new file mode 100644 index 0000000000..04676ed9d8 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -0,0 +1,171 @@ +import Foundation +import MenuBarCore + +/// Write-action behaviour, especially the timing: `/api/stop` answers before it drains, +/// so "returned 200" and "actually stopped" are different facts. +enum ActionSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + private struct NoCredentials: CredentialStore { func loadAPIKey() -> String? { nil } } + + /// A clock the test drives, so the timeout path runs in milliseconds. + private final class FakeClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_784_915_000) + func now() -> Date { lock.lock(); defer { lock.unlock() }; return current } + func advance(_ seconds: TimeInterval) { + lock.lock(); current = current.addingTimeInterval(seconds); lock.unlock() + } + } + + private static func makeCoordinator(clock: FakeClock = FakeClock()) -> ActionCoordinator { + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + // Skip the real wall-clock wait, but advance the clock by the same amount so the + // deadline still expires. + return ActionCoordinator( + client: client, + sleeper: { seconds in clock.advance(seconds) }, + now: { clock.now() } + ) + } + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + // The proxy stops the launchd service on purpose, so a successful stop is + // reported as "you will have to start it again", not as a plain success. + t.test("stop: reports manual-start once the port stops answering") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), // POST /api/stop + .init(status: 0, body: "", urlError: .cannotConnectToHost), // probe: gone + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx service start") } + t.equal(outcome, .requiresManualStart("ocx service start")) + t.expect(paths().first == "/api/stop", "stop called first, got \(paths())") + } + + // A 200 that never drains must not be reported as success. + t.test("stop: a proxy that keeps answering is a failure, not a success") { + // The stub falls back to "connection refused" once its queue drains, which + // would look like a successful stop. Queue well past the poll count so the + // timeout path is what actually runs. + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), + count: 400 + )) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an unreachable proxy fails without claiming it stopped anything") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + + t.test("stop: a failure message never carries the response body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG", urlError: nil)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(!message.contains("SECRET"), "leaked body: \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: disabling sends exactly one PATCH and succeeds") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("anthropic", disabled: true, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + t.equal(StubProtocol.recorded.count, 1) + t.equal(StubProtocol.recorded.first?.httpMethod, "PATCH") + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + } + + // The proxy answers 400 for this, so the request is never sent at all. + t.test("provider: the default provider is refused before any request") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(message.contains("default provider"), "expected an explanation, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + t.equal(StubProtocol.recorded.count, 0, "no request should be sent") + } + + t.test("provider: enabling the default provider is allowed") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: false, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + } + + t.test("provider: a 400 from the proxy surfaces without quoting its body") { + StubProtocol.reset([.init(status: 400, body: "cannot disable the default provider", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(!message.contains("cannot disable"), "leaked body: \(message)") + t.expect(message.contains("refused"), "expected a refusal message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: an unreachable proxy fails cleanly") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) + } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + + t.test("actions: no outcome message is empty") { + let outcomes: [ActionOutcome] = [ + .succeeded, + .requiresManualStart("ocx start"), + .failed("something went wrong"), + ] + for outcome in outcomes { + switch outcome { + case .succeeded: break + case .requiresManualStart(let value), .failed(let value): + t.expect(!value.isEmpty, "outcome carried an empty message") + } + } + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift index df707b3191..8db237c4c5 100644 --- a/app/Sources/MenuBarCoreTests/main.swift +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -11,5 +11,6 @@ FormattingSuite.run(runner) TransportSuite.run(runner) SnapshotStateSuite.run(runner) PollingSuite.run(runner) +ActionSuite.run(runner) exit(runner.summarize()) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 04c1438842..536a25c693 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -14,7 +14,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private let panel = PopoverPanel() private let controller = PopoverViewController() private var coordinator: PollingCoordinator? + private var actions: ActionCoordinator? private var client: ProxyClient? + /// The snapshot the UI is currently showing, for decisions that need context + /// (the start command to display, the default provider to protect). + private var latest: ProxySnapshot? private var endpoint = ProxyEndpoint.default private var pollTask: Task? /// Fallback Escape handling for the case where the panel is visible but another @@ -29,6 +33,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { self.client = client let coordinator = PollingCoordinator(client: client, endpoint: endpoint) self.coordinator = coordinator + self.actions = ActionCoordinator(client: client) let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) item.button?.image = StatusIcon.image(for: .loading) @@ -43,6 +48,9 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { controller.onRefresh = { [weak self] in self?.refreshNow() } controller.onAddKey = { [weak self] in self?.openDashboard() } controller.onRetry = { [weak self] in self?.refreshNow() } + controller.onToggleProvider = { [weak self] name, disable in + self?.toggleProvider(name, disable: disable) + } controller.onQuit = { NSApp.terminate(nil) } panel.contentViewController = controller @@ -88,6 +96,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor fileprivate func render(_ snapshot: ProxySnapshot) { + latest = snapshot statusItem?.button?.image = StatusIcon.image(for: snapshot.state) statusItem?.button?.toolTip = "OpenCodex — \(snapshot.state.title) (\(snapshot.endpoint.display))" controller.apply(snapshot) @@ -147,8 +156,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } /// Stopping is destructive: it interrupts in-flight requests and stops the launchd - /// service, so nothing restarts the proxy. It always confirms first. Drain polling - /// and failure reporting land in Phase 3. + /// service, so nothing restarts the proxy. It always confirms first. private func stopProxy() { let alert = NSAlert() alert.messageText = "Stop the OpenCodex proxy?" @@ -169,11 +177,50 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { panel.makeKeyAndOrderFront(nil) return } - panel.dismiss() - Task { [client, coordinator] in - try? await client?.stop() + let startCommand = latest?.lastKnownStartCommand ?? "ocx start" + controller.showResult("Stopping…", isError: false) + + Task { [actions, coordinator] in + let outcome = await actions?.stop(startCommand: startCommand) ?? .failed("Unavailable.") await coordinator?.refresh() + await MainActor.run { [weak self] in + switch outcome { + case .succeeded: + self?.controller.showResult("Proxy stopped.", isError: false) + case .requiresManualStart(let command): + // Not a failure — the API has no start endpoint by design. + self?.controller.showResult("Proxy stopped. Start it again with \(command)", isError: false) + case .failed(let message): + self?.controller.showResult(message, isError: true) + } + } + } + } + + /// Optimistic toggle: the switch has already moved, so a rejection must move it back + /// rather than leave the UI showing a state the proxy refused. + private func toggleProvider(_ name: String, disable: Bool) { + let defaultProvider = latest?.defaultProvider + + Task { [actions, coordinator] in + let outcome = await actions?.setProvider(name, disabled: disable, defaultProvider: defaultProvider) + ?? .failed("Unavailable.") + await MainActor.run { [weak self] in + switch outcome { + case .succeeded: + self?.controller.showResult( + disable ? "\(name) disabled." : "\(name) enabled.", + isError: false + ) + case .failed(let message), .requiresManualStart(let message): + self?.controller.revertProvider(name, to: !disable) + self?.controller.showResult(message, isError: true) + } + } + // Re-read so the summary line and switch states match the proxy, not our + // optimistic guess. + await coordinator?.refresh(includeHeavy: true) } } } diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index 4f6f2efbf0..df042a45b9 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -34,7 +34,10 @@ public final class PopoverViewController: NSViewController { private let sparkline = SparklineView() private let quotaStack = NSStackView() private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) - private let providerSummary = makeLabel("", font: Theme.caption, color: Theme.muted) + private let providers = ProviderListView() + /// Transient result of the last write action. Actions that report nothing leave the + /// user guessing whether anything happened. + private let resultBanner = makeLabel("", font: Theme.caption, color: Theme.muted) private let skeleton = SkeletonView() private let guidanceLabel: NSTextField = { let field = makeLabel("", font: Theme.caption, color: Theme.muted) @@ -53,6 +56,8 @@ public final class PopoverViewController: NSViewController { public var onStop: (() -> Void)? public var onQuit: (() -> Void)? public var onRefresh: (() -> Void)? + /// `(provider, shouldDisable)`. + public var onToggleProvider: ((String, Bool) -> Void)? /// Distinct callbacks: "Retry" must retry in place, while "Add key…" navigates to /// the dashboard. Routing both through one handler made Retry open a browser. public var onAddKey: (() -> Void)? @@ -60,16 +65,25 @@ public final class PopoverViewController: NSViewController { private var snapshot: ProxySnapshot? private var scrollHeight: NSLayoutConstraint? + /// Guards the banner's auto-hide so a newer result is not cleared by an older timer. + private var resultToken = 0 public override func loadView() { configureControls() + resultBanner.isHidden = true + resultBanner.lineBreakMode = .byWordWrapping + resultBanner.maximumNumberOfLines = 3 + resultBanner.preferredMaxLayoutWidth = Theme.width - Theme.gutter * 2 + providers.onToggle = { [weak self] name, disable in + self?.onToggleProvider?(name, disable) + } body.orientation = .vertical body.alignment = .leading body.spacing = Theme.rowGap body.setViews( [skeleton, metrics, sparkline, metricsSeparator, quotaStack, quotaEmpty, - providerSummary, quotaSeparator, guidanceLabel, commandField], + providers, quotaSeparator, resultBanner, guidanceLabel, commandField], in: .top ) body.translatesAutoresizingMaskIntoConstraints = false @@ -178,12 +192,12 @@ public final class PopoverViewController: NSViewController { metrics.apply(snapshot) sparkline.apply(snapshot) applyQuotas(snapshot) - applyProviders(snapshot) + providers.apply(snapshot) } else { sparkline.isHidden = true quotaStack.isHidden = true quotaEmpty.isHidden = true - providerSummary.isHidden = true + providers.isHidden = true } applyGuidance(snapshot) @@ -208,20 +222,30 @@ public final class PopoverViewController: NSViewController { } } - private func applyProviders(_ snapshot: ProxySnapshot) { - guard snapshot.providersLoaded else { - providerSummary.isHidden = true - return - } - providerSummary.isHidden = false - if snapshot.providers.isEmpty { - providerSummary.stringValue = "No providers configured." - } else { - let enabled = snapshot.providers.filter(\.isEnabled).count - providerSummary.stringValue = "\(enabled) of \(snapshot.providers.count) providers enabled" + /// Shows the outcome of a write action, then clears itself. A banner that never + /// leaves would become permanent furniture. + public func showResult(_ text: String, isError: Bool) { + resultBanner.stringValue = text + resultBanner.textColor = isError ? Theme.red : Theme.muted + resultBanner.isHidden = false + refreshSize() + + resultToken &+= 1 + let token = resultToken + DispatchQueue.main.asyncAfter(deadline: .now() + 6) { [weak self] in + guard let self, self.resultToken == token else { return } + self.resultBanner.isHidden = true + self.refreshSize() } } + public func revertProvider(_ name: String, to enabled: Bool) { + providers.revert(name, to: enabled) + } + + /// Re-measures after content changes height (disclosure, banner). + public func refreshSize() { resize() } + /// Guidance text plus any command the user should run. Commands are shown as /// selectable text; the app never executes them. private func applyGuidance(_ snapshot: ProxySnapshot) { diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift new file mode 100644 index 0000000000..ec089a5428 --- /dev/null +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -0,0 +1,170 @@ +import AppKit +import MenuBarCore + +/// Collapsed provider list with per-provider enable/disable switches. +/// +/// Collapsed by default: reading status is frequent, toggling a provider is rare, and +/// the urgency order in `003` puts actions below information. +final class ProviderListView: NSView { + private let disclosure = NSButton() + private let summary = makeLabel("", font: Theme.caption, color: Theme.muted) + private let rows = NSStackView() + private var expanded = false + private var snapshot: ProxySnapshot? + + /// `(provider, shouldDisable)`. + var onToggle: ((String, Bool) -> Void)? + + init() { + super.init(frame: .zero) + + disclosure.bezelStyle = .disclosure + disclosure.setButtonType(.onOff) + disclosure.title = "" + disclosure.target = self + disclosure.action = #selector(toggleExpanded) + disclosure.setAccessibilityLabel("Show providers") + + rows.orientation = .vertical + rows.alignment = .leading + rows.spacing = Theme.tightGap + rows.isHidden = true + + let header = NSStackView(views: [disclosure, summary]) + header.orientation = .horizontal + header.spacing = Theme.tightGap + header.alignment = .centerY + + let column = NSStackView(views: [header, rows]) + column.orientation = .vertical + column.alignment = .leading + column.spacing = Theme.tightGap + column.translatesAutoresizingMaskIntoConstraints = false + addSubview(column) + NSLayoutConstraint.activate([ + column.topAnchor.constraint(equalTo: topAnchor), + column.leadingAnchor.constraint(equalTo: leadingAnchor), + column.trailingAnchor.constraint(equalTo: trailingAnchor), + column.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + self.snapshot = snapshot + + guard snapshot.providersLoaded else { + isHidden = true + return + } + isHidden = false + + if snapshot.providers.isEmpty { + summary.stringValue = "No providers configured." + disclosure.isHidden = true + rows.isHidden = true + return + } + + disclosure.isHidden = false + let enabled = snapshot.providers.filter(\.isEnabled).count + summary.stringValue = "\(enabled) of \(snapshot.providers.count) providers enabled" + rebuildRows(snapshot) + rows.isHidden = !expanded + } + + private func rebuildRows(_ snapshot: ProxySnapshot) { + for view in rows.arrangedSubviews { + rows.removeArrangedSubview(view) + view.removeFromSuperview() + } + + for provider in snapshot.providers.sorted(by: { $0.name < $1.name }) { + let isDefault = provider.name == snapshot.defaultProvider + let row = ProviderRowView( + provider: provider, + isDefault: isDefault + ) { [weak self] shouldDisable in + self?.onToggle?(provider.name, shouldDisable) + } + row.translatesAutoresizingMaskIntoConstraints = false + rows.addArrangedSubview(row) + row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true + } + } + + @objc private func toggleExpanded() { + expanded = disclosure.state == .on + rows.isHidden = !expanded + disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") + // The popover has to grow or shrink with the disclosure. + (window?.contentViewController as? PopoverViewController)?.refreshSize() + } + + /// Reverts a switch after the proxy rejected the change. + func revert(_ name: String, to enabled: Bool) { + for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { + row.setEnabled(enabled) + } + } +} + +final class ProviderRowView: NSView { + let providerName: String + private let toggle = NSSwitch() + private let onToggle: (Bool) -> Void + + init(provider: ProviderSummary, isDefault: Bool, onToggle: @escaping (Bool) -> Void) { + self.providerName = provider.name + self.onToggle = onToggle + super.init(frame: .zero) + + let name = makeLabel(provider.name, font: Theme.caption, color: Theme.text) + let detail = makeLabel( + isDefault ? "default" : (provider.authMode ?? ""), + font: Theme.micro, + color: Theme.faint + ) + + let labels = NSStackView(views: [name, detail]) + labels.orientation = .vertical + labels.alignment = .leading + labels.spacing = 0 + + toggle.state = provider.isEnabled ? .on : .off + toggle.controlSize = .mini + toggle.target = self + toggle.action = #selector(switched) + + // The proxy rejects disabling the default provider with a 400, so the control is + // inert and explains itself rather than offering an action that cannot succeed. + toggle.isEnabled = !isDefault + toggle.toolTip = isDefault + ? "This is the default provider. Choose another default in the dashboard first." + : nil + toggle.setAccessibilityLabel("\(provider.name) enabled") + + let row = NSStackView(views: [labels, NSView(), toggle]) + row.orientation = .horizontal + row.spacing = Theme.rowGap + row.alignment = .centerY + row.translatesAutoresizingMaskIntoConstraints = false + addSubview(row) + NSLayoutConstraint.activate([ + row.topAnchor.constraint(equalTo: topAnchor), + row.leadingAnchor.constraint(equalTo: leadingAnchor), + row.trailingAnchor.constraint(equalTo: trailingAnchor), + row.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func setEnabled(_ enabled: Bool) { toggle.state = enabled ? .on : .off } + + @objc private func switched() { + // Optimistic: the switch has already moved. The caller reverts on failure. + onToggle(toggle.state == .off) + } +} diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index 33e323e7a3..f1059c4238 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -101,6 +101,15 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { } await MainActor.run { self.controller.apply(snap) + // Expand the provider list so its toggles are visible in the capture. + if ProcessInfo.processInfo.environment["PROBE_EXPAND"] == "1" { + self.expandProviders(in: self.controller.view) + } + if ProcessInfo.processInfo.environment["PROBE_RESULT"] != nil { + self.controller.showResult( + ProcessInfo.processInfo.environment["PROBE_RESULT"]!, + isError: ProcessInfo.processInfo.environment["PROBE_RESULT_ERROR"] == "1") + } self.controller.view.layoutSubtreeIfNeeded() // Match the real popover: size to content instead of a fixed frame. let h = self.controller.preferredContentSize.height @@ -113,6 +122,18 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { } } + @MainActor func expandProviders(in view: NSView) { + for sub in view.subviews { + if let button = sub as? NSButton, button.bezelStyle == .disclosure { + button.state = .on + if let target = button.target, let action = button.action { + _ = target.perform(action, with: button) + } + } + expandProviders(in: sub) + } + } + @MainActor func capture() { guard let w = window else { return } let tag = ProcessInfo.processInfo.environment["PROBE_TAG"] ?? "light" diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index a922f6f545..917aa450b4 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -7,15 +7,28 @@ running proxy, with the observed response and the resulting UI state. Constraint from the user's scope: **no new proxy endpoints.** Everything here calls routes inventoried in `002` §4. +## Stale check at P (what Phase 2 already landed) + +Re-verifying this document against the tree found three items already done, because the +UI phase could not ship a `Stop proxy` button without them: + +- `ProxyClient.stop()` and `setProviderDisabled(_:disabled:)` exist (`010`/`020`). +- The confirmation sheet exists as an `NSAlert` in `AppDelegate.stopProxy()`, including + the `isPresentingModal` guard that keeps the panel alive behind it. +- `ConfirmSheet.swift` is therefore not needed as a separate file. + +What remained, and is what this phase delivers: an `ActionCoordinator` that reports what +actually happened, the provider toggle UI, and result feedback in the popover. + ## File change map | Path | Action | | --- | --- | | `app/Sources/MenuBarCore/ProxyClient.swift` | MODIFY — add write methods | | `app/Sources/MenuBarCore/ActionCoordinator.swift` | NEW | -| `app/Sources/MenuBarApp/Views/ActionBarView.swift` | MODIFY — wire Stop proxy | -| `app/Sources/MenuBarApp/Views/ProviderListView.swift` | NEW — disclosure + toggles | -| `app/Sources/MenuBarApp/Views/ConfirmSheet.swift` | NEW | +| `app/Sources/MenuBarUI/ProviderListView.swift` | NEW — disclosure + toggles | +| `app/Sources/MenuBarUI/PopoverViewController.swift` | MODIFY — result banner, provider section | +| `app/Sources/MenuBarUI/AppDelegate.swift` | MODIFY — wire both actions to the coordinator | | `app/Sources/MenuBarCoreTests/ActionSuite.swift` | NEW | ## `ProxyClient` additions @@ -164,6 +177,32 @@ Stubbed `URLProtocol`: - No code path constructs a `Process` / `NSTask`. - No error path leaks a response body into `ActionOutcome`. +## Implementation notes + +**The stop timeout needed an injectable clock, not just a no-op sleeper.** The first test +for "a proxy that keeps answering is a failure" passed a sleeper that did nothing — and +the test failed, reporting success. The loop is bounded by a wall-clock deadline, so +skipping the sleep without advancing the clock means the deadline never arrives. Both the +sleeper and `now` are injected. + +The same test also exposed a harness trap worth recording: `StubProtocol` falls back to +"connection refused" once its response queue drains, which reads as a successful stop. A +test that queues too few responses will pass for the wrong reason. + +**Live verification** against the running proxy (`ActionProbe`, removed after use): + +```text +default provider: openai +target: anthropic enabled: true +disable -> succeeded proxy now reports enabled: false +re-enable -> succeeded proxy now reports enabled: true +default-provider guard -> failed("openai is the default provider. Choose another default…") +``` + +Proxy state was confirmed restored afterwards: 10 providers, 10 enabled. `stop` was +deliberately not exercised live — it would interrupt the user's own proxy, and its +timing behaviour is covered by the stubbed timeout tests. + ## Accept criteria 1. Stop executed live against the running proxy, with the observed outcome, and the From 8c8324b06f5cc3d873d82fb4528ade8e8df6f59a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 05:57:03 +0900 Subject: [PATCH 17/61] fix(app): distinguish liveness states and serialize provider writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of ef1c59c5 returned FAIL on 6 findings, all verified against the proxy source. - isReachable() treated every non-401 error as "gone", so a 500 or a decode failure while polling after /api/stop reported the stop as confirmed while an HTTP server was still listening. Replaced with three-state liveness: reachable (any HTTP answer proves the port is occupied), refused (the only proof the proxy is gone), indeterminate (a timeout proves nothing). - /api/stop returns success:false when restoreNativeCodex() fails (management-api.ts:145-147). The proxy still exits, but native Codex is left pointing at a closing port. The body was discarded, so the app said "Proxy stopped". Now decodes only the boolean — never the server's message — and reports stoppedWithRestoreFailure telling the user to run ocx restore. - Two rapid toggles could reach the server out of order and leave it opposite to the user's last click, since both actors are reentrant across awaits. One in-flight write per provider, and the row stays inert until its authoritative refresh lands. Pending state survives rebuildRows so a poll cannot resurrect the pre-toggle switch. - A default provider that was already disabled could never be re-enabled: the switch was inert whenever isDefault, but the proxy guard fires only when disabled is true AND the name matches the default — enabling is valid. - The "exact body" test encoded its own dictionary rather than reading the request, so it would have passed with no body at all. StubProtocol now drains httpBodyStream and the test asserts on the decoded actual body. - Acceptance criterion 1 demanded a live stop while the notes said stop was deliberately not run live. Amended with reasoning: stopping the developer's proxy is out of bounds, and the branches that matter cannot be produced on demand from a healthy proxy. Also corrected 002 (the success flag was undocumented) and 050's stale "scroll-free column". 87 -> 93 tests. --- .../MenuBarCore/ActionCoordinator.swift | 37 +++++- app/Sources/MenuBarCore/ProxyClient.swift | 61 ++++++++-- .../MenuBarCoreTests/ActionSuite.swift | 111 ++++++++++++++++-- .../MenuBarCoreTests/TransportSuite.swift | 34 +++++- app/Sources/MenuBarUI/AppDelegate.swift | 15 ++- .../MenuBarUI/PopoverViewController.swift | 4 + app/Sources/MenuBarUI/ProviderListView.swift | 37 +++++- .../002_api_surface.md | 11 +- .../030_phase3_actions.md | 32 ++++- .../050_phase5_handoff.md | 2 +- 10 files changed, 297 insertions(+), 47 deletions(-) diff --git a/app/Sources/MenuBarCore/ActionCoordinator.swift b/app/Sources/MenuBarCore/ActionCoordinator.swift index e076df3ad1..e1cca81d7d 100644 --- a/app/Sources/MenuBarCore/ActionCoordinator.swift +++ b/app/Sources/MenuBarCore/ActionCoordinator.swift @@ -5,6 +5,9 @@ public enum ActionOutcome: Equatable, Sendable { case succeeded /// The stop was confirmed, but nothing will restart the proxy — the user has to. case requiresManualStart(String) + /// The proxy stopped, but it could not restore native Codex on the way out, so the + /// user's Codex config still points at a port that is now closed. + case stoppedWithRestoreFailure(String) /// A human sentence. Never a response body: bodies can echo configuration. case failed(String) } @@ -20,6 +23,10 @@ public actor ActionCoordinator { public static let pollInterval: TimeInterval = 0.5 private let client: ProxyClient + /// One in-flight write per provider. Both this actor and `ProxyClient` are reentrant + /// across network awaits, so two rapid toggles could otherwise reach the server out + /// of order and leave it opposite to the user's last click. + private var inFlight: Set = [] private let sleeper: @Sendable (TimeInterval) async -> Void /// Injected so tests can advance time without waiting for it. A no-op sleeper alone /// is not enough: the loop is bounded by a deadline, so the clock has to move too. @@ -43,8 +50,9 @@ public actor ActionCoordinator { /// 200 means "accepted", not "stopped". Reporting success on the response alone /// would make the UI claim a state the system has not reached yet. public func stop(startCommand: String) async -> ActionOutcome { + let restored: Bool do { - try await client.stop() + restored = try await client.stop() } catch let error as ProxyError { return .failed(error.userMessage) } catch { @@ -52,13 +60,28 @@ public actor ActionCoordinator { } let deadline = now().addingTimeInterval(Self.stopTimeout) + var sawIndeterminate = false while now() < deadline { await sleeper(Self.pollInterval) - if await !client.isReachable() { - return .requiresManualStart(startCommand) + switch await client.liveness() { + case .refused: + // The only proof the proxy is actually gone. + return restored + ? .requiresManualStart(startCommand) + : .stoppedWithRestoreFailure(startCommand) + case .reachable: + sawIndeterminate = false + case .indeterminate: + // A timeout proves nothing; keep polling rather than declaring victory. + sawIndeterminate = true } } - return .failed("The proxy accepted the stop but was still responding after \(Int(Self.stopTimeout)) seconds.") + + return .failed( + sawIndeterminate + ? "The proxy accepted the stop, but its state could not be confirmed. Check with `ocx status`." + : "The proxy accepted the stop but was still responding after \(Int(Self.stopTimeout)) seconds." + ) } /// Enables or disables a provider. @@ -74,6 +97,12 @@ public actor ActionCoordinator { if disabled, name == defaultProvider { return .failed("\(name) is the default provider. Choose another default in the dashboard first.") } + guard !inFlight.contains(name) else { + return .failed("A change to \(name) is still in progress.") + } + inFlight.insert(name) + defer { inFlight.remove(name) } + do { try await client.setProviderDisabled(name, disabled: disabled) return .succeeded diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 0937c4f347..18eead5e1a 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -90,28 +90,63 @@ public actor ProxyClient { return envelope.reports ?? [] } - /// Cheapest possible liveness probe. - public func isReachable() async -> Bool { + /// What a liveness probe actually established. + /// + /// Three states, not two. "Did not get a usable answer" and "nothing is listening" + /// are different facts, and conflating them let a stop be reported as confirmed + /// while an HTTP server was still running behind a 500 or a decode failure. + public enum Liveness: Equatable, Sendable { + /// Something answered — any HTTP status, including 401/403/500, or a body we + /// could not decode. The port is occupied. + case reachable + /// The connection was refused. This is the only proof that the proxy is gone. + case refused + /// A timeout or other transport failure: no conclusion either way. + case indeterminate + } + + public func liveness() async -> Liveness { do { _ = try await settings() - return true - } catch ProxyError.unauthorized { - // Answering 401 still proves something is listening. - return true + return .reachable + } catch ProxyError.unauthorized, ProxyError.decoding { + // Both prove a server answered. + return .reachable + } catch ProxyError.http { + return .reachable + } catch ProxyError.unreachable { + return .refused } catch { - return false + return .indeterminate } } + /// Convenience for callers that only need "is anything there". + public func isReachable() async -> Bool { + await liveness() != .refused + } + // MARK: - Writes /// `POST /api/stop`. Returns once the proxy has accepted the request. /// /// The proxy answers 200 *before* draining, and it stops the launchd service first so - /// nothing respawns it. Callers must poll `isReachable()` rather than treat this - /// return as "stopped". - public func stop() async throws { - _ = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) + /// nothing respawns it. Callers must poll `liveness()` rather than treat this return + /// as "stopped". + /// + /// The response carries `success: false` when `restoreNativeCodex()` failed + /// (`src/server/management-api.ts:145-147`): the proxy still shuts down, but native + /// Codex was left pointing at a port that is about to close. Only the boolean is + /// decoded — the accompanying message is a server-formatted string and never reaches + /// the UI. + @discardableResult + public func stop() async throws -> Bool { + let data = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) + guard let result = try? JSONDecoder().decode(StopResult.self, from: data) else { + // An undecodable body is not a reason to claim the restore failed. + return true + } + return result.success ?? true } /// `PATCH /api/providers?name=` with a body of exactly `{"disabled": }`. @@ -239,4 +274,8 @@ private struct ProviderDisabledPatch: Encodable { let disabled: Bool } +private struct StopResult: Decodable { + let success: Bool? +} + private struct EmptyBody: Encodable {} diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift index 04676ed9d8..a276bb9721 100644 --- a/app/Sources/MenuBarCoreTests/ActionSuite.swift +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -152,20 +152,107 @@ enum ActionSuite { } t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) } + // Was tautological: it built its own non-empty literals and then asserted they + // were non-empty. Now drives real failures and checks the message the user sees. + t.test("actions: every real failure path produces a usable message") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let unreachable = sync { await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) } + + StubProtocol.reset([.init(status: 400, body: "raw body", urlError: nil)]) + let rejected = sync { await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") } + + let guarded = sync { await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") } - t.test("actions: no outcome message is empty") { - let outcomes: [ActionOutcome] = [ - .succeeded, - .requiresManualStart("ocx start"), - .failed("something went wrong"), - ] - for outcome in outcomes { - switch outcome { - case .succeeded: break - case .requiresManualStart(let value), .failed(let value): - t.expect(!value.isEmpty, "outcome carried an empty message") + for outcome in [unreachable, rejected, guarded] { + guard case .failed(let message) = outcome else { + t.expect(false, "expected .failed, got \(outcome)") + continue } + t.expect(!message.isEmpty, "empty failure message") + t.expect(message.hasSuffix(".") || message.hasSuffix("!"), + "message should read as a sentence: \(message)") + t.expect(!message.contains("raw body"), "leaked body: \(message)") + } + } + + // The stop response carries success:false when restoreNativeCodex() failed + // (src/server/management-api.ts:145-147). The proxy still shuts down, but native + // Codex is left pointing at a port that is closing. + t.test("stop: a restore failure is reported, not swallowed as success") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":false,"message":"restore failed: /some/path"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .stoppedWithRestoreFailure("ocx start")) + } + + t.test("stop: a success:true body reports the ordinary manual-start outcome") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":true,"message":"ok"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + t.equal(sync { await makeCoordinator().stop(startCommand: "ocx start") }, + .requiresManualStart("ocx start")) + } + + // Only a refused connection proves the proxy is gone. A 500 or an undecodable + // 200 means an HTTP server is still listening. + t.test("stop: a 500 during polling is not mistaken for a stopped proxy") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 500, body: "", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an undecodable 200 during polling still counts as reachable") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: "not json", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed = outcome { + t.expect(true, "timed out rather than claiming success") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: a second write while one is in flight is refused, not raced") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let first = coordinator.setProvider("x", disabled: true, defaultProvider: nil) + async let second = coordinator.setProvider("x", disabled: false, defaultProvider: nil) + return await [first, second] + } + let refused = outcomes.filter { if case .failed = $0 { return true }; return false } + t.equal(refused.count, 1, "exactly one of the two concurrent writes is refused") + } + + t.test("provider: writes to different providers are not blocked by each other") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let a = coordinator.setProvider("a", disabled: true, defaultProvider: nil) + async let b = coordinator.setProvider("b", disabled: true, defaultProvider: nil) + return await [a, b] } + t.equal(outcomes, [.succeeded, .succeeded]) } } -} +} \ No newline at end of file diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index fe37c6aa19..423e416e0e 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -18,11 +18,30 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { lock.lock(); defer { lock.unlock() } queue = responses recorded = [] + bodies = [] } + nonisolated(unsafe) static var bodies: [Data] = [] + static func record(_ request: URLRequest) { lock.lock(); defer { lock.unlock() } recorded.append(request) + // URLProtocol replaces httpBody with a stream, so read it here or the body is + // unobservable — which let an "exact body" assertion pass with no body at all. + if let body = request.httpBody { + bodies.append(body) + } else if let stream = request.httpBodyStream { + stream.open() + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + if read <= 0 { break } + data.append(buffer, count: read) + } + stream.close() + bodies.append(data) + } } static func next() -> Response? { @@ -238,12 +257,15 @@ enum TransportSuite { let url = request?.url?.absoluteString ?? "" t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") - // URLProtocol strips httpBody into a stream, so assert on the encoder directly. - let encoded = String( - data: try JSONEncoder().encode(["disabled": true]), - encoding: .utf8 - ) - t.equal(encoded, #"{"disabled":true}"#) + // Assert on the ACTUAL request body. An earlier version encoded its own + // dictionary and compared that, so it would have passed with no body at all. + guard let body = StubProtocol.bodies.first else { + t.expect(false, "no request body captured") + return + } + let decoded = try JSONSerialization.jsonObject(with: body) as? [String: Any] + t.equal(decoded?.keys.sorted() ?? [], ["disabled"], "body must carry only 'disabled'") + t.equal(decoded?["disabled"] as? Bool, true) } t.test("liveness: a 401 still proves something is listening") { diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 536a25c693..44a4d7992c 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -191,6 +191,12 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { case .requiresManualStart(let command): // Not a failure — the API has no start endpoint by design. self?.controller.showResult("Proxy stopped. Start it again with \(command)", isError: false) + case .stoppedWithRestoreFailure(let command): + // The proxy is down but native Codex still points at the dead port. + self?.controller.showResult( + "Proxy stopped, but restoring native Codex failed. Run `ocx restore`, then \(command)", + isError: true + ) case .failed(let message): self?.controller.showResult(message, isError: true) } @@ -202,6 +208,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { /// rather than leave the UI showing a state the proxy refused. private func toggleProvider(_ name: String, disable: Bool) { let defaultProvider = latest?.defaultProvider + controller.setProviderBusy(name, true) Task { [actions, coordinator] in let outcome = await actions?.setProvider(name, disabled: disable, defaultProvider: defaultProvider) @@ -213,14 +220,20 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { disable ? "\(name) disabled." : "\(name) enabled.", isError: false ) - case .failed(let message), .requiresManualStart(let message): + case .failed(let message): self?.controller.revertProvider(name, to: !disable) self?.controller.showResult(message, isError: true) + case .requiresManualStart, .stoppedWithRestoreFailure: + // Not reachable for a provider write. + break } } // Re-read so the summary line and switch states match the proxy, not our // optimistic guess. await coordinator?.refresh(includeHeavy: true) + await MainActor.run { [weak self] in + self?.controller.setProviderBusy(name, false) + } } } } diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index df042a45b9..c628d72d69 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -243,6 +243,10 @@ public final class PopoverViewController: NSViewController { providers.revert(name, to: enabled) } + public func setProviderBusy(_ name: String, _ busy: Bool) { + providers.setBusy(name, busy) + } + /// Re-measures after content changes height (disclosure, banner). public func refreshSize() { resize() } diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index ec089a5428..53b20fc476 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -11,6 +11,8 @@ final class ProviderListView: NSView { private let rows = NSStackView() private var expanded = false private var snapshot: ProxySnapshot? + /// Providers with a write in flight; their rows must not be reset by a poll. + private var pending: Set = [] /// `(provider, shouldDisable)`. var onToggle: ((String, Bool) -> Void)? @@ -88,6 +90,8 @@ final class ProviderListView: NSView { ) { [weak self] shouldDisable in self?.onToggle?(provider.name, shouldDisable) } + // A refresh that lands mid-write must not undo the optimistic state. + if pending.contains(provider.name) { row.setBusy(true) } row.translatesAutoresizingMaskIntoConstraints = false rows.addArrangedSubview(row) row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true @@ -104,8 +108,20 @@ final class ProviderListView: NSView { /// Reverts a switch after the proxy rejected the change. func revert(_ name: String, to enabled: Bool) { + pending.remove(name) for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { row.setEnabled(enabled) + row.setBusy(false) + } + } + + /// Marks a provider as having a write in flight. Its switch stays inert until the + /// authoritative refresh lands, so a poll cannot resurrect the pre-toggle state and + /// a second click cannot race the first. + func setBusy(_ name: String, _ busy: Bool) { + if busy { pending.insert(name) } else { pending.remove(name) } + for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { + row.setBusy(busy) } } } @@ -114,6 +130,8 @@ final class ProviderRowView: NSView { let providerName: String private let toggle = NSSwitch() private let onToggle: (Bool) -> Void + private var baseEnabled = true + private var isBusy = false init(provider: ProviderSummary, isDefault: Bool, onToggle: @escaping (Bool) -> Void) { self.providerName = provider.name @@ -137,12 +155,16 @@ final class ProviderRowView: NSView { toggle.target = self toggle.action = #selector(switched) - // The proxy rejects disabling the default provider with a 400, so the control is - // inert and explains itself rather than offering an action that cannot succeed. - toggle.isEnabled = !isDefault - toggle.toolTip = isDefault + // The proxy rejects only DISABLING the default provider (`provider-routes.ts:178` + // guards on `rawBody.disabled && name === defaultProvider`). Enabling it is + // valid, so a default provider that is currently off must stay toggleable — + // otherwise the app strands the user in a state it cannot leave. + let wouldDisableDefault = isDefault && provider.isEnabled + toggle.isEnabled = !wouldDisableDefault + toggle.toolTip = wouldDisableDefault ? "This is the default provider. Choose another default in the dashboard first." : nil + baseEnabled = toggle.isEnabled toggle.setAccessibilityLabel("\(provider.name) enabled") let row = NSStackView(views: [labels, NSView(), toggle]) @@ -163,6 +185,13 @@ final class ProviderRowView: NSView { func setEnabled(_ enabled: Bool) { toggle.state = enabled ? .on : .off } + /// Inert while its write is in flight, so a second click cannot race the first. + func setBusy(_ busy: Bool) { + isBusy = busy + toggle.isEnabled = busy ? false : baseEnabled + alphaValue = busy ? 0.6 : 1 + } + @objc private func switched() { // Optimistic: the switch has already moved. The caller reverts on failure. onToggle(toggle.state == .off) diff --git a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md index efacac921c..b18eba9e5a 100644 --- a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md +++ b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md @@ -205,11 +205,18 @@ setTimeout(async () => { await drainAndShutdown(...); process.exit(0); }, 200); return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." }); ``` -Two consequences, both load-bearing: +The response body carries a `success` boolean: `false` when `restoreNativeCodex()` +failed, in which case the proxy still exits but native Codex is left pointing at a port +that is about to close. Clients should decode the boolean and tell the user to run +`ocx restore`; the accompanying `message` is a server-formatted string and should not be +surfaced verbatim. + +Three consequences, all load-bearing: 1. **It answers `200` before draining.** The app treats `200` as "stop accepted", not "stopped", and re-probes until the port stops answering. -2. **It calls `stopServiceIfInstalled()` first — deliberately stopping launchd so the +2. **A 200 does not mean the restore succeeded.** See the `success` flag above. +3. **It calls `stopServiceIfInstalled()` first — deliberately stopping launchd so the supervisor cannot respawn the proxy.** A service-managed proxy therefore stays down. **There is no automatic restart, and no start endpoint exists.** Any UI that says "Restart" would be lying. See `030` for the corrected action design. diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index 917aa450b4..2b4f58d834 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -24,7 +24,7 @@ actually happened, the provider toggle UI, and result feedback in the popover. | Path | Action | | --- | --- | -| `app/Sources/MenuBarCore/ProxyClient.swift` | MODIFY — add write methods | +| `app/Sources/MenuBarCore/ProxyClient.swift` | MODIFY — three-state liveness, decode the stop `success` flag | | `app/Sources/MenuBarCore/ActionCoordinator.swift` | NEW | | `app/Sources/MenuBarUI/ProviderListView.swift` | NEW — disclosure + toggles | | `app/Sources/MenuBarUI/PopoverViewController.swift` | MODIFY — result banner, provider section | @@ -177,6 +177,18 @@ Stubbed `URLProtocol`: - No code path constructs a `Process` / `NSTask`. - No error path leaks a response body into `ActionOutcome`. +## Code-review corrections (folded before B closed) + +| Finding | Correction | +| --- | --- | +| `isReachable()` treated every non-401 error as "gone", so a 500 or a decode failure during polling reported a stop as confirmed while an HTTP server was still listening | Three-state `liveness()`: `reachable` (any HTTP answer, including 401/403/500 and undecodable bodies), `refused` (the only proof), `indeterminate` (timeouts prove nothing) | +| `/api/stop` returns `success: false` when `restoreNativeCodex()` fails — the proxy still exits, but native Codex is left pointing at a closing port. The body was discarded and the app said "Proxy stopped" | Decode only the boolean, never the server's message. New `stoppedWithRestoreFailure` outcome tells the user to run `ocx restore` | +| Two rapid toggles could reach the server out of order, leaving it opposite to the user's last click | One in-flight write per provider in the coordinator, and the row goes inert until its authoritative refresh lands. Pending state survives `rebuildRows`, so a poll cannot resurrect the pre-toggle switch | +| A default provider that was already disabled could never be re-enabled: the switch was inert whenever `isDefault`. The proxy guard is `rawBody.disabled && name === defaultProvider` — only *disabling* is refused | The switch is inert only when it would disable an enabled default | +| The "exact body" test encoded its own dictionary and compared that, so it would pass with no request body at all | `StubProtocol` now drains `httpBodyStream` and the test asserts on the decoded actual body | +| An outcome test built non-empty literals and asserted they were non-empty | Replaced with one that drives three real failure paths and checks the user-visible message, including that no response body leaks | +| Acceptance criterion 1 demanded a live stop while the notes said stop was deliberately not run live | Criterion amended with its reasoning; see below | + ## Implementation notes **The stop timeout needed an injectable clock, not just a no-op sleeper.** The first test @@ -199,14 +211,22 @@ re-enable -> succeeded proxy now reports enabled: true default-provider guard -> failed("openai is the default provider. Choose another default…") ``` -Proxy state was confirmed restored afterwards: 10 providers, 10 enabled. `stop` was -deliberately not exercised live — it would interrupt the user's own proxy, and its -timing behaviour is covered by the stubbed timeout tests. +Proxy state was confirmed restored afterwards: 10 providers, 10 enabled. + +`stop` is covered by the stubbed suite rather than live, per the amended criterion 1 +above. The branches proven there are the ones a healthy proxy cannot demonstrate: +`success: false` from a failed native-Codex restore, a 500 mid-poll, an undecodable 200, +and a proxy that accepts the stop but keeps answering. ## Accept criteria -1. Stop executed live against the running proxy, with the observed outcome, and the - resulting `unreachable` state showing the manual start command. +1. Stop behaviour proven deterministically rather than by stopping the user's proxy. + **Amended criterion:** stopping the developer's own running proxy is out of bounds — + it would interrupt their work, and the failure modes that matter (a 200 that never + drains, a 500 during polling, `success: false`, an undecodable body) cannot be + produced on demand from a healthy proxy anyway. The gate is therefore the stubbed + transport suite, which covers every branch, plus a live read confirming the proxy is + still healthy afterwards. 2. Provider disable + re-enable executed live and reflected in `/api/providers`. 3. The default provider's toggle is inert and explains why, using `/api/config`. 4. Failure paths surface a human sentence, never a raw body. diff --git a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md index 49dad1b4c0..3583f5e536 100644 --- a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md +++ b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md @@ -84,7 +84,7 @@ heavier build stack for a project whose premise is a single Bun process, and `macOSPrivateApi: true` — a notarization and App-Store-rejection risk that `NSPopover` avoids through public API. -The four-tab layout became a single scroll-free column so the primary question — "is it +The four-tab layout became a single column with a bounded scrolling middle so the primary question — "is it running?" — is answered without a click. Both comments state that the work is not discarded, point at this devlog unit, and invite From a0c61736d4e48918bcf2484d0738d534c16ed9a1 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:03:53 +0900 Subject: [PATCH 18/61] fix(app): only a refused connection proves the proxy stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review found the three-state liveness contract was still two states in practice, plus three follow-on defects. - perform() mapped .timedOut, .networkConnectionLost, .cannotFindHost, and .notConnectedToInternet to ProxyError.unreachable, which liveness() then read as .refused. So a timeout during the stop poll could still confirm a stop while the proxy was running — the exact defect round 1 was meant to fix. Added ProxyError.inconclusive; only .cannotConnectToHost becomes .refused now. Liveness probes also take a 1.5s timeout so a single probe cannot overrun the 10s stop deadline it is supposed to respect. - rebuildRows() initialised each switch from the server snapshot, so a poll landing mid-write snapped the switch back to its pre-toggle value even though the row was marked busy. pending now stores the intended state and applies it before marking the row busy. - The post-write refresh coalesced: refresh() queues and returns immediately when another cycle holds the lock, so the switch became interactive again against pre-write data. Added refreshAndWait(). - 030 still demanded a live stop in its verification line and carried three pre-review snippets (void stop(), boolean isReachable() loop, unconditional default guard) that would have reintroduced the reviewed defects. Added liveness classification tests for every URLError code that matters, an HTTP-answer table (200/401/403/500 all prove the port is occupied), an undecodable-200 case, and a stop-with-timeout case asserting the inconclusive message rather than a false success. 93 -> 97 tests. Also corrected the 002 stop snippet, which showed only the success:true branch while the prose below it described both. --- .../MenuBarCore/PollingCoordinator.swift | 18 ++++- app/Sources/MenuBarCore/ProxyClient.swift | 45 ++++++++--- .../MenuBarCoreTests/ActionSuite.swift | 60 ++++++++++++++- app/Sources/MenuBarUI/AppDelegate.swift | 8 +- .../MenuBarUI/PopoverViewController.swift | 4 +- app/Sources/MenuBarUI/ProviderListView.swift | 27 +++++-- .../002_api_surface.md | 4 +- .../030_phase3_actions.md | 74 +++++++++++++------ 8 files changed, 191 insertions(+), 49 deletions(-) diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 2cfc3ba2d4..a1e7da5e6c 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -129,6 +129,21 @@ public actor PollingCoordinator { await drainPendingRefresh() } + /// Refreshes and does not return until a cycle has actually completed. + /// + /// `refresh()` coalesces: if another cycle holds the lock it queues and returns + /// immediately. A caller that needs authoritative state afterwards — such as + /// re-enabling a switch after a write — would otherwise act on pre-write data. + public func refreshAndWait(includeHeavy: Bool = true) async { + await refresh(includeHeavy: includeHeavy) + // If this call was coalesced, wait for the cycle that absorbed it. + var spins = 0 + while (refreshInFlight || pendingOpenRefresh), spins < 100 { + spins += 1 + try? await Task.sleep(nanoseconds: 50_000_000) + } + } + /// Runs a refresh that arrived while another cycle held the lock. private func drainPendingRefresh() async { guard pendingOpenRefresh, popoverOpen else { @@ -191,7 +206,8 @@ public actor PollingCoordinator { snapshot.state = .unreachable case .unauthorized: snapshot.state = .unauthorized - case .http, .decoding, .transport: + case .http, .decoding, .transport, .inconclusive: + // A timeout is degraded, not stopped: something may well still be running. snapshot.state = .degraded(error.userMessage) } } diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 18eead5e1a..a58559f3a5 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -9,6 +9,9 @@ public enum ProxyError: Error, Equatable { case decoding /// A transport failure that is not evidence the proxy is down (TLS, policy, DNS). case transport + /// The request never completed — a timeout or a socket dropped mid-response. This + /// proves nothing either way, and must not be read as "the proxy is gone". + case inconclusive /// Human sentences only. Response bodies can echo configuration values, so they /// never reach the UI or a log. @@ -19,6 +22,7 @@ public enum ProxyError: Error, Equatable { case .http(let code): return "The proxy returned an unexpected status (\(code))." case .decoding: return "The proxy returned a response this app could not read." case .transport: return "The connection to the proxy failed." + case .inconclusive: return "The proxy did not respond in time." } } } @@ -105,9 +109,11 @@ public actor ProxyClient { case indeterminate } - public func liveness() async -> Liveness { + /// A short probe: the default 4s read timeout would let a single liveness check + /// overrun the stop deadline it is supposed to respect. + public func liveness(timeout: TimeInterval = 1.5) async -> Liveness { do { - _ = try await settings() + _ = try await get("api/settings", timeout: timeout) as ProxySettings return .reachable } catch ProxyError.unauthorized, ProxyError.decoding { // Both prove a server answered. @@ -115,8 +121,10 @@ public actor ProxyClient { } catch ProxyError.http { return .reachable } catch ProxyError.unreachable { + // Connection refused: nothing is listening on the port. return .refused } catch { + // Timeouts, dropped sockets, and anything else: no conclusion. return .indeterminate } } @@ -164,8 +172,15 @@ public actor ProxyClient { // MARK: - Transport - private func get(_ path: String, query: [URLQueryItem] = []) async throws -> T { - let data = try await send(method: "GET", path: path, query: query, body: nil as EmptyBody?) + private func get( + _ path: String, + query: [URLQueryItem] = [], + timeout: TimeInterval? = nil + ) async throws -> T { + let data = try await send( + method: "GET", path: path, query: query, + body: nil as EmptyBody?, timeout: timeout + ) do { return try JSONDecoder().decode(T.self, from: data) } catch { @@ -177,11 +192,12 @@ public actor ProxyClient { method: String, path: String, query: [URLQueryItem] = [], - body: Body? + body: Body?, + timeout: TimeInterval? = nil ) async throws -> Data { let keyAtStart = apiKey do { - return try await perform(method: method, path: path, query: query, body: body) + return try await perform(method: method, path: path, query: query, body: body, timeout: timeout) } catch ProxyError.unauthorized { // A loopback proxy needs no credential, so a 401 means this install is bound // to a non-loopback host. @@ -194,7 +210,7 @@ public actor ProxyClient { guard let key = try await credentialForRetry(after: keyAtStart) else { throw ProxyError.unauthorized } - return try await perform(method: method, path: path, query: query, body: body, key: key) + return try await perform(method: method, path: path, query: query, body: body, key: key, timeout: timeout) } } @@ -219,7 +235,8 @@ public actor ProxyClient { path: String, query: [URLQueryItem], body: Body?, - key: String? = nil + key: String? = nil, + timeout: TimeInterval? = nil ) async throws -> Data { guard var components = URLComponents( url: endpoint.baseURL.appendingPathComponent(path), @@ -230,7 +247,7 @@ public actor ProxyClient { var request = URLRequest(url: url) request.httpMethod = method - request.timeoutInterval = method == "GET" ? 4 : 6 + request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) if let credential = key ?? apiKey { request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") } @@ -255,9 +272,15 @@ public actor ProxyClient { // Propagate cancellation rather than reporting a stopped proxy: the // polling coordinator cancels in-flight work whenever the popover closes. throw CancellationError() - case .cannotConnectToHost, .timedOut, .networkConnectionLost, - .cannotFindHost, .notConnectedToInternet: + case .cannotConnectToHost: + // The one code that actually proves nothing is listening. throw ProxyError.unreachable + case .timedOut, .networkConnectionLost, .cannotFindHost, + .notConnectedToInternet, .dnsLookupFailed: + // A timeout or a dropped socket says the request failed, not that the + // server is gone. Collapsing these into `.unreachable` is what let a + // stop be reported as confirmed while the proxy was still running. + throw ProxyError.inconclusive default: throw ProxyError.transport } diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift index a276bb9721..fe289f6976 100644 --- a/app/Sources/MenuBarCoreTests/ActionSuite.swift +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -254,5 +254,63 @@ enum ActionSuite { } t.equal(outcomes, [.succeeded, .succeeded]) } + + // The distinction that matters: only a refused connection proves the proxy is + // gone. Collapsing timeouts into "unreachable" is what made a stop report as + // confirmed while the proxy was still running. + t.test("liveness: only a refused connection reads as gone") { + let cases: [(URLError.Code, ProxyClient.Liveness, String)] = [ + (.cannotConnectToHost, .refused, "connection refused"), + (.timedOut, .indeterminate, "timeout"), + (.networkConnectionLost, .indeterminate, "socket dropped"), + (.cannotFindHost, .indeterminate, "host lookup"), + (.notConnectedToInternet, .indeterminate, "no network"), + ] + for (code, expected, label) in cases { + StubProtocol.reset([.init(status: 0, body: "", urlError: code)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, expected, label) + } + } + + t.test("liveness: any HTTP answer proves the port is occupied") { + for status in [200, 401, 403, 500] { + let body = status == 200 ? #"{"port":10100}"# : "" + StubProtocol.reset([ + .init(status: status, body: body, urlError: nil), + .init(status: status, body: body, urlError: nil), + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "k")) + t.equal(sync { await client.liveness() }, .reachable, "status \(status)") + } + } + + t.test("liveness: an undecodable 200 is reachable, not gone") { + StubProtocol.reset([.init(status: 200, body: "not json at all", urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, .reachable) + } + + // A timeout must not end the stop as a confirmed success. + t.test("stop: a timeout during polling never confirms the stop") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .timedOut), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("could not be confirmed"), + "expected an inconclusive message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + } + + private struct StubCredentialsFixed: CredentialStore { + let key: String? + func loadAPIKey() -> String? { key } } -} \ No newline at end of file +} diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 44a4d7992c..3d8b3da75c 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -208,7 +208,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { /// rather than leave the UI showing a state the proxy refused. private func toggleProvider(_ name: String, disable: Bool) { let defaultProvider = latest?.defaultProvider - controller.setProviderBusy(name, true) + controller.setProviderBusy(name, true, intended: !disable) Task { [actions, coordinator] in let outcome = await actions?.setProvider(name, disabled: disable, defaultProvider: defaultProvider) @@ -229,8 +229,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } } // Re-read so the summary line and switch states match the proxy, not our - // optimistic guess. - await coordinator?.refresh(includeHeavy: true) + // optimistic guess. refreshAndWait rather than refresh: a coalesced refresh + // returns immediately, which would re-enable the switch against pre-write + // data. + await coordinator?.refreshAndWait() await MainActor.run { [weak self] in self?.controller.setProviderBusy(name, false) } diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index c628d72d69..502bd50783 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -243,8 +243,8 @@ public final class PopoverViewController: NSViewController { providers.revert(name, to: enabled) } - public func setProviderBusy(_ name: String, _ busy: Bool) { - providers.setBusy(name, busy) + public func setProviderBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { + providers.setBusy(name, busy, intended: intended) } /// Re-measures after content changes height (disclosure, banner). diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index 53b20fc476..dfecf0bf15 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -11,8 +11,10 @@ final class ProviderListView: NSView { private let rows = NSStackView() private var expanded = false private var snapshot: ProxySnapshot? - /// Providers with a write in flight; their rows must not be reset by a poll. - private var pending: Set = [] + /// Providers with a write in flight, mapped to the state the USER chose. A poll can + /// still be carrying pre-write data, so the intended value — not the snapshot — is + /// what a rebuilt row must show. + private var pending: [String: Bool] = [:] /// `(provider, shouldDisable)`. var onToggle: ((String, Bool) -> Void)? @@ -90,8 +92,12 @@ final class ProviderListView: NSView { ) { [weak self] shouldDisable in self?.onToggle?(provider.name, shouldDisable) } - // A refresh that lands mid-write must not undo the optimistic state. - if pending.contains(provider.name) { row.setBusy(true) } + // A refresh that lands mid-write must not undo the optimistic state: apply + // the intended value first, then mark the row busy. + if let intended = pending[provider.name] { + row.setEnabled(intended) + row.setBusy(true) + } row.translatesAutoresizingMaskIntoConstraints = false rows.addArrangedSubview(row) row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true @@ -108,7 +114,7 @@ final class ProviderListView: NSView { /// Reverts a switch after the proxy rejected the change. func revert(_ name: String, to enabled: Bool) { - pending.remove(name) + pending[name] = nil for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { row.setEnabled(enabled) row.setBusy(false) @@ -118,9 +124,16 @@ final class ProviderListView: NSView { /// Marks a provider as having a write in flight. Its switch stays inert until the /// authoritative refresh lands, so a poll cannot resurrect the pre-toggle state and /// a second click cannot race the first. - func setBusy(_ name: String, _ busy: Bool) { - if busy { pending.insert(name) } else { pending.remove(name) } + /// `intended` is the state the user selected, retained so a poll landing mid-write + /// cannot snap the switch back. + func setBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { + if busy { + pending[name] = intended ?? pending[name] ?? true + } else { + pending[name] = nil + } for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { + if busy, let value = pending[name] { row.setEnabled(value) } row.setBusy(busy) } } diff --git a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md index b18eba9e5a..f6f789dce2 100644 --- a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md +++ b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md @@ -202,7 +202,9 @@ drives the toggle in Phase 3. stopServiceIfInstalled(); const restore = restoreNativeCodex(); setTimeout(async () => { await drainAndShutdown(...); process.exit(0); }, 200); -return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." }); +return jsonResponse(restore.success + ? { success: true, message: "Proxy stopping, native Codex restored." } + : { success: false, message: "Proxy stopping, but native Codex restore failed: … Run `ocx restore`." }); ``` The response body carries a `success` boolean: `false` when `restoreNativeCodex()` diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index 2b4f58d834..97969f383d 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -1,8 +1,10 @@ # 030 — Phase 3: write actions on existing endpoints **Depends on:** `020` (the UI must exist to report a result into). -**Independently verifiable by:** a live stop and a live provider toggle against the -running proxy, with the observed response and the resulting UI state. +**Independently verifiable by:** a live provider toggle against the running proxy, plus +the stubbed transport suite for stop. Stopping the developer's own proxy is out of +bounds, and the branches that matter cannot be produced on demand from a healthy one — +see the amended acceptance criterion 1. Constraint from the user's scope: **no new proxy endpoints.** Everything here calls routes inventoried in `002` §4. @@ -34,15 +36,14 @@ actually happened, the provider toggle UI, and result feedback in the popover. ## `ProxyClient` additions ```swift -public func stop() async throws { - var request = URLRequest(url: endpoint.baseURL.appendingPathComponent("api/stop")) - request.httpMethod = "POST" - request.timeoutInterval = 6 - if let key = apiKey { request.setValue(key, forHTTPHeaderField: "x-opencodex-api-key") } - let (_, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - throw ProxyError.http((response as? HTTPURLResponse)?.statusCode ?? -1) - } +/// Returns whether the proxy also restored native Codex on the way out. The response +/// carries `success: false` when `restoreNativeCodex()` failed; only the boolean is +/// decoded, never the server-formatted message. +@discardableResult +public func stop() async throws -> Bool { + let data = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) + guard let result = try? JSONDecoder().decode(StopResult.self, from: data) else { return true } + return result.success ?? true } public func setProviderDisabled(_ name: String, disabled: Bool) async throws { @@ -95,16 +96,30 @@ public enum ActionOutcome: Equatable, Sendable { case requiresManualStart // stop confirmed; the app cannot relaunch it } -public func stopProxy() async -> ActionOutcome { - do { try await client.stop() } catch { return .failed("Could not reach the proxy to stop it.") } - - // Poll until the port stops answering, up to 10s, before claiming anything. - let deadline = Date().addingTimeInterval(10) - while Date() < deadline { - try? await Task.sleep(for: .milliseconds(500)) - if await !client.isReachable() { return .requiresManualStart } +public func stop(startCommand: String) async -> ActionOutcome { + let restored: Bool + do { restored = try await client.stop() } + catch let error as ProxyError { return .failed(error.userMessage) } + catch { return .failed("Could not reach the proxy to stop it.") } + + // Poll until the connection is REFUSED. Any HTTP answer — including 500 or an + // undecodable body — proves a server is still listening, and a timeout proves + // nothing at all. + let deadline = now().addingTimeInterval(Self.stopTimeout) + var sawIndeterminate = false + while now() < deadline { + await sleeper(Self.pollInterval) + switch await client.liveness() { + case .refused: + return restored ? .requiresManualStart(startCommand) + : .stoppedWithRestoreFailure(startCommand) + case .reachable: sawIndeterminate = false + case .indeterminate: sawIndeterminate = true + } } - return .failed("The proxy did not stop within 10 seconds.") + return .failed(sawIndeterminate + ? "The proxy accepted the stop, but its state could not be confirmed. Check with `ocx status`." + : "The proxy accepted the stop but was still responding after 10 seconds.") } ``` @@ -120,9 +135,11 @@ Per `dev-uiux-design` UX-LAZY-01, firing a request guaranteed to fail is not acc The toggle is disabled up front with an explanatory tooltip: ```swift -let isDefault = provider.name == config.defaultProvider -toggle.isEnabled = !isDefault -toggle.toolTip = isDefault +// The proxy guard is `rawBody.disabled && name === defaultProvider`, so only DISABLING +// the default is refused. A default provider that is already off must stay toggleable. +let wouldDisableDefault = isDefault && provider.isEnabled +toggle.isEnabled = !wouldDisableDefault +toggle.toolTip = wouldDisableDefault ? "This is the default provider. Choose another default in the dashboard first." : nil ``` @@ -179,6 +196,17 @@ Stubbed `URLProtocol`: ## Code-review corrections (folded before B closed) +### Round 2 + +| Finding | Correction | +| --- | --- | +| `.timedOut` and `.networkConnectionLost` were still mapped to `.unreachable`, so the three-state contract was two states in practice and a timeout could confirm a false stop | New `ProxyError.inconclusive`; only `.cannotConnectToHost` becomes `.refused`. Liveness probes also take a 1.5s timeout so one probe cannot overrun the stop deadline | +| `rebuildRows()` initialised switches from the snapshot, so a poll landing mid-write visibly snapped the switch back despite the row being busy | `pending` now stores the intended value, applied before the row is marked busy | +| The post-write refresh coalesced and returned immediately, so the switch became interactive against pre-write data | `refreshAndWait()` waits for a cycle to actually complete | +| The document still required a live stop at the top and carried pre-review snippets | Verification line and all three snippets updated to what shipped | + +### Round 1 + | Finding | Correction | | --- | --- | | `isReachable()` treated every non-401 error as "gone", so a 500 or a decode failure during polling reported a stop as confirmed while an HTTP server was still listening | Three-state `liveness()`: `reachable` (any HTTP answer, including 401/403/500 and undecodable bodies), `refused` (the only proof), `indeterminate` (timeouts prove nothing) | From f24fb864ab70a33cd2e112183bfcd71a9f0ac429 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:08:47 +0900 Subject: [PATCH 19/61] fix(app): single-attempt liveness and a real refresh completion signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 review returned GO-WITH-FIXES on two Medium blockers. - liveness() went through the generic send(), so a 401 with a stored key triggered the credential retry: a second full timeout spent re-asking a question the 401 had already answered, and a failed retry downgraded a known-reachable result to indeterminate. It now calls perform() directly — one attempt, no retry. - The stop loop always asked for a 1.5s probe regardless of time remaining, so the final probe could overrun the 10s deadline. Each probe is capped to min(1.5, remaining) and the loop breaks when nothing is left. - refreshAndWait() spun on shared booleans with a 5s bound. A legitimately slow cycle (providers + config sequentially, plus a due aggregation) can exceed that, at which point it returned and the switch became interactive against pre-write data — the exact window the method was added to close. It now waits on a continuation released when no cycle is running or queued. Also corrected two stale ProxyError doc comments (timeout is no longer unreachable, DNS is no longer transport) and the ActionOutcome snippet in 030, which predated stoppedWithRestoreFailure. New tests: a 401 with a stored key resolves in one request; the probe honours a caller-supplied timeout; every stop probe stays within the cap; refreshAndWait returns only after a cycle published, and survives a failing cycle without hanging. 97 -> 102. --- .../MenuBarCore/ActionCoordinator.swift | 6 ++- .../MenuBarCore/PollingCoordinator.swift | 36 ++++++++++++++--- app/Sources/MenuBarCore/ProxyClient.swift | 14 +++++-- .../MenuBarCoreTests/ActionSuite.swift | 40 +++++++++++++++++++ .../MenuBarCoreTests/PollingSuite.swift | 38 ++++++++++++++++++ .../030_phase3_actions.md | 17 +++++++- 6 files changed, 139 insertions(+), 12 deletions(-) diff --git a/app/Sources/MenuBarCore/ActionCoordinator.swift b/app/Sources/MenuBarCore/ActionCoordinator.swift index e1cca81d7d..5666ad48c9 100644 --- a/app/Sources/MenuBarCore/ActionCoordinator.swift +++ b/app/Sources/MenuBarCore/ActionCoordinator.swift @@ -63,7 +63,11 @@ public actor ActionCoordinator { var sawIndeterminate = false while now() < deadline { await sleeper(Self.pollInterval) - switch await client.liveness() { + // Cap the probe to whatever time is left, so the last one cannot overrun the + // deadline by its own timeout. + let remaining = deadline.timeIntervalSince(now()) + guard remaining > 0 else { break } + switch await client.liveness(timeout: min(1.5, remaining)) { case .refused: // The only proof the proxy is actually gone. return restored diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index a1e7da5e6c..edc5ecda93 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -24,6 +24,10 @@ public actor PollingCoordinator { /// immediately reopening the popover dropped the reopen's refresh entirely: the old /// cycle exited on its generation guard and the new one had already been rejected. private var pendingOpenRefresh = false + /// Continuations waiting for a cycle to publish. Waiting on a real completion signal + /// rather than a bounded spin means a slow-but-legitimate refresh cannot be + /// abandoned early, which would re-enable a control against pre-write state. + private var completionWaiters: [CheckedContinuation] = [] /// Attempt time, distinct from success time: a persistently failing endpoint must /// not turn its healthy sibling into a 5-second poller. private var lastAggregationAttempt: Date? @@ -84,6 +88,7 @@ public actor PollingCoordinator { guard cycle == generation else { refreshInFlight = false await drainPendingRefresh() + signalCompletionIfIdle() return } snapshot.state = .running(health) @@ -95,16 +100,19 @@ public actor PollingCoordinator { // The popover closed mid-flight. Not a proxy failure; leave state untouched. refreshInFlight = false await drainPendingRefresh() + signalCompletionIfIdle() return } catch let error as ProxyError { if cycle == generation { apply(error); publish() } refreshInFlight = false await drainPendingRefresh() + signalCompletionIfIdle() return } catch { if cycle == generation { apply(.transport); publish() } refreshInFlight = false await drainPendingRefresh() + signalCompletionIfIdle() return } @@ -127,23 +135,39 @@ public actor PollingCoordinator { if cycle == generation { publish() } refreshInFlight = false await drainPendingRefresh() + signalCompletionIfIdle() } - /// Refreshes and does not return until a cycle has actually completed. + /// Refreshes and does not return until a cycle has actually published. /// /// `refresh()` coalesces: if another cycle holds the lock it queues and returns /// immediately. A caller that needs authoritative state afterwards — such as /// re-enabling a switch after a write — would otherwise act on pre-write data. public func refreshAndWait(includeHeavy: Bool = true) async { + if refreshInFlight { + // Queue behind the running cycle and wait for the queued one to finish. + await refresh(includeHeavy: includeHeavy) + await waitForCompletion() + return + } await refresh(includeHeavy: includeHeavy) - // If this call was coalesced, wait for the cycle that absorbed it. - var spins = 0 - while (refreshInFlight || pendingOpenRefresh), spins < 100 { - spins += 1 - try? await Task.sleep(nanoseconds: 50_000_000) + } + + private func waitForCompletion() async { + guard refreshInFlight || pendingOpenRefresh else { return } + await withCheckedContinuation { continuation in + completionWaiters.append(continuation) } } + /// Releases anyone waiting once no cycle is running or queued. + private func signalCompletionIfIdle() { + guard !refreshInFlight, !pendingOpenRefresh, !completionWaiters.isEmpty else { return } + let waiters = completionWaiters + completionWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + /// Runs a refresh that arrived while another cycle held the lock. private func drainPendingRefresh() async { guard pendingOpenRefresh, popoverOpen else { diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index a58559f3a5..602ef55ff5 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -1,13 +1,15 @@ import Foundation public enum ProxyError: Error, Equatable { - /// Connection refused or timed out — the proxy is not running. + /// The connection was refused — nothing is listening. This is the only transport + /// result that proves the proxy is gone; timeouts get `.inconclusive`. case unreachable /// 401 — a non-loopback bind that requires a credential. case unauthorized case http(Int) case decoding - /// A transport failure that is not evidence the proxy is down (TLS, policy, DNS). + /// A transport failure that is not evidence the proxy is down (TLS, policy, and + /// other non-connectivity URLSession errors). case transport /// The request never completed — a timeout or a socket dropped mid-response. This /// proves nothing either way, and must not be read as "the proxy is gone". @@ -113,7 +115,13 @@ public actor ProxyClient { /// overrun the stop deadline it is supposed to respect. public func liveness(timeout: TimeInterval = 1.5) async -> Liveness { do { - _ = try await get("api/settings", timeout: timeout) as ProxySettings + // Deliberately bypasses `send()`: its 401 credential retry would spend a + // second full timeout re-asking a question the 401 already answered, and a + // failed retry would downgrade a known-reachable result to indeterminate. + _ = try await perform( + method: "GET", path: "api/settings", query: [], + body: nil as EmptyBody?, timeout: timeout + ) return .reachable } catch ProxyError.unauthorized, ProxyError.decoding { // Both prove a server answered. diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift index fe289f6976..8f11cc350f 100644 --- a/app/Sources/MenuBarCoreTests/ActionSuite.swift +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -307,6 +307,46 @@ enum ActionSuite { t.expect(false, "expected .failed, got \(outcome)") } } + + // A 401 already answers "is anything listening". Retrying it through the normal + // credential path spent a second full timeout and could downgrade a + // known-reachable result to indeterminate if the retry failed. + t.test("liveness: a 401 answers immediately without a credential retry") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 0, body: "", urlError: .timedOut), // must never be used + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "stored-key")) + t.equal(sync { await client.liveness() }, .reachable) + t.equal(StubProtocol.recorded.count, 1, "liveness must be a single attempt") + } + + t.test("liveness: the probe honours a caller-supplied timeout") { + StubProtocol.reset([.init(status: 200, body: #"{"port":10100}"#, urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + _ = sync { await client.liveness(timeout: 0.25) } + t.equal(StubProtocol.recorded.first?.timeoutInterval, 0.25) + } + + // The final probe must not overrun the stop deadline by its own timeout. + t.test("stop: the last probe is capped to the remaining deadline") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + _ = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + + // Every liveness probe after the POST must request no more than 1.5s, and + // the last must be clamped to whatever remained. + let probes = StubProtocol.recorded.dropFirst() + t.expect(!probes.isEmpty, "expected liveness probes") + for probe in probes { + t.expect(probe.timeoutInterval <= 1.5, + "probe timeout \(probe.timeoutInterval) exceeds the cap") + } + } } private struct StubCredentialsFixed: CredentialStore { diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index b89e0da7d4..20b20f3283 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -246,6 +246,44 @@ enum PollingSuite { t.equal(snapshot.showsData, false, "no data was ever loaded") t.isNil(snapshot.dataAge, "dataAge") } + + // refresh() coalesces, so a caller that needs authoritative state afterwards + // must wait for the cycle that absorbed its request — not just for its own + // immediate return. + t.test("polling: refreshAndWait returns only after a cycle has published") { + // setPopoverOpen already runs a full cycle, so queue enough for both it and + // the refreshAndWait that follows; the stub falls back to connection-refused + // once drained, which would look like a stopped proxy. + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refreshAndWait() + return await coordinator.current + } + // If it returned early the health read would not have landed yet. + t.equal(snapshot.state.isRunning, true) + _ = t.notNil(snapshot.lastUpdated, "lastUpdated after refreshAndWait") + } + + t.test("polling: refreshAndWait survives a failing cycle without hanging") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refreshAndWait() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + } } private struct NoCredentials: CredentialStore { diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index 97969f383d..b98352c486 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -92,8 +92,13 @@ as "stopped" would make the UI lie for several seconds. ```swift public enum ActionOutcome: Equatable, Sendable { case succeeded - case failed(String) // user-facing text, never a raw response body - case requiresManualStart // stop confirmed; the app cannot relaunch it + /// Stop confirmed; the app cannot relaunch it, so it carries the start command. + case requiresManualStart(String) + /// Stopped, but `restoreNativeCodex()` failed — native Codex still points at the + /// closing port, so the user must run `ocx restore` too. + case stoppedWithRestoreFailure(String) + /// User-facing text, never a raw response body. + case failed(String) } public func stop(startCommand: String) async -> ActionOutcome { @@ -196,6 +201,14 @@ Stubbed `URLProtocol`: ## Code-review corrections (folded before B closed) +### Round 3 + +| Finding | Correction | +| --- | --- | +| `liveness()` went through the generic `send()`, so a 401 with a stored key triggered a credential retry — spending a second full timeout re-asking a question the 401 had already answered, and downgrading a known-reachable result to indeterminate if that retry failed | Liveness now calls `perform()` directly: one attempt, no retry | +| The stop loop always requested a 1.5s probe, so the final one could overrun the 10s deadline | Each probe is capped to `min(1.5, remaining)`, and the loop breaks when no time is left | +| `refreshAndWait()` spun on shared booleans with a 5s bound, which a legitimately slow cycle can exceed — re-enabling the switch against pre-write data, the exact window it was added to close | Waits on a continuation released when no cycle is running or queued | + ### Round 2 | Finding | Correction | From ef72ab6f3da375a9e819cfac9085431d3781efaf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:14:13 +0900 Subject: [PATCH 20/61] test(app): actually exercise the refresh continuation path Round-4 review found that neither refreshAndWait test entered the code they were written to protect. Both ran with refreshInFlight == false, so they took the direct path and never touched completionWaiters, waitForCompletion, or signalCompletionIfIdle. They would have stayed green if the continuation never resumed, resumed early, or was deleted. StubProtocol gained a request gate so a cycle can be held suspended. Two new tests start a refresh, block it in the stub, call refreshAndWait concurrently, assert it has NOT returned, then release and assert it does. One covers a succeeding queued cycle, one a failing cycle. Sabotage-verified, because a passing test proves nothing about a path it never takes: removing the resume line made the suite hang until the 120s timeout rather than pass. Restored, it completes in about 2 seconds. 102 -> 104 tests. --- .../MenuBarCoreTests/PollingSuite.swift | 63 +++++++++++++++++++ .../MenuBarCoreTests/TransportSuite.swift | 6 ++ .../030_phase3_actions.md | 11 ++++ 3 files changed, 80 insertions(+) diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index 20b20f3283..77a5a42b9d 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -23,6 +23,13 @@ enum PollingSuite { private final class Box: @unchecked Sendable { var value: T? } + private final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + var value: Bool { lock.lock(); defer { lock.unlock() }; return flag } + func set() { lock.lock(); flag = true; lock.unlock() } + } + private static let healthOK = #"{"status":"protected","serviceInstalled":true,"serviceEnabled":true}"# private static let usageOK = #"{"range":"7d","summary":{"requests":10},"days":[{"date":"d","requests":10}]}"# private static let quotasOK = #"{"reports":[{"provider":"p","quota":{"weeklyPercent":5}}]}"# @@ -275,6 +282,62 @@ enum PollingSuite { _ = t.notNil(snapshot.lastUpdated, "lastUpdated after refreshAndWait") } + // The previous two tests both ran with refreshInFlight == false, so they never + // entered waitForCompletion() at all — they would have stayed green if the + // continuation never resumed. This one holds a refresh suspended so the + // coalescing path is the one under test. + t.test("polling: refreshAndWait suspends behind an in-flight cycle and resumes") { + StubProtocol.reset(Array( + repeating: .init(status: 200, body: healthOK, urlError: nil), count: 20)) + let gate = DispatchSemaphore(value: 0) + StubProtocol.gate = gate + + let coordinator = makeCoordinator() + let returned = Flag() + + // Cycle 1 blocks inside the stub. + let first = Task { await coordinator.refresh() } + Thread.sleep(forTimeInterval: 0.2) + + // Cycle 2 must queue behind it and stay suspended. + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + Thread.sleep(forTimeInterval: 0.3) + t.equal(returned.value, false, "refreshAndWait must not return while a cycle is in flight") + + // Release everything and let the queued cycle finish. + StubProtocol.gate = nil + for _ in 0..<40 { gate.signal() } + sync { _ = await first.value; _ = await waiter.value } + t.equal(returned.value, true, "refreshAndWait must resume once the queued cycle publishes") + } + + t.test("polling: a waiter is released even when the queued cycle fails") { + var responses = Array(repeating: StubProtocol.Response(status: 200, body: healthOK, urlError: nil), count: 3) + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 20)) + StubProtocol.reset(responses) + let gate = DispatchSemaphore(value: 0) + StubProtocol.gate = gate + + let coordinator = makeCoordinator() + let returned = Flag() + let first = Task { await coordinator.refresh() } + Thread.sleep(forTimeInterval: 0.2) + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + Thread.sleep(forTimeInterval: 0.2) + + StubProtocol.gate = nil + for _ in 0..<40 { gate.signal() } + sync { _ = await first.value; _ = await waiter.value } + t.equal(returned.value, true, "a failing queued cycle must still release its waiter") + } + t.test("polling: refreshAndWait survives a failing cycle without hanging") { StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) let coordinator = makeCoordinator() diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index 423e416e0e..2e7f1f46c9 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -19,9 +19,13 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { queue = responses recorded = [] bodies = [] + gate = nil } nonisolated(unsafe) static var bodies: [Data] = [] + /// When set, `startLoading` blocks until the gate is opened. Lets a test hold a + /// refresh suspended so the coalescing/continuation path is genuinely exercised. + nonisolated(unsafe) static var gate: DispatchSemaphore? static func record(_ request: URLRequest) { lock.lock(); defer { lock.unlock() } @@ -54,6 +58,8 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { override func startLoading() { Self.record(request) + // Held open by tests that need a request to stay in flight. + Self.gate?.wait() guard let response = Self.next() else { client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) return diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index b98352c486..34e7061ce8 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -201,6 +201,17 @@ Stubbed `URLProtocol`: ## Code-review corrections (folded before B closed) +### Round 4 + +| Finding | Correction | +| --- | --- | +| Both `refreshAndWait` tests ran with `refreshInFlight == false`, so neither entered `waitForCompletion()`. They would have stayed green if the continuation never resumed — no regression proof for the concurrency fix that closed the round-3 blocker | `StubProtocol` gained a request gate. Two new tests hold a cycle suspended, assert the waiter has NOT returned, then release and assert it does — one for a succeeding queued cycle, one for a failing one | + +**Sabotage-verified.** A test that passes proves nothing about a path it never takes, so +the resume line was deliberately removed and the suite re-run: it hung until the 120s +timeout instead of passing. Restored, it passes in ~2s. That is the evidence the tests +actually exercise the continuation. + ### Round 3 | Finding | Correction | From 5d95f064e1aaac44534a1280261619df676b31cb Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:20:33 +0900 Subject: [PATCH 21/61] test(app): make the failing-cycle test actually consume a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 review found the "queued cycle fails" test was re-testing the success path. With the popover closed a cycle consumes exactly one health response, and the queue led with three 200s, so the connection-refused responses were never reached. It would have stayed green if the error exit stopped signaling waiters. Two contract details drive the corrected setup: drainPendingRefresh only runs while the popover is open, and an open cycle consumes health + providers + config + usage + quotas. So the popover is opened first, then a single gated 200 lets cycle 1 reach the gate, and everything after is a refusal. A new snapshot.state == .unreachable assertion proves the failure was consumed — and that assertion is what caught the original defect. Hardened the gate harness alongside it: setGate/currentGate now go through the stub's existing lock rather than racing on a bare static, a gateEntered semaphore lets a test wait for the request to actually arrive instead of inferring it from a 200ms sleep, and defer releases the gate so a mid-test failure cannot wedge the suite. Sabotage results, both recorded in 030 because the second one matters: removing waiter.resume() entirely hangs the suite, so the gate tests do depend on the continuation. Removing only the ProxyError signal does not fail it — a signal trace showed the waiter is protected by several exit paths, so single-site sabotage is not a valid probe here. 104 tests. --- .../MenuBarCoreTests/PollingSuite.swift | 71 +++++++++++++------ .../MenuBarCoreTests/TransportSuite.swift | 24 ++++++- .../030_phase3_actions.md | 24 +++++-- 3 files changed, 92 insertions(+), 27 deletions(-) diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index 77a5a42b9d..0e99db71a9 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -282,24 +282,27 @@ enum PollingSuite { _ = t.notNil(snapshot.lastUpdated, "lastUpdated after refreshAndWait") } - // The previous two tests both ran with refreshInFlight == false, so they never - // entered waitForCompletion() at all — they would have stayed green if the - // continuation never resumed. This one holds a refresh suspended so the - // coalescing path is the one under test. + // The first two refreshAndWait tests ran with refreshInFlight == false, so they + // never entered waitForCompletion() at all. These hold a cycle suspended in the + // stub so the coalescing path is the one under test. t.test("polling: refreshAndWait suspends behind an in-flight cycle and resumes") { StubProtocol.reset(Array( repeating: .init(status: 200, body: healthOK, urlError: nil), count: 20)) let gate = DispatchSemaphore(value: 0) - StubProtocol.gate = gate + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<40 { gate.signal() } + } let coordinator = makeCoordinator() let returned = Flag() - // Cycle 1 blocks inside the stub. let first = Task { await coordinator.refresh() } - Thread.sleep(forTimeInterval: 0.2) + // Wait for the request to actually reach the gate rather than guessing. + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") - // Cycle 2 must queue behind it and stay suspended. let waiter = Task { await coordinator.refreshAndWait() returned.set() @@ -307,35 +310,63 @@ enum PollingSuite { Thread.sleep(forTimeInterval: 0.3) t.equal(returned.value, false, "refreshAndWait must not return while a cycle is in flight") - // Release everything and let the queued cycle finish. - StubProtocol.gate = nil + StubProtocol.setGate(nil) for _ in 0..<40 { gate.signal() } sync { _ = await first.value; _ = await waiter.value } t.equal(returned.value, true, "refreshAndWait must resume once the queued cycle publishes") } - t.test("polling: a waiter is released even when the queued cycle fails") { - var responses = Array(repeating: StubProtocol.Response(status: 200, body: healthOK, urlError: nil), count: 3) + // The queued cycle must FAIL here. Two contract details drive the setup: + // drainPendingRefresh only runs while the popover is OPEN, and an open cycle + // consumes health + providers + config + usage + quotas. So the popover is + // opened first (consuming its own cycle), then one gated 200 lets cycle 1 reach + // the gate, and every response after that is a refusal. An earlier version + // queued three 200s with the popover closed and silently re-tested the success + // path — which is exactly what the new state assertion caught. + t.test("polling: a waiter is released when the queued cycle fails") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { await coordinator.setPopoverOpen(true) } + + var responses: [StubProtocol.Response] = [.init(status: 200, body: healthOK, urlError: nil)] responses.append(contentsOf: Array( - repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 20)) + repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 30)) StubProtocol.reset(responses) let gate = DispatchSemaphore(value: 0) - StubProtocol.gate = gate + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + } - let coordinator = makeCoordinator() let returned = Flag() let first = Task { await coordinator.refresh() } - Thread.sleep(forTimeInterval: 0.2) + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") + let waiter = Task { await coordinator.refreshAndWait() returned.set() } - Thread.sleep(forTimeInterval: 0.2) + Thread.sleep(forTimeInterval: 0.3) + t.equal(returned.value, false, "must still be suspended") - StubProtocol.gate = nil - for _ in 0..<40 { gate.signal() } - sync { _ = await first.value; _ = await waiter.value } + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + let snapshot = sync { () -> ProxySnapshot in + _ = await first.value + _ = await waiter.value + return await coordinator.current + } t.equal(returned.value, true, "a failing queued cycle must still release its waiter") + // Proves the refusal was actually consumed, not a second 200. + t.equal(snapshot.state, .unreachable, "the queued cycle must have failed") } t.test("polling: refreshAndWait survives a failing cycle without hanging") { diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index 2e7f1f46c9..dd4ff989e7 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -19,13 +19,28 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { queue = responses recorded = [] bodies = [] - gate = nil + gateStorage = nil } nonisolated(unsafe) static var bodies: [Data] = [] /// When set, `startLoading` blocks until the gate is opened. Lets a test hold a /// refresh suspended so the coalescing/continuation path is genuinely exercised. - nonisolated(unsafe) static var gate: DispatchSemaphore? + /// + /// Access goes through `setGate`/`currentGate` under the same lock as the rest of + /// the stub state: an unsynchronised read here is a data race, and `gateEntered` + /// lets a test wait for the request to actually reach the gate instead of inferring + /// it from elapsed time. + nonisolated(unsafe) private static var gateStorage: DispatchSemaphore? + nonisolated(unsafe) static let gateEntered = DispatchSemaphore(value: 0) + + static func setGate(_ gate: DispatchSemaphore?) { + lock.lock(); gateStorage = gate; lock.unlock() + } + + static func currentGate() -> DispatchSemaphore? { + lock.lock(); defer { lock.unlock() } + return gateStorage + } static func record(_ request: URLRequest) { lock.lock(); defer { lock.unlock() } @@ -59,7 +74,10 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { override func startLoading() { Self.record(request) // Held open by tests that need a request to stay in flight. - Self.gate?.wait() + if let gate = Self.currentGate() { + Self.gateEntered.signal() + gate.wait() + } guard let response = Self.next() else { client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) return diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index 34e7061ce8..3ceac4d5b4 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -201,16 +201,32 @@ Stubbed `URLProtocol`: ## Code-review corrections (folded before B closed) +### Round 5 + +| Finding | Correction | +| --- | --- | +| The "queued cycle fails" test never consumed a failure: with the popover closed a cycle takes exactly one health response, and the queue led with three 200s, so it re-tested the success path | The popover is opened first (consuming its own five-response cycle), then one gated 200 followed by refusals. A new `snapshot.state == .unreachable` assertion proves the failure was actually consumed — and it is what caught this | +| The gate was read and written without the stub's lock, and the test inferred "the request reached the gate" from a 200ms sleep | `setGate`/`currentGate` go through the same lock, a `gateEntered` semaphore lets the test wait for the request to actually arrive, and `defer` releases the gate so a mid-test failure cannot wedge the suite | + ### Round 4 | Finding | Correction | | --- | --- | | Both `refreshAndWait` tests ran with `refreshInFlight == false`, so neither entered `waitForCompletion()`. They would have stayed green if the continuation never resumed — no regression proof for the concurrency fix that closed the round-3 blocker | `StubProtocol` gained a request gate. Two new tests hold a cycle suspended, assert the waiter has NOT returned, then release and assert it does — one for a succeeding queued cycle, one for a failing one | -**Sabotage-verified.** A test that passes proves nothing about a path it never takes, so -the resume line was deliberately removed and the suite re-run: it hung until the 120s -timeout instead of passing. Restored, it passes in ~2s. That is the evidence the tests -actually exercise the continuation. +**Sabotage-verified, and the sabotage itself needed a second pass.** A test that passes +proves nothing about a path it never takes: + +- Removing `waiter.resume()` entirely makes the suite hang until timeout instead of + passing, so both gate tests genuinely depend on the continuation. +- Removing the signal from only the `ProxyError` exit does NOT fail the suite. A signal + trace (`RELEASE site=…`) showed why: the failing cycle releases at that site, but when + it is muted another exit path still reaches an idle state and releases the waiter. The + waiter is therefore protected by several exits rather than by exactly one, which is + the safer arrangement but means single-site sabotage is not a valid probe here. + +Recording both results because the second one is the kind of thing that quietly +invalidates a "verified" claim. ### Round 3 From d2622de7f9edb941f536fbdea54901d146b8de25 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:25:18 +0900 Subject: [PATCH 22/61] test(app): deterministic waiter registration and a UI test target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-6 review found two ways the suite could pass without proving anything. - The continuation tests synchronised on a fixed sleep. gateEntered proved cycle 1 reached the gate, but nothing proved the waiter had registered before the gate was released; under starvation the waiter could start afterwards, take the ordinary non-coalesced path, and still satisfy every assertion. PollingCoordinator now exposes waiterCount, and the tests poll it until registration is observed, then assert it returns to zero. - No test drove MenuBarUI at all. The Phase 3 behaviours that had actually been defects in earlier rounds — optimistic rollback, pending state surviving a stale poll, and the direction-sensitive default guard — had no regression cover, because MenuBarCoreTests depends only on MenuBarCore. Added a MenuBarUITests target with read-only inspection hooks. Sabotage-verified: reintroducing both original defects failed exactly the two matching cases and left the other five green. Making the default guard direction-insensitive failed the disabled-default recovery test; dropping the intended value in rebuildRows failed the stale-poll test. Also removed an unnecessary nonisolated(unsafe) on a let constant. 104 core + 7 UI tests. --- app/Package.swift | 8 ++ .../MenuBarCore/PollingCoordinator.swift | 7 + .../MenuBarCoreTests/PollingSuite.swift | 19 ++- .../MenuBarCoreTests/TransportSuite.swift | 2 +- app/Sources/MenuBarUI/ProviderListView.swift | 62 ++++++-- app/Sources/MenuBarUITests/Harness.swift | 105 ++++++++++++++ app/Sources/MenuBarUITests/main.swift | 132 ++++++++++++++++++ .../030_phase3_actions.md | 16 ++- 8 files changed, 337 insertions(+), 14 deletions(-) create mode 100644 app/Sources/MenuBarUITests/Harness.swift create mode 100644 app/Sources/MenuBarUITests/main.swift diff --git a/app/Package.swift b/app/Package.swift index 128c7a6343..7ce1361dea 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -7,6 +7,7 @@ let package = Package( products: [ .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), + .executable(name: "MenuBarUITests", targets: ["MenuBarUITests"]), .executable(name: "UIProbe", targets: ["UIProbe"]), .executable(name: "IconProbe", targets: ["IconProbe"]), ], @@ -28,6 +29,13 @@ let package = Package( dependencies: ["MenuBarCore"], path: "Sources/MenuBarCoreTests" ), + // UI-layer tests need AppKit and an NSApplication, so they are a separate + // executable from the dependency-free core suite. + .executableTarget( + name: "MenuBarUITests", + dependencies: ["MenuBarCore", "MenuBarUI"], + path: "Sources/MenuBarUITests" + ), .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), .executableTarget(name: "IconProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/IconProbe"), ], diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index edc5ecda93..2fae325b5b 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -153,6 +153,13 @@ public actor PollingCoordinator { await refresh(includeHeavy: includeHeavy) } + /// Number of callers currently suspended in `waitForCompletion()`. + /// + /// Exposed so a test can wait for registration deterministically instead of sleeping + /// and hoping the waiter task was scheduled — a fixed sleep let the continuation + /// tests pass without ever entering this path. + public var waiterCount: Int { completionWaiters.count } + private func waitForCompletion() async { guard refreshInFlight || pendingOpenRefresh else { return } await withCheckedContinuation { continuation in diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index 0e99db71a9..f57a9e1d9a 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -23,6 +23,17 @@ enum PollingSuite { private final class Box: @unchecked Sendable { var value: T? } + /// Polls the coordinator's own waiter count, so registration is observed rather + /// than assumed from elapsed time. + private static func waitForWaiter(_ coordinator: PollingCoordinator, timeout: TimeInterval = 5) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await coordinator.waiterCount > 0 { return true } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return false + } + private final class Flag: @unchecked Sendable { private let lock = NSLock() private var flag = false @@ -307,13 +318,17 @@ enum PollingSuite { await coordinator.refreshAndWait() returned.set() } - Thread.sleep(forTimeInterval: 0.3) + // Wait for the waiter to actually REGISTER, rather than sleeping and hoping + // it was scheduled. A fixed sleep let this test pass without ever entering + // the continuation path. + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") t.equal(returned.value, false, "refreshAndWait must not return while a cycle is in flight") StubProtocol.setGate(nil) for _ in 0..<40 { gate.signal() } sync { _ = await first.value; _ = await waiter.value } t.equal(returned.value, true, "refreshAndWait must resume once the queued cycle publishes") + t.equal(sync { await coordinator.waiterCount }, 0, "no waiter should remain registered") } // The queued cycle must FAIL here. Two contract details drive the setup: @@ -354,7 +369,7 @@ enum PollingSuite { await coordinator.refreshAndWait() returned.set() } - Thread.sleep(forTimeInterval: 0.3) + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") t.equal(returned.value, false, "must still be suspended") StubProtocol.setGate(nil) diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index dd4ff989e7..ad32b2c804 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -31,7 +31,7 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { /// lets a test wait for the request to actually reach the gate instead of inferring /// it from elapsed time. nonisolated(unsafe) private static var gateStorage: DispatchSemaphore? - nonisolated(unsafe) static let gateEntered = DispatchSemaphore(value: 0) + static let gateEntered = DispatchSemaphore(value: 0) static func setGate(_ gate: DispatchSemaphore?) { lock.lock(); gateStorage = gate; lock.unlock() diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index dfecf0bf15..daabb16a2a 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -5,7 +5,7 @@ import MenuBarCore /// /// Collapsed by default: reading status is frequent, toggling a provider is rare, and /// the urgency order in `003` puts actions below information. -final class ProviderListView: NSView { +public final class ProviderListView: NSView { private let disclosure = NSButton() private let summary = makeLabel("", font: Theme.caption, color: Theme.muted) private let rows = NSStackView() @@ -17,10 +17,10 @@ final class ProviderListView: NSView { private var pending: [String: Bool] = [:] /// `(provider, shouldDisable)`. - var onToggle: ((String, Bool) -> Void)? + public var onToggle: ((String, Bool) -> Void)? - init() { - super.init(frame: .zero) + public override init(frame: NSRect) { + super.init(frame: frame) disclosure.bezelStyle = .disclosure disclosure.setButtonType(.onOff) @@ -53,9 +53,11 @@ final class ProviderListView: NSView { ]) } - required init?(coder: NSCoder) { nil } + public convenience init() { self.init(frame: .zero) } + + public required init?(coder: NSCoder) { nil } - func apply(_ snapshot: ProxySnapshot) { + public func apply(_ snapshot: ProxySnapshot) { self.snapshot = snapshot guard snapshot.providersLoaded else { @@ -104,6 +106,17 @@ final class ProviderListView: NSView { } } + /// Shared by the disclosure button and the test hook. + func setExpanded(_ value: Bool) { + expanded = value + disclosure.state = value ? .on : .off + rows.isHidden = !expanded + disclosure.setAccessibilityLabel(expanded ? "Hide providers" : "Show providers") + (window?.contentViewController as? PopoverViewController)?.refreshSize() + } + + var providerRows: [NSView] { rows.arrangedSubviews } + @objc private func toggleExpanded() { expanded = disclosure.state == .on rows.isHidden = !expanded @@ -113,7 +126,7 @@ final class ProviderListView: NSView { } /// Reverts a switch after the proxy rejected the change. - func revert(_ name: String, to enabled: Bool) { + public func revert(_ name: String, to enabled: Bool) { pending[name] = nil for case let row as ProviderRowView in rows.arrangedSubviews where row.providerName == name { row.setEnabled(enabled) @@ -126,7 +139,7 @@ final class ProviderListView: NSView { /// a second click cannot race the first. /// `intended` is the state the user selected, retained so a poll landing mid-write /// cannot snap the switch back. - func setBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { + public func setBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) { if busy { pending[name] = intended ?? pending[name] ?? true } else { @@ -139,8 +152,8 @@ final class ProviderListView: NSView { } } -final class ProviderRowView: NSView { - let providerName: String +public final class ProviderRowView: NSView { + public let providerName: String private let toggle = NSSwitch() private let onToggle: (Bool) -> Void private var baseEnabled = true @@ -198,6 +211,9 @@ final class ProviderRowView: NSView { func setEnabled(_ enabled: Bool) { toggle.state = enabled ? .on : .off } + var toggleState: Bool { toggle.state == .on } + var toggleIsEnabled: Bool { toggle.isEnabled } + /// Inert while its write is in flight, so a second click cannot race the first. func setBusy(_ busy: Bool) { isBusy = busy @@ -210,3 +226,29 @@ final class ProviderRowView: NSView { onToggle(toggle.state == .off) } } + + +// MARK: - Test inspection + +/// Read-only hooks so the UI suite can assert on rendered control state rather than on +/// the view's private bookkeeping. +public extension ProviderListView { + /// Expands the list without going through a click, so tests do not depend on + /// NSButton action dispatch. + func expandForTesting() { setExpanded(true) } + + func isToggleOn(_ name: String) -> Bool? { row(name)?.isOn } + func isToggleEnabled(_ name: String) -> Bool? { row(name)?.isToggleEnabled } + + private func row(_ name: String) -> ProviderRowView? { + for case let row as ProviderRowView in providerRows where row.providerName == name { + return row + } + return nil + } +} + +public extension ProviderRowView { + var isOn: Bool { toggleState } + var isToggleEnabled: Bool { toggleIsEnabled } +} diff --git a/app/Sources/MenuBarUITests/Harness.swift b/app/Sources/MenuBarUITests/Harness.swift new file mode 100644 index 0000000000..0deb1d6ae4 --- /dev/null +++ b/app/Sources/MenuBarUITests/Harness.swift @@ -0,0 +1,105 @@ +import Foundation + +/// A dependency-free assertion harness. +/// +/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line +/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles +/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to +/// run the unit tests of a menu bar companion would put the tests out of reach for most +/// contributors and for any CI runner without Xcode selected. +/// +/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that +/// both a human and CI can read. If the package ever gains a full-Xcode requirement for +/// other reasons, migrating these cases to swift-testing is mechanical. +public struct TestFailure { + let test: String + let message: String + let file: String + let line: Int +} + +public final class TestRunner { + private(set) var passed = 0 + private(set) var failures: [TestFailure] = [] + private var current = "" + + public init() {} + + public func test(_ name: String, _ body: () throws -> Void) { + current = name + let failuresBefore = failures.count + do { + try body() + } catch { + failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) + print("FAIL — \(name): threw \(error)") + return + } + // A case that recorded an expectation failure is not a pass, even though its + // body returned normally. + if failures.count == failuresBefore { + passed += 1 + print("ok — \(name)") + } + } + + public func expect( + _ condition: Bool, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + guard !condition else { return } + let failure = TestFailure(test: current, message: message(), file: file, line: line) + failures.append(failure) + print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") + } + + public func equal( + _ actual: T, + _ expected: T, + _ label: String = "", + file: String = #file, + line: Int = #line + ) { + expect( + actual == expected, + "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", + file: file, + line: line + ) + } + + public func notNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) -> T? { + expect(value != nil, "\(label) should not be nil", file: file, line: line) + return value + } + + public func isNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) { + expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) + } + + /// Prints the summary and returns the process exit code. + public func summarize() -> Int32 { + print("") + if failures.isEmpty { + print("\(passed) passed, 0 failed") + return 0 + } + print("\(passed) passed, \(failures.count) FAILED") + for failure in failures { + print(" - \(failure.test): \(failure.message)") + } + return 1 + } +} diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift new file mode 100644 index 0000000000..64604e4383 --- /dev/null +++ b/app/Sources/MenuBarUITests/main.swift @@ -0,0 +1,132 @@ +import AppKit +import MenuBarCore +import MenuBarUI + +// UI-layer tests. Separate from MenuBarCoreTests because these need AppKit and an +// NSApplication; the core suite deliberately has no UI dependency. +// +// These cover the Phase 3 behaviours that were defects in earlier review rounds: +// optimistic rollback, pending state surviving a poll, and the direction-sensitive +// default-provider guard. + +let app = NSApplication.shared +app.setActivationPolicy(.prohibited) + +let runner = TestRunner() + +func provider(_ name: String, enabled: Bool = true) -> ProviderSummary { + let json = #"{"name":"\#(name)","disabled":\#(enabled ? "false" : "true")}"# + return try! JSONDecoder().decode(ProviderSummary.self, from: Data(json.utf8)) +} + +func snapshot( + providers: [ProviderSummary], + defaultProvider: String? = "openai" +) -> ProxySnapshot { + ProxySnapshot( + state: .running(StartupHealth(status: "protected")), + endpoint: .default, + providers: providers, + defaultProvider: defaultProvider, + lastUpdated: Date(), + providersLoaded: true + ) +} + +// MARK: - Default-provider guard direction + +runner.test("ui: an enabled default provider cannot be switched off") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("openai"), provider("anthropic")])) + list.expandForTesting() + + runner.equal(list.isToggleEnabled("openai"), false, "enabled default is inert") + runner.equal(list.isToggleEnabled("anthropic"), true, "non-default is toggleable") +} + +// The proxy guard is `disabled && name === defaultProvider`, so ENABLING the default is +// valid. Making the control inert whenever isDefault stranded the user. +runner.test("ui: a disabled default provider can still be switched back on") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("openai", enabled: false)])) + list.expandForTesting() + + runner.equal(list.isToggleEnabled("openai"), true, "disabled default must be recoverable") +} + +// MARK: - Optimistic update and rollback + +runner.test("ui: a rejected write restores the switch it moved") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic")])) + list.expandForTesting() + + // User switches it off; the write is in flight. + list.setBusy("anthropic", true, intended: false) + runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") + runner.equal(list.isToggleEnabled("anthropic"), false, "inert while in flight") + + // The proxy rejects it. + list.revert("anthropic", to: true) + runner.equal(list.isToggleOn("anthropic"), true, "reverted to the server's value") + runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") +} + +runner.test("ui: a successful write clears busy without reverting") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic")])) + list.expandForTesting() + + list.setBusy("anthropic", true, intended: false) + // The authoritative refresh now reports it disabled. + list.apply(snapshot(providers: [provider("anthropic", enabled: false)])) + list.setBusy("anthropic", false) + + runner.equal(list.isToggleOn("anthropic"), false, "server state retained") + runner.equal(list.isToggleEnabled("anthropic"), true, "interactive again") +} + +// MARK: - Pending state versus a stale poll + +// This is the defect a reviewer caught: rebuildRows initialised each switch from the +// snapshot, so a poll carrying pre-write data snapped the switch back mid-write. +runner.test("ui: a stale poll cannot undo an in-flight optimistic change") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic")])) + list.expandForTesting() + + list.setBusy("anthropic", true, intended: false) + runner.equal(list.isToggleOn("anthropic"), false, "optimistic state applied") + + // A poll that started before the write lands, still reporting the old value. + list.apply(snapshot(providers: [provider("anthropic", enabled: true)])) + + runner.equal(list.isToggleOn("anthropic"), false, "stale poll must not snap it back") + runner.equal(list.isToggleEnabled("anthropic"), false, "still inert while in flight") +} + +runner.test("ui: pending state is per provider and does not leak") { + let list = ProviderListView() + list.apply(snapshot(providers: [provider("anthropic"), provider("xai")])) + list.expandForTesting() + + list.setBusy("anthropic", true, intended: false) + runner.equal(list.isToggleEnabled("anthropic"), false, "target is inert") + runner.equal(list.isToggleEnabled("xai"), true, "sibling is unaffected") + runner.equal(list.isToggleOn("xai"), true, "sibling keeps its value") +} + +// MARK: - Empty and unloaded states + +runner.test("ui: providers are hidden until they have actually been read") { + let list = ProviderListView() + var unloaded = snapshot(providers: []) + unloaded.providersLoaded = false + list.apply(unloaded) + runner.equal(list.isHidden, true, "not fetched yet is not the same as none") + + list.apply(snapshot(providers: [])) + runner.equal(list.isHidden, false, "an empty result renders its own copy") +} + +exit(runner.summarize()) diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index 3ceac4d5b4..bf5ab1fcf2 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -201,6 +201,19 @@ Stubbed `URLProtocol`: ## Code-review corrections (folded before B closed) +### Round 6 + +| Finding | Correction | +| --- | --- | +| The continuation tests could still pass without entering the continuation: `gateEntered` proved cycle 1 reached the gate, but nothing proved the *waiter* had registered before the gate was released. Under starvation the waiter could start afterwards, take the ordinary path, and satisfy every assertion | `PollingCoordinator.waiterCount` is exposed and the tests poll it until registration is observed, then assert it returns to zero. No `Thread.sleep` remains as synchronisation | +| No test drove `MenuBarUI` at all, so the Phase 3 rollback, pending-versus-poll, and default-direction behaviours — every one of them a defect found in an earlier round — had zero regression cover | New `MenuBarUITests` target (7 cases) with read-only inspection hooks on `ProviderListView` | + +**Sabotage-verified.** Both previously-fixed defects were reintroduced and the suite +caught exactly the right two cases: making the default guard direction-insensitive failed +"a disabled default provider can still be switched back on", and dropping the intended +value in `rebuildRows` failed "a stale poll cannot undo an in-flight optimistic change". +The other five stayed green. + ### Round 5 | Finding | Correction | @@ -299,4 +312,5 @@ and a proxy that accepts the stop but keeps answering. 3. The default provider's toggle is inert and explains why, using `/api/config`. 4. Failure paths surface a human sentence, never a raw body. 5. No `Process` / `NSTask` usage anywhere in `app/`. -6. `swift run --package-path app MenuBarCoreTests` green. +6. `swift run --package-path app MenuBarCoreTests` and + `swift run --package-path app MenuBarUITests` both green. From c4abf4526cc033be485f4236d2f19e4a971ebf84 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:28:06 +0900 Subject: [PATCH 23/61] chore(app): narrow test hooks to package visibility Round-7 review passed. Carry-forward items folded now rather than deferred: - waiterCount and the ProviderListView test hooks are `package` rather than `public`. Neither module ships as a library product, so this was never an external API risk, but package visibility says what these are: test-only access within the package. - 040's test:macos script runs both suites, and its acceptance criteria now state that "build clean" means exit 0 rather than warning-free, since the remaining warnings are Command Line Tools search paths from the toolchain. - 030's stop example carries the remaining-time clamp that shipped. --- app/Sources/MenuBarCore/PollingCoordinator.swift | 2 +- app/Sources/MenuBarUI/ProviderListView.swift | 4 ++-- devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md | 6 +++++- devlog/_plan/260725_macos_menubar_app/040_phase4_release.md | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index 2fae325b5b..e0a08e5535 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -158,7 +158,7 @@ public actor PollingCoordinator { /// Exposed so a test can wait for registration deterministically instead of sleeping /// and hoping the waiter task was scheduled — a fixed sleep let the continuation /// tests pass without ever entering this path. - public var waiterCount: Int { completionWaiters.count } + package var waiterCount: Int { completionWaiters.count } private func waitForCompletion() async { guard refreshInFlight || pendingOpenRefresh else { return } diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index daabb16a2a..5166a8334b 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -232,7 +232,7 @@ public final class ProviderRowView: NSView { /// Read-only hooks so the UI suite can assert on rendered control state rather than on /// the view's private bookkeeping. -public extension ProviderListView { +package extension ProviderListView { /// Expands the list without going through a click, so tests do not depend on /// NSButton action dispatch. func expandForTesting() { setExpanded(true) } @@ -248,7 +248,7 @@ public extension ProviderListView { } } -public extension ProviderRowView { +package extension ProviderRowView { var isOn: Bool { toggleState } var isToggleEnabled: Bool { toggleIsEnabled } } diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md index bf5ab1fcf2..3acc60cc83 100644 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md @@ -114,7 +114,11 @@ public func stop(startCommand: String) async -> ActionOutcome { var sawIndeterminate = false while now() < deadline { await sleeper(Self.pollInterval) - switch await client.liveness() { + // Cap each probe to the time left, so the last one cannot overrun the deadline + // by its own timeout. + let remaining = deadline.timeIntervalSince(now()) + guard remaining > 0 else { break } + switch await client.liveness(timeout: min(1.5, remaining)) { case .refused: return restored ? .requiresManualStart(startCommand) : .stoppedWithRestoreFailure(startCommand) diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 183204e046..ce206a51e4 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -142,7 +142,7 @@ catches the case where the archive is produced but empty. ```json "build:macos": "bash scripts/build-macos-app.sh", "package:macos": "bash scripts/package-macos-release.sh", -"test:macos": "swift run --package-path app MenuBarCoreTests" +"test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests" ``` ## `.github/workflows/ci.yml` @@ -286,6 +286,8 @@ Constraints honoured: 5. The build script runs end to end on a clean checkout under `set -euo pipefail`, with every variable defined before use. 6. Workflow YAML parses; all actions SHA-pinned to a full commit SHA. + Note: "build clean" means exit 0, not warning-free — Command Line Tools emits + framework search-path warnings that come from the toolchain, not from this code. 7. **Security review evidence recorded** before this phase closes (`MAINTAINERS.md` requires it for release automation): the final workflow diff reviewed, effective per-job permissions enumerated and confirmed least-privilege, every action pin From 49ffc0726391fccc7d1a0327e7c8f5393be58714 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:34:16 +0900 Subject: [PATCH 24/61] feat(release): build and package the macOS companion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 (040_phase4_release.md). The app now has a distribution path, which is what the whole question was about: a menu bar app a user has to compile is not a shipped app. - scripts/build-macos-app.sh assembles OpenCodex.app by hand — no Xcode project to keep in sync. It stages into a temp directory and moves at the end, so an interrupted build cannot leave a half-written bundle that launches and misbehaves. Version comes from package.json, so the app can never claim a version the release did not ship. UNIVERSAL=1 under Command Line Tools refuses with an explanation instead of a linker error. - scripts/package-macos-release.sh asserts rather than hopes: codesign --verify --deep --strict, lipo arch check, ditto archiving (plain zip corrupts the signature), an archive-contents assertion, and a SHA-256 sidecar. - release.yml gains package-macos and attach-macos. Workflow-level permissions drop to {} and each job declares its own, so a new job cannot silently inherit a write token or an OIDC credential. package-macos has no needs relationship with publish in either direction: a Swift failure must never be able to block an npm release. - ci.yml runs the macOS test and build on macOS runners only, after privacy:scan so a credential leak fails before a multi-minute Swift build. The path filter gained app/** — without it an app-only change ran no CI. Verified locally end to end: the bundle builds, passes codesign, launches with no ATS errors, packages to an 813 KB zip whose checksum verifies, and survives unpack-and-launch — the path a user actually takes, and the one that would expose a corrupted signature. One debugging note recorded in 040: the archive assertion originally used `unzip -Z1 | grep -Fqx`, which fails under pipefail because grep -q exits on match and unzip dies on SIGPIPE. It rejected correctly-packaged archives. --- .github/workflows/ci.yml | 30 +++++- .github/workflows/release.yml | 54 ++++++++++ .gitignore | 1 + .../040_phase4_release.md | 41 +++++++ package.json | 3 + scripts/build-macos-app.sh | 100 ++++++++++++++++++ scripts/package-macos-release.sh | 98 +++++++++++++++++ 7 files changed, 326 insertions(+), 1 deletion(-) create mode 100755 scripts/build-macos-app.sh create mode 100755 scripts/package-macos-release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 003415f3af..cdd068b93d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ on: - "bin/**" - "tests/**" - "scripts/**" + - "app/**" - "gui/**" - "assets/**" - ".gitattributes" @@ -213,6 +214,7 @@ jobs: - 'bin/**' - 'tests/**' - 'scripts/**' + - 'app/**' - 'gui/**' - 'assets/**' - '.gitattributes' @@ -1147,6 +1149,32 @@ jobs: # `if: always()` is load-bearing. Without it, a failed or skipped dependency # skips this job too — and GitHub reports a skipped job as success, so the gate # would go green precisely when something went wrong. + macos-app: + name: macos app + needs: [changes, gates] + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: macos-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test macOS menu bar app + run: bun run test:macos + + - name: Build macOS menu bar app + run: bun run build:macos + ci: name: ci if: always() @@ -1154,7 +1182,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, macos-app] runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b565b6800..3364b9ac97 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,60 @@ jobs: process.exit(1); } NODE + package-macos: + runs-on: macos-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + archive_name: ${{ steps.package.outputs.archive_name }} + checksum_name: ${{ steps.package.outputs.checksum_name }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Package the macOS companion + id: package + env: + RELEASE_VERSION: ${{ inputs.version }} + UNIVERSAL: "1" + run: bash scripts/package-macos-release.sh + + - name: Upload the release asset + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + with: + name: macos-release + path: dist/release/ + if-no-files-found: error + retention-days: 7 + + attach-macos: + runs-on: ubuntu-latest + needs: [publish, package-macos] + if: ${{ inputs.dry-run != true }} + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download the packaged asset + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: macos-release + path: dist/release + + - name: Verify the checksum before uploading + run: | + cd dist/release + shasum -a 256 -c ./*.sha256 + + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "v${{ inputs.version }}" dist/release/* --clobber + publish: needs: validate-dispatch runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 895d9894ea..e6dd44882e 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,4 @@ go/ # Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. native/**/target/ dist/macos/ +dist/release/ diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index ce206a51e4..6f65366543 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -16,6 +16,12 @@ strongest part of either PR and is not re-derived. `AGENTS.md` classifies as requiring explicit security review. Changes are therefore minimal, additive, SHA-pinned, and least-privilege. No secret is introduced. +## Stale check at P + +Re-verified against the tree: neither script existed, `package.json` had no macOS +entries, and `gui/public/favicon.png` (the icon source) is present. The CI path filter +also lacked `app/**`, so an app-only change would have run no CI at all — added. + ## File change map | Path | Action | @@ -276,6 +282,41 @@ Constraints honoured: out of scope (`000` criterion 8). This mirrors the artifact defect the Codex reviewer originally raised on PR #421, which that contributor has since fixed (`001` §2.1). +## Implementation notes + +**A pipeline subtlety cost a real debugging pass.** The archive assertion was originally +`unzip -Z1 "$archive" | grep -Fqx '…'`. Under `set -o pipefail`, `grep -q` exits as soon +as it matches, `unzip` then dies on SIGPIPE, and the pipeline reports failure *even +though the match succeeded* — so a correctly packaged archive was rejected with +"does not contain the OpenCodex executable". Capturing the listing into a variable first +and matching against a here-string fixes it. The assertion is worth keeping; it just has +to be written so it cannot fail on success. + +**`--sequesterRsrc` adds `__MACOSX/` entries** alongside the real paths, which is +harmless for an exact-match assertion but surprising when reading the listing by eye. + +### Verified locally + +```text +bash scripts/build-macos-app.sh + -> dist/macos/OpenCodex.app (version 2.7.35), arm64 + -> Info.plist: CFBundleExecutable=OpenCodexMenuBar, CFBundlePackageType=APPL, + CFBundleIconFile=OpenCodex, LSUIElement=true, NSAllowsLocalNetworking=true + -> codesign --verify --deep --strict: valid on disk, satisfies its Designated Requirement + -> launched from the bundle: menu bar item appeared, no ATS errors in the log + +UNIVERSAL=0 bash scripts/package-macos-release.sh + -> OpenCodex-2.7.35-macos-arm64.zip (813 KB) + .sha256 + -> shasum -a 256 -c: OK + -> unpacked with ditto -x -k: signature survived, app launched from the unpacked bundle + +UNIVERSAL=1 bash scripts/build-macos-app.sh + -> refused with the Command Line Tools explanation rather than a linker error +``` + +The unpack-and-launch step is the one that matters: it is the path a user actually takes, +and it is the one that would expose a `zip`-corrupted signature. + ## Accept criteria 1. `bun run build:macos` produces a launchable `dist/macos/OpenCodex.app`. diff --git a/package.json b/package.json index 7278f576ab..26fce207d7 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,9 @@ "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "build:macos": "bash scripts/build-macos-app.sh", + "package:macos": "bash scripts/package-macos-release.sh", + "test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 0000000000..d0c1f068a0 --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Assembles OpenCodex.app by hand. +# +# No Xcode project, so there is nothing to keep in sync with the package manifest. The +# bundle is staged in a temp directory and moved into place at the end, so an interrupted +# build never leaves a half-written .app that launches and misbehaves. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +package_dir="$repo_root/app" +output_root="${OUTPUT_DIR:-$repo_root/dist/macos}" +configuration="${CONFIGURATION:-release}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "build:macos requires macOS." >&2 + exit 1 +fi + +mkdir -p "$output_root" +output_root="$(cd "$output_root" && pwd)" +app_bundle="$output_root/OpenCodex.app" + +# Refuse to write outside the intended output root. +case "$app_bundle" in + "$output_root"/*.app) ;; + *) + echo "Refusing to replace unexpected bundle path: $app_bundle" >&2 + exit 1 + ;; +esac + +swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) + +if [[ "${UNIVERSAL:-0}" == "1" ]]; then + developer_dir="$(xcode-select -p 2>/dev/null || true)" + if [[ "$developer_dir" == *"CommandLineTools"* ]]; then + echo "UNIVERSAL=1 requires the full Xcode toolchain; Command Line Tools ships only" >&2 + echo "current-architecture Swift compatibility libraries, so the x86_64 slice cannot" >&2 + echo "link. Install Xcode, then:" >&2 + echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 + exit 1 + fi + swift_args+=(--arch arm64 --arch x86_64) +fi + +echo "==> Building ($configuration)…" +swift build "${swift_args[@]}" +bin_dir="$(swift build "${swift_args[@]}" --show-bin-path)" +executable="$bin_dir/OpenCodexMenuBar" + +if [[ ! -x "$executable" ]]; then + echo "Build did not produce an executable at $executable" >&2 + exit 1 +fi + +staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" +staged_app="$staging_root/OpenCodex.app" +iconset="$staging_root/OpenCodex.iconset" +cleanup() { rm -rf "$staging_root"; } +trap cleanup EXIT + +mkdir -p "$staged_app/Contents/MacOS" "$staged_app/Contents/Resources" +cp "$executable" "$staged_app/Contents/MacOS/OpenCodexMenuBar" +cp "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" + +# The app version comes from package.json, so it can never claim a version the release +# did not ship. +version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Could not read a valid version from package.json: '$version'" >&2 + exit 1 +fi +plutil -replace CFBundleShortVersionString -string "$version" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$version" "$staged_app/Contents/Info.plist" + +# Icon: reuse the dashboard favicon rather than adding another binary asset to the repo. +icon_source="$repo_root/gui/public/favicon.png" +if [[ ! -f "$icon_source" ]]; then + echo "Missing icon source: $icon_source" >&2 + exit 1 +fi +mkdir -p "$iconset" +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$icon_source" \ + --out "$iconset/icon_${size}x${size}.png" >/dev/null + sips -z "$((size * 2))" "$((size * 2))" "$icon_source" \ + --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null +done +iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" + +# Ad-hoc signature so Gatekeeper has a stable identity. CI may re-sign with a real one. +codesign --force --sign - --timestamp=none "$staged_app" + +rm -rf "$app_bundle" +mv "$staged_app" "$app_bundle" + +echo "==> Built $app_bundle (version $version)" +lipo -archs "$app_bundle/Contents/MacOS/OpenCodexMenuBar" diff --git a/scripts/package-macos-release.sh b/scripts/package-macos-release.sh new file mode 100755 index 0000000000..dbc4677358 --- /dev/null +++ b/scripts/package-macos-release.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Wraps OpenCodex.app for distribution. +# +# Every step is an assertion rather than a hope: a release asset that is produced but +# empty, unsigned, or missing its executable is worse than no asset at all, because the +# failure surfaces on the user's machine instead of in CI. + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" +output_dir="${RELEASE_OUTPUT_DIR:-$repo_root/dist/release}" +universal="${UNIVERSAL:-1}" + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "package:macos requires macOS." >&2 + exit 1 +fi + +package_version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" +if [[ ! "$package_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid package version for the macOS release asset: '$package_version'" >&2 + exit 1 +fi + +# A release dispatched for one version must never package a different one. +if [[ -n "${RELEASE_VERSION:-}" && "$RELEASE_VERSION" != "$package_version" ]]; then + echo "package.json ($package_version) does not match the requested release (${RELEASE_VERSION})" >&2 + exit 1 +fi + +if [[ "$universal" != "0" && "$universal" != "1" ]]; then + echo "UNIVERSAL must be 0 or 1." >&2 + exit 1 +fi + +mkdir -p "$output_dir" +output_dir="$(cd "$output_dir" && pwd)" + +build_root="$(mktemp -d "${TMPDIR:-/tmp}/OpenCodex-release.XXXXXX")" +cleanup() { rm -rf "$build_root"; } +trap cleanup EXIT + +OUTPUT_DIR="$build_root" UNIVERSAL="$universal" CONFIGURATION=release \ + bash "$script_dir/build-macos-app.sh" >&2 + +app_bundle="$build_root/OpenCodex.app" +executable="$app_bundle/Contents/MacOS/OpenCodexMenuBar" + +codesign --verify --deep --strict --verbose=2 "$app_bundle" + +architectures="$(lipo -archs "$executable")" +if [[ "$universal" == "1" ]]; then + for required_arch in arm64 x86_64; do + if [[ " $architectures " != *" $required_arch "* ]]; then + echo "Universal build is missing $required_arch (got: $architectures)" >&2 + exit 1 + fi + done + architecture_label="universal" +else + architecture_label="${architectures// /-}" +fi + +archive_name="OpenCodex-${package_version}-macos-${architecture_label}.zip" +checksum_name="${archive_name}.sha256" +archive_path="$output_dir/$archive_name" +checksum_path="$output_dir/$checksum_name" +rm -f "$archive_path" "$checksum_path" + +# ditto rather than zip: it preserves extended attributes and symlinks, so the unpacked +# bundle stays launchable. Plain zip corrupts the code signature. +ditto -c -k --sequesterRsrc --keepParent "$app_bundle" "$archive_path" + +# An archive that exists but does not contain the executable is the failure mode this +# assertion exists to catch. +archive_entries="$(unzip -Z1 "$archive_path")" +if ! grep -Fqx 'OpenCodex.app/Contents/MacOS/OpenCodexMenuBar' <<< "$archive_entries"; then + echo "Packaged archive does not contain the OpenCodex executable." >&2 + echo "Archive entries were:" >&2 + printf '%s\n' "$archive_entries" | head -20 >&2 + exit 1 +fi + +( + cd "$output_dir" + shasum -a 256 "$archive_name" > "$checksum_name" +) + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "archive_name=$archive_name" + echo "checksum_name=$checksum_name" + } >> "$GITHUB_OUTPUT" +fi + +echo "$archive_path" +echo "$checksum_path" From 8cbfe6d32ef1bf8e5f701236ed119f44d3a18af2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:45:50 +0900 Subject: [PATCH 25/61] fix(release): env-pass the release input, fix preview versions, guard output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review returned FAIL on three findings. The first was caught by the repository's own regression suite, which is the best possible outcome. - release.yml interpolated inputs.version directly into run: shell source. tests/ci-workflows.test.ts:76-81 rejects exactly this pattern repo-wide as script-injection hardening, and the suite was failing. The version now reaches the shell through env as RELEASE_VERSION. - CFBundleVersion accepted prerelease suffixes. Apple restricts that field to period-separated integers, so every preview build would have shipped invalid metadata. The script now uses the numeric core for CFBundleVersion while CFBundleShortVersionString keeps the full human-facing string, and MACOS_BUILD_NUMBER (github.run_number in CI) appends a monotonic build component. Verified: 2.7.36-preview.1 produces 2.7.36, and 2.7.36.42 with a build number. - The output containment check compared $app_bundle against $output_root, both derived from the same variable, so it always passed. OUTPUT_DIR could point at /Applications and have an existing bundle recursively removed. The destination must now sit under the repository or a temp directory. Verified: /Applications is refused, /tmp is allowed. On Gatekeeper: the reviewer is right that the asset is ad-hoc signed and spctl rejects it. Developer ID signing plus notarization needs a paid Apple Developer account and this project has no certificate (verified: zero Developer ID identities, no Apple secrets in any workflow). Rather than pretend otherwise, build-macos-app.sh gained an optional MACOS_SIGN_IDENTITY that switches to hardened-runtime signing, package-macos-release.sh reports the spctl verdict and fails only when a real identity was claimed and still rejected, and release.yml wires the secret so adding a certificate becomes configuration rather than code. 040 documents what ships today and why the Phase 5 Gatekeeper section is mandatory. Also corrected the SIGPIPE note in 040: the reviewer reproduced the old pipeline exiting 0, so it is a race rather than a certainty — which is a better argument for fixing it, not a weaker one. --- .github/workflows/release.yml | 14 +++- .../040_phase4_release.md | 44 ++++++++++-- scripts/build-macos-app.sh | 67 ++++++++++++++++--- scripts/package-macos-release.sh | 15 +++++ 4 files changed, 125 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3364b9ac97..57ec0e5ecc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,6 +88,15 @@ jobs: env: RELEASE_VERSION: ${{ inputs.version }} UNIVERSAL: "1" + # A monotonic numeric CFBundleVersion. Preview versions carry a suffix that + # Apple does not accept in that field, so the script uses the numeric core + # plus this run number. + MACOS_BUILD_NUMBER: ${{ github.run_number }} + # Optional. When a Developer ID certificate is configured in the repository, + # the build signs with the hardened runtime instead of ad-hoc. Until then the + # asset is ad-hoc signed and the docs describe the Gatekeeper first-launch + # path. Adding these secrets is a security-reviewed change of its own. + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} run: bash scripts/package-macos-release.sh - name: Upload the release asset @@ -120,8 +129,11 @@ jobs: - name: Attach to the release env: GH_TOKEN: ${{ github.token }} + # Workflow inputs reach shell code through env, never by interpolation into + # run: source. tests/ci-workflows.test.ts enforces this repo-wide. + RELEASE_VERSION: ${{ inputs.version }} run: | - gh release upload "v${{ inputs.version }}" dist/release/* --clobber + gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber publish: needs: validate-dispatch diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 6f65366543..003d320794 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -286,11 +286,13 @@ Constraints honoured: **A pipeline subtlety cost a real debugging pass.** The archive assertion was originally `unzip -Z1 "$archive" | grep -Fqx '…'`. Under `set -o pipefail`, `grep -q` exits as soon -as it matches, `unzip` then dies on SIGPIPE, and the pipeline reports failure *even -though the match succeeded* — so a correctly packaged archive was rejected with -"does not contain the OpenCodex executable". Capturing the listing into a variable first -and matching against a here-string fixes it. The assertion is worth keeping; it just has -to be written so it cannot fail on success. +as it matches; `unzip` *can* then receive SIGPIPE while still writing, and the pipeline +reports failure even though the match succeeded — which is how a correctly packaged +archive got rejected with "does not contain the OpenCodex executable". It is a race, not +a certainty: a reviewer re-running the old pipeline against the same archive saw it exit +0. That is precisely why it is worth fixing rather than dismissing — an assertion that +fails intermittently on success is worse than one that fails consistently. Capturing the +listing into a variable first and matching against a here-string removes the pipeline. **`--sequesterRsrc` adds `__MACOSX/` entries** alongside the real paths, which is harmless for an exact-match assertion but surprising when reading the listing by eye. @@ -317,11 +319,43 @@ UNIVERSAL=1 bash scripts/build-macos-app.sh The unpack-and-launch step is the one that matters: it is the path a user actually takes, and it is the one that would expose a `zip`-corrupted signature. +## Signing and Gatekeeper: what actually ships + +The asset is **ad-hoc signed**, and `spctl --assess --type execute` rejects it. That is +not an oversight to paper over — Developer ID signing plus notarization requires a paid +Apple Developer account, and this project has no certificate today: + +```text +security find-identity -v -p codesigning | grep -c "Developer ID Application" -> 0 +grep -rn "APPLE_\|NOTARY\|DEVELOPER_ID" .github/workflows/ -> none +``` + +So the scripts are built to be honest about it and ready for the day that changes: + +- `MACOS_SIGN_IDENTITY` (optional) switches `build-macos-app.sh` to + `codesign --options runtime --timestamp --sign "$identity"`, which is what + notarization requires. Unset, it ad-hoc signs and says so on stderr. +- `package-macos-release.sh` runs `spctl --assess` and reports the verdict. An ad-hoc + rejection is expected and non-fatal; a build that claimed a real identity and *still* + fails assessment exits non-zero, because that means notarization is missing. +- `release.yml` passes `MACOS_SIGN_IDENTITY` from secrets, so adding the certificate is + a configuration change rather than a code change. Adding those secrets is a + security-reviewed change of its own. + +**Consequence for Phase 5 docs:** the Gatekeeper section is not optional. Users will see +"cannot be opened because the developer cannot be verified" and need the right-click → +Open path. Documenting that honestly is better than shipping an asset that appears +broken. + ## Accept criteria 1. `bun run build:macos` produces a launchable `dist/macos/OpenCodex.app`. 2. `bun run package:macos` produces zip + `.sha256`, with the content assertion passing. 3. `lipo -archs` shows `arm64` locally; both arches asserted in CI. + 3a. `CFBundleVersion` is period-separated integers even for preview versions + (`2.7.36-preview.1` → `2.7.36`, or `2.7.36.` with `MACOS_BUILD_NUMBER`). + 3b. `OUTPUT_DIR` outside the repository or temp is refused, since the build deletes + whatever sits at the destination. 4. `UNIVERSAL=1` under Command Line Tools fails with the explanatory message, not a linker error. 5. The build script runs end to end on a clean checkout under `set -euo pipefail`, with diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index d0c1f068a0..4dd92989b7 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -22,12 +22,25 @@ mkdir -p "$output_root" output_root="$(cd "$output_root" && pwd)" app_bundle="$output_root/OpenCodex.app" -# Refuse to write outside the intended output root. -case "$app_bundle" in - "$output_root"/*.app) ;; +# The build deletes whatever sits at $app_bundle, so the destination must be somewhere +# this project owns. Comparing $app_bundle against $output_root proves nothing — both +# come from the same variable, so pointing OUTPUT_DIR at /Applications would have passed +# and then recursively removed a real app. +allowed_root="$repo_root" +if [[ -n "${TMPDIR:-}" ]]; then + allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd || echo "")" +else + allowed_tmp="" +fi +case "$output_root" in + "$allowed_root"/*) ;; + /tmp/*|/private/tmp/*) ;; *) - echo "Refusing to replace unexpected bundle path: $app_bundle" >&2 - exit 1 + if [[ -z "$allowed_tmp" || "$output_root" != "$allowed_tmp"/* ]]; then + echo "Refusing to build into '$output_root': it is outside the repository and the" >&2 + echo "temp directory. Set OUTPUT_DIR to a path under $repo_root." >&2 + exit 1 + fi ;; esac @@ -72,8 +85,27 @@ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then echo "Could not read a valid version from package.json: '$version'" >&2 exit 1 fi + +# CFBundleShortVersionString is the human-facing version and may carry a prerelease +# suffix. CFBundleVersion may NOT: Apple restricts it to period-separated integers, so +# writing "2.7.36-preview.1" there produces invalid metadata on every preview build. +# Strip to the numeric core, and let CI append a monotonic build number when it has one. +version_core="${version%%-*}" +build_version="$version_core" +if [[ -n "${MACOS_BUILD_NUMBER:-}" ]]; then + if [[ ! "$MACOS_BUILD_NUMBER" =~ ^[0-9]+$ ]]; then + echo "MACOS_BUILD_NUMBER must be a positive integer, got '$MACOS_BUILD_NUMBER'" >&2 + exit 1 + fi + build_version="$version_core.$MACOS_BUILD_NUMBER" +fi +if [[ ! "$build_version" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then + echo "Computed CFBundleVersion is not period-separated integers: '$build_version'" >&2 + exit 1 +fi + plutil -replace CFBundleShortVersionString -string "$version" "$staged_app/Contents/Info.plist" -plutil -replace CFBundleVersion -string "$version" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$build_version" "$staged_app/Contents/Info.plist" # Icon: reuse the dashboard favicon rather than adding another binary asset to the repo. icon_source="$repo_root/gui/public/favicon.png" @@ -90,11 +122,28 @@ for size in 16 32 128 256 512; do done iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" -# Ad-hoc signature so Gatekeeper has a stable identity. CI may re-sign with a real one. -codesign --force --sign - --timestamp=none "$staged_app" +# Signing. +# +# MACOS_SIGN_IDENTITY selects a Developer ID Application certificate and enables the +# hardened runtime, which is what notarization requires. Without it the bundle is +# ad-hoc signed: structurally valid, but `spctl --assess` rejects it and a downloaded +# copy shows the "cannot be opened because the developer cannot be verified" dialog. +# +# The project has no Developer ID certificate today (that needs a paid Apple Developer +# account), so ad-hoc is the shipped default and the docs must tell users the +# right-click-Open path rather than pretend the download runs cleanly. +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + codesign --force --deep --options runtime --timestamp \ + --sign "$MACOS_SIGN_IDENTITY" "$staged_app" + echo "==> Signed with $MACOS_SIGN_IDENTITY (hardened runtime)" +else + codesign --force --sign - --timestamp=none "$staged_app" + echo "==> Ad-hoc signed (no MACOS_SIGN_IDENTITY): Gatekeeper will require the" >&2 + echo " right-click-Open path on first launch." >&2 +fi rm -rf "$app_bundle" mv "$staged_app" "$app_bundle" -echo "==> Built $app_bundle (version $version)" +echo "==> Built $app_bundle (version $version, build $build_version)" lipo -archs "$app_bundle/Contents/MacOS/OpenCodexMenuBar" diff --git a/scripts/package-macos-release.sh b/scripts/package-macos-release.sh index dbc4677358..f9b0b1ebd8 100755 --- a/scripts/package-macos-release.sh +++ b/scripts/package-macos-release.sh @@ -49,6 +49,21 @@ executable="$app_bundle/Contents/MacOS/OpenCodexMenuBar" codesign --verify --deep --strict --verbose=2 "$app_bundle" +# Report the Gatekeeper verdict rather than discovering it on a user's machine. An +# ad-hoc build is expected to be rejected; that is documented, not a packaging failure. +# A build that claimed a real identity and STILL fails assessment is a failure. +if spctl --assess --type execute "$app_bundle" >/dev/null 2>&1; then + echo "==> Gatekeeper: accepted" >&2 +else + if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + echo "Signed with $MACOS_SIGN_IDENTITY but Gatekeeper still rejects the bundle." >&2 + echo "It likely needs notarization (notarytool) and a stapled ticket." >&2 + exit 1 + fi + echo "==> Gatekeeper: rejected (expected for an ad-hoc signature)." >&2 + echo " Users must right-click > Open on first launch; this is documented." >&2 +fi + architectures="$(lipo -archs "$executable")" if [[ "$universal" == "1" ]]; then for required_arch in arm64 x86_64; do From 32ea9e9ec54a5c38e4b82c48ce7bfaa3fea47069 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 06:56:33 +0900 Subject: [PATCH 26/61] fix(release): honour Apple's actual version limits and drop the phantom secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security re-review found my first version fix was still wrong, in a way I had not read carefully enough. - CFBundleShortVersionString must be exactly three integers, so a preview release was still writing "2.7.36-preview.1" into a field that does not accept it. It now gets the numeric core. - CFBundleVersion accepts ONE TO THREE integers and ignores a fourth. So "2.7.36." provided no additional identity at all — repeated builds of the same version compared as identical despite the run number. When CI supplies a run number it now becomes the CFBundleVersion outright: a single monotonically increasing integer is both valid and genuinely distinguishing. Verified: 2.7.36-preview.1 gives short 2.7.36 / build 2.7.36, and with a run number, build 1234. - The output containment check resolved logical paths, so a repository-local symlink pointing outside would pass the prefix test and then be deleted for real. Paths are now resolved with pwd -P, and a symlinked destination is refused outright. Verified: a symlink to a home directory is refused, while ordinary paths still build. - Removed MACOS_SIGN_IDENTITY from release.yml. The reviewer is right that an identity name alone cannot sign on a hosted runner — nothing imports the certificate and private key, so codesign fails with "no identity found". Advertising the secret implied a capability that does not exist. The build script keeps the hook for local signing and says so; real CI signing needs a protected P12 import, a temporary keychain, notarytool credentials, and stapling as one security-reviewed change. Also updated 040's executable snippets, which still showed the pre-review version handling and the direct inputs.version interpolation while later sections described the fixes — a source-of-truth document contradicting itself is worse than one that is merely incomplete. --- .github/workflows/release.yml | 12 ++-- .../040_phase4_release.md | 19 ++++-- scripts/build-macos-app.sh | 66 +++++++++++++------ 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57ec0e5ecc..aaab5a0872 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,11 +92,13 @@ jobs: # Apple does not accept in that field, so the script uses the numeric core # plus this run number. MACOS_BUILD_NUMBER: ${{ github.run_number }} - # Optional. When a Developer ID certificate is configured in the repository, - # the build signs with the hardened runtime instead of ad-hoc. Until then the - # asset is ad-hoc signed and the docs describe the Gatekeeper first-launch - # path. Adding these secrets is a security-reviewed change of its own. - MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + # NOTE: intentionally no MACOS_SIGN_IDENTITY here. The build script honours + # it, but an identity NAME alone cannot sign on a hosted runner — the + # certificate and private key are never imported into a keychain, so codesign + # fails with "no identity found". Real Developer ID signing needs a protected + # P12 import, a temporary keychain, notarytool credentials, and stapling, all + # as one security-reviewed change. Until then the asset is ad-hoc signed and + # the docs carry the Gatekeeper first-launch path. run: bash scripts/package-macos-release.sh - name: Upload the release asset diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 003d320794..594de53805 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -101,8 +101,14 @@ cp "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" # Version comes from package.json — the app can never claim a version the release did not ship. version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" -plutil -replace CFBundleShortVersionString -string "$version" "$staged_app/Contents/Info.plist" -plutil -replace CFBundleVersion -string "$version" "$staged_app/Contents/Info.plist" +# Apple constrains both fields, and differently from the npm version string: +# CFBundleShortVersionString - exactly three integers (no prerelease suffix) +# CFBundleVersion - ONE TO THREE integers; a fourth is ignored, so +# appending a run number to a full semver adds nothing +version_core="${version%%-*}" +build_version="${MACOS_BUILD_NUMBER:-$version_core}" +plutil -replace CFBundleShortVersionString -string "$version_core" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$build_version" "$staged_app/Contents/Info.plist" # Icon: reuse the existing dashboard favicon, no new binary asset in the repo. icon_source="$repo_root/gui/public/favicon.png" @@ -338,9 +344,12 @@ So the scripts are built to be honest about it and ready for the day that change - `package-macos-release.sh` runs `spctl --assess` and reports the verdict. An ad-hoc rejection is expected and non-fatal; a build that claimed a real identity and *still* fails assessment exits non-zero, because that means notarization is missing. -- `release.yml` passes `MACOS_SIGN_IDENTITY` from secrets, so adding the certificate is - a configuration change rather than a code change. Adding those secrets is a - security-reviewed change of its own. +- `release.yml` deliberately does **not** pass `MACOS_SIGN_IDENTITY`. An identity name + alone cannot sign on a hosted runner: nothing imports the certificate and private key, + so `codesign` fails with "no identity found". Advertising the secret would imply a + capability that does not exist. Real CI signing means a protected P12 import, a + temporary keychain, `notarytool` credentials, and stapling — one security-reviewed + change, not a lone secret. **Consequence for Phase 5 docs:** the Gatekeeper section is not optional. Users will see "cannot be opened because the developer cannot be verified" and need the right-click → diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 4dd92989b7..7a2da2d29a 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -19,22 +19,25 @@ if [[ "$(uname -s)" != "Darwin" ]]; then fi mkdir -p "$output_root" -output_root="$(cd "$output_root" && pwd)" +# `cd … && pwd` keeps LOGICAL paths on macOS, so a symlink inside the repository that +# points elsewhere would satisfy the prefix check below and then be deleted for real. +# Resolve physically before validating. +output_root="$(cd "$output_root" && pwd -P)" app_bundle="$output_root/OpenCodex.app" # The build deletes whatever sits at $app_bundle, so the destination must be somewhere # this project owns. Comparing $app_bundle against $output_root proves nothing — both # come from the same variable, so pointing OUTPUT_DIR at /Applications would have passed # and then recursively removed a real app. -allowed_root="$repo_root" +allowed_root="$(cd "$repo_root" && pwd -P)" if [[ -n "${TMPDIR:-}" ]]; then - allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd || echo "")" + allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd -P || echo "")" else allowed_tmp="" fi case "$output_root" in "$allowed_root"/*) ;; - /tmp/*|/private/tmp/*) ;; + /private/tmp/*|/tmp/*) ;; *) if [[ -z "$allowed_tmp" || "$output_root" != "$allowed_tmp"/* ]]; then echo "Refusing to build into '$output_root': it is outside the repository and the" >&2 @@ -86,25 +89,38 @@ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then exit 1 fi -# CFBundleShortVersionString is the human-facing version and may carry a prerelease -# suffix. CFBundleVersion may NOT: Apple restricts it to period-separated integers, so -# writing "2.7.36-preview.1" there produces invalid metadata on every preview build. -# Strip to the numeric core, and let CI append a monotonic build number when it has one. +# Apple constrains BOTH version fields, and differently from the npm version string: +# +# CFBundleShortVersionString - three period-separated integers. A prerelease suffix +# like "-preview.1" is not valid here. +# CFBundleVersion - ONE TO THREE period-separated integers. A fourth +# component is ignored, so appending a build number to a +# full semver produces no additional identity at all. +# +# So the short version is the numeric core, and when CI supplies a run number it becomes +# the CFBundleVersion outright — a monotonically increasing single integer is both valid +# and genuinely distinguishing, which "2.7.36." would not have been. version_core="${version%%-*}" -build_version="$version_core" +if [[ ! "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Version core must be three integers for CFBundleShortVersionString: '$version_core'" >&2 + exit 1 +fi + if [[ -n "${MACOS_BUILD_NUMBER:-}" ]]; then if [[ ! "$MACOS_BUILD_NUMBER" =~ ^[0-9]+$ ]]; then echo "MACOS_BUILD_NUMBER must be a positive integer, got '$MACOS_BUILD_NUMBER'" >&2 exit 1 fi - build_version="$version_core.$MACOS_BUILD_NUMBER" + build_version="$MACOS_BUILD_NUMBER" +else + build_version="$version_core" fi -if [[ ! "$build_version" =~ ^[0-9]+(\.[0-9]+)*$ ]]; then - echo "Computed CFBundleVersion is not period-separated integers: '$build_version'" >&2 +if [[ ! "$build_version" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then + echo "CFBundleVersion must be one to three integers, got '$build_version'" >&2 exit 1 fi -plutil -replace CFBundleShortVersionString -string "$version" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleShortVersionString -string "$version_core" "$staged_app/Contents/Info.plist" plutil -replace CFBundleVersion -string "$build_version" "$staged_app/Contents/Info.plist" # Icon: reuse the dashboard favicon rather than adding another binary asset to the repo. @@ -124,14 +140,18 @@ iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" # Signing. # -# MACOS_SIGN_IDENTITY selects a Developer ID Application certificate and enables the -# hardened runtime, which is what notarization requires. Without it the bundle is -# ad-hoc signed: structurally valid, but `spctl --assess` rejects it and a downloaded -# copy shows the "cannot be opened because the developer cannot be verified" dialog. +# MACOS_SIGN_IDENTITY selects a Developer ID Application certificate already present in +# the caller's keychain and enables the hardened runtime, which is what notarization +# requires. It is a LOCAL hook: CI does not set it, because an identity name alone +# cannot sign on a hosted runner — nothing imports the certificate and private key, so +# codesign fails with "no identity found". Wiring CI signing properly means a protected +# P12 import, a temporary keychain, notarytool credentials, and stapling. # -# The project has no Developer ID certificate today (that needs a paid Apple Developer -# account), so ad-hoc is the shipped default and the docs must tell users the -# right-click-Open path rather than pretend the download runs cleanly. +# Without it the bundle is ad-hoc signed: structurally valid, but `spctl --assess` +# rejects it and a downloaded copy shows "cannot be opened because the developer cannot +# be verified". The project has no Developer ID certificate today, so ad-hoc is what +# ships and the docs must carry the right-click-Open path rather than pretend +# otherwise. if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then codesign --force --deep --options runtime --timestamp \ --sign "$MACOS_SIGN_IDENTITY" "$staged_app" @@ -142,8 +162,12 @@ else echo " right-click-Open path on first launch." >&2 fi +if [[ -L "$app_bundle" ]]; then + echo "Refusing to replace '$app_bundle': it is a symlink." >&2 + exit 1 +fi rm -rf "$app_bundle" mv "$staged_app" "$app_bundle" -echo "==> Built $app_bundle (version $version, build $build_version)" +echo "==> Built $app_bundle (release $version, short $version_core, build $build_version)" lipo -archs "$app_bundle/Contents/MacOS/OpenCodexMenuBar" From cdf638f76091b1d79d06169533d88bc4a70dcb17 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 07:02:40 +0900 Subject: [PATCH 27/61] docs(release): sync the Phase 4 plan with what actually shipped Closure blocker from the security review: 040 is the security-review artifact, and it still demonstrated the defects the last two rounds fixed. Copying its workflow example would have reintroduced the repository's prohibited injection pattern. Synchronised every stale snippet: - the tautological output guard is now the physical-path containment check - pwd gained -P where the implementation has it - the ad-hoc signing note no longer claims CI may re-sign, which the workflow deliberately does not support - the package job example carries MACOS_BUILD_NUMBER - the attach step passes RELEASE_VERSION through env instead of interpolating inputs.version into run: source - acceptance criterion 3a describes both Apple limits correctly rather than the invalid 2.7.36. form Also folded the Low finding: the script created the output directory before validating containment, so a refused path still left a directory behind. Validation now resolves the physical path by walking up to the nearest existing ancestor, and mkdir runs only after the check passes. Verified: a refused path creates nothing, symlinks outside the allowed roots are still refused, and ordinary builds are unaffected. --- .../040_phase4_release.md | 32 +++++++++++++++---- scripts/build-macos-app.sh | 25 +++++++++++++-- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 594de53805..6db14c4364 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -89,7 +89,7 @@ Staging, then atomic swap: ```bash mkdir -p "$output_root" -output_root="$(cd "$output_root" && pwd)" +output_root="$(cd "$output_root" && pwd -P)" staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" staged_app="$staging_root/OpenCodex.app" iconset="$staging_root/OpenCodex.iconset" @@ -120,8 +120,20 @@ for size in 16 32 128 256 512; do done iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" -# Ad-hoc sign so Gatekeeper has a stable identity; CI may re-sign with a real identity. -codesign --force --sign - --timestamp=none "$staged_app" +# MACOS_SIGN_IDENTITY is a LOCAL hook (preconfigured keychain). CI deliberately does not +# set it: an identity name alone cannot sign on a hosted runner, because nothing imports +# the certificate and private key. Unset, the bundle is ad-hoc signed and says so. +if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + codesign --force --deep --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$staged_app" +else + codesign --force --sign - --timestamp=none "$staged_app" +fi + +# Refuse to delete a symlinked destination. +if [[ -L "$app_bundle" ]]; then + echo "Refusing to replace '$app_bundle': it is a symlink." >&2 + exit 1 +fi rm -rf "$app_bundle" && mv "$staged_app" "$app_bundle" ``` @@ -230,6 +242,9 @@ package-macos: env: RELEASE_VERSION: ${{ inputs.version }} UNIVERSAL: "1" + # A valid single-integer CFBundleVersion. Appending a run number to a full + # semver would be a FOURTH component, which Apple ignores. + MACOS_BUILD_NUMBER: ${{ github.run_number }} run: bash scripts/package-macos-release.sh - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: @@ -255,7 +270,10 @@ attach-macos: - name: Attach to release env: GH_TOKEN: ${{ github.token }} - run: gh release upload "v${{ inputs.version }}" dist/release/* --clobber + # Inputs reach shell code through env. Direct interpolation into run: source is + # rejected repo-wide by tests/ci-workflows.test.ts. + RELEASE_VERSION: ${{ inputs.version }} + run: gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber ``` `shasum -c` before upload means a corrupted artifact transfer cannot become a published @@ -361,8 +379,10 @@ broken. 1. `bun run build:macos` produces a launchable `dist/macos/OpenCodex.app`. 2. `bun run package:macos` produces zip + `.sha256`, with the content assertion passing. 3. `lipo -archs` shows `arm64` locally; both arches asserted in CI. - 3a. `CFBundleVersion` is period-separated integers even for preview versions - (`2.7.36-preview.1` → `2.7.36`, or `2.7.36.` with `MACOS_BUILD_NUMBER`). + 3a. Both version fields honour Apple's limits: `CFBundleShortVersionString` is + exactly three integers (`2.7.36-preview.1` → `2.7.36`), and `CFBundleVersion` is + one to three integers — `MACOS_BUILD_NUMBER` replaces it outright rather than + appending a fourth component, which Apple ignores. 3b. `OUTPUT_DIR` outside the repository or temp is refused, since the build deletes whatever sits at the destination. 4. `UNIVERSAL=1` under Command Line Tools fails with the explanatory message, not a diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 7a2da2d29a..caae454ffe 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -18,11 +18,28 @@ if [[ "$(uname -s)" != "Darwin" ]]; then exit 1 fi -mkdir -p "$output_root" +# Validate BEFORE creating anything, so the script cannot leave a directory behind at a +# path it then refuses to build into. +# # `cd … && pwd` keeps LOGICAL paths on macOS, so a symlink inside the repository that # points elsewhere would satisfy the prefix check below and then be deleted for real. -# Resolve physically before validating. -output_root="$(cd "$output_root" && pwd -P)" +# Resolve physically: walk up to the nearest existing ancestor, resolve that, and +# re-append the parts that do not exist yet. +resolve_physical() { + local target="$1" tail="" + # Absolute-ise relative input against the caller's directory. + [[ "$target" = /* ]] || target="$PWD/$target" + while [[ ! -d "$target" && "$target" != "/" ]]; do + tail="$(basename "$target")${tail:+/$tail}" + target="$(dirname "$target")" + done + local resolved + resolved="$(cd "$target" && pwd -P)" + printf '%s' "${tail:+$resolved/$tail}${tail:+}" + [[ -n "$tail" ]] || printf '%s' "$resolved" +} + +output_root="$(resolve_physical "$output_root")" app_bundle="$output_root/OpenCodex.app" # The build deletes whatever sits at $app_bundle, so the destination must be somewhere @@ -47,6 +64,8 @@ case "$output_root" in ;; esac +mkdir -p "$output_root" + swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) if [[ "${UNIVERSAL:-0}" == "1" ]]; then From 9a214ad4f98e7b8ae4455f377f0aaeb0447d23d6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 07:11:37 +0900 Subject: [PATCH 28/61] fix(release): normalise .. before the containment check, and cover it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The containment fix was itself bypassable, which the reviewer demonstrated and I reproduced: resolve_physical walked up to the nearest existing ancestor and re-appended the missing tail verbatim, so /.ocx-nope/../../outside-probe resolved to itself, satisfied the prefix check, and mkdir -p then followed the .. components out of the repository. The build landed outside the permitted roots, where the destructive replace runs. The resolver now normalises the collected tail component by component, dropping "." and popping a level for "..". Verified: the same traversal is now refused, naming the RESOLVED path, and creates no directory. Added tests/macos-build-script.test.ts, which runs the real script: outside paths refused with nothing created, unresolved .. traversal refused, repository paths allowed, temp allowed. Writing that test surfaced its own trap worth recording: building the traversal with path.join() silently normalises the .. away, so the script never receives the bypass and the test passes against broken code. It is built by string concatenation instead. Sabotage-verified — reverting the normaliser fails exactly the traversal case and leaves the other three green. Also synced 040's snippet, which still showed the plain pwd -P form. --- .../040_phase4_release.md | 9 +- scripts/build-macos-app.sh | 22 ++++- tests/macos-build-script.test.ts | 86 +++++++++++++++++++ 3 files changed, 111 insertions(+), 6 deletions(-) create mode 100644 tests/macos-build-script.test.ts diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 6db14c4364..5003125489 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -89,7 +89,10 @@ Staging, then atomic swap: ```bash mkdir -p "$output_root" -output_root="$(cd "$output_root" && pwd -P)" +# Resolve physically AND normalise: walking up to the nearest existing ancestor and +# re-appending the tail verbatim was a real bypass — /.nope/../../outside resolved +# to itself, passed the prefix check, and mkdir -p then followed the .. out of the repo. +output_root="$(resolve_physical "$output_root")" staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" staged_app="$staging_root/OpenCodex.app" iconset="$staging_root/OpenCodex.iconset" @@ -384,7 +387,9 @@ broken. one to three integers — `MACOS_BUILD_NUMBER` replaces it outright rather than appending a fourth component, which Apple ignores. 3b. `OUTPUT_DIR` outside the repository or temp is refused, since the build deletes - whatever sits at the destination. + whatever sits at the destination. Covered by `tests/macos-build-script.test.ts`, + including an unresolved `..` traversal and a symlinked destination, and asserting + that a refused path creates no directory. 4. `UNIVERSAL=1` under Command Line Tools fails with the explanatory message, not a linker error. 5. The build script runs end to end on a clean checkout under `set -euo pipefail`, with diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index caae454ffe..04c9dbb1b2 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -26,17 +26,31 @@ fi # Resolve physically: walk up to the nearest existing ancestor, resolve that, and # re-append the parts that do not exist yet. resolve_physical() { - local target="$1" tail="" + local target="$1" tail="" part resolved # Absolute-ise relative input against the caller's directory. [[ "$target" = /* ]] || target="$PWD/$target" + + # Walk up to the nearest EXISTING ancestor and resolve that physically, collecting the + # not-yet-existing components on the way. while [[ ! -d "$target" && "$target" != "/" ]]; do tail="$(basename "$target")${tail:+/$tail}" target="$(dirname "$target")" done - local resolved resolved="$(cd "$target" && pwd -P)" - printf '%s' "${tail:+$resolved/$tail}${tail:+}" - [[ -n "$tail" ]] || printf '%s' "$resolved" + + # Normalise the collected tail. Re-appending it verbatim was a real bypass: a path + # like /.does-not-exist/../../outside resolved to itself, satisfied the prefix + # check, and then `mkdir -p` followed the `..` components straight out of the + # repository — after which the destructive replace ran outside the permitted roots. + local IFS=/ + for part in $tail; do + case "$part" in + "" | ".") continue ;; + "..") resolved="$(dirname "$resolved")" ;; + *) resolved="$resolved/$part" ;; + esac + done + printf '%s' "$resolved" } output_root="$(resolve_physical "$output_root")" diff --git a/tests/macos-build-script.test.ts b/tests/macos-build-script.test.ts new file mode 100644 index 0000000000..98e6b27528 --- /dev/null +++ b/tests/macos-build-script.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +// The macOS build script deletes whatever sits at its destination, so its containment +// check is a safety boundary rather than a convenience. These run the real script. +// +// Each case previously shipped as a defect: +// - the original check compared two values derived from the same variable, so any +// OUTPUT_DIR passed; +// - resolving logical paths let a repo-local symlink point outside; +// - re-appending an unresolved tail let `.ocx-nope/../../outside` escape entirely. + +const repoRoot = resolve(import.meta.dir, ".."); +const script = join(repoRoot, "scripts", "build-macos-app.sh"); +const isMacOS = process.platform === "darwin"; + +async function runScript(outputDir: string) { + const proc = Bun.spawn(["bash", script], { + cwd: repoRoot, + env: { ...process.env, OUTPUT_DIR: outputDir }, + stdout: "pipe", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([ + new Response(proc.stderr).text(), + proc.exited, + ]); + return { stderr, exitCode }; +} + +describe.skipIf(!isMacOS)("macOS build script containment", () => { + test("refuses a destination outside the repository and creates nothing", async () => { + const outside = join(tmpdir(), "..", "..", "ocx-containment-probe"); + const target = resolve(outside); + rmSync(target, { recursive: true, force: true }); + + const { stderr, exitCode } = await runScript(target); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build into"); + expect(existsSync(target)).toBe(false); + }, 120_000); + + // The bypass: neither component exists, so the resolver walked up to the repository + // and re-appended the tail verbatim. `..` then escaped during mkdir -p. + test("refuses an unresolved .. traversal before creating any directory", async () => { + // Built by string concatenation, NOT path.join: join() normalises `..` itself, so + // the script would never receive the traversal that was the actual bypass. + const traversal = `${repoRoot}/.ocx-traversal-probe/../../ocx-escaped-probe`; + const escaped = resolve(repoRoot, "..", "ocx-escaped-probe"); + const intermediate = join(repoRoot, ".ocx-traversal-probe"); + rmSync(escaped, { recursive: true, force: true }); + rmSync(intermediate, { recursive: true, force: true }); + + const { stderr, exitCode } = await runScript(traversal); + + expect(exitCode).not.toBe(0); + // The message must name the RESOLVED path, proving normalization happened. + expect(stderr).toContain("ocx-escaped-probe"); + expect(stderr).toContain("Refusing to build into"); + expect(existsSync(escaped)).toBe(false); + expect(existsSync(intermediate)).toBe(false); + }, 120_000); + + test("allows a destination inside the repository", async () => { + const inside = join(repoRoot, "dist", "macos-containment-check"); + rmSync(inside, { recursive: true, force: true }); + + const { stderr, exitCode } = await runScript(inside); + rmSync(inside, { recursive: true, force: true }); + + // Building may fail for toolchain reasons; what matters is that it was not refused. + expect(stderr).not.toContain("Refusing to build into"); + if (exitCode === 0) expect(stderr).not.toContain("Refusing"); + }, 300_000); + + test("allows a temp destination", async () => { + const temp = mkdtempSync(join(tmpdir(), "ocx-containment-")); + const { stderr } = await runScript(temp); + rmSync(temp, { recursive: true, force: true }); + + expect(stderr).not.toContain("Refusing to build into"); + }, 300_000); +}); From 468f3d3eecdaf2b7a684933799188d0c2fe5d6bf Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 07:27:51 +0900 Subject: [PATCH 29/61] fix(release): normalise before resolving, and stop the test deleting fixed paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second bypass in the same boundary, found by review and reproduced here. - resolve_physical resolved physically BEFORE normalising, so `..` could reveal a symlink that was then never followed: /.missing/../outward-link passed containment while pointing elsewhere. The order is now inverted — normalise lexically, then resolve the surviving path component by component so a symlink anywhere along it is followed. - Iteration is over a quoted array. `for part in $tail` word-split, so a literal glob such as `rel*` expanded against the filesystem. - Found while fixing it: `unset 'stack[-1]'` is a bad subscript in bash 3.2, which is what macOS ships. It failed silently, so `..` was never applied at all and the previous fix only appeared to work. Computes the index instead. Verified against every construction the reviewer named: a symlink reached through `..`, a direct outward symlink, a plain `..` traversal, and a literal glob. Each is refused naming the RESOLVED path, and none creates a directory. The regression test was itself unsafe: it recursively deleted fixed paths outside the repository, including /ocx-escaped-probe, which would have destroyed unrelated data if anything already lived there. A test for a safety boundary must not itself be destructive. Every fixture now lives in a mkdtemp sandbox or carries a pid-and-timestamp suffix, and the suite only removes what it created. Grew from 4 to 7 cases, adding both symlink forms and the glob. Sabotage-verified: restoring the bash 3.2 unset fails exactly the traversal and symlink cases and leaves the other five green. --- .../040_phase4_release.md | 6 +- scripts/build-macos-app.sh | 72 +++++++--- tests/macos-build-script.test.ts | 135 ++++++++++++++---- 3 files changed, 161 insertions(+), 52 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 5003125489..cb37c1bac8 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -88,11 +88,7 @@ explanation. PR #387 discovered this and its message is kept nearly verbatim. Staging, then atomic swap: ```bash -mkdir -p "$output_root" -# Resolve physically AND normalise: walking up to the nearest existing ancestor and -# re-appending the tail verbatim was a real bypass — /.nope/../../outside resolved -# to itself, passed the prefix check, and mkdir -p then followed the .. out of the repo. -output_root="$(resolve_physical "$output_root")" +# See the containment block above: validation happens before any mkdir. staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" staged_app="$staging_root/OpenCodex.app" iconset="$staging_root/OpenCodex.iconset" diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 04c9dbb1b2..076506a257 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -26,30 +26,70 @@ fi # Resolve physically: walk up to the nearest existing ancestor, resolve that, and # re-append the parts that do not exist yet. resolve_physical() { - local target="$1" tail="" part resolved + local target="$1" part resolved # Absolute-ise relative input against the caller's directory. [[ "$target" = /* ]] || target="$PWD/$target" - # Walk up to the nearest EXISTING ancestor and resolve that physically, collecting the - # not-yet-existing components on the way. - while [[ ! -d "$target" && "$target" != "/" ]]; do - tail="$(basename "$target")${tail:+/$tail}" - target="$(dirname "$target")" - done - resolved="$(cd "$target" && pwd -P)" - - # Normalise the collected tail. Re-appending it verbatim was a real bypass: a path - # like /.does-not-exist/../../outside resolved to itself, satisfied the prefix - # check, and then `mkdir -p` followed the `..` components straight out of the - # repository — after which the destructive replace ran outside the permitted roots. + # ORDER MATTERS, and getting it wrong has been a bypass twice. + # + # 1. Normalise lexically FIRST. Resolving physically first and normalising afterwards + # lets `..` reveal a symlink that is then never physically resolved — so + # /.missing/../some-symlink passed containment while pointing elsewhere. + # 2. THEN walk up to the nearest existing ancestor of the normalised path and resolve + # that with `pwd -P`, which follows any symlinks that survived normalisation. + # + # Iteration is over a quoted array, never `for part in $tail`: word splitting there + # let a literal glob such as `rel*` expand against the filesystem. + local -a parts=() stack=() local IFS=/ - for part in $tail; do + read -r -a parts <<< "$target" + unset IFS + + for part in "${parts[@]}"; do case "$part" in "" | ".") continue ;; - "..") resolved="$(dirname "$resolved")" ;; - *) resolved="$resolved/$part" ;; + "..") + # `unset 'stack[-1]'` is a bad subscript in bash 3.2 (what macOS ships), so it + # silently failed and `..` was never applied. Compute the index instead. + if [[ ${#stack[@]} -gt 0 ]]; then + unset "stack[$(( ${#stack[@]} - 1 ))]" + stack=("${stack[@]}") + fi + ;; + *) stack+=("$part") ;; esac done + + local normalised="/" + if [[ ${#stack[@]} -gt 0 ]]; then + printf -v normalised '/%s' "${stack[@]}" + normalised="${normalised//\/\//\/}" + fi + + # Now resolve physically, component by component, so a symlink ANYWHERE along the + # surviving path is followed — including one that only became reachable because a + # `..` removed a non-existent parent above it. + # + # Resolving only the nearest existing ancestor is not enough: for + # /.missing/../outward-link the ancestor is , and the trailing + # `outward-link` symlink was re-appended unresolved and never followed. + resolved="/" + for part in "${stack[@]}"; do + local candidate="${resolved%/}/$part" + if [[ -d "$candidate" ]]; then + # Follows the symlink when there is one. + resolved="$(cd "$candidate" && pwd -P)" + elif [[ -L "$candidate" ]]; then + # A symlink to something that is not a directory (or is dangling): resolve its + # target lexically rather than trusting the link path. + local link_target + link_target="$(readlink "$candidate")" + [[ "$link_target" = /* ]] || link_target="${resolved%/}/$link_target" + resolved="$link_target" + else + resolved="${resolved%/}/$part" + fi + done printf '%s' "$resolved" } diff --git a/tests/macos-build-script.test.ts b/tests/macos-build-script.test.ts index 98e6b27528..64a41c6305 100644 --- a/tests/macos-build-script.test.ts +++ b/tests/macos-build-script.test.ts @@ -1,16 +1,18 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; // The macOS build script deletes whatever sits at its destination, so its containment // check is a safety boundary rather than a convenience. These run the real script. // -// Each case previously shipped as a defect: +// Every case here shipped as a defect at some point: // - the original check compared two values derived from the same variable, so any // OUTPUT_DIR passed; -// - resolving logical paths let a repo-local symlink point outside; -// - re-appending an unresolved tail let `.ocx-nope/../../outside` escape entirely. +// - resolving logical paths let a repository-local symlink point outside; +// - re-appending an unresolved tail let `.nope/../../outside` escape entirely; +// - resolving physically BEFORE normalising let `..` reveal a symlink that was then +// never followed. const repoRoot = resolve(import.meta.dir, ".."); const script = join(repoRoot, "scripts", "build-macos-app.sh"); @@ -30,11 +32,28 @@ async function runScript(outputDir: string) { return { stderr, exitCode }; } +/// Runs `body` with a uniquely named sandbox that this test owns and always removes. +/// +/// An earlier version deleted FIXED paths such as `/ocx-escaped-probe`, +/// which would have destroyed unrelated data if anything already lived there. A test +/// for a safety boundary must not itself be destructive. +async function withSandbox(body: (sandbox: string) => Promise): Promise { + const sandbox = mkdtempSync(join(tmpdir(), "ocx-containment-")); + try { + return await body(sandbox); + } finally { + rmSync(sandbox, { recursive: true, force: true }); + } +} + describe.skipIf(!isMacOS)("macOS build script containment", () => { test("refuses a destination outside the repository and creates nothing", async () => { - const outside = join(tmpdir(), "..", "..", "ocx-containment-probe"); - const target = resolve(outside); - rmSync(target, { recursive: true, force: true }); + // A home-directory path: temp is an explicitly permitted root, so it cannot be used + // to prove refusal. The name is unique so it cannot collide with anything real. + const target = join( + process.env.HOME ?? "/Users/shared", + `.ocx-outside-${process.pid}-${Date.now()}`, + ); const { stderr, exitCode } = await runScript(target); @@ -43,44 +62,98 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { expect(existsSync(target)).toBe(false); }, 120_000); - // The bypass: neither component exists, so the resolver walked up to the repository - // and re-appended the tail verbatim. `..` then escaped during mkdir -p. test("refuses an unresolved .. traversal before creating any directory", async () => { - // Built by string concatenation, NOT path.join: join() normalises `..` itself, so - // the script would never receive the traversal that was the actual bypass. - const traversal = `${repoRoot}/.ocx-traversal-probe/../../ocx-escaped-probe`; - const escaped = resolve(repoRoot, "..", "ocx-escaped-probe"); - const intermediate = join(repoRoot, ".ocx-traversal-probe"); - rmSync(escaped, { recursive: true, force: true }); - rmSync(intermediate, { recursive: true, force: true }); + const intermediate = join(repoRoot, `.ocx-traversal-${process.pid}`); + const escapedName = `.ocx-escaped-${process.pid}-${Date.now()}`; + const escaped = resolve(repoRoot, "..", escapedName); + + // String concatenation, NOT path.join: join() normalises `..` itself, so the script + // would never receive the traversal that was the actual bypass. Written with join() + // this test passed against the broken resolver. + const traversal = `${intermediate}/../../${escapedName}`; const { stderr, exitCode } = await runScript(traversal); expect(exitCode).not.toBe(0); - // The message must name the RESOLVED path, proving normalization happened. - expect(stderr).toContain("ocx-escaped-probe"); + // The message names the RESOLVED path, which is the proof normalisation happened. + expect(stderr).toContain(escapedName); expect(stderr).toContain("Refusing to build into"); expect(existsSync(escaped)).toBe(false); expect(existsSync(intermediate)).toBe(false); }, 120_000); - test("allows a destination inside the repository", async () => { - const inside = join(repoRoot, "dist", "macos-containment-check"); - rmSync(inside, { recursive: true, force: true }); + test("follows a symlink revealed by a .. traversal instead of trusting the link path", async () => { + const link = join(repoRoot, `.ocx-link-${process.pid}`); + const missing = join(repoRoot, `.ocx-missing-${process.pid}`); - const { stderr, exitCode } = await runScript(inside); - rmSync(inside, { recursive: true, force: true }); + const { stderr, exitCode } = await withSandbox(async (sandbox) => { + const outside = join(sandbox, "outside-target"); + rmSync(link, { recursive: true, force: true }); + symlinkSync(outside, link); + try { + // Nothing exists at the missing component, so `..` has to be applied lexically + // before the symlink can be resolved. + return await runScript(`${missing}/../${link.split("/").pop()}`); + } finally { + rmSync(link, { recursive: true, force: true }); + rmSync(missing, { recursive: true, force: true }); + } + }); - // Building may fail for toolchain reasons; what matters is that it was not refused. - expect(stderr).not.toContain("Refusing to build into"); - if (exitCode === 0) expect(stderr).not.toContain("Refusing"); + // The sandbox lives under temp, which is a permitted root, so acceptance is fine. + // What must never happen is treating the unresolved link path as the destination. + if (exitCode !== 0) expect(stderr).toContain("Refusing to build into"); + expect(stderr).not.toContain(`${missing}/`); + expect(existsSync(link)).toBe(false); + expect(existsSync(missing)).toBe(false); }, 300_000); - test("allows a temp destination", async () => { - const temp = mkdtempSync(join(tmpdir(), "ocx-containment-")); - const { stderr } = await runScript(temp); - rmSync(temp, { recursive: true, force: true }); + test("refuses a symlink that points outside the permitted roots", async () => { + const link = join(repoRoot, `.ocx-outward-${process.pid}`); + const outside = join( + process.env.HOME ?? "/Users/shared", + `.ocx-symtarget-${process.pid}-${Date.now()}`, + ); + + rmSync(link, { recursive: true, force: true }); + symlinkSync(outside, link); + try { + const { stderr, exitCode } = await runScript(link); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build into"); + // Named by its physical target, not by the link path inside the repository. + expect(stderr).toContain(".ocx-symtarget-"); + expect(existsSync(outside)).toBe(false); + } finally { + rmSync(link, { recursive: true, force: true }); + } + }, 120_000); + + test("treats glob characters as literal path components", async () => { + const target = join(repoRoot, "dist", `ocx-glob-*-${process.pid}`); + try { + const { stderr } = await runScript(target); + expect(stderr).not.toContain("Refusing to build into"); + } finally { + rmSync(target, { recursive: true, force: true }); + } + }, 300_000); - expect(stderr).not.toContain("Refusing to build into"); + test("allows a destination inside the repository", async () => { + const inside = join(repoRoot, "dist", `ocx-inside-${process.pid}`); + try { + const { stderr } = await runScript(inside); + expect(stderr).not.toContain("Refusing to build into"); + } finally { + rmSync(inside, { recursive: true, force: true }); + } + }, 300_000); + + test("allows a temp destination", async () => { + await withSandbox(async (sandbox) => { + const { stderr } = await runScript(join(sandbox, "build")); + expect(stderr).not.toContain("Refusing to build into"); + }); }, 300_000); }); From 2d4c4cc1fc1fe9a885d44203239908f0b35e4980 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 07:40:55 +0900 Subject: [PATCH 30/61] fix(release): refuse symlinks that do not resolve to a directory Third bypass in this boundary, found by review and reproduced first. A symlink with a RELATIVE dangling target was joined onto the resolved prefix without normalising, so `link -> ../../outside` became `/../../outside`, satisfied the `/*` prefix check, and escaped during mkdir -p. Confirmed by building straight out of the repository before the fix. Rather than recursively resolve dangling targets with cycle detection, the script now refuses any symlink that does not resolve to an existing directory. OUTPUT_DIR has no legitimate reason to pass through one, and a refusal is easier to reason about than a clever resolver that has now been wrong three times. Two test-quality fixes from the same review: - The outside-path test derived its destination from process.env.HOME. Other suites replace HOME with a temp directory, and temp is a permitted root, so the script built there and the assertion failed during a full-suite run. It now uses a sibling of the repository, which no suite mutates. The full suite is green again: 4076 pass / 0 fail. - The glob test ran the child with cwd at the repository root while the glob sat under dist/, so the old unquoted loop had nothing to expand and the test would have passed against the broken implementation. It now runs in a sandbox that contains a matching entry and asserts the literal-star path was used rather than the decoy. Added a relative-escaping-symlink regression. Sabotage-verified: disabling the new symlink guard fails exactly the three symlink cases. 040's containment snippet now shows the real implementation, with all four bypasses recorded as the reason it looks the way it does, and criterion 3b describes the eight cases plus the two harness traps. --- .../040_phase4_release.md | 28 +++++++- scripts/build-macos-app.sh | 19 +++--- tests/macos-build-script.test.ts | 68 +++++++++++++------ 3 files changed, 84 insertions(+), 31 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index cb37c1bac8..7d274b71ec 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -47,9 +47,33 @@ configuration="${CONFIGURATION:-release}" [[ "$(uname -s)" == "Darwin" ]] || { echo "build:macos requires macOS." >&2; exit 1; } -# Refuse to write outside the intended output root (inherited from PR #387). +# The build DELETES whatever sits at the destination, so containment is a safety +# boundary. It took four attempts to get right, and each failure is why the final shape +# looks the way it does: +# +# 1. comparing $app_bundle against $output_root proved nothing — same variable; +# 2. `cd … && pwd` keeps LOGICAL paths, so a repo-local symlink pointing outside +# satisfied the prefix check; +# 3. resolving physically BEFORE normalising let `..` reveal a symlink that was then +# never followed — and `unset 'stack[-1]'` is a bad subscript in bash 3.2 (what +# macOS ships), so `..` was silently never applied at all; +# 4. a RELATIVE dangling target was joined on without normalising, so +# `link -> ../../outside` became `/../../outside`, passed the `/*` +# check, and escaped during mkdir -p. +# +# resolve_physical therefore normalises lexically first (quoted array iteration, so a +# literal glob is not expanded), then resolves component by component, and refuses any +# symlink that does not resolve to an existing directory. +output_root="$(resolve_physical "$output_root")" +allowed_root="$(cd "$repo_root" && pwd -P)" +case "$output_root" in + "$allowed_root"/*|/private/tmp/*|/tmp/*) ;; + *) echo "Refusing to build into '$output_root'" >&2; exit 1 ;; +esac + +# Only NOW create it, so a refused path leaves nothing behind. +mkdir -p "$output_root" app_bundle="$output_root/OpenCodex.app" -case "$app_bundle" in "$output_root"/*.app) ;; *) echo "Refusing unexpected bundle path" >&2; exit 1;; esac swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) if [[ "${UNIVERSAL:-0}" == "1" ]]; then diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 076506a257..6c746e1138 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -76,16 +76,19 @@ resolve_physical() { resolved="/" for part in "${stack[@]}"; do local candidate="${resolved%/}/$part" + if [[ -L "$candidate" && ! -d "$candidate" ]]; then + # A symlink that is not a directory: dangling, or pointing at a file. Following it + # lexically was the third bypass here — a link to `../../outside` produced + # `/../../outside`, which satisfied the `/*` prefix check and then + # escaped during `mkdir -p`. There is no legitimate reason for OUTPUT_DIR to pass + # through such a link, so refuse instead of trying to be clever. + echo "Refusing to build through '$candidate': it is a symlink that does not" >&2 + echo "resolve to an existing directory." >&2 + exit 1 + fi if [[ -d "$candidate" ]]; then - # Follows the symlink when there is one. + # `cd … && pwd -P` follows the symlink and any chain behind it. resolved="$(cd "$candidate" && pwd -P)" - elif [[ -L "$candidate" ]]; then - # A symlink to something that is not a directory (or is dangling): resolve its - # target lexically rather than trusting the link path. - local link_target - link_target="$(readlink "$candidate")" - [[ "$link_target" = /* ]] || link_target="${resolved%/}/$link_target" - resolved="$link_target" else resolved="${resolved%/}/$part" fi diff --git a/tests/macos-build-script.test.ts b/tests/macos-build-script.test.ts index 64a41c6305..44f47c8011 100644 --- a/tests/macos-build-script.test.ts +++ b/tests/macos-build-script.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -18,9 +18,9 @@ const repoRoot = resolve(import.meta.dir, ".."); const script = join(repoRoot, "scripts", "build-macos-app.sh"); const isMacOS = process.platform === "darwin"; -async function runScript(outputDir: string) { +async function runScript(outputDir: string, cwd: string = repoRoot) { const proc = Bun.spawn(["bash", script], { - cwd: repoRoot, + cwd, env: { ...process.env, OUTPUT_DIR: outputDir }, stdout: "pipe", stderr: "pipe", @@ -48,17 +48,16 @@ async function withSandbox(body: (sandbox: string) => Promise): Promise describe.skipIf(!isMacOS)("macOS build script containment", () => { test("refuses a destination outside the repository and creates nothing", async () => { - // A home-directory path: temp is an explicitly permitted root, so it cannot be used - // to prove refusal. The name is unique so it cannot collide with anything real. - const target = join( - process.env.HOME ?? "/Users/shared", - `.ocx-outside-${process.pid}-${Date.now()}`, - ); + // Deliberately NOT derived from process.env.HOME: other suites replace HOME with a + // temp directory, and temp is a permitted root — so this test built successfully and + // failed during a full-suite run. A sibling of the repository is stable and is + // outside every permitted root. + const target = resolve(repoRoot, "..", `.ocx-outside-${process.pid}-${Date.now()}`); const { stderr, exitCode } = await runScript(target); expect(exitCode).not.toBe(0); - expect(stderr).toContain("Refusing to build into"); + expect(stderr).toContain("Refusing to build"); expect(existsSync(target)).toBe(false); }, 120_000); @@ -100,9 +99,11 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { } }); - // The sandbox lives under temp, which is a permitted root, so acceptance is fine. - // What must never happen is treating the unresolved link path as the destination. - if (exitCode !== 0) expect(stderr).toContain("Refusing to build into"); + // The link points at a directory that does not exist, so the script refuses to + // build THROUGH it rather than guessing where it leads. What must never happen is + // treating the unresolved link path as a destination inside the repository. + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build"); expect(stderr).not.toContain(`${missing}/`); expect(existsSync(link)).toBe(false); expect(existsSync(missing)).toBe(false); @@ -121,23 +122,48 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { const { stderr, exitCode } = await runScript(link); expect(exitCode).not.toBe(0); - expect(stderr).toContain("Refusing to build into"); - // Named by its physical target, not by the link path inside the repository. - expect(stderr).toContain(".ocx-symtarget-"); + expect(stderr).toContain("Refusing to build"); expect(existsSync(outside)).toBe(false); } finally { rmSync(link, { recursive: true, force: true }); } }, 120_000); - test("treats glob characters as literal path components", async () => { - const target = join(repoRoot, "dist", `ocx-glob-*-${process.pid}`); + // The third bypass: a RELATIVE dangling target was joined onto the resolved prefix + // without normalising, so `link -> ../../outside` became `/../../outside`, + // satisfied the `/*` prefix check, and escaped during mkdir -p. + test("refuses a symlink whose relative target escapes the repository", async () => { + const link = join(repoRoot, `.ocx-rel-${process.pid}`); + const escaped = resolve(repoRoot, "..", "..", `ocx-rel-target-${process.pid}`); + + rmSync(link, { recursive: true, force: true }); + symlinkSync(`../../ocx-rel-target-${process.pid}`, link); try { - const { stderr } = await runScript(target); - expect(stderr).not.toContain("Refusing to build into"); + const { stderr, exitCode } = await runScript(link); + + expect(exitCode).not.toBe(0); + expect(stderr).toContain("Refusing to build"); + expect(existsSync(escaped)).toBe(false); } finally { - rmSync(target, { recursive: true, force: true }); + rmSync(link, { recursive: true, force: true }); } + }, 120_000); + + // Runs the child in a directory that CONTAINS a matching entry, so the old unquoted + // loop would have expanded the star. With cwd=repoRoot and the glob under dist/, the + // pattern matched nothing and the test passed against the broken implementation too. + test("treats glob characters as literal path components", async () => { + await withSandbox(async (sandbox) => { + const decoy = join(sandbox, "ocx-glob-decoy-probe"); + mkdirSync(decoy, { recursive: true }); + + const { stderr } = await runScript(join(sandbox, "ocx-glob-*-probe"), sandbox); + + expect(stderr).not.toContain("Refusing to build"); + // The literal-star path is the one that was used, not the decoy it could match. + expect(existsSync(join(sandbox, "ocx-glob-*-probe"))).toBe(true); + expect(existsSync(join(decoy, "OpenCodex.app"))).toBe(false); + }); }, 300_000); test("allows a destination inside the repository", async () => { From de08775ce09dae88aacb7619f6f650fb22382236 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 07:47:49 +0900 Subject: [PATCH 31/61] docs(release): make the Phase 4 containment snippet honest and complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review passed the implementation and left one blocker: 040 is the security-review artifact, and its containment snippet still could not be trusted. - It called resolve_physical without defining it, so it was not executable. Now explicitly marked ABBREVIATED with the script named as authoritative. - It omitted the allowed_tmp branch. That is not cosmetic: macOS puts TMPDIR under /var/folders, so the documented version would have rejected the packaging script's own temporary build root while claiming to describe it. - Criterion 3b claimed coverage it did not describe. It now enumerates the eight cases and all three harness traps — the HOME mutation, the path.join normalisation, and the glob cwd — each of which made a test pass against broken code at some point. Also removed the `normalised` variable, which was computed and never read after the resolver was restructured. --- .../040_phase4_release.md | 40 +++++++++++++++++-- scripts/build-macos-app.sh | 6 --- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 7d274b71ec..0fb00eeaae 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -64,11 +64,23 @@ configuration="${CONFIGURATION:-release}" # resolve_physical therefore normalises lexically first (quoted array iteration, so a # literal glob is not expanded), then resolves component by component, and refuses any # symlink that does not resolve to an existing directory. +# +# ABBREVIATED. scripts/build-macos-app.sh is authoritative — in particular resolve_physical +# itself, and the $TMPDIR handling below, which matters because macOS puts TMPDIR under +# /var/folders rather than /tmp. A containment check that allowed only /tmp would reject +# the packaging script's own temporary build root. output_root="$(resolve_physical "$output_root")" allowed_root="$(cd "$repo_root" && pwd -P)" +allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd -P || echo "")" case "$output_root" in - "$allowed_root"/*|/private/tmp/*|/tmp/*) ;; - *) echo "Refusing to build into '$output_root'" >&2; exit 1 ;; + "$allowed_root"/*) ;; + /private/tmp/*|/tmp/*) ;; + *) + if [[ -z "$allowed_tmp" || "$output_root" != "$allowed_tmp"/* ]]; then + echo "Refusing to build into '$output_root'" >&2 + exit 1 + fi + ;; esac # Only NOW create it, so a refused path leaves nothing behind. @@ -408,8 +420,28 @@ broken. appending a fourth component, which Apple ignores. 3b. `OUTPUT_DIR` outside the repository or temp is refused, since the build deletes whatever sits at the destination. Covered by `tests/macos-build-script.test.ts`, - including an unresolved `..` traversal and a symlinked destination, and asserting - that a refused path creates no directory. + **8 cases**, each asserting that a refused path creates nothing: + + 1. a sibling-of-repository path + 2. an unresolved `..` traversal + 3. a symlink revealed by a `..` + 4. a symlink pointing outside the permitted roots + 5. a symlink with a *relative* escaping target + 6. a literal glob, run from a directory containing a matching entry + 7. a repository path (accepted) + 8. a temp path (accepted) + + Two harness details are load-bearing, both learned by getting them wrong: + + - The outside path is a **sibling of the repository**, not anything under `$HOME`. + Other suites replace `HOME` with a temp directory, and temp is a permitted root, + so a `HOME`-derived path made this test pass alone and fail in the full suite. + - The traversal fixture is built by **string concatenation**, never `path.join()`, + which normalises `..` itself — with `join()` the test passed against the broken + resolver. + - The glob case runs the child in a directory that **contains a matching entry**. + With `cwd` at the repository root and the glob under `dist/`, the old unquoted + loop had nothing to expand and the test passed against the broken implementation. 4. `UNIVERSAL=1` under Command Line Tools fails with the explanatory message, not a linker error. 5. The build script runs end to end on a clean checkout under `set -euo pipefail`, with diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 6c746e1138..12074e8122 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -60,12 +60,6 @@ resolve_physical() { esac done - local normalised="/" - if [[ ${#stack[@]} -gt 0 ]]; then - printf -v normalised '/%s' "${stack[@]}" - normalised="${normalised//\/\//\/}" - fi - # Now resolve physically, component by component, so a symlink ANYWHERE along the # surviving path is followed — including one that only became reachable because a # `..` removed a non-existent parent above it. From 0a6fcf59120f585cf14d98795bbbcc654e27a5c0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 07:52:05 +0900 Subject: [PATCH 32/61] docs(release): correct two counting errors in the Phase 4 criteria Review passed. Editorial only: 'Two harness details' introduced three bullets, and the eight-case list implied every case was a refusal when two are acceptance cases. --- devlog/_plan/260725_macos_menubar_app/040_phase4_release.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md index 0fb00eeaae..8d24fcf0f7 100644 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md @@ -420,7 +420,8 @@ broken. appending a fourth component, which Apple ignores. 3b. `OUTPUT_DIR` outside the repository or temp is refused, since the build deletes whatever sits at the destination. Covered by `tests/macos-build-script.test.ts`, - **8 cases**, each asserting that a refused path creates nothing: + **8 cases** — six refusals, each asserting that nothing is created, and two + acceptances: 1. a sibling-of-repository path 2. an unresolved `..` traversal @@ -431,7 +432,7 @@ broken. 7. a repository path (accepted) 8. a temp path (accepted) - Two harness details are load-bearing, both learned by getting them wrong: + Three harness details are load-bearing, each learned by getting it wrong: - The outside path is a **sibling of the repository**, not anything under `$HOME`. Other suites replace `HOME` with a temp directory, and temp is a permitted root, From 19585b941c30db7701e47db8d4bf5b35d8d9582c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 07:58:17 +0900 Subject: [PATCH 33/61] docs(macos): document the companion and the Gatekeeper first launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 documentation. The guide ships in all five locales and is linked from the sidebar; docs-site builds 131 pages with all five present. The Gatekeeper section is the reason this guide is mandatory rather than nice-to-have. Users WILL see "cannot be opened because the developer cannot be verified", and the honest explanation is that Developer ID signing plus notarization needs a paid Apple Developer account the project does not have. So the guide says that plainly, gives the right-click-Open path and the xattr alternative, and points at building from source for anyone who wants neither. The rest documents what the app actually does rather than what a menu bar app usually does: the monochrome icon states and why colour is not used up there, the quota row showing the window under most pressure rather than the longest horizon, why the button says Stop proxy and not Restart, and the polling cadence — since a companion that hammers your own proxy every five seconds is a battery complaint waiting to happen. Also registered app/ in AGENTS.md and structure/00_overview.md. A new top-level directory that neither file mentions is invisible to the next agent, and the overview now states the boundary explicitly: the app is a client of the management API, so a change that needs a new endpoint is a change to the proxy first. --- AGENTS.md | 5 + README.md | 10 ++ docs-site/astro.config.mjs | 1 + .../src/content/docs/guides/macos-menu-bar.md | 138 ++++++++++++++++++ .../content/docs/ja/guides/macos-menu-bar.md | 135 +++++++++++++++++ .../content/docs/ko/guides/macos-menu-bar.md | 134 +++++++++++++++++ .../content/docs/ru/guides/macos-menu-bar.md | 136 +++++++++++++++++ .../docs/zh-cn/guides/macos-menu-bar.md | 121 +++++++++++++++ structure/overview.md | 5 + 9 files changed, 685 insertions(+) create mode 100644 docs-site/src/content/docs/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/ja/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/ko/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/ru/guides/macos-menu-bar.md create mode 100644 docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md diff --git a/AGENTS.md b/AGENTS.md index 1f621b99b4..469912b5e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,11 @@ Bun-native TypeScript with no separate server compile step. seeds in `layout.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. +- `app/` — native macOS menu bar companion (Swift + AppKit, no third-party + dependencies). `MenuBarCore` is the testable transport/model layer, + `MenuBarUI` the AppKit views, `MenuBarApp` the entry point. Its tests are + executables, not XCTest bundles — Command Line Tools ships neither a usable + XCTest module nor the swift-testing runtime. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. - `go/` — retired Go native-runtime experiment; kept only where the TypeScript runtime still references it. New work does not go here. diff --git a/README.md b/README.md index d29cd2f43a..61b1340c5a 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,16 @@ Use `ocx service` to run it in the background. Open **http://localhost:10100** and configure everything in the web dashboard — add providers (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. + +### macOS menu bar app + +A native companion for proxy status, usage, and provider quotas without opening the +dashboard. Download it from the [releases page](https://github.com/lidge-jun/opencodex/releases) +or build it locally with `bun run build:macos`. + +The first launch needs a right-click → Open, because the app is ad-hoc signed rather +than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +for the full explanation. It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 29b5abb1e7..e249e42b52 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -96,6 +96,7 @@ export default defineConfig({ { label: "Codex App Model Picker", translations: { fr: "Sélecteur de modèles de Codex App", ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", "zh-TW": "Codex App 模型選擇器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー", tr: "Codex App Model Seçici" }, slug: "guides/codex-app-models" }, { label: "Codex Prompt Layers", translations: { fr: "Couches d'invite Codex", ko: "Codex 프롬프트 레이어", "zh-CN": "Codex 提示词层", "zh-TW": "Codex 提示詞層", ru: "Слои промпта Codex", ja: "Codex プロンプトレイヤー", tr: "Codex İstem Katmanları" }, slug: "guides/codex-prompt" }, { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성" }, slug: "guides/codex-native-context" }, + { label: "macOS Menu Bar App", translations: { fr: "Application barre de menus macOS", ko: "macOS 메뉴바 앱", "zh-CN": "macOS 菜单栏应用", "zh-TW": "macOS 選單列 App", ru: "Приложение в строке меню macOS", ja: "macOS メニューバーアプリ", tr: "macOS Menü Çubuğu Uygulaması" }, slug: "guides/macos-menu-bar" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md new file mode 100644 index 0000000000..8197e59cdb --- /dev/null +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -0,0 +1,138 @@ +--- +title: macOS Menu Bar App +description: A native menu bar companion that shows OpenCodex proxy status, usage, and provider quotas at a glance. +--- + +The macOS companion puts OpenCodex in your menu bar: proxy health, recent usage, and +per-provider quota pressure, without opening the dashboard. + +It is a separate application from the proxy. `ocx` keeps running as it always has; the +companion is a read-mostly client that talks to the local management API. + +## Install + +Download `OpenCodex--macos-universal.zip` from the +[latest release](https://github.com/lidge-jun/opencodex/releases), unzip it, and move +`OpenCodex.app` to your Applications folder. + +Verify the download if you like — every release ships a checksum beside it: + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## First launch: Gatekeeper + +**The first launch will be blocked.** macOS will say: + +> "OpenCodex.app" cannot be opened because the developer cannot be verified. + +This is expected, and it is worth explaining rather than talking you past it. Gatekeeper +wants a Developer ID signature and a notarization ticket from Apple, both of which +require a paid Apple Developer account. OpenCodex does not have one, so the app ships +ad-hoc signed: the bundle is intact and its signature is valid, but Apple has not +vouched for the publisher. + +To open it anyway: + +1. Right-click (or Control-click) `OpenCodex.app` in Finder. +2. Choose **Open**. +3. Click **Open** in the dialog that appears. + +macOS remembers the decision, so this is a one-time step per version. + +Alternatively, remove the quarantine attribute from the terminal: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +If you would rather not do either, build from source — a local build carries no +quarantine attribute at all. See [Build from source](#build-from-source). + +## What it shows + +The menu bar icon reflects proxy state without using colour, since macOS menu bar items +are monochrome by convention: + +| Icon | Meaning | +| --- | --- | +| Solid mark | Running and protected | +| Solid mark with a notch | Running, but routing protection is at risk | +| Outlined mark | Starting up, or degraded | +| Faded outline | Not running, or needs an API key | + +Clicking it opens a panel with four sections: + +**Status** — whether the proxy is running, the address it is listening on, and the +protection state. When the proxy recommends a remediation command (for example +`ocx service install`), it appears here as selectable text. The app never runs it for +you. + +**Usage** — requests, tokens, and estimated cost over the last 7 days, with a daily +trend. A `~` after the request count means part of it is estimated rather than reported +by the provider. + +**Quotas** — one row per provider, showing the window under the most pressure. A +provider at 99% of a five-hour limit and 10% of its monthly limit shows the five-hour +figure, because that is the one currently blocking you. The window name is printed under +the provider so `42% of API usage` and `42% of a month` are never confused. + +**Providers** — a collapsible list with a switch per provider. The default provider's +switch is inert while it is enabled, because the proxy refuses to disable it; choose a +different default in the dashboard first. + +## What it can do + +- **Dashboard** opens the web dashboard in your browser. +- **Stop proxy** stops the proxy, after confirming. This is deliberately not called + "Restart": stopping also stops the launchd service, so nothing brings the proxy back + automatically. The panel then shows the command to start it again. +- **Provider switches** enable or disable a provider. + +Everything else — accounts, model configuration, storage — stays in the dashboard. + +## Connecting to the proxy + +The app finds the proxy automatically. It reads `~/.opencodex/runtime-port.json` (or +`$OPENCODEX_HOME/runtime-port.json`) and falls back to port `10100`. Only the port is +taken from that file; the host is always loopback. + +If your proxy is bound to a non-loopback address it will require an API key. The panel +says so and offers a link to the dashboard, where you can configure one. The key is +stored in your macOS Keychain and never written to logs or preferences. + +## Polling + +The app is deliberately quiet. It checks whether the proxy is alive every 5 seconds, and +fetches the expensive aggregate data — usage and quotas — only while the panel is open, +at most once a minute. After three consecutive failures it backs off to every 30 seconds +rather than hammering a proxy you stopped on purpose. + +## Build from source + +Requires macOS 13 or later and the Xcode Command Line Tools: + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +The bundle appears at `dist/macos/OpenCodex.app`. + +Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command +Line Tools ships only current-architecture Swift compatibility libraries, and the build +will tell you so rather than failing with a linker error. + +If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTITY` to +sign with the hardened runtime instead of ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## Uninstall + +Drag `OpenCodex.app` to the Trash. The app stores nothing outside its Keychain entry, +which you can remove in Keychain Access by searching for `com.opencodex.menubar`. diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md new file mode 100644 index 0000000000..5a545e0660 --- /dev/null +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -0,0 +1,135 @@ +--- +title: macOS メニューバーアプリ +description: OpenCodex プロキシの状態、使用量、プロバイダーのクォータをメニューバーから確認できるネイティブアプリ。 +--- + +メニューバーアプリは、ダッシュボードを開かずにプロキシの状態、直近の使用量、プロバイダーごとの +クォータ状況を表示します。 + +プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは +ローカルの管理 API に接続するクライアントとして動きます。 + +## インストール + +[リリースページ](https://github.com/lidge-jun/opencodex/releases)から +`OpenCodex--macos-universal.zip` をダウンロードし、展開して `OpenCodex.app` を +アプリケーションフォルダに移動します。 + +ダウンロードを検証する場合、リリースごとにチェックサムが添付されています。 + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## 初回起動: Gatekeeper + +**初回起動はブロックされます。** 次のメッセージが表示されます。 + +> "OpenCodex.app"は、開発元を検証できないため開けません。 + +これは想定された動作なので、読み飛ばさずに理由を説明します。Gatekeeper は Apple の +Developer ID 署名と公証(notarization)チケットを要求しますが、どちらも有料の Apple +Developer アカウントが必要です。OpenCodex はそのアカウントを持たないため、アプリは ad-hoc +署名で配布されます。バンドル自体は壊れておらず署名も有効ですが、Apple が配布元を保証しては +いない、という状態です。 + +それでも開くには: + +1. Finder で `OpenCodex.app` を右クリック(または Control クリック)します。 +2. **開く** を選択します。 +3. 表示されたダイアログで再度 **開く** をクリックします。 + +一度許可すれば macOS が記憶するため、バージョンごとに一度だけの操作です。 + +ターミナルから隔離属性を削除する方法もあります。 + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +どちらも避けたい場合はソースからビルドしてください。ローカルビルドには隔離属性が付きません。 +[ソースからビルド](#ソースからビルド)を参照してください。 + +## 表示される内容 + +メニューバーのアイコンは色ではなく形で状態を示します。macOS のメニューバーアイコンは単色が +慣例だからです。 + +| アイコン | 意味 | +| --- | --- | +| 塗りつぶし | 実行中、ルーティング保護あり | +| 切り欠き付き | 実行中だがルーティング保護が不安定 | +| 輪郭のみ | 確認中、または応答が異常 | +| 薄い輪郭 | 停止中、または API キーが必要 | + +アイコンをクリックすると 4 つのセクションを持つパネルが開きます。 + +**ステータス** — プロキシの稼働状況、待ち受けアドレス、保護状態。プロキシが対処コマンド +(例: `ocx service install`)を推奨している場合は選択可能なテキストとして表示します。アプリが +代わりに実行することはありません。 + +**使用量** — 直近 7 日間のリクエスト数、トークン、推定コストと日別の推移。リクエスト数の後ろの +`~` は、一部がプロバイダー報告値ではなく推定値であることを示します。 + +**クォータ** — プロバイダーごとに 1 行、最も逼迫しているウィンドウを表示します。5 時間枠を +99%、月間枠を 10% 使っているプロバイダーなら 5 時間枠の数値を出します。いま実際に制限に +かかっているのはそちらだからです。ウィンドウ名を併記するため、`API usage の 42%` と +`1 か月の 42%` を取り違えることはありません。 + +**プロバイダー** — 展開できる一覧で、プロバイダーごとにスイッチがあります。デフォルト +プロバイダーは有効な間スイッチが無効化されます。プロキシがデフォルトの無効化を拒否するため、 +先にダッシュボードでデフォルトを変更してください。 + +## できること + +- **Dashboard** — ブラウザで Web ダッシュボードを開きます。 +- **Stop proxy** — 確認のうえプロキシを停止します。あえて「再起動」とは呼びません。停止すると + launchd サービスも止まり、自動的には復帰しないためです。停止後は再起動用のコマンドを + パネルに表示します。 +- **プロバイダースイッチ** — プロバイダーの有効・無効を切り替えます。 + +アカウント、モデル設定、ストレージなどはダッシュボードで操作します。 + +## プロキシへの接続 + +アプリが自動で見つけます。`~/.opencodex/runtime-port.json`(または +`$OPENCODEX_HOME/runtime-port.json`)を読み、無ければポート `10100` を使います。この +ファイルから取得するのはポートのみで、ホストは常にループバックです。 + +プロキシがループバック以外のアドレスにバインドされている場合は API キーが必要です。パネルが +その旨を表示し、ダッシュボードへのボタンを出します。キーは macOS キーチェーンに保存され、 +ログや設定ファイルには書き込まれません。 + +## ポーリング + +アプリは意図的に控えめに動作します。プロキシの生存確認は 5 秒ごと、負荷の大きい集計データ +(使用量とクォータ)はパネルが開いている間のみ、最大でも 1 分に 1 回取得します。3 回連続で +失敗した場合は 30 秒間隔に広げます。ユーザーが意図的に停止したプロキシを叩き続けないためです。 + +## ソースからビルド + +macOS 13 以降と Xcode Command Line Tools が必要です。 + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +バンドルは `dist/macos/OpenCodex.app` に生成されます。 + +ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には +現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは +なく理由を説明するメッセージが表示されます。 + +キーチェーンに Developer ID 証明書がある場合は、`MACOS_SIGN_IDENTITY` を指定すると ad-hoc +ではなく hardened runtime で署名できます。 + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## アンインストール + +`OpenCodex.app` をゴミ箱に移動してください。アプリが残すのはキーチェーン項目のみで、 +キーチェーンアクセスで `com.opencodex.menubar` を検索して削除できます。 diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md new file mode 100644 index 0000000000..df4a3a5d29 --- /dev/null +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -0,0 +1,134 @@ +--- +title: macOS 메뉴바 앱 +description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를 메뉴바에서 바로 확인하는 네이티브 앱입니다. +--- + +메뉴바 앱은 대시보드를 열지 않아도 프록시 상태와 최근 사용량, 프로바이더별 쿼터를 한눈에 +보여줍니다. + +프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리 +API에 붙는 클라이언트입니다. + +## 설치 + +[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 +`OpenCodex-<버전>-macos-universal.zip`을 받아 압축을 풀고 `OpenCodex.app`을 응용 +프로그램 폴더로 옮기세요. + +받은 파일을 검증하고 싶다면 릴리스마다 체크섬이 함께 올라갑니다. + +```bash +shasum -a 256 -c OpenCodex-<버전>-macos-universal.zip.sha256 +``` + +## 첫 실행: Gatekeeper 차단 + +**처음 실행하면 macOS가 막습니다.** 이런 메시지가 뜹니다. + +> "OpenCodex.app"은(는) 개발자를 확인할 수 없기 때문에 열 수 없습니다. + +예상된 동작이라 그냥 넘어가지 않고 이유를 적어둡니다. Gatekeeper는 Apple의 Developer ID +서명과 공증(notarization) 티켓을 요구하는데, 둘 다 유료 Apple Developer 계정이 있어야 +합니다. OpenCodex에는 그 계정이 없어서 앱은 ad-hoc 서명 상태로 배포됩니다. 번들 자체는 +온전하고 서명도 유효하지만, Apple이 배포자를 보증해 주지는 않았다는 뜻입니다. + +그래도 열려면: + +1. Finder에서 `OpenCodex.app`을 우클릭(또는 Control-클릭)합니다. +2. **열기**를 선택합니다. +3. 뜨는 대화상자에서 다시 **열기**를 누릅니다. + +한 번 허용하면 macOS가 기억하므로 버전마다 한 번씩만 하면 됩니다. + +터미널에서 격리 속성을 지워도 됩니다. + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +둘 다 내키지 않으면 직접 빌드하세요. 로컬 빌드에는 격리 속성이 아예 붙지 않습니다. +[소스에서 빌드하기](#소스에서-빌드하기)를 참고하세요. + +## 무엇을 보여주나 + +메뉴바 아이콘은 색이 아니라 형태로 상태를 나타냅니다. macOS 메뉴바 아이콘은 단색이 +관례이기 때문입니다. + +| 아이콘 | 의미 | +| --- | --- | +| 꽉 찬 마크 | 실행 중이고 라우팅이 보호됨 | +| 홈이 파인 마크 | 실행 중이지만 라우팅 보호가 불안정함 | +| 외곽선 마크 | 확인 중이거나 응답이 이상함 | +| 흐린 외곽선 | 실행 중이 아니거나 API 키가 필요함 | + +아이콘을 누르면 네 영역이 있는 패널이 열립니다. + +**상태** — 프록시 실행 여부, 수신 주소, 보호 상태를 보여줍니다. 프록시가 조치 명령을 +권할 때(예: `ocx service install`) 선택 가능한 텍스트로 표시합니다. 앱이 대신 실행하지는 +않습니다. + +**사용량** — 최근 7일간 요청 수, 토큰, 예상 비용과 일자별 추이입니다. 요청 수 뒤의 `~`는 +일부가 프로바이더 보고값이 아니라 추정치라는 표시입니다. + +**쿼터** — 프로바이더마다 한 줄씩, 가장 압박이 큰 창을 보여줍니다. 5시간 한도를 99% 쓰고 +월 한도는 10%만 쓴 프로바이더라면 5시간 수치를 표시합니다. 지금 막고 있는 쪽이 그것이기 +때문입니다. 창 이름을 아래에 적어두어 `API usage의 42%`와 `한 달의 42%`를 헷갈릴 일이 +없습니다. + +**프로바이더** — 펼칠 수 있는 목록이고 프로바이더마다 스위치가 있습니다. 기본 프로바이더는 +켜져 있는 동안 스위치가 잠깁니다. 프록시가 기본 프로바이더 비활성화를 거부하기 때문이며, +대시보드에서 기본값을 먼저 바꿔야 합니다. + +## 무엇을 할 수 있나 + +- **Dashboard** — 브라우저에서 웹 대시보드를 엽니다. +- **Stop proxy** — 확인을 거쳐 프록시를 중지합니다. 일부러 "재시작"이라고 부르지 않습니다. + 중지하면 launchd 서비스도 함께 멈춰서 자동으로 다시 뜨지 않기 때문입니다. 중지 후에는 + 다시 시작하는 명령을 패널에 보여줍니다. +- **프로바이더 스위치** — 프로바이더를 켜고 끕니다. + +계정, 모델 설정, 저장소 관리 같은 나머지는 대시보드에서 합니다. + +## 프록시 연결 + +앱이 알아서 찾습니다. `~/.opencodex/runtime-port.json`(또는 +`$OPENCODEX_HOME/runtime-port.json`)을 읽고, 없으면 `10100` 포트를 씁니다. 이 파일에서 +가져오는 건 포트뿐이고 호스트는 항상 루프백입니다. + +프록시가 루프백이 아닌 주소에 바인딩돼 있으면 API 키가 필요합니다. 패널이 그 사실을 알려주고 +대시보드로 가는 버튼을 보여줍니다. 키는 macOS 키체인에 저장되며 로그나 환경설정 파일에는 +기록하지 않습니다. + +## 폴링 주기 + +앱은 일부러 조용하게 동작합니다. 프록시 생존 확인은 5초마다 하고, 비용이 큰 집계 데이터인 +사용량과 쿼터는 패널이 열려 있을 때만, 그것도 최대 1분에 한 번 가져옵니다. 연속 세 번 +실패하면 30초 간격으로 늘립니다. 사용자가 일부러 끈 프록시를 계속 두드리지 않기 위해서입니다. + +## 소스에서 빌드하기 + +macOS 13 이상과 Xcode Command Line Tools가 필요합니다. + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +번들은 `dist/macos/OpenCodex.app`에 생깁니다. + +유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools +에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 +설명하는 메시지가 나옵니다. + +키체인에 Developer ID 인증서가 있다면 `MACOS_SIGN_IDENTITY`를 지정해 ad-hoc 대신 하드닝된 +런타임으로 서명할 수 있습니다. + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## 삭제 + +`OpenCodex.app`을 휴지통으로 옮기면 됩니다. 앱이 남기는 건 키체인 항목 하나뿐이고, +키체인 접근에서 `com.opencodex.menubar`로 검색해 지울 수 있습니다. diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md new file mode 100644 index 0000000000..db4470c5cd --- /dev/null +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -0,0 +1,136 @@ +--- +title: Приложение в строке меню macOS +description: Нативное приложение, показывающее состояние прокси OpenCodex, расход и квоты провайдеров прямо в строке меню. +--- + +Приложение показывает состояние прокси, недавний расход и загрузку квот по провайдерам, +не требуя открывать панель управления. + +Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню — +клиент, который обращается к локальному management API. + +## Установка + +Скачайте `OpenCodex-<версия>-macos-universal.zip` со +[страницы релизов](https://github.com/lidge-jun/opencodex/releases), распакуйте и +переместите `OpenCodex.app` в папку «Программы». + +Если хотите проверить загрузку, к каждому релизу прилагается контрольная сумма: + +```bash +shasum -a 256 -c OpenCodex-<версия>-macos-universal.zip.sha256 +``` + +## Первый запуск: Gatekeeper + +**Первый запуск будет заблокирован.** macOS покажет: + +> Не удаётся открыть «OpenCodex.app», так как не удалось проверить разработчика. + +Это ожидаемо, поэтому объясняем причину, а не предлагаем просто нажать дальше. Gatekeeper +требует подпись Developer ID и билет нотаризации от Apple — и то и другое доступно только +с платным аккаунтом Apple Developer. У OpenCodex его нет, поэтому приложение выпускается +с ad-hoc подписью: сам бандл цел и подпись корректна, но Apple не подтверждает издателя. + +Чтобы всё-таки открыть: + +1. Нажмите правой кнопкой (или Control-клик) на `OpenCodex.app` в Finder. +2. Выберите **Открыть**. +3. В появившемся диалоге снова нажмите **Открыть**. + +macOS запомнит решение, так что это разовое действие для каждой версии. + +Можно также снять атрибут карантина из терминала: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +Если ни один вариант не подходит, соберите приложение сами — у локальной сборки атрибута +карантина нет вовсе. См. [Сборка из исходников](#сборка-из-исходников). + +## Что показывает + +Иконка в строке меню передаёт состояние формой, а не цветом: в macOS иконки строки меню +по традиции монохромны. + +| Иконка | Значение | +| --- | --- | +| Сплошная метка | Работает, маршрутизация защищена | +| Метка с выемкой | Работает, но защита маршрутизации под угрозой | +| Контурная метка | Проверка или нештатный ответ | +| Блёклый контур | Не запущен или нужен API-ключ | + +По клику открывается панель с четырьмя разделами. + +**Состояние** — работает ли прокси, адрес прослушивания и состояние защиты. Если прокси +рекомендует команду (например, `ocx service install`), она показывается выделяемым +текстом. Приложение её не выполняет. + +**Расход** — запросы, токены и оценочная стоимость за последние 7 дней с дневной +динамикой. Знак `~` после числа запросов означает, что часть значения оценочная, а не +сообщённая провайдером. + +**Квоты** — по строке на провайдера, показывается окно под наибольшим давлением. Если +провайдер израсходовал 99% пятичасового лимита и 10% месячного, показывается пятичасовое +значение — именно оно сейчас блокирует работу. Название окна печатается под провайдером, +поэтому `42% от API usage` и `42% от месяца` невозможно перепутать. + +**Провайдеры** — раскрывающийся список с переключателем для каждого провайдера. +Переключатель провайдера по умолчанию заблокирован, пока тот включён: прокси отказывается +отключать провайдера по умолчанию, поэтому сначала смените его в панели управления. + +## Что умеет + +- **Dashboard** — открывает веб-панель в браузере. +- **Stop proxy** — останавливает прокси после подтверждения. Намеренно не называется + «перезапуск»: остановка также останавливает службу launchd, поэтому прокси не поднимется + сам. После остановки панель показывает команду для повторного запуска. +- **Переключатели провайдеров** — включают и выключают провайдера. + +Всё остальное — аккаунты, настройка моделей, хранилище — остаётся в панели управления. + +## Подключение к прокси + +Приложение находит прокси само. Оно читает `~/.opencodex/runtime-port.json` (или +`$OPENCODEX_HOME/runtime-port.json`), а при отсутствии использует порт `10100`. Из файла +берётся только порт; хост всегда локальный. + +Если прокси привязан не к локальному адресу, потребуется API-ключ. Панель сообщит об этом +и предложит перейти в панель управления. Ключ хранится в связке ключей macOS и не +записывается ни в логи, ни в настройки. + +## Опрос + +Приложение намеренно ведёт себя тихо. Проверка доступности — раз в 5 секунд, а тяжёлые +агрегаты (расход и квоты) запрашиваются только при открытой панели и не чаще раза в +минуту. После трёх неудач подряд интервал увеличивается до 30 секунд, чтобы не долбить +прокси, который вы остановили намеренно. + +## Сборка из исходников + +Требуются macOS 13 или новее и Xcode Command Line Tools: + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +Бандл появится в `dist/macos/OpenCodex.app`. + +Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть +только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом +вместо ошибки компоновщика. + +Если в связке ключей есть сертификат Developer ID, задайте `MACOS_SIGN_IDENTITY`, чтобы +подписать с hardened runtime вместо ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## Удаление + +Перетащите `OpenCodex.app` в корзину. Приложение оставляет только запись в связке ключей — +найдите `com.opencodex.menubar` в «Связке ключей» и удалите её. diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md new file mode 100644 index 0000000000..65648872e7 --- /dev/null +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -0,0 +1,121 @@ +--- +title: macOS 菜单栏应用 +description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供商配额的原生应用。 +--- + +菜单栏应用让你无需打开仪表板,就能看到代理状态、近期用量和各提供商的配额压力。 + +它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。 + +## 安装 + +从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 +`OpenCodex--macos-universal.zip`,解压后把 `OpenCodex.app` 移到「应用程序」文件夹。 + +如果需要校验下载文件,每个版本都附带校验和: + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## 首次启动:Gatekeeper + +**首次启动会被阻止。** macOS 会提示: + +> 无法打开“OpenCodex.app”,因为无法验证开发者。 + +这是预期行为,所以这里说明原因而不是直接略过。Gatekeeper 需要 Apple 的 Developer ID 签名和 +公证(notarization)票据,两者都需要付费的 Apple Developer 账号。OpenCodex 没有该账号,因此 +应用以 ad-hoc 签名发布:程序包本身完整、签名有效,但 Apple 并未为发布者背书。 + +仍要打开: + +1. 在 Finder 中右键点击(或按住 Control 点击)`OpenCodex.app`。 +2. 选择**打开**。 +3. 在弹出的对话框中再次点击**打开**。 + +macOS 会记住这个选择,因此每个版本只需操作一次。 + +也可以在终端移除隔离属性: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +如果两种方式都不想用,可以自行构建——本地构建不会带有隔离属性。参见[从源码构建](#从源码构建)。 + +## 显示的内容 + +菜单栏图标用形状而非颜色表示状态,因为 macOS 菜单栏图标按惯例是单色的: + +| 图标 | 含义 | +| --- | --- | +| 实心标记 | 运行中,路由受保护 | +| 带缺口的实心标记 | 运行中,但路由保护存在风险 | +| 轮廓标记 | 正在检查,或响应异常 | +| 淡色轮廓 | 未运行,或需要 API 密钥 | + +点击图标会打开包含四个部分的面板。 + +**状态** — 代理是否运行、监听地址以及保护状态。当代理给出修复命令(例如 +`ocx service install`)时,会以可选中的文本显示。应用不会替你执行。 + +**用量** — 最近 7 天的请求数、令牌数和预估成本,以及每日趋势。请求数后的 `~` 表示其中一部分 +是估算值,而非提供商上报的数据。 + +**配额** — 每个提供商一行,显示压力最大的那个窗口。如果某个提供商 5 小时额度用了 99%、月度 +额度只用了 10%,会显示 5 小时的数值,因为真正卡住你的是它。窗口名称标注在提供商下方,因此 +`API usage 的 42%` 和`一个月的 42%` 不会混淆。 + +**提供商** — 可展开的列表,每个提供商带一个开关。默认提供商在启用状态下开关是锁定的,因为 +代理会拒绝停用默认提供商;请先在仪表板中更换默认值。 + +## 可以做什么 + +- **Dashboard** — 在浏览器中打开 Web 仪表板。 +- **Stop proxy** — 确认后停止代理。这里刻意不叫「重启」:停止会同时停掉 launchd 服务,代理不会 + 自动恢复。停止后面板会显示重新启动的命令。 +- **提供商开关** — 启用或停用某个提供商。 + +账号、模型配置、存储等其余操作仍在仪表板中完成。 + +## 连接到代理 + +应用会自动查找。它读取 `~/.opencodex/runtime-port.json`(或 +`$OPENCODEX_HOME/runtime-port.json`),找不到则使用端口 `10100`。该文件只提供端口,主机始终 +为回环地址。 + +如果代理绑定在非回环地址上,就需要 API 密钥。面板会说明这一点并提供前往仪表板的按钮。密钥 +保存在 macOS 钥匙串中,不会写入日志或偏好设置文件。 + +## 轮询 + +应用刻意保持安静。存活检查每 5 秒一次;开销较大的聚合数据(用量和配额)只在面板打开时获取, +且最多每分钟一次。连续三次失败后会退避到 30 秒一次,以免不断敲打你主动停掉的代理。 + +## 从源码构建 + +需要 macOS 13 或更高版本以及 Xcode Command Line Tools: + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +程序包会生成在 `dist/macos/OpenCodex.app`。 + +构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift +兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 + +如果钥匙串中有 Developer ID 证书,可以设置 `MACOS_SIGN_IDENTITY`,以 hardened runtime 签名 +替代 ad-hoc 签名: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## 卸载 + +把 `OpenCodex.app` 拖到废纸篓即可。应用只留下一个钥匙串条目,可在「钥匙串访问」中搜索 +`com.opencodex.menubar` 删除。 diff --git a/structure/overview.md b/structure/overview.md index c0627e2dd7..c3124e7573 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -33,6 +33,11 @@ native Anthropic passthrough branch that forwards without translation. The Live/ different in kind — it resolves an OpenAI/ChatGPT relay and forwards to it directly, without the adapter bridge. +`app/` is a second, optional surface: a native macOS menu bar companion. It is a client +of the management API, not part of the proxy — it adds no endpoint and changes no +routing. Treat it the way you treat `gui/`: it may consume what `src/` already exposes, +and a change that requires a new endpoint is a change to the proxy first. + The default install keeps native OpenAI/ChatGPT passthrough working through one option-aware `openai` provider. Pool is the default and selects across main plus added accounts; Direct uses only the current caller/main login. `openai-apikey` explicitly selects API-key transport, and the two From 6d9a85d76f19c05419c0c18c46a15226ce97496d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 08:16:30 +0900 Subject: [PATCH 34/61] docs(macos): correct the API-key claim the app does not implement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the guide describing a flow that does not exist, in all five locales, and the same overclaim repeated in the #421 closing comment. Nothing calls Keychain.write. The app can READ a key under com.opencodex.menubar and retries once with it, but there is no entry UI, and "Add key…" only opens the dashboard — whose token lives in session storage and is unreachable from a native app. So a user with a non-loopback proxy stays on "Needs API key" no matter what the guide promised. All five locales now say that plainly: the key can be read from the Keychain, there is no way to enter one yet, a loopback proxy needs none, and native entry is planned. The uninstall section no longer claims a Keychain entry exists unless the user created it. Keychain.swift documents the same thing at the source. Also folded: - Bun was missing from the build prerequisites, so a machine with only Command Line Tools would hit "command not found" after satisfying the stated requirements. Added, with `bash scripts/build-macos-app.sh` as the no-Bun path. - README now names app/ as the source directory, which criterion 2 asked for literally and the previous wording only implied. - "the address it is listening on" was imprecise: the panel shows the loopback endpoint the app is using, which is not necessarily the proxy's configured bind. Reworded in all five locales. - Added the System Settings → Privacy & Security → Open Anyway fallback, since current macOS does not always offer an Open button in the first dialog. A corrective note on #421 follows separately — the credit there also needs fixing, and closing a PR with an inaccurate credit is worse than not crediting at all. --- README.md | 7 +++-- app/Sources/MenuBarCore/Keychain.swift | 5 ++++ .../src/content/docs/guides/macos-menu-bar.md | 25 ++++++++++++------ .../content/docs/ja/guides/macos-menu-bar.md | 23 +++++++++++----- .../content/docs/ko/guides/macos-menu-bar.md | 23 +++++++++++----- .../content/docs/ru/guides/macos-menu-bar.md | 26 ++++++++++++++----- .../docs/zh-cn/guides/macos-menu-bar.md | 21 ++++++++++----- 7 files changed, 92 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 61b1340c5a..2edc34129b 100644 --- a/README.md +++ b/README.md @@ -94,12 +94,15 @@ re-opens the dashboard at any time. ### macOS menu bar app A native companion for proxy status, usage, and provider quotas without opening the -dashboard. Download it from the [releases page](https://github.com/lidge-jun/opencodex/releases) -or build it locally with `bun run build:macos`. +dashboard. The source lives in [`app/`](./app) (Swift + AppKit, no third-party +dependencies). Download it from the +[releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with +`bun run build:macos`. The first launch needs a right-click → Open, because the app is ad-hoc signed rather than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) for the full explanation. + It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex diff --git a/app/Sources/MenuBarCore/Keychain.swift b/app/Sources/MenuBarCore/Keychain.swift index 66cc0774b4..600250fac7 100644 --- a/app/Sources/MenuBarCore/Keychain.swift +++ b/app/Sources/MenuBarCore/Keychain.swift @@ -6,6 +6,11 @@ import Security /// The key is read lazily — only after a 401 — and is never written to UserDefaults, /// never logged, and never included in an error surfaced to the UI. /// +/// **Read-only in practice today.** Nothing in the app calls `write`: there is no key +/// entry UI yet, so a user with a non-loopback proxy has to create the Keychain item +/// themselves. `write`/`delete` exist for the entry flow that is planned, and the docs +/// say plainly that the case is not fully supported rather than implying it works. +/// /// Every query sets `kSecUseDataProtectionKeychain`. Without it, `kSecAttrAccessible` is /// ignored on macOS (it applies only to data-protection or synchronizable items), so the /// declared accessibility class would be decorative. Setting it on *all* operations also diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 8197e59cdb..f1f81d97ae 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -39,6 +39,9 @@ To open it anyway: 2. Choose **Open**. 3. Click **Open** in the dialog that appears. +If that dialog does not offer an Open button, go to **System Settings → Privacy & +Security**, find the blocked-app notice, and click **Open Anyway**. + macOS remembers the decision, so this is a one-time step per version. Alternatively, remove the quarantine attribute from the terminal: @@ -64,8 +67,8 @@ are monochrome by convention: Clicking it opens a panel with four sections: -**Status** — whether the proxy is running, the address it is listening on, and the -protection state. When the proxy recommends a remediation command (for example +**Status** — whether the proxy is running, the loopback endpoint the app is using, and +the protection state. When the proxy recommends a remediation command (for example `ocx service install`), it appears here as selectable text. The app never runs it for you. @@ -99,8 +102,12 @@ The app finds the proxy automatically. It reads `~/.opencodex/runtime-port.json` taken from that file; the host is always loopback. If your proxy is bound to a non-loopback address it will require an API key. The panel -says so and offers a link to the dashboard, where you can configure one. The key is -stored in your macOS Keychain and never written to logs or preferences. +says so and offers a link to the dashboard. + +**This case is not fully supported yet.** The app can read a key from the macOS Keychain +under `com.opencodex.menubar` and will retry once with it, but it has no UI for entering +one — so unless you add that Keychain item yourself, the panel stays on "Needs API key". +A loopback proxy, which is the default, needs no key at all. Native key entry is planned. ## Polling @@ -111,7 +118,7 @@ rather than hammering a proxy you stopped on purpose. ## Build from source -Requires macOS 13 or later and the Xcode Command Line Tools: +Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun.sh): ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -119,7 +126,8 @@ cd opencodex bun run build:macos ``` -The bundle appears at `dist/macos/OpenCodex.app`. +The bundle appears at `dist/macos/OpenCodex.app`. Without Bun you can run the script +directly: `bash scripts/build-macos-app.sh`. Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command Line Tools ships only current-architecture Swift compatibility libraries, and the build @@ -134,5 +142,6 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## Uninstall -Drag `OpenCodex.app` to the Trash. The app stores nothing outside its Keychain entry, -which you can remove in Keychain Access by searching for `com.opencodex.menubar`. +Drag `OpenCodex.app` to the Trash. The app writes no preferences or state of its own. If +you manually added a Keychain item for a non-loopback proxy, remove it in Keychain Access +by searching for `com.opencodex.menubar`. diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index 5a545e0660..6173616c65 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -39,6 +39,9 @@ Developer アカウントが必要です。OpenCodex はそのアカウントを 2. **開く** を選択します。 3. 表示されたダイアログで再度 **開く** をクリックします。 +ダイアログに「開く」が無い場合は、**システム設定 → プライバシーとセキュリティ** でブロック +通知を探し、**このまま開く** をクリックしてください。 + 一度許可すれば macOS が記憶するため、バージョンごとに一度だけの操作です。 ターミナルから隔離属性を削除する方法もあります。 @@ -64,7 +67,7 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app アイコンをクリックすると 4 つのセクションを持つパネルが開きます。 -**ステータス** — プロキシの稼働状況、待ち受けアドレス、保護状態。プロキシが対処コマンド +**ステータス** — プロキシの稼働状況、アプリが使用しているループバックアドレス、保護状態。プロキシが対処コマンド (例: `ocx service install`)を推奨している場合は選択可能なテキストとして表示します。アプリが 代わりに実行することはありません。 @@ -97,8 +100,12 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ファイルから取得するのはポートのみで、ホストは常にループバックです。 プロキシがループバック以外のアドレスにバインドされている場合は API キーが必要です。パネルが -その旨を表示し、ダッシュボードへのボタンを出します。キーは macOS キーチェーンに保存され、 -ログや設定ファイルには書き込まれません。 +その旨を表示し、ダッシュボードへのボタンを出します。 + +**この経路はまだ完全にはサポートされていません。** アプリは macOS キーチェーンの +`com.opencodex.menubar` を読み取って一度だけ再試行しますが、キーを入力する画面がありません。 +自分でキーチェーン項目を作成しない限り、パネルは「Needs API key」のままです。既定である +ループバックのプロキシではキーは不要です。ネイティブのキー入力は今後追加予定です。 ## ポーリング @@ -108,7 +115,7 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ## ソースからビルド -macOS 13 以降と Xcode Command Line Tools が必要です。 +macOS 13 以降、Xcode Command Line Tools、および [Bun](https://bun.sh) が必要です。 ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -116,7 +123,8 @@ cd opencodex bun run build:macos ``` -バンドルは `dist/macos/OpenCodex.app` に生成されます。 +バンドルは `dist/macos/OpenCodex.app` に生成されます。Bun がない場合はスクリプトを直接 +実行できます: `bash scripts/build-macos-app.sh`。 ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には 現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは @@ -131,5 +139,6 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## アンインストール -`OpenCodex.app` をゴミ箱に移動してください。アプリが残すのはキーチェーン項目のみで、 -キーチェーンアクセスで `com.opencodex.menubar` を検索して削除できます。 +`OpenCodex.app` をゴミ箱に移動してください。アプリは設定ファイルなどを残しません。ループ +バック以外のプロキシ用にキーチェーン項目を手動で作成した場合は、キーチェーンアクセスで +`com.opencodex.menubar` を検索して削除してください。 diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index df4a3a5d29..3f15926f17 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -38,6 +38,9 @@ shasum -a 256 -c OpenCodex-<버전>-macos-universal.zip.sha256 2. **열기**를 선택합니다. 3. 뜨는 대화상자에서 다시 **열기**를 누릅니다. +대화상자에 열기 버튼이 없다면 **시스템 설정 → 개인정보 보호 및 보안**에서 차단 알림을 찾아 +**그래도 열기**를 누르세요. + 한 번 허용하면 macOS가 기억하므로 버전마다 한 번씩만 하면 됩니다. 터미널에서 격리 속성을 지워도 됩니다. @@ -63,7 +66,7 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app 아이콘을 누르면 네 영역이 있는 패널이 열립니다. -**상태** — 프록시 실행 여부, 수신 주소, 보호 상태를 보여줍니다. 프록시가 조치 명령을 +**상태** — 프록시 실행 여부, 앱이 사용 중인 루프백 주소, 보호 상태를 보여줍니다. 프록시가 조치 명령을 권할 때(예: `ocx service install`) 선택 가능한 텍스트로 표시합니다. 앱이 대신 실행하지는 않습니다. @@ -96,8 +99,12 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app 가져오는 건 포트뿐이고 호스트는 항상 루프백입니다. 프록시가 루프백이 아닌 주소에 바인딩돼 있으면 API 키가 필요합니다. 패널이 그 사실을 알려주고 -대시보드로 가는 버튼을 보여줍니다. 키는 macOS 키체인에 저장되며 로그나 환경설정 파일에는 -기록하지 않습니다. +대시보드로 가는 버튼을 보여줍니다. + +**아직 완전히 지원되는 경로는 아닙니다.** 앱은 macOS 키체인의 `com.opencodex.menubar` +항목을 읽어 한 번 재시도하지만, 키를 입력하는 화면이 없습니다. 직접 키체인 항목을 만들지 +않으면 패널은 "Needs API key" 상태로 남습니다. 기본값인 루프백 프록시는 키가 필요 없습니다. +네이티브 키 입력은 예정돼 있습니다. ## 폴링 주기 @@ -107,7 +114,7 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ## 소스에서 빌드하기 -macOS 13 이상과 Xcode Command Line Tools가 필요합니다. +macOS 13 이상, Xcode Command Line Tools, 그리고 [Bun](https://bun.sh)이 필요합니다. ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -115,7 +122,8 @@ cd opencodex bun run build:macos ``` -번들은 `dist/macos/OpenCodex.app`에 생깁니다. +번들은 `dist/macos/OpenCodex.app`에 생깁니다. Bun 없이 쓰려면 스크립트를 직접 실행하세요: +`bash scripts/build-macos-app.sh`. 유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools 에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 @@ -130,5 +138,6 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## 삭제 -`OpenCodex.app`을 휴지통으로 옮기면 됩니다. 앱이 남기는 건 키체인 항목 하나뿐이고, -키체인 접근에서 `com.opencodex.menubar`로 검색해 지울 수 있습니다. +`OpenCodex.app`을 휴지통으로 옮기면 됩니다. 앱은 환경설정이나 별도 상태 파일을 남기지 +않습니다. 루프백이 아닌 프록시를 위해 키체인 항목을 직접 만들었다면, 키체인 접근에서 +`com.opencodex.menubar`로 검색해 지우세요. diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md index db4470c5cd..9f71cc7298 100644 --- a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -38,6 +38,10 @@ shasum -a 256 -c OpenCodex-<версия>-macos-universal.zip.sha256 2. Выберите **Открыть**. 3. В появившемся диалоге снова нажмите **Открыть**. +Если в диалоге нет кнопки «Открыть», откройте **Системные настройки → Конфиденциальность и +безопасность**, найдите уведомление о заблокированной программе и нажмите **Всё равно +открыть**. + macOS запомнит решение, так что это разовое действие для каждой версии. Можно также снять атрибут карантина из терминала: @@ -63,7 +67,8 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app По клику открывается панель с четырьмя разделами. -**Состояние** — работает ли прокси, адрес прослушивания и состояние защиты. Если прокси +**Состояние** — работает ли прокси, локальный адрес, который использует приложение, и +состояние защиты. Если прокси рекомендует команду (например, `ocx service install`), она показывается выделяемым текстом. Приложение её не выполняет. @@ -97,8 +102,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app берётся только порт; хост всегда локальный. Если прокси привязан не к локальному адресу, потребуется API-ключ. Панель сообщит об этом -и предложит перейти в панель управления. Ключ хранится в связке ключей macOS и не -записывается ни в логи, ни в настройки. +и предложит перейти в панель управления. + +**Этот сценарий поддержан не полностью.** Приложение читает элемент +`com.opencodex.menubar` из связки ключей macOS и делает одну повторную попытку, но +интерфейса для ввода ключа нет. Пока вы не создадите этот элемент вручную, панель останется +в состоянии «Needs API key». Локальному прокси, который используется по умолчанию, ключ не +нужен. Нативный ввод ключа запланирован. ## Опрос @@ -109,7 +119,7 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ## Сборка из исходников -Требуются macOS 13 или новее и Xcode Command Line Tools: +Требуются macOS 13 или новее, Xcode Command Line Tools и [Bun](https://bun.sh): ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -117,7 +127,8 @@ cd opencodex bun run build:macos ``` -Бандл появится в `dist/macos/OpenCodex.app`. +Бандл появится в `dist/macos/OpenCodex.app`. Без Bun скрипт можно запустить напрямую: +`bash scripts/build-macos-app.sh`. Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом @@ -132,5 +143,6 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## Удаление -Перетащите `OpenCodex.app` в корзину. Приложение оставляет только запись в связке ключей — -найдите `com.opencodex.menubar` в «Связке ключей» и удалите её. +Перетащите `OpenCodex.app` в корзину. Приложение не оставляет ни настроек, ни собственных +файлов состояния. Если вы вручную создавали элемент связки ключей для нелокального прокси, +найдите `com.opencodex.menubar` в «Связке ключей» и удалите его. diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 65648872e7..98cc441d32 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -34,6 +34,9 @@ shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 2. 选择**打开**。 3. 在弹出的对话框中再次点击**打开**。 +如果对话框没有「打开」按钮,请前往**系统设置 → 隐私与安全性**,找到被拦截的提示并点击 +**仍要打开**。 + macOS 会记住这个选择,因此每个版本只需操作一次。 也可以在终端移除隔离属性: @@ -57,7 +60,7 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app 点击图标会打开包含四个部分的面板。 -**状态** — 代理是否运行、监听地址以及保护状态。当代理给出修复命令(例如 +**状态** — 代理是否运行、应用正在使用的回环地址以及保护状态。当代理给出修复命令(例如 `ocx service install`)时,会以可选中的文本显示。应用不会替你执行。 **用量** — 最近 7 天的请求数、令牌数和预估成本,以及每日趋势。请求数后的 `~` 表示其中一部分 @@ -85,8 +88,11 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app `$OPENCODEX_HOME/runtime-port.json`),找不到则使用端口 `10100`。该文件只提供端口,主机始终 为回环地址。 -如果代理绑定在非回环地址上,就需要 API 密钥。面板会说明这一点并提供前往仪表板的按钮。密钥 -保存在 macOS 钥匙串中,不会写入日志或偏好设置文件。 +如果代理绑定在非回环地址上,就需要 API 密钥。面板会说明这一点并提供前往仪表板的按钮。 + +**该路径尚未完全支持。** 应用会读取 macOS 钥匙串中的 `com.opencodex.menubar` 条目并重试一次, +但没有输入密钥的界面。除非你自己创建该钥匙串条目,否则面板会一直停在「Needs API key」。默认的 +回环代理不需要密钥。原生密钥输入已在计划中。 ## 轮询 @@ -95,7 +101,7 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app ## 从源码构建 -需要 macOS 13 或更高版本以及 Xcode Command Line Tools: +需要 macOS 13 或更高版本、Xcode Command Line Tools 以及 [Bun](https://bun.sh): ```bash git clone https://github.com/lidge-jun/opencodex.git @@ -103,7 +109,8 @@ cd opencodex bun run build:macos ``` -程序包会生成在 `dist/macos/OpenCodex.app`。 +程序包会生成在 `dist/macos/OpenCodex.app`。若没有 Bun,可以直接运行脚本: +`bash scripts/build-macos-app.sh`。 构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift 兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 @@ -117,5 +124,5 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## 卸载 -把 `OpenCodex.app` 拖到废纸篓即可。应用只留下一个钥匙串条目,可在「钥匙串访问」中搜索 -`com.opencodex.menubar` 删除。 +把 `OpenCodex.app` 拖到废纸篓即可。应用不会留下偏好设置或其他状态文件。如果你为非回环代理 +手动创建过钥匙串条目,可在「钥匙串访问」中搜索 `com.opencodex.menubar` 删除。 From 7ccf7374ea9826d140d64b22b2f77b990df8506a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 08:23:17 +0900 Subject: [PATCH 35/61] docs(macos): stop pointing users at a Keychain item they cannot create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the documented identifier does not match the code: the app queries service com.opencodex.menubar.apikey with account "default", while the guide named com.opencodex.menubar. All five locales repeated it, so the workaround I had just added would have left users exactly where they started. Rather than publish the exact identifier, the guides now say there is no supported way to provision the key by hand. That is the honest answer: the entry is a data-protection Keychain item, which Keychain Access does not create, so naming the service would send people down a path that does not work either. A loopback proxy — the default — needs no key, and native entry is planned. The uninstall sections no longer describe removing a Keychain item, since the app stores nothing there today. Also took the reviewer's suggestion on 050: criterion 1 said "Guide published", which implied a deployment this phase does not perform. It now says the source is added and the docs build verified, with publication following merge and Pages. --- .../050_phase5_handoff.md | 4 +++- .../src/content/docs/guides/macos-menu-bar.md | 15 ++++++++------- .../src/content/docs/ja/guides/macos-menu-bar.md | 15 ++++++++------- .../src/content/docs/ko/guides/macos-menu-bar.md | 13 +++++++------ .../src/content/docs/ru/guides/macos-menu-bar.md | 15 ++++++++------- .../content/docs/zh-cn/guides/macos-menu-bar.md | 12 +++++++----- 6 files changed, 41 insertions(+), 33 deletions(-) diff --git a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md index 3583f5e536..8d337c0556 100644 --- a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md +++ b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md @@ -131,7 +131,9 @@ another contributor's machine. ## Accept criteria -1. Guide published in five locales, linked from the sidebar, no locale contradictions. +1. Guide source added in five locales, linked from the sidebar, no locale contradictions, + and `docs-site` builds with all five pages present. Public publication follows merge + and a Pages deployment; this phase delivers the branch, not the deploy. 2. `README.md`, `AGENTS.md`, `structure/00_overview.md` mention `app/`. 3. #387 and #421 `CLOSED` with the comments above, each verified against the PR's head commit at the moment of posting. diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index f1f81d97ae..58f558a376 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -104,10 +104,12 @@ taken from that file; the host is always loopback. If your proxy is bound to a non-loopback address it will require an API key. The panel says so and offers a link to the dashboard. -**This case is not fully supported yet.** The app can read a key from the macOS Keychain -under `com.opencodex.menubar` and will retry once with it, but it has no UI for entering -one — so unless you add that Keychain item yourself, the panel stays on "Needs API key". -A loopback proxy, which is the default, needs no key at all. Native key entry is planned. +**This case is not supported yet.** The app reads a key from the macOS Keychain and +retries once with it, but there is no UI for entering one and no supported way to +provision it by hand — the item is a data-protection Keychain entry, which Keychain +Access does not create. So on a non-loopback bind the panel stays on "Needs API key". + +A loopback proxy — the default — needs no key at all. Native key entry is planned. ## Polling @@ -142,6 +144,5 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## Uninstall -Drag `OpenCodex.app` to the Trash. The app writes no preferences or state of its own. If -you manually added a Keychain item for a non-loopback proxy, remove it in Keychain Access -by searching for `com.opencodex.menubar`. +Drag `OpenCodex.app` to the Trash. The app writes no preferences or state of its own, and +stores nothing in the Keychain today. diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index 6173616c65..e7d584c681 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -102,10 +102,12 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app プロキシがループバック以外のアドレスにバインドされている場合は API キーが必要です。パネルが その旨を表示し、ダッシュボードへのボタンを出します。 -**この経路はまだ完全にはサポートされていません。** アプリは macOS キーチェーンの -`com.opencodex.menubar` を読み取って一度だけ再試行しますが、キーを入力する画面がありません。 -自分でキーチェーン項目を作成しない限り、パネルは「Needs API key」のままです。既定である -ループバックのプロキシではキーは不要です。ネイティブのキー入力は今後追加予定です。 +**この経路はまだサポートされていません。** アプリは macOS キーチェーンからキーを読み取って +一度だけ再試行しますが、キーを入力する画面はなく、手動で用意する方法もありません。データ保護 +キーチェーンの項目であり、キーチェーンアクセスでは作成できないためです。したがってループバック +以外のバインドではパネルは「Needs API key」のままになります。 + +既定であるループバックのプロキシではキーは不要です。ネイティブのキー入力は今後追加予定です。 ## ポーリング @@ -139,6 +141,5 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## アンインストール -`OpenCodex.app` をゴミ箱に移動してください。アプリは設定ファイルなどを残しません。ループ -バック以外のプロキシ用にキーチェーン項目を手動で作成した場合は、キーチェーンアクセスで -`com.opencodex.menubar` を検索して削除してください。 +`OpenCodex.app` をゴミ箱に移動してください。アプリは設定ファイルなどを残さず、現時点では +キーチェーンにも何も保存しません。 diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index 3f15926f17..f913268ac1 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -101,10 +101,12 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app 프록시가 루프백이 아닌 주소에 바인딩돼 있으면 API 키가 필요합니다. 패널이 그 사실을 알려주고 대시보드로 가는 버튼을 보여줍니다. -**아직 완전히 지원되는 경로는 아닙니다.** 앱은 macOS 키체인의 `com.opencodex.menubar` -항목을 읽어 한 번 재시도하지만, 키를 입력하는 화면이 없습니다. 직접 키체인 항목을 만들지 -않으면 패널은 "Needs API key" 상태로 남습니다. 기본값인 루프백 프록시는 키가 필요 없습니다. -네이티브 키 입력은 예정돼 있습니다. +**아직 지원되지 않는 경로입니다.** 앱은 macOS 키체인에서 키를 읽어 한 번 재시도하지만, +키를 입력하는 화면이 없고 손으로 넣을 방법도 없습니다. 데이터 보호 키체인 항목이라 키체인 +접근으로는 만들 수 없기 때문입니다. 따라서 루프백이 아닌 바인딩에서는 패널이 "Needs API key" +상태로 남습니다. + +기본값인 루프백 프록시는 키가 필요 없습니다. 네이티브 키 입력은 예정돼 있습니다. ## 폴링 주기 @@ -139,5 +141,4 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## 삭제 `OpenCodex.app`을 휴지통으로 옮기면 됩니다. 앱은 환경설정이나 별도 상태 파일을 남기지 -않습니다. 루프백이 아닌 프록시를 위해 키체인 항목을 직접 만들었다면, 키체인 접근에서 -`com.opencodex.menubar`로 검색해 지우세요. +않고, 현재는 키체인에도 아무것도 저장하지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md index 9f71cc7298..a0ed399171 100644 --- a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -104,11 +104,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app Если прокси привязан не к локальному адресу, потребуется API-ключ. Панель сообщит об этом и предложит перейти в панель управления. -**Этот сценарий поддержан не полностью.** Приложение читает элемент -`com.opencodex.menubar` из связки ключей macOS и делает одну повторную попытку, но -интерфейса для ввода ключа нет. Пока вы не создадите этот элемент вручную, панель останется -в состоянии «Needs API key». Локальному прокси, который используется по умолчанию, ключ не -нужен. Нативный ввод ключа запланирован. +**Этот сценарий пока не поддержан.** Приложение читает ключ из связки ключей macOS и делает +одну повторную попытку, но интерфейса для ввода ключа нет и нет поддерживаемого способа +создать его вручную: это элемент data-protection keychain, который «Связка ключей» не +создаёт. Поэтому при нелокальной привязке панель остаётся в состоянии «Needs API key». + +Локальному прокси, который используется по умолчанию, ключ не нужен. Нативный ввод ключа +запланирован. ## Опрос @@ -144,5 +146,4 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## Удаление Перетащите `OpenCodex.app` в корзину. Приложение не оставляет ни настроек, ни собственных -файлов состояния. Если вы вручную создавали элемент связки ключей для нелокального прокси, -найдите `com.opencodex.menubar` в «Связке ключей» и удалите его. +файлов состояния и пока ничего не хранит в связке ключей. diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 98cc441d32..421d394664 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -90,9 +90,11 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app 如果代理绑定在非回环地址上,就需要 API 密钥。面板会说明这一点并提供前往仪表板的按钮。 -**该路径尚未完全支持。** 应用会读取 macOS 钥匙串中的 `com.opencodex.menubar` 条目并重试一次, -但没有输入密钥的界面。除非你自己创建该钥匙串条目,否则面板会一直停在「Needs API key」。默认的 -回环代理不需要密钥。原生密钥输入已在计划中。 +**该路径尚未支持。** 应用会从 macOS 钥匙串读取密钥并重试一次,但没有输入密钥的界面,也没有 +手动写入的办法——它是数据保护钥匙串条目,「钥匙串访问」无法创建。因此在非回环绑定下,面板会 +一直停在「Needs API key」。 + +默认的回环代理不需要密钥。原生密钥输入已在计划中。 ## 轮询 @@ -124,5 +126,5 @@ MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build ## 卸载 -把 `OpenCodex.app` 拖到废纸篓即可。应用不会留下偏好设置或其他状态文件。如果你为非回环代理 -手动创建过钥匙串条目,可在「钥匙串访问」中搜索 `com.opencodex.menubar` 删除。 +把 `OpenCodex.app` 拖到废纸篓即可。应用不会留下偏好设置或其他状态文件,目前也不会在钥匙串中 +保存任何内容。 From 633f5abd6f3fdd26be918c6af747f92e7a29c902 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 25 Jul 2026 08:31:51 +0900 Subject: [PATCH 36/61] docs(app): align the Keychain comment with what the guides now say MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source comment still told a maintainer that users create the Keychain item themselves — the exact workaround the guides just stopped publishing, because every query sets kSecUseDataProtectionKeychain and Keychain Access does not create data-protection items. Left as-is it would have reintroduced the invalid advice the next time someone read the source instead of the guide. --- app/Sources/MenuBarCore/Keychain.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/Sources/MenuBarCore/Keychain.swift b/app/Sources/MenuBarCore/Keychain.swift index 600250fac7..2d67ecb5de 100644 --- a/app/Sources/MenuBarCore/Keychain.swift +++ b/app/Sources/MenuBarCore/Keychain.swift @@ -6,10 +6,15 @@ import Security /// The key is read lazily — only after a 401 — and is never written to UserDefaults, /// never logged, and never included in an error surfaced to the UI. /// -/// **Read-only in practice today.** Nothing in the app calls `write`: there is no key -/// entry UI yet, so a user with a non-loopback proxy has to create the Keychain item -/// themselves. `write`/`delete` exist for the entry flow that is planned, and the docs -/// say plainly that the case is not fully supported rather than implying it works. +/// **Read-only in practice today, and there is no way to provision the key.** Nothing in +/// the app calls `write`, because there is no key-entry UI yet — and a user cannot fill +/// the gap by hand either: every query sets `kSecUseDataProtectionKeychain`, and +/// Keychain Access does not create data-protection items. So a non-loopback bind is +/// genuinely unsupported rather than merely inconvenient, and the docs say exactly that. +/// +/// `write`/`delete` exist for the native entry flow that is planned. Do not document a +/// manual workaround on top of them: an earlier revision of the guide did, naming a +/// service that was both wrong and unreachable. /// /// Every query sets `kSecUseDataProtectionKeychain`. Without it, `kSecAttrAccessible` is /// ignored on macOS (it applies only to data-protection or synchronizable items), so the From af6ef54dd82660c88edcf1f8eca589acfb98819e Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 10:36:38 -0700 Subject: [PATCH 37/61] ci: declare macos-app in the aggregate gate after the dev rebase Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdd068b93d..e5eacaa1e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1248,12 +1248,12 @@ jobs: GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" - GATED_JOBS="$GATED_JOBS structure-gate" + GATED_JOBS="$GATED_JOBS structure-gate macos-app" expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke) + test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|macos-app) echo "$scoped" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; From ffd97fcb9d214b0a1817c45717e36a1da03853e4 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 10:50:47 -0700 Subject: [PATCH 38/61] feat: add usage timeline companion settings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/test-layout/layout.json | 5 +- .../ocx/references/01_management_surface.md | 20 +- src/cli/capabilities.ts | 12 ++ src/cli/dispatch.ts | 12 ++ src/cli/registry.ts | 5 + src/companion/settings.ts | 131 ++++++++++++ src/server/management-api.ts | 6 +- src/server/management/companion-routes.ts | 38 ++++ src/server/management/route-registry.ts | 5 + .../management/usage-timeline-routes.ts | 39 ++++ src/usage/summary.ts | 4 +- src/usage/timeline.ts | 202 ++++++++++++++++++ structure/INDEX.md | 1 + structure/gui-and-management-api.md | 6 +- structure/manifest.json | 2 + structure/overview.md | 1 + tests/fixtures/test-layout-expected.json | 5 +- tests/{ => gui}/macos-build-script.test.ts | 0 tests/server/companion-settings.test.ts | 58 +++++ tests/usage/usage-timeline.test.ts | 99 +++++++++ 20 files changed, 643 insertions(+), 8 deletions(-) create mode 100644 src/companion/settings.ts create mode 100644 src/server/management/companion-routes.ts create mode 100644 src/server/management/usage-timeline-routes.ts create mode 100644 src/usage/timeline.ts rename tests/{ => gui}/macos-build-script.test.ts (100%) create mode 100644 tests/server/companion-settings.test.ts create mode 100644 tests/usage/usage-timeline.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ef1c68922b..dd6c816f0d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,7 @@ } }, "explicit": { + "macos-build-script.test.ts": "gui", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", @@ -1566,7 +1567,9 @@ "web-search-sidecar-429.test.ts": "web-search", "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", - "client-runtime.test.ts": "clients" + "client-runtime.test.ts": "clients", + "usage-timeline.test.ts": "usage", + "companion-settings.test.ts": "server" }, "migrated": [ "adapters", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 78d2047d1a..0082405e2f 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -537,6 +537,22 @@ JSON mode: `payload`. - `store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched. - Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there. +### `ocx companion` + +Inspect and configure menu-bar and widget companion usage settings. + +| Method | Route | +|---|---| +| GET | `/api/companion/settings` | +| GET | `/api/usage/timeline` | +| PUT | `/api/companion/settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit companion settings as JSON. | + +JSON mode: `payload`. + ### `ocx account main reauth` Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring. @@ -896,6 +912,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 48 -- of those, state-changing: 24 +- declared capabilities: 49 +- of those, state-changing: 25 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index e67390041b..8491d74ff7 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -314,6 +314,18 @@ export const CAPABILITIES: readonly Capability[] = [ "Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.", ], }, + { + command: ["companion"], + summary: "Inspect and configure menu-bar and widget companion usage settings.", + routes: [ + { method: "GET", path: "/api/companion/settings" }, + { method: "GET", path: "/api/usage/timeline" }, + { method: "PUT", path: "/api/companion/settings" }, + ], + flags: [{ name: "--json", value: "boolean", summary: "Emit companion settings as JSON." }], + mutates: true, + json: "payload", + }, { command: ["account", "history"], summary: "Cached quota observations for one stored Codex pool account.", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 029336f66c..700c49ebde 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -782,6 +782,18 @@ const commandRunners: Record = { const { handleComboCommand } = await import("./combo"); return await handleComboCommand(deps.args.slice(1)); }, + companion: async deps => { + const { printData, runtimeRequest, takeFlag } = await import("./runtime-api"); + const args = deps.args.slice(1); + const wantsJson = takeFlag(args, "--json"); + if (args.length) { + console.error("Usage: ocx companion [--json]"); + return 64; + } + const payload = await runtimeRequest("/api/companion/settings"); + printData(payload, wantsJson); + return 0; + }, route: async deps => { if (deps.args[1] !== "combo" && deps.args[1] !== "policy") { console.error("Usage: ocx route "); diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f9b9d95d96..8ec80587bd 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -298,6 +298,11 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx model ", summary: "Alias of ocx models.", }, + { + name: "companion", + usage: "ocx companion [--json]", + summary: "Inspect menu-bar and widget companion usage settings.", + }, { name: "combo", usage: "ocx combo ...", diff --git a/src/companion/settings.ts b/src/companion/settings.ts new file mode 100644 index 0000000000..5429884776 --- /dev/null +++ b/src/companion/settings.ts @@ -0,0 +1,131 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { + TIMELINE_HOURS, + type TimelineAggregation, + type TimelineGrouping, + type TimelineMetric, +} from "../usage/timeline"; + +export interface CompanionSettings { + menuBarMetric: "requests" | "tokens" | "cost" | "quota" | "none"; + menuBarTemplate: string | null; + showToday: boolean; + showChart: boolean; + showModels: boolean; + showCost: boolean; + showAccounts: boolean; + chartHours: typeof TIMELINE_HOURS[number]; + bucketMinutes: number; + chartStyle: "line" | "stackedBar"; + tokenMetric: TimelineMetric; + aggregation: TimelineAggregation; + chartGrouping: TimelineGrouping; + models: string[] | null; + hiddenProviders: string[]; +} + +export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = { + menuBarMetric: "requests", + menuBarTemplate: null, + showToday: true, + showChart: true, + showModels: true, + showCost: true, + showAccounts: true, + chartHours: 24, + bucketMinutes: 60, + chartStyle: "line", + tokenMetric: "total", + aggregation: "sum", + chartGrouping: "model", + models: null, + hiddenProviders: [], +}; + +const TEMPLATE_FIELDS = new Set(["requests", "totalTokens", "inputTokens", "outputTokens", "costUsd", "quotaPercent"]); +const MENU_BAR_METRICS = new Set(["requests", "tokens", "cost", "quota", "none"]); +const CHART_STYLES = new Set(["line", "stackedBar"]); +const TIMELINE_METRICS = new Set(["total", "input", "output", "cached"]); +const AGGREGATIONS = new Set(["sum", "average", "max"]); +const GROUPINGS = new Set(["model", "modelAccount"]); +const SETTINGS_KEYS = Object.keys(DEFAULT_COMPANION_SETTINGS) as (keyof CompanionSettings)[]; + +export function companionSettingsPath(): string { + return join(getConfigDir(), "companion.json"); +} + +function invalid(message: string): { error: string } { + return { error: message }; +} + +function validModels(value: unknown, key: string): value is string[] | null { + return value === null + || (Array.isArray(value) + && value.length <= 100 + && value.every(model => typeof model === "string" && /^[^/\s]+\/[^/\s]+$/.test(model))); +} + +function validateValue(key: keyof CompanionSettings, value: unknown): string | null { + if (key === "menuBarMetric") return typeof value === "string" && MENU_BAR_METRICS.has(value) ? null : "menuBarMetric is invalid"; + if (key === "menuBarTemplate") { + if (value === null) return null; + if (typeof value !== "string" || value.length > 200) return "menuBarTemplate must be null or at most 200 characters"; + for (const match of value.matchAll(/\{([^{}]+)\}/g)) { + if (!TEMPLATE_FIELDS.has(match[1]!)) return `menuBarTemplate contains unknown placeholder: ${match[1]}`; + } + return null; + } + if (["showToday", "showChart", "showModels", "showCost", "showAccounts"].includes(key)) { + return typeof value === "boolean" ? null : `${key} must be a boolean`; + } + if (key === "chartHours") return TIMELINE_HOURS.includes(value as typeof TIMELINE_HOURS[number]) ? null : "chartHours is invalid"; + if (key === "bucketMinutes") return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1440 ? null : "bucketMinutes must be an integer from 1 through 1440"; + if (key === "chartStyle") return typeof value === "string" && CHART_STYLES.has(value) ? null : "chartStyle is invalid"; + if (key === "tokenMetric") return typeof value === "string" && TIMELINE_METRICS.has(value) ? null : "tokenMetric is invalid"; + if (key === "aggregation") return typeof value === "string" && AGGREGATIONS.has(value) ? null : "aggregation is invalid"; + if (key === "chartGrouping") return typeof value === "string" && GROUPINGS.has(value) ? null : "chartGrouping is invalid"; + if (key === "models") return validModels(value, key) ? null : "models must be null or at most 100 provider/model identifiers"; + if (key === "hiddenProviders") return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/\s/.test(item)) + ? null : "hiddenProviders must contain at most 100 provider names"; + return `${key} is unsupported`; +} + +export function applyCompanionSettingsPatch( + current: CompanionSettings, + patch: unknown, +): CompanionSettings | { error: string } { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) return invalid("settings must be an object"); + const values = patch as Record; + for (const key of Object.keys(values)) { + if (!SETTINGS_KEYS.includes(key as keyof CompanionSettings)) return invalid(`unknown settings key: ${key}`); + const error = validateValue(key as keyof CompanionSettings, values[key]); + if (error) return invalid(error); + } + return { ...current, ...values } as CompanionSettings; +} + +export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null } { + const path = companionSettingsPath(); + if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed); + if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; + return { settings, updatedAt: statSync(path).mtimeMs }; + } catch { + return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; + } +} + +export function saveCompanionSettings(settings: CompanionSettings): void { + const path = companionSettingsPath(); + const dir = getConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const temp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); + chmodSync(temp, 0o600); + renameSync(temp, path); + chmodSync(path, 0o600); +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 7c8766367c..dbd5d7345d 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -71,6 +71,8 @@ import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; import { handleSystemRoutes } from "./management/system-routes"; import { handleSidebarRoutes } from "./management/sidebar-routes"; +import { handleUsageTimelineRoutes } from "./management/usage-timeline-routes"; +import { handleCompanionRoutes } from "./management/companion-routes"; import { handleCodexPromptRoutes } from "./management/codex-prompt-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; @@ -266,7 +268,7 @@ export async function handleManagementAPI( } catch { /* best-effort */ } } const ctx: ManagementContext = { req, url, config, deps, version: VERSION, principal, sessionControl, convergeCodexCatalog, syncClaudeAgentDefsBestEffort }; - let routed: Response | null; + let routed: Response | null | undefined; try { routed = handleSessionRoutes(ctx) ?? (await handleRemoteWorkspaceRoutesOnDemand(ctx)) @@ -291,6 +293,8 @@ export async function handleManagementAPI( ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) ?? (await handleLabRoutesOnDemand(ctx)) + ?? (await handleUsageTimelineRoutes(ctx)) + ?? (await handleCompanionRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); } catch (error) { const tooLarge = managementBodyTooLargeResponse(error, req, config); diff --git a/src/server/management/companion-routes.ts b/src/server/management/companion-routes.ts new file mode 100644 index 0000000000..c4d63fb25f --- /dev/null +++ b/src/server/management/companion-routes.ts @@ -0,0 +1,38 @@ +import { + applyCompanionSettingsPatch, + DEFAULT_COMPANION_SETTINGS, + loadCompanionSettings, + saveCompanionSettings, +} from "../../companion/settings"; +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +function response(): Response { + const loaded = loadCompanionSettings(); + return jsonResponse({ settings: loaded.settings, updatedAt: loaded.updatedAt, defaults: DEFAULT_COMPANION_SETTINGS }); +} + +export async function handleCompanionRoutes(ctx: ManagementContext): Promise { + if (ctx.url.pathname === "/api/companion/settings" && ctx.req.method === "GET") return response(); + if (ctx.url.pathname !== "/api/companion/settings" || ctx.req.method !== "PUT") return null; + let body: unknown; + try { + body = await readManagementJsonBody(ctx.req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, ctx.req, ctx.config); + } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid settings body" }, 400, ctx.req, ctx.config); + const input = body as { reset?: unknown; settings?: unknown }; + if (input.reset === true) { + saveCompanionSettings(DEFAULT_COMPANION_SETTINGS); + return response(); + } + if (!("settings" in input)) return jsonResponse({ error: "provide settings or reset:true" }, 400, ctx.req, ctx.config); + const current = loadCompanionSettings().settings; + const updated = applyCompanionSettingsPatch(current, input.settings); + if ("error" in updated) return jsonResponse(updated, 400, ctx.req, ctx.config); + saveCompanionSettings(updated); + return response(); +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 95dc71be74..fbaafcb5d3 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -240,6 +240,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/storage/trash", module: "server/management/logs-usage-routes", mutates: false }, { method: "GET", path: "/api/storage/trash/restore/test-stream", module: "server/management/logs-usage-routes", mutates: false, exempt: { reason: "test-seam", why: "Opt-in streaming seam declared at src/storage/restore-job.ts:34." } }, { method: "GET", path: "/api/usage", module: "server/management/logs-usage-routes", mutates: false }, + // server/management/usage-timeline-routes + { method: "GET", path: "/api/usage/timeline", module: "server/management/usage-timeline-routes", mutates: false }, + // server/management/companion-routes + { method: "GET", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: false }, + { method: "PUT", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup", module: "server/management/logs-usage-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup-policy/run", module: "server/management/logs-usage-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup/preview", module: "server/management/logs-usage-routes", mutates: true }, diff --git a/src/server/management/usage-timeline-routes.ts b/src/server/management/usage-timeline-routes.ts new file mode 100644 index 0000000000..2ae627f17c --- /dev/null +++ b/src/server/management/usage-timeline-routes.ts @@ -0,0 +1,39 @@ +import { scanUsageLedgerCooperatively } from "../../usage/ledger-scanner"; +import { createTimelineAccumulator, parseTimelineQuery } from "../../usage/timeline"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; + +const TIMELINE_CACHE_TTL_MS = 15_000; +const cache = new Map["finish"]>> }>(); + +export async function handleUsageTimelineRoutes(ctx: ManagementContext): Promise { + const { req, url } = ctx; + if (url.pathname !== "/api/usage/timeline" || req.method !== "GET") return undefined; + const query = parseTimelineQuery(url.searchParams, Date.now()); + if ("error" in query) return jsonResponse(query, 400, req, ctx.config); + const bucketMs = query.bucketMinutes * 60_000; + const roundedNow = Math.floor(query.now / bucketMs) * bucketMs; + const key = JSON.stringify({ ...query, now: roundedNow }); + const current = Date.now(); + const cached = cache.get(key); + if (cached && cached.expiresAt > current) return jsonResponse(await cached.promise); + let promise: Promise["finish"]>>; + promise = (async () => { + const accumulator = createTimelineAccumulator(query); + await scanUsageLedgerCooperatively({ signal: req.signal, onEntry: entry => accumulator.add(entry) }); + return accumulator.finish(); + })().catch(error => { + const entry = cache.get(key); + if (entry?.promise === promise) cache.delete(key); + throw error; + }); + cache.set(key, { expiresAt: current + TIMELINE_CACHE_TTL_MS, promise }); + try { + return jsonResponse(await promise, 200, req, ctx.config); + } finally { + setTimeout(() => { + const entry = cache.get(key); + if (entry?.promise === promise && entry.expiresAt <= Date.now()) cache.delete(key); + }, TIMELINE_CACHE_TTL_MS + 1); + } +} diff --git a/src/usage/summary.ts b/src/usage/summary.ts index b1468fd125..091dac1763 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -410,7 +410,7 @@ function isMeasuredStatus(status: UsageStatus): boolean { return status === "reported" || status === "estimated"; } -interface UsageAttribution { +export interface UsageAttribution { requestId: string; provider: string; model: string; @@ -454,7 +454,7 @@ function usageModelKey(providerKey: string, model: string): string { return `${providerKey}\0${model}`; } -function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { +export function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { if (!entry.attempts?.length) { return [{ requestId: entry.requestId, diff --git a/src/usage/timeline.ts b/src/usage/timeline.ts new file mode 100644 index 0000000000..bd514ea047 --- /dev/null +++ b/src/usage/timeline.ts @@ -0,0 +1,202 @@ +import { cacheTokensFromUsage, usageAttributions } from "./summary"; +import type { PersistedUsageEntry } from "./log"; +import { usageDisplayTotalTokens } from "./totals"; + +export type TimelineMetric = "total" | "input" | "output" | "cached"; +export type TimelineAggregation = "sum" | "average" | "max"; +export type TimelineGrouping = "model" | "modelAccount"; +export const TIMELINE_HOURS = [6, 24, 72, 168] as const; + +export interface TimelineQuery { + hours: typeof TIMELINE_HOURS[number]; + bucketMinutes: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + models: string[] | null; + now: number; +} + +export interface TimelineSeries { + id: string; + provider: string; + model: string; + accountLogLabel?: string; + total: number; + points: number[]; +} + +export interface UsageTimeline { + start: number; + end: number; + bucketSeconds: number; + buckets: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + series: TimelineSeries[]; + availableModels: string[]; + missingMeasurements: number; +} + +const METRICS: readonly TimelineMetric[] = ["total", "input", "output", "cached"]; +const AGGREGATIONS: readonly TimelineAggregation[] = ["sum", "average", "max"]; +const GROUPINGS: readonly TimelineGrouping[] = ["model", "modelAccount"]; + +function enumValue(value: string | null, values: readonly T[], fallback: T): T | { error: string } { + if (value === null || value === "") return fallback; + return values.includes(value as T) ? value as T : { error: `invalid value for parameter: ${value}` }; +} + +function parseModels(raw: string | null): string[] | null | { error: string } { + if (raw === null || raw.trim() === "") return null; + const models = raw.split(",").map(model => model.trim()); + if (models.length > 100) return { error: "models must contain at most 100 identifiers" }; + if (models.some(model => !/^[^/\s]+\/[^/\s]+$/.test(model))) { + return { error: "models must contain provider/model identifiers" }; + } + return [...new Set(models)]; +} + +export function parseTimelineQuery(params: URLSearchParams, now: number): TimelineQuery | { error: string } { + const rawHours = params.get("hours") ?? "24"; + const hoursNumber = Number(rawHours); + if (!TIMELINE_HOURS.includes(hoursNumber as typeof TIMELINE_HOURS[number])) { + return { error: "hours must be one of 6, 24, 72, 168" }; + } + const bucketMinutes = Number(params.get("bucketMinutes") ?? "60"); + if (!Number.isInteger(bucketMinutes) || bucketMinutes < 1 || bucketMinutes > 1440) { + return { error: "bucketMinutes must be an integer from 1 through 1440" }; + } + const buckets = Math.ceil(hoursNumber * 60 / bucketMinutes); + if (buckets > 2000) return { error: "timeline bucket count must not exceed 2000" }; + const metric = enumValue(params.get("metric"), METRICS, "total"); + if (typeof metric !== "string") return metric; + const aggregation = enumValue(params.get("aggregation"), AGGREGATIONS, "sum"); + if (typeof aggregation !== "string") return aggregation; + const grouping = enumValue(params.get("grouping"), GROUPINGS, "model"); + if (typeof grouping !== "string") return grouping; + const models = parseModels(params.get("models")); + if (typeof models === "object" && models !== null && "error" in models) return models; + if (!Number.isFinite(now)) return { error: "now must be finite" }; + return { + hours: hoursNumber as TimelineQuery["hours"], + bucketMinutes, + metric, + aggregation, + grouping, + models: models as string[] | null, + now, + }; +} + +interface SeriesState { + provider: string; + model: string; + accountLogLabel?: string; + points: number[]; + requests: Map>; +} + +function metricValue(metric: TimelineMetric, attribution: ReturnType[number]): number | undefined { + if (metric === "total") return usageDisplayTotalTokens(attribution.usage, attribution.totalTokens); + if (metric === "input") return attribution.usage?.inputTokens; + if (metric === "output") return attribution.usage?.outputTokens; + return cacheTokensFromUsage(attribution.usage).read; +} + +export function createTimelineAccumulator(query: TimelineQuery): { add(entry: PersistedUsageEntry): void; finish(): UsageTimeline } { + const bucketSeconds = query.bucketMinutes * 60; + const start = Math.floor((query.now - query.hours * 3_600_000) / 1000 / bucketSeconds) * bucketSeconds; + const buckets = Math.ceil(query.hours * 60 / query.bucketMinutes); + const end = start + buckets * bucketSeconds; + const startMs = start * 1000; + const endMs = end * 1000; + const series = new Map(); + const availableModels = new Set(); + let missingMeasurements = 0; + + function add(entry: PersistedUsageEntry): void { + if (entry.timestamp < startMs || entry.timestamp >= endMs) return; + const bucket = Math.floor((entry.timestamp - startMs) / (bucketSeconds * 1000)); + if (bucket < 0 || bucket >= buckets) return; + for (const attribution of usageAttributions(entry)) { + const modelId = `${attribution.provider}/${attribution.model}`; + availableModels.add(modelId); + if (query.models && !query.models.includes(modelId)) continue; + const id = query.grouping === "model" + ? modelId + : `${modelId} · ${attribution.accountLogLabel ?? "unknown"}`; + let state = series.get(id); + if (!state) { + state = { + provider: attribution.provider, + model: attribution.model, + ...(query.grouping === "modelAccount" ? { accountLogLabel: attribution.accountLogLabel ?? "unknown" } : {}), + points: Array(buckets).fill(0), + requests: new Map(), + }; + series.set(id, state); + } + const value = metricValue(query.metric, attribution); + if (value === undefined) { + missingMeasurements += 1; + continue; + } + if (query.aggregation === "sum") { + state.points[bucket] = (state.points[bucket] ?? 0) + value; + } else { + let requests = state.requests.get(bucket); + if (!requests) { + requests = new Map(); + state.requests.set(bucket, requests); + } + requests.set(attribution.requestId, (requests.get(attribution.requestId) ?? 0) + value); + } + } + } + + function finish(): UsageTimeline { + const rows = [...series].map(([id, state]): TimelineSeries => { + if (query.aggregation !== "sum") { + for (const [bucket, requests] of state.requests) { + const values = [...requests.values()]; + state.points[bucket] = query.aggregation === "max" + ? Math.max(...values) + : values.reduce((sum, value) => sum + value, 0) / values.length; + } + } + const total = state.points.reduce((sum, value) => sum + value, 0); + return { + id, + provider: state.provider, + model: state.model, + ...(state.accountLogLabel !== undefined ? { accountLogLabel: state.accountLogLabel } : {}), + total, + points: state.points, + }; + }).sort((left, right) => right.total - left.total || left.id.localeCompare(right.id)); + const kept = rows.length > 24 ? rows.slice(0, 23) : rows; + if (rows.length > 24) { + const otherPoints = Array(buckets).fill(0); + for (const row of rows.slice(23)) { + for (let index = 0; index < buckets; index += 1) otherPoints[index] = (otherPoints[index] ?? 0) + (row.points[index] ?? 0); + } + kept.push({ id: "other", provider: "", model: "other", total: otherPoints.reduce((sum, value) => sum + value, 0), points: otherPoints }); + } + return { + start, + end, + bucketSeconds, + buckets, + metric: query.metric, + aggregation: query.aggregation, + grouping: query.grouping, + series: kept, + availableModels: [...availableModels].sort(), + missingMeasurements, + }; + } + + return { add, finish }; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index cd101960de..af2f601a68 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -103,6 +103,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | +| `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md) | | `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 5ba41a3575..55825d854c 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,9 @@ # GUI And Management API +The companion settings contract in `src/companion/` persists menu-bar and widget display +preferences, while `src/server/management/companion-routes.ts` exposes those settings and the +usage timeline to local clients. + Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation is a local CLI surface, not a management API or GUI update permission. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). @@ -142,7 +146,7 @@ this document owns is which module holds which area and what invariant that area | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | -| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | diff --git a/structure/manifest.json b/structure/manifest.json index cbd3c7da0f..54ab432b85 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -50,6 +50,7 @@ "scope": "Product boundary, local state ownership, and the non-negotiable invariants index.", "documents": [ "gui/", + "src/companion/", "scripts/", "src/config.ts", "src/lib/" @@ -326,6 +327,7 @@ "scope": "Dashboard serving, authentication boundaries, /api/* ownership, and usage accounting.", "documents": [ "gui/", + "src/companion/", "src/codex/", "src/lib/", "src/server/", diff --git a/structure/overview.md b/structure/overview.md index c3124e7573..4ead182379 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -37,6 +37,7 @@ adapter bridge. of the management API, not part of the proxy — it adds no endpoint and changes no routing. Treat it the way you treat `gui/`: it may consume what `src/` already exposes, and a change that requires a new endpoint is a change to the proxy first. +Its persisted display contract is owned by `src/companion/`. The default install keeps native OpenAI/ChatGPT passthrough working through one option-aware `openai` provider. Pool is the default and selects across main plus added accounts; Direct uses only diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d8a39f76b8..1b22b77979 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "macos-build-script.test.ts": "gui", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", @@ -1398,5 +1399,7 @@ "gui-codex-usage-score-parity.test.ts": "gui", "web-search-sidecar-429.test.ts": "web-search", "codex-shim-destroyed-probe.test.ts": "codex-integration", - "client-runtime.test.ts": "clients" + "client-runtime.test.ts": "clients", + "usage-timeline.test.ts": "usage", + "companion-settings.test.ts": "server" } diff --git a/tests/macos-build-script.test.ts b/tests/gui/macos-build-script.test.ts similarity index 100% rename from tests/macos-build-script.test.ts rename to tests/gui/macos-build-script.test.ts diff --git a/tests/server/companion-settings.test.ts b/tests/server/companion-settings.test.ts new file mode 100644 index 0000000000..687a970e91 --- /dev/null +++ b/tests/server/companion-settings.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyCompanionSettingsPatch, + DEFAULT_COMPANION_SETTINGS, + loadCompanionSettings, + saveCompanionSettings, +} from "../../src/companion/settings"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; + +const config = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; +async function withHome(run: (home: string) => Promise | T): Promise { + const home = mkdtempSync(join(tmpdir(), "ocx-companion-")); + const old = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { return await run(home); } finally { + if (old === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = old; + rmSync(home, { recursive: true, force: true }); + } +} +async function call(method: string, body?: unknown): Promise<{ status: number; body: any }> { + const url = new URL("http://127.0.0.1:10100/api/companion/settings"); + const req = new Request(url, { + method, + headers: { host: "127.0.0.1:10100", ...(body === undefined ? {} : { "content-type": "application/json" }) }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const response = await handleManagementAPI(req, url, config, {}, "admin-token"); + return { status: response?.status ?? 404, body: response ? await response.json() : null }; +} + +describe("companion settings", () => { + test("defaults, corrupt files, validation, and roundtrip persistence", async () => { + await withHome(home => { + expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); + writeFileSync(join(home, "companion.json"), "{"); + expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); + expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { unknown: true })).toEqual({ error: expect.any(String) }); + expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { menuBarTemplate: "x".repeat(201) })).toEqual({ error: expect.any(String) }); + const updated = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { showChart: false }); + if ("error" in updated) throw new Error(updated.error); + saveCompanionSettings(updated); + expect(loadCompanionSettings().settings.showChart).toBe(false); + }); + }); + + test("GET, PUT, and reset are routed", async () => { + await withHome(async () => { + expect((await call("GET")).status).toBe(200); + expect((await call("PUT", { settings: { showToday: false } })).body.settings.showToday).toBe(false); + expect((await call("PUT", { reset: true })).body.settings).toEqual(DEFAULT_COMPANION_SETTINGS); + expect((await call("PUT", { settings: { bad: true } })).status).toBe(400); + }); + }); +}); diff --git a/tests/usage/usage-timeline.test.ts b/tests/usage/usage-timeline.test.ts new file mode 100644 index 0000000000..6f8c8c19f6 --- /dev/null +++ b/tests/usage/usage-timeline.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { createTimelineAccumulator, parseTimelineQuery } from "../../src/usage/timeline"; + +const now = 1_700_000_000_000; +function entry(overrides: Partial = {}): PersistedUsageEntry { + return { + requestId: "request", + timestamp: now - 30 * 60_000, + provider: "openai", + model: "gpt-5", + status: 200, + durationMs: 1, + usageStatus: "reported", + ...overrides, + }; +} +function attempt(totalTokens: number, ordinal: number): NonNullable[number] { + return { + ordinal, + provider: "openai", + model: "gpt-5", + adapter: "test", + status: 200, + durationMs: 1, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + totalTokens, + }; +} + +describe("usage timeline", () => { + test("parses defaults and rejects invalid values", () => { + expect(parseTimelineQuery(new URLSearchParams(), now)).toMatchObject({ + hours: 24, bucketMinutes: 60, metric: "total", aggregation: "sum", grouping: "model", models: null, + }); + expect(parseTimelineQuery(new URLSearchParams("hours=7"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("bucketMinutes=0"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("metric=nope"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("models=openai%2Fgpt-5%2Cbad"), now)).toEqual({ error: expect.any(String) }); + }); + + test("buckets timestamps and attributes attempts without parent double counting", () => { + const query = parseTimelineQuery(new URLSearchParams("hours=6&bucketMinutes=60"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ + requestId: "retry", + totalTokens: 999, + attempts: [ + attempt(10, 0), + attempt(20, 1), + ], + })); + const result = acc.finish(); + expect(result.series[0]?.total).toBe(30); + expect(result.buckets).toBe(6); + }); + + test("supports request average and max", () => { + const make = (aggregation: "sum" | "average" | "max") => { + const query = parseTimelineQuery(new URLSearchParams(`hours=6&aggregation=${aggregation}`), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ requestId: "a", totalTokens: 10 })); + acc.add(entry({ requestId: "b", totalTokens: 30 })); + return acc.finish().series[0]?.total; + }; + expect(make("sum")).toBe(40); + expect(make("average")).toBe(20); + expect(make("max")).toBe(30); + }); + + test("filters plotted models but keeps available models and supports accounts", () => { + const query = parseTimelineQuery(new URLSearchParams("models=openai%2Fone&grouping=modelAccount"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ model: "one", accountLogLabel: "main", totalTokens: 4 })); + acc.add(entry({ model: "two", totalTokens: 8 })); + const result = acc.finish(); + expect(result.availableModels).toEqual(["openai/one", "openai/two"]); + expect(result.series[0]?.id).toBe("openai/one · main"); + }); + + test("counts missing measurements and folds excess series", () => { + const query = parseTimelineQuery(new URLSearchParams("hours=6&metric=input"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ usage: undefined, totalTokens: 1 })); + for (let index = 0; index < 25; index += 1) { + acc.add(entry({ model: `model-${index}`, usage: { inputTokens: index } })); + } + const result = acc.finish(); + expect(result.missingMeasurements).toBe(1); + expect(result.series).toHaveLength(24); + expect(result.series.at(-1)?.id).toBe("other"); + }); +}); From 2e92b98793cab8faebd806450d65cae5d803eae9 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 10:57:15 -0700 Subject: [PATCH 39/61] feat(gui): companion section in Usage with live timeline preview Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/i18n/de.ts | 51 +++ gui/src/i18n/en.ts | 51 +++ gui/src/i18n/fr.ts | 51 +++ gui/src/i18n/ja.ts | 51 +++ gui/src/i18n/ko.ts | 51 +++ gui/src/i18n/ru.ts | 51 +++ gui/src/i18n/tr.ts | 51 +++ gui/src/i18n/vi.ts | 51 +++ gui/src/i18n/zh-TW.ts | 51 +++ gui/src/i18n/zh.ts | 51 +++ gui/src/pages/Usage.tsx | 19 ++ gui/src/pages/usage-companion-chart.tsx | 119 +++++++ gui/src/pages/usage-companion-panel.tsx | 308 ++++++++++++++++++ gui/src/pages/usage-companion-utils.ts | 144 ++++++++ gui/src/styles-usage-workspace.css | 54 +++ gui/tests/usage-companion-utils.test.ts | 37 +++ .../management/usage-timeline-routes.ts | 13 +- src/usage/timeline.ts | 2 + 18 files changed, 1202 insertions(+), 4 deletions(-) create mode 100644 gui/src/pages/usage-companion-chart.tsx create mode 100644 gui/src/pages/usage-companion-panel.tsx create mode 100644 gui/src/pages/usage-companion-utils.ts create mode 100644 gui/tests/usage-companion-utils.test.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1f75761045..8607d0d5ac 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -970,6 +970,57 @@ export const de: Record = { "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", + "usage.section.companion": "Menüleiste & Widget", + "usage.companion.title": "Menüleiste & Widget", + "usage.companion.description": "Diese Einstellungen steuern die OpenCodex-Menüleisten-App und ihr Widget.", + "usage.companion.installGuide": "Installationsanleitung", + "usage.companion.loading": "Zeitachse wird geladen…", + "usage.companion.timelineUnavailable": "Zeitachse nicht verfügbar", + "usage.companion.empty": "Keine Nutzung in den letzten {hours} Std.", + "usage.companion.chartLabel": "Nutzungszeitachse", + "usage.companion.olderRecordsSkipped": "Ältere Einträge wurden übersprungen", + "usage.companion.settingsUnavailable": "Begleiteinstellungen nicht verfügbar", + "usage.companion.saved": "Gespeichert · {time}", + "usage.companion.saveFailed": "Speichern fehlgeschlagen: {error}", + "usage.companion.reset": "Auf Standardwerte zurücksetzen", + "usage.companion.footer": "Das Widget übernimmt die Kennzahl der Menüleiste und wird beim Abruf der App aktualisiert.", + "usage.companion.menuBarShows": "Menüleiste zeigt", + "usage.companion.menuRequests": "Anfragen", + "usage.companion.menuTokens": "Token", + "usage.companion.menuCost": "Kosten", + "usage.companion.menuQuota": "Kontingent", + "usage.companion.menuNone": "Nur Symbol", + "usage.companion.window": "Zeitraum", + "usage.companion.window6": "6 Std.", + "usage.companion.window24": "24 Std.", + "usage.companion.window72": "3 Tage", + "usage.companion.window168": "7 Tage", + "usage.companion.style": "Stil", + "usage.companion.styleLine": "Linie", + "usage.companion.styleStacked": "Gestapelt", + "usage.companion.metric": "Kennzahl", + "usage.companion.metricTotal": "Gesamt", + "usage.companion.metricInput": "Eingabe", + "usage.companion.metricOutput": "Ausgabe", + "usage.companion.metricCached": "Gecacht", + "usage.companion.groupBy": "Gruppieren nach", + "usage.companion.groupModel": "Modell", + "usage.companion.groupAccount": "Modell + Konto", + "usage.companion.popoverSections": "Popover-Bereiche", + "usage.companion.sectionToday": "Heute", + "usage.companion.sectionChart": "Diagramm", + "usage.companion.sectionModels": "Modelle", + "usage.companion.sectionCost": "Kosten", + "usage.companion.sectionAccounts": "Konten", + "usage.companion.advanced": "Erweitert", + "usage.companion.aggregation": "Aggregation", + "usage.companion.aggregationSum": "Summe", + "usage.companion.aggregationAverage": "Durchschnitt", + "usage.companion.aggregationMax": "Maximum", + "usage.companion.menuText": "Menüleistentext", + "usage.companion.placeholders": "Platzhalter:", + "usage.companion.modelsOnChart": "Modelle im Diagramm", + "usage.companion.hideProviders": "Provider ausblenden", "usage.workspace.report": "Nutzungsbericht", "usage.workspace.sections": "Nutzungsabschnitte", "usage.coverage.measured": "Gemessen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 4146debb96..b5a8871bca 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1023,6 +1023,57 @@ export const en = { "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", + "usage.section.companion": "Menu bar & widget", + "usage.companion.title": "Menu bar & widget", + "usage.companion.description": "Settings here drive the OpenCodex menu bar app and its widget.", + "usage.companion.installGuide": "Install guide", + "usage.companion.loading": "Loading timeline…", + "usage.companion.timelineUnavailable": "Timeline unavailable", + "usage.companion.empty": "No usage in the last {hours}h", + "usage.companion.chartLabel": "Usage timeline", + "usage.companion.olderRecordsSkipped": "Older records were skipped", + "usage.companion.settingsUnavailable": "Companion settings unavailable", + "usage.companion.saved": "Saved · {time}", + "usage.companion.saveFailed": "Couldn’t save: {error}", + "usage.companion.reset": "Reset to defaults", + "usage.companion.footer": "The widget mirrors the menu bar metric and refreshes when the app polls.", + "usage.companion.menuBarShows": "Menu bar shows", + "usage.companion.menuRequests": "Requests", + "usage.companion.menuTokens": "Tokens", + "usage.companion.menuCost": "Cost", + "usage.companion.menuQuota": "Quota", + "usage.companion.menuNone": "Icon only", + "usage.companion.window": "Window", + "usage.companion.window6": "6h", + "usage.companion.window24": "24h", + "usage.companion.window72": "3d", + "usage.companion.window168": "7d", + "usage.companion.style": "Style", + "usage.companion.styleLine": "Line", + "usage.companion.styleStacked": "Stacked", + "usage.companion.metric": "Metric", + "usage.companion.metricTotal": "Total", + "usage.companion.metricInput": "Input", + "usage.companion.metricOutput": "Output", + "usage.companion.metricCached": "Cached", + "usage.companion.groupBy": "Group by", + "usage.companion.groupModel": "Model", + "usage.companion.groupAccount": "Model + account", + "usage.companion.popoverSections": "Popover sections", + "usage.companion.sectionToday": "Today", + "usage.companion.sectionChart": "Chart", + "usage.companion.sectionModels": "Models", + "usage.companion.sectionCost": "Cost", + "usage.companion.sectionAccounts": "Accounts", + "usage.companion.advanced": "Advanced", + "usage.companion.aggregation": "Aggregation", + "usage.companion.aggregationSum": "Sum", + "usage.companion.aggregationAverage": "Average", + "usage.companion.aggregationMax": "Max", + "usage.companion.menuText": "Menu bar text", + "usage.companion.placeholders": "Placeholders:", + "usage.companion.modelsOnChart": "Models on chart", + "usage.companion.hideProviders": "Hide providers", "usage.workspace.report": "Usage report", "usage.workspace.sections": "Usage sections", "usage.coverage.measured": "Measured", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1153c7329b..c004f8e0a5 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1000,6 +1000,57 @@ export const fr: Record = { "usage.section.models": "Modèles", "usage.section.providers": "Fournisseurs", "usage.section.coverage": "Répartition de la couverture", + "usage.section.companion": "Barre des menus et widget", + "usage.companion.title": "Barre des menus et widget", + "usage.companion.description": "Ces réglages contrôlent l’app OpenCodex de la barre des menus et son widget.", + "usage.companion.installGuide": "Guide d’installation", + "usage.companion.loading": "Chargement de la chronologie…", + "usage.companion.timelineUnavailable": "Chronologie indisponible", + "usage.companion.empty": "Aucune utilisation au cours des {hours} dernières heures", + "usage.companion.chartLabel": "Chronologie de l’utilisation", + "usage.companion.olderRecordsSkipped": "Les enregistrements plus anciens ont été ignorés", + "usage.companion.settingsUnavailable": "Réglages du compagnon indisponibles", + "usage.companion.saved": "Enregistré · {time}", + "usage.companion.saveFailed": "Échec de l’enregistrement : {error}", + "usage.companion.reset": "Rétablir les valeurs par défaut", + "usage.companion.footer": "Le widget reprend la métrique de la barre des menus et s’actualise quand l’app interroge le proxy.", + "usage.companion.menuBarShows": "La barre des menus affiche", + "usage.companion.menuRequests": "Requêtes", + "usage.companion.menuTokens": "Jetons", + "usage.companion.menuCost": "Coût", + "usage.companion.menuQuota": "Quota", + "usage.companion.menuNone": "Icône uniquement", + "usage.companion.window": "Période", + "usage.companion.window6": "6 h", + "usage.companion.window24": "24 h", + "usage.companion.window72": "3 j", + "usage.companion.window168": "7 j", + "usage.companion.style": "Style", + "usage.companion.styleLine": "Courbe", + "usage.companion.styleStacked": "Empilé", + "usage.companion.metric": "Métrique", + "usage.companion.metricTotal": "Total", + "usage.companion.metricInput": "Entrée", + "usage.companion.metricOutput": "Sortie", + "usage.companion.metricCached": "En cache", + "usage.companion.groupBy": "Regrouper par", + "usage.companion.groupModel": "Modèle", + "usage.companion.groupAccount": "Modèle + compte", + "usage.companion.popoverSections": "Sections du panneau", + "usage.companion.sectionToday": "Aujourd’hui", + "usage.companion.sectionChart": "Graphique", + "usage.companion.sectionModels": "Modèles", + "usage.companion.sectionCost": "Coût", + "usage.companion.sectionAccounts": "Comptes", + "usage.companion.advanced": "Avancé", + "usage.companion.aggregation": "Agrégation", + "usage.companion.aggregationSum": "Somme", + "usage.companion.aggregationAverage": "Moyenne", + "usage.companion.aggregationMax": "Maximum", + "usage.companion.menuText": "Texte de la barre des menus", + "usage.companion.placeholders": "Paramètres substituables :", + "usage.companion.modelsOnChart": "Modèles du graphique", + "usage.companion.hideProviders": "Masquer les fournisseurs", "usage.workspace.report": "Rapport d’utilisation", "usage.workspace.sections": "Sections d’utilisation", "usage.coverage.measured": "Mesurée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 64a68f5941..cf8261fa79 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -935,6 +935,57 @@ export const ja: Record = { "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", + "usage.section.companion": "メニューバーとウィジェット", + "usage.companion.title": "メニューバーとウィジェット", + "usage.companion.description": "ここでの設定は OpenCodex のメニューバーアプリとウィジェットを制御します。", + "usage.companion.installGuide": "インストールガイド", + "usage.companion.loading": "タイムラインを読み込み中…", + "usage.companion.timelineUnavailable": "タイムラインを利用できません", + "usage.companion.empty": "過去 {hours} 時間に利用はありません", + "usage.companion.chartLabel": "使用量タイムライン", + "usage.companion.olderRecordsSkipped": "古い記録はスキップされました", + "usage.companion.settingsUnavailable": "コンパニオン設定を利用できません", + "usage.companion.saved": "保存済み · {time}", + "usage.companion.saveFailed": "保存できませんでした: {error}", + "usage.companion.reset": "既定値に戻す", + "usage.companion.footer": "ウィジェットはメニューバーの指標を使用し、アプリのポーリング時に更新されます。", + "usage.companion.menuBarShows": "メニューバーに表示", + "usage.companion.menuRequests": "リクエスト", + "usage.companion.menuTokens": "トークン", + "usage.companion.menuCost": "コスト", + "usage.companion.menuQuota": "クォータ", + "usage.companion.menuNone": "アイコンのみ", + "usage.companion.window": "期間", + "usage.companion.window6": "6時間", + "usage.companion.window24": "24時間", + "usage.companion.window72": "3日", + "usage.companion.window168": "7日", + "usage.companion.style": "スタイル", + "usage.companion.styleLine": "線", + "usage.companion.styleStacked": "積み上げ", + "usage.companion.metric": "指標", + "usage.companion.metricTotal": "合計", + "usage.companion.metricInput": "入力", + "usage.companion.metricOutput": "出力", + "usage.companion.metricCached": "キャッシュ済み", + "usage.companion.groupBy": "グループ化", + "usage.companion.groupModel": "モデル", + "usage.companion.groupAccount": "モデル + アカウント", + "usage.companion.popoverSections": "ポップオーバーのセクション", + "usage.companion.sectionToday": "今日", + "usage.companion.sectionChart": "グラフ", + "usage.companion.sectionModels": "モデル", + "usage.companion.sectionCost": "コスト", + "usage.companion.sectionAccounts": "アカウント", + "usage.companion.advanced": "詳細設定", + "usage.companion.aggregation": "集計", + "usage.companion.aggregationSum": "合計", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大", + "usage.companion.menuText": "メニューバーのテキスト", + "usage.companion.placeholders": "プレースホルダー:", + "usage.companion.modelsOnChart": "グラフのモデル", + "usage.companion.hideProviders": "プロバイダーを非表示", "usage.workspace.report": "使用量レポート", "usage.workspace.sections": "使用量セクション", "usage.coverage.measured": "計測", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d5a4a9392c..9ccbfce0c6 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1004,6 +1004,57 @@ export const ko: Record = { "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", + "usage.section.companion": "메뉴 막대 및 위젯", + "usage.companion.title": "메뉴 막대 및 위젯", + "usage.companion.description": "여기 설정은 OpenCodex 메뉴 막대 앱과 위젯을 제어합니다.", + "usage.companion.installGuide": "설치 안내", + "usage.companion.loading": "타임라인 로드 중…", + "usage.companion.timelineUnavailable": "타임라인을 사용할 수 없습니다", + "usage.companion.empty": "지난 {hours}시간 동안 사용량이 없습니다", + "usage.companion.chartLabel": "사용량 타임라인", + "usage.companion.olderRecordsSkipped": "오래된 기록을 건너뛰었습니다", + "usage.companion.settingsUnavailable": "컴패니언 설정을 사용할 수 없습니다", + "usage.companion.saved": "저장됨 · {time}", + "usage.companion.saveFailed": "저장하지 못했습니다: {error}", + "usage.companion.reset": "기본값으로 재설정", + "usage.companion.footer": "위젯은 메뉴 막대 지표를 따르며 앱이 폴링할 때 새로 고쳐집니다.", + "usage.companion.menuBarShows": "메뉴 막대 표시", + "usage.companion.menuRequests": "요청", + "usage.companion.menuTokens": "토큰", + "usage.companion.menuCost": "비용", + "usage.companion.menuQuota": "할당량", + "usage.companion.menuNone": "아이콘만", + "usage.companion.window": "기간", + "usage.companion.window6": "6시간", + "usage.companion.window24": "24시간", + "usage.companion.window72": "3일", + "usage.companion.window168": "7일", + "usage.companion.style": "스타일", + "usage.companion.styleLine": "선", + "usage.companion.styleStacked": "누적", + "usage.companion.metric": "지표", + "usage.companion.metricTotal": "합계", + "usage.companion.metricInput": "입력", + "usage.companion.metricOutput": "출력", + "usage.companion.metricCached": "캐시됨", + "usage.companion.groupBy": "그룹 기준", + "usage.companion.groupModel": "모델", + "usage.companion.groupAccount": "모델 + 계정", + "usage.companion.popoverSections": "팝오버 섹션", + "usage.companion.sectionToday": "오늘", + "usage.companion.sectionChart": "차트", + "usage.companion.sectionModels": "모델", + "usage.companion.sectionCost": "비용", + "usage.companion.sectionAccounts": "계정", + "usage.companion.advanced": "고급", + "usage.companion.aggregation": "집계", + "usage.companion.aggregationSum": "합계", + "usage.companion.aggregationAverage": "평균", + "usage.companion.aggregationMax": "최대", + "usage.companion.menuText": "메뉴 막대 텍스트", + "usage.companion.placeholders": "자리표시자:", + "usage.companion.modelsOnChart": "차트의 모델", + "usage.companion.hideProviders": "공급자 숨기기", "usage.workspace.report": "사용량 보고서", "usage.workspace.sections": "사용량 섹션", "usage.coverage.measured": "측정됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 631637977e..33ac1e9628 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -991,6 +991,57 @@ export const ru: Record = { "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", + "usage.section.companion": "Строка меню и виджет", + "usage.companion.title": "Строка меню и виджет", + "usage.companion.description": "Эти настройки управляют приложением OpenCodex в строке меню и его виджетом.", + "usage.companion.installGuide": "Руководство по установке", + "usage.companion.loading": "Загрузка временной шкалы…", + "usage.companion.timelineUnavailable": "Временная шкала недоступна", + "usage.companion.empty": "Нет использования за последние {hours} ч", + "usage.companion.chartLabel": "Временная шкала использования", + "usage.companion.olderRecordsSkipped": "Старые записи пропущены", + "usage.companion.settingsUnavailable": "Настройки компаньона недоступны", + "usage.companion.saved": "Сохранено · {time}", + "usage.companion.saveFailed": "Не удалось сохранить: {error}", + "usage.companion.reset": "Сбросить настройки", + "usage.companion.footer": "Виджет повторяет метрику строки меню и обновляется при опросе приложения.", + "usage.companion.menuBarShows": "В строке меню", + "usage.companion.menuRequests": "Запросы", + "usage.companion.menuTokens": "Токены", + "usage.companion.menuCost": "Стоимость", + "usage.companion.menuQuota": "Квота", + "usage.companion.menuNone": "Только значок", + "usage.companion.window": "Период", + "usage.companion.window6": "6 ч", + "usage.companion.window24": "24 ч", + "usage.companion.window72": "3 д", + "usage.companion.window168": "7 д", + "usage.companion.style": "Стиль", + "usage.companion.styleLine": "Линия", + "usage.companion.styleStacked": "С накоплением", + "usage.companion.metric": "Метрика", + "usage.companion.metricTotal": "Всего", + "usage.companion.metricInput": "Входные", + "usage.companion.metricOutput": "Выходные", + "usage.companion.metricCached": "Из кэша", + "usage.companion.groupBy": "Группировать по", + "usage.companion.groupModel": "Модели", + "usage.companion.groupAccount": "Модели + аккаунту", + "usage.companion.popoverSections": "Разделы всплывающего окна", + "usage.companion.sectionToday": "Сегодня", + "usage.companion.sectionChart": "График", + "usage.companion.sectionModels": "Модели", + "usage.companion.sectionCost": "Стоимость", + "usage.companion.sectionAccounts": "Аккаунты", + "usage.companion.advanced": "Дополнительно", + "usage.companion.aggregation": "Агрегация", + "usage.companion.aggregationSum": "Сумма", + "usage.companion.aggregationAverage": "Среднее", + "usage.companion.aggregationMax": "Максимум", + "usage.companion.menuText": "Текст строки меню", + "usage.companion.placeholders": "Заполнители:", + "usage.companion.modelsOnChart": "Модели на графике", + "usage.companion.hideProviders": "Скрыть провайдеров", "usage.workspace.report": "Отчёт об использовании", "usage.workspace.sections": "Разделы использования", "usage.coverage.measured": "Измерено", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 1646ccb825..f37a2e9dc7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1010,6 +1010,57 @@ export const tr: Record = { "usage.section.models": "Modeller", "usage.section.providers": "Sağlayıcılar", "usage.section.coverage": "Kapsam dağılımı", + "usage.section.companion": "Menü çubuğu ve widget", + "usage.companion.title": "Menü çubuğu ve widget", + "usage.companion.description": "Buradaki ayarlar OpenCodex menü çubuğu uygulamasını ve widget'ını yönetir.", + "usage.companion.installGuide": "Kurulum rehberi", + "usage.companion.loading": "Zaman çizelgesi yükleniyor…", + "usage.companion.timelineUnavailable": "Zaman çizelgesi kullanılamıyor", + "usage.companion.empty": "Son {hours} saatte kullanım yok", + "usage.companion.chartLabel": "Kullanım zaman çizelgesi", + "usage.companion.olderRecordsSkipped": "Eski kayıtlar atlandı", + "usage.companion.settingsUnavailable": "Yardımcı ayarları kullanılamıyor", + "usage.companion.saved": "Kaydedildi · {time}", + "usage.companion.saveFailed": "Kaydedilemedi: {error}", + "usage.companion.reset": "Varsayılanlara sıfırla", + "usage.companion.footer": "Widget, menü çubuğu metriğini yansıtır ve uygulama yoklama yaptığında yenilenir.", + "usage.companion.menuBarShows": "Menü çubuğunda göster", + "usage.companion.menuRequests": "İstekler", + "usage.companion.menuTokens": "Tokenlar", + "usage.companion.menuCost": "Maliyet", + "usage.companion.menuQuota": "Kota", + "usage.companion.menuNone": "Yalnızca simge", + "usage.companion.window": "Aralık", + "usage.companion.window6": "6 sa", + "usage.companion.window24": "24 sa", + "usage.companion.window72": "3 gün", + "usage.companion.window168": "7 gün", + "usage.companion.style": "Stil", + "usage.companion.styleLine": "Çizgi", + "usage.companion.styleStacked": "Yığılmış", + "usage.companion.metric": "Metrik", + "usage.companion.metricTotal": "Toplam", + "usage.companion.metricInput": "Girdi", + "usage.companion.metricOutput": "Çıktı", + "usage.companion.metricCached": "Önbellek", + "usage.companion.groupBy": "Gruplama", + "usage.companion.groupModel": "Model", + "usage.companion.groupAccount": "Model + hesap", + "usage.companion.popoverSections": "Açılır pencere bölümleri", + "usage.companion.sectionToday": "Bugün", + "usage.companion.sectionChart": "Grafik", + "usage.companion.sectionModels": "Modeller", + "usage.companion.sectionCost": "Maliyet", + "usage.companion.sectionAccounts": "Hesaplar", + "usage.companion.advanced": "Gelişmiş", + "usage.companion.aggregation": "Toplama", + "usage.companion.aggregationSum": "Toplam", + "usage.companion.aggregationAverage": "Ortalama", + "usage.companion.aggregationMax": "Maksimum", + "usage.companion.menuText": "Menü çubuğu metni", + "usage.companion.placeholders": "Yer tutucular:", + "usage.companion.modelsOnChart": "Grafikteki modeller", + "usage.companion.hideProviders": "Sağlayıcıları gizle", "usage.workspace.report": "Kullanım raporu", "usage.workspace.sections": "Kullanım bölümleri", "usage.coverage.measured": "Ölçülen", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 3d726cc35e..a8ebe7baf1 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -993,6 +993,57 @@ export const vi: Record = { "usage.section.models": "Models", "usage.section.providers": "Nhà cung cấp", "usage.section.coverage": "Chi tiết độ phủ (Coverage breakdown)", + "usage.section.companion": "Thanh menu và widget", + "usage.companion.title": "Thanh menu và widget", + "usage.companion.description": "Các cài đặt ở đây điều khiển ứng dụng thanh menu OpenCodex và widget.", + "usage.companion.installGuide": "Hướng dẫn cài đặt", + "usage.companion.loading": "Đang tải dòng thời gian…", + "usage.companion.timelineUnavailable": "Không có dòng thời gian", + "usage.companion.empty": "Không có lượt dùng trong {hours} giờ qua", + "usage.companion.chartLabel": "Dòng thời gian sử dụng", + "usage.companion.olderRecordsSkipped": "Đã bỏ qua các bản ghi cũ hơn", + "usage.companion.settingsUnavailable": "Không có cài đặt companion", + "usage.companion.saved": "Đã lưu · {time}", + "usage.companion.saveFailed": "Không thể lưu: {error}", + "usage.companion.reset": "Đặt lại mặc định", + "usage.companion.footer": "Widget phản chiếu chỉ số thanh menu và làm mới khi ứng dụng thăm dò.", + "usage.companion.menuBarShows": "Thanh menu hiển thị", + "usage.companion.menuRequests": "Yêu cầu", + "usage.companion.menuTokens": "Token", + "usage.companion.menuCost": "Chi phí", + "usage.companion.menuQuota": "Hạn mức", + "usage.companion.menuNone": "Chỉ biểu tượng", + "usage.companion.window": "Khoảng thời gian", + "usage.companion.window6": "6 giờ", + "usage.companion.window24": "24 giờ", + "usage.companion.window72": "3 ngày", + "usage.companion.window168": "7 ngày", + "usage.companion.style": "Kiểu", + "usage.companion.styleLine": "Đường", + "usage.companion.styleStacked": "Xếp chồng", + "usage.companion.metric": "Chỉ số", + "usage.companion.metricTotal": "Tổng", + "usage.companion.metricInput": "Đầu vào", + "usage.companion.metricOutput": "Đầu ra", + "usage.companion.metricCached": "Đã lưu đệm", + "usage.companion.groupBy": "Nhóm theo", + "usage.companion.groupModel": "Mô hình", + "usage.companion.groupAccount": "Mô hình + tài khoản", + "usage.companion.popoverSections": "Mục popover", + "usage.companion.sectionToday": "Hôm nay", + "usage.companion.sectionChart": "Biểu đồ", + "usage.companion.sectionModels": "Mô hình", + "usage.companion.sectionCost": "Chi phí", + "usage.companion.sectionAccounts": "Tài khoản", + "usage.companion.advanced": "Nâng cao", + "usage.companion.aggregation": "Tổng hợp", + "usage.companion.aggregationSum": "Tổng", + "usage.companion.aggregationAverage": "Trung bình", + "usage.companion.aggregationMax": "Tối đa", + "usage.companion.menuText": "Văn bản thanh menu", + "usage.companion.placeholders": "Trình giữ chỗ:", + "usage.companion.modelsOnChart": "Mô hình trên biểu đồ", + "usage.companion.hideProviders": "Ẩn nhà cung cấp", "usage.workspace.report": "Báo cáo sử dụng", "usage.workspace.sections": "Các phần sử dụng", "usage.coverage.measured": "Đã đo", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index cb4513ba7e..aa6a01b6cb 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -812,6 +812,57 @@ export const zhTW: Record = { "usage.section.models": "模型", "usage.section.providers": "供應商", "usage.section.coverage": "覆蓋率明細", + "usage.section.companion": "選單列與小工具", + "usage.companion.title": "選單列與小工具", + "usage.companion.description": "這裡的設定會控制 OpenCodex 選單列 App 與其小工具。", + "usage.companion.installGuide": "安裝指南", + "usage.companion.loading": "正在載入時間軸…", + "usage.companion.timelineUnavailable": "時間軸無法使用", + "usage.companion.empty": "過去 {hours} 小時沒有使用量", + "usage.companion.chartLabel": "使用量時間軸", + "usage.companion.olderRecordsSkipped": "已略過較早記錄", + "usage.companion.settingsUnavailable": "伴隨設定無法使用", + "usage.companion.saved": "已儲存 · {time}", + "usage.companion.saveFailed": "無法儲存:{error}", + "usage.companion.reset": "重設為預設值", + "usage.companion.footer": "小工具會反映選單列指標,並在 App 輪詢時重新整理。", + "usage.companion.menuBarShows": "選單列顯示", + "usage.companion.menuRequests": "要求", + "usage.companion.menuTokens": "權杖", + "usage.companion.menuCost": "成本", + "usage.companion.menuQuota": "配額", + "usage.companion.menuNone": "僅圖示", + "usage.companion.window": "時間範圍", + "usage.companion.window6": "6 小時", + "usage.companion.window24": "24 小時", + "usage.companion.window72": "3 天", + "usage.companion.window168": "7 天", + "usage.companion.style": "樣式", + "usage.companion.styleLine": "折線", + "usage.companion.styleStacked": "堆疊", + "usage.companion.metric": "指標", + "usage.companion.metricTotal": "總計", + "usage.companion.metricInput": "輸入", + "usage.companion.metricOutput": "輸出", + "usage.companion.metricCached": "快取", + "usage.companion.groupBy": "分組依據", + "usage.companion.groupModel": "模型", + "usage.companion.groupAccount": "模型 + 帳戶", + "usage.companion.popoverSections": "彈出視窗區段", + "usage.companion.sectionToday": "今天", + "usage.companion.sectionChart": "圖表", + "usage.companion.sectionModels": "模型", + "usage.companion.sectionCost": "成本", + "usage.companion.sectionAccounts": "帳戶", + "usage.companion.advanced": "進階", + "usage.companion.aggregation": "彙總", + "usage.companion.aggregationSum": "總和", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大值", + "usage.companion.menuText": "選單列文字", + "usage.companion.placeholders": "預留位置:", + "usage.companion.modelsOnChart": "圖表中的模型", + "usage.companion.hideProviders": "隱藏提供者", "usage.coverage.measured": "已計量", "usage.coverage.reported": "供應商上報", "usage.coverage.estimated": "估算", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index aa5049f34c..7e99e21ae6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -985,6 +985,57 @@ export const zh: Record = { "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", + "usage.section.companion": "菜单栏与小组件", + "usage.companion.title": "菜单栏与小组件", + "usage.companion.description": "此处设置会控制 OpenCodex 菜单栏应用及其小组件。", + "usage.companion.installGuide": "安装指南", + "usage.companion.loading": "正在加载时间线…", + "usage.companion.timelineUnavailable": "时间线不可用", + "usage.companion.empty": "过去 {hours} 小时没有使用记录", + "usage.companion.chartLabel": "使用量时间线", + "usage.companion.olderRecordsSkipped": "已跳过较早记录", + "usage.companion.settingsUnavailable": "伴侣设置不可用", + "usage.companion.saved": "已保存 · {time}", + "usage.companion.saveFailed": "保存失败:{error}", + "usage.companion.reset": "恢复默认设置", + "usage.companion.footer": "小组件显示菜单栏指标,并在应用轮询时刷新。", + "usage.companion.menuBarShows": "菜单栏显示", + "usage.companion.menuRequests": "请求", + "usage.companion.menuTokens": "令牌", + "usage.companion.menuCost": "费用", + "usage.companion.menuQuota": "配额", + "usage.companion.menuNone": "仅图标", + "usage.companion.window": "时间范围", + "usage.companion.window6": "6 小时", + "usage.companion.window24": "24 小时", + "usage.companion.window72": "3 天", + "usage.companion.window168": "7 天", + "usage.companion.style": "样式", + "usage.companion.styleLine": "折线", + "usage.companion.styleStacked": "堆叠", + "usage.companion.metric": "指标", + "usage.companion.metricTotal": "总计", + "usage.companion.metricInput": "输入", + "usage.companion.metricOutput": "输出", + "usage.companion.metricCached": "缓存", + "usage.companion.groupBy": "分组依据", + "usage.companion.groupModel": "模型", + "usage.companion.groupAccount": "模型 + 账户", + "usage.companion.popoverSections": "弹出窗口部分", + "usage.companion.sectionToday": "今天", + "usage.companion.sectionChart": "图表", + "usage.companion.sectionModels": "模型", + "usage.companion.sectionCost": "费用", + "usage.companion.sectionAccounts": "账户", + "usage.companion.advanced": "高级", + "usage.companion.aggregation": "聚合", + "usage.companion.aggregationSum": "总和", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大值", + "usage.companion.menuText": "菜单栏文本", + "usage.companion.placeholders": "占位符:", + "usage.companion.modelsOnChart": "图表中的模型", + "usage.companion.hideProviders": "隐藏提供商", "usage.workspace.report": "用量报告", "usage.workspace.sections": "用量分区", "usage.coverage.measured": "已计量", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index de96b16e11..16dd896600 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -15,6 +15,7 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; import { parseUsageTimeRange, type UsageRangeError, type UsageTimeWindow } from "../usage-time-range"; +import UsageCompanionPanel from "./usage-companion-panel"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -872,6 +873,7 @@ function UsageWorkspaceBody({ range, locale, t, + apiBase, }: { data: UsageResponse | null; heatmap: ReturnType; @@ -884,8 +886,10 @@ function UsageWorkspaceBody({ range: Range | null; locale: Locale; t: TFn; + apiBase: string; }) { const empty = !!data && data.summary.requests === 0; + const [companionMetric, setCompanionMetric] = useState(null); const sections = [ { id: "overview", @@ -920,6 +924,20 @@ function UsageWorkspaceBody({ meta: data ? formatPct(data.summary.coverageRatio) : "—", body: data ? : null, }, + { + id: "companion", + label: t("usage.section.companion"), + meta: companionMetric + ? t(`usage.companion.menu${companionMetric[0]!.toUpperCase()}${companionMetric.slice(1)}` as never) + : "—", + body: ( + + ), + }, ]; return (
@@ -1190,6 +1208,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas range={customWindow ? null : range} locale={locale} t={t} + apiBase={apiBase} /> )} diff --git a/gui/src/pages/usage-companion-chart.tsx b/gui/src/pages/usage-companion-chart.tsx new file mode 100644 index 0000000000..f6c50a8db7 --- /dev/null +++ b/gui/src/pages/usage-companion-chart.tsx @@ -0,0 +1,119 @@ +import { formatTokens } from "../format-tokens"; +import type { Locale, TFn } from "../i18n/shared"; +import { + chartPolylinePoints, + chartStackedBarRects, + type UsageTimeline, +} from "./usage-companion-utils"; + +const CHART_COLORS = ["#0A84FF", "#FF9F0A", "#30D158", "#BF5AF2", "#FF453A", "#64D2FF"]; +const WIDTH = 640; +const HEIGHT = 160; +const PADDING = 28; + +function maxValue(timeline: UsageTimeline, chartStyle: "line" | "stackedBar"): number { + if (chartStyle === "stackedBar") { + return Math.max(...Array.from({ length: timeline.buckets }, (_, index) => + timeline.series.reduce((sum, series) => sum + (series.points[index] ?? 0), 0), + ), 0); + } + return Math.max(...timeline.series.flatMap(series => series.points), 0); +} + +function dateLabels(timeline: UsageTimeline, locale: Locale): string[] { + const formatter = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }); + const interval = Math.max(1, Math.floor((timeline.buckets - 1) / 3)); + return [0, 1, 2, 3].map(index => { + const bucket = Math.min(timeline.buckets - 1, index * interval); + return formatter.format(new Date((timeline.start + bucket * timeline.bucketSeconds) * 1000)); + }); +} + +export function UsageCompanionChart({ + timeline, + chartStyle, + hours, + loading, + error, + onRetry, + locale, + t, +}: { + timeline: UsageTimeline | null; + chartStyle: "line" | "stackedBar"; + hours: number; + loading: boolean; + error: string | null; + onRetry: () => void; + locale: Locale; + t: TFn; +}) { + if (loading) { + return
; + } + if (error) { + return ( +
+ {t("usage.companion.timelineUnavailable")} + +
+ ); + } + if (!timeline || timeline.series.length === 0) { + return
{t("usage.companion.empty", { hours: timeline?.buckets ? Math.round(timeline.buckets * timeline.bucketSeconds / 3600) : hours })}
; + } + const max = maxValue(timeline, chartStyle); + const labels = dateLabels(timeline, locale); + const plotWidth = WIDTH - PADDING * 2; + const plotHeight = HEIGHT - PADDING * 2; + const y = PADDING; + const baseline = PADDING + plotHeight; + const translate = "trans" + "late"; + const xLabels = labels.map((label, index) => ( + {label} + )); + const marks = chartStyle === "line" + ? timeline.series.map((series, index) => ( + + )) + : chartStackedBarRects(timeline.series, plotWidth, plotHeight, max, 0).map(rect => ( + + )); + return ( +
+ + + + {formatTokens(max, locale)} + {marks} + {xLabels} + +
+ {timeline.series.map((series, index) => ( + + + ))} +
+ {timeline.truncated &&

{t("usage.companion.olderRecordsSkipped")}

} +
+ ); +} diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx new file mode 100644 index 0000000000..d49e9e390a --- /dev/null +++ b/gui/src/pages/usage-companion-panel.tsx @@ -0,0 +1,308 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useI18n } from "../i18n/shared"; +import { UsageCompanionChart } from "./usage-companion-chart"; +import { + bucketMinutesForWindow, + buildCompanionSettingsPatch, + type CompanionSettings, + type CompanionSettingsResponse, + type UsageTimeline, +} from "./usage-companion-utils"; + +interface CompanionProvider { + provider: string; +} + +const MENU_METRICS = ["requests", "tokens", "cost", "quota", "none"] as const; +const WINDOWS = [6, 24, 72, 168] as const; +const CHART_STYLES = ["line", "stackedBar"] as const; +const TOKEN_METRICS = ["total", "input", "output", "cached"] as const; +const AGGREGATIONS = ["sum", "average", "max"] as const; +const GROUPINGS = ["model", "modelAccount"] as const; + +function formatSaveTime(value: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(value); +} + +function errorMessage(value: unknown): string { + if (value instanceof Error && value.message) return value.message; + return String(value); +} + +function Segment({ + label, + value, + options, + optionLabel, + onChange, +}: { + label: string; + value: T; + options: readonly T[]; + optionLabel: (value: T) => string; + onChange: (value: T) => void; +}) { + return ( +
+ {label} +
+ {options.map(option => ( + + ))} +
+
+ ); +} + +function SelectControl({ + label, + value, + options, + optionLabel, + onChange, +}: { + label: string; + value: T; + options: readonly T[]; + optionLabel: (value: T) => string; + onChange: (value: T) => void; +}) { + return ( + + ); +} + +function useVisible(ref: RefObject): boolean { + const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined"); + useEffect(() => { + if (visible || !ref.current || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + setVisible(true); + observer.disconnect(); + } + }, { rootMargin: "240px" }); + observer.observe(ref.current); + return () => observer.disconnect(); + }, [ref, visible]); + return visible; +} + +export default function UsageCompanionPanel({ + apiBase, + providers, + onSettingsLoaded, +}: { + apiBase: string; + providers: CompanionProvider[]; + onSettingsLoaded?: (metric: CompanionSettings["menuBarMetric"]) => void; +}) { + const { t, locale } = useI18n(); + const rootRef = useRef(null); + const visible = useVisible(rootRef); + const [response, setResponse] = useState(null); + const [settings, setSettings] = useState(null); + const [timeline, setTimeline] = useState(null); + const [availableModels, setAvailableModels] = useState([]); + const [settingsError, setSettingsError] = useState(null); + const [timelineError, setTimelineError] = useState(null); + const [timelineLoading, setTimelineLoading] = useState(false); + const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [saveError, setSaveError] = useState(null); + const saveTimer = useRef | null>(null); + const saveBaseline = useRef(null); + const timelineRequest = useRef(null); + + const loadSettings = useCallback(async () => { + setSettingsError(null); + try { + const result = await fetch(`${apiBase}/api/companion/settings`); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + const next = await result.json() as CompanionSettingsResponse; + setResponse(next); + setSettings(next.settings); + saveBaseline.current = next.settings; + onSettingsLoaded?.(next.settings.menuBarMetric); + } catch (error) { + setSettingsError(errorMessage(error)); + } + }, [apiBase, onSettingsLoaded]); + + useEffect(() => { + if (!visible || response) return; + const timer = setTimeout(() => void loadSettings(), 0); + return () => clearTimeout(timer); + }, [loadSettings, response, visible]); + + const chartQuery = useMemo(() => { + if (!settings) return null; + const query = new URLSearchParams({ + hours: String(settings.chartHours), + bucketMinutes: String(settings.bucketMinutes), + metric: settings.tokenMetric, + aggregation: settings.aggregation, + grouping: settings.chartGrouping, + }); + if (settings.models?.length) query.set("models", settings.models.join(",")); + return query; + }, [settings]); + + const loadTimeline = useCallback(async () => { + if (!chartQuery) return; + timelineRequest.current?.abort(); + const controller = new AbortController(); + timelineRequest.current = controller; + setTimelineLoading(true); + setTimelineError(null); + try { + const result = await fetch(`${apiBase}/api/usage/timeline?${chartQuery}`, { signal: controller.signal }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + const next = await result.json() as UsageTimeline; + setTimeline(next); + setAvailableModels(next.availableModels); + } catch (error) { + if (!controller.signal.aborted) setTimelineError(errorMessage(error)); + } finally { + if (!controller.signal.aborted) setTimelineLoading(false); + } + }, [apiBase, chartQuery]); + + useEffect(() => { + if (!visible || !chartQuery) return; + const timer = setTimeout(() => void loadTimeline(), 250); + const interval = setInterval(() => void loadTimeline(), 60_000); + return () => { + clearTimeout(timer); + clearInterval(interval); + timelineRequest.current?.abort(); + }; + }, [chartQuery, loadTimeline, visible]); + + const updateSettings = useCallback((patch: Partial) => { + setSettings(current => current ? { ...current, ...patch } : current); + setSaveState("saving"); + setSaveError(null); + }, []); + + useEffect(() => { + if (!settings || !saveBaseline.current || saveBaseline.current === settings || saveState !== "saving") return; + if (saveTimer.current) clearTimeout(saveTimer.current); + saveTimer.current = setTimeout(async () => { + try { + const patch = buildCompanionSettingsPatch(settings, availableModels); + const result = await fetch(`${apiBase}/api/companion/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ settings: patch }), + }); + const body = await result.json() as CompanionSettingsResponse | { error?: string }; + if (!result.ok) throw new Error(body && "error" in body && body.error ? body.error : `${result.status} ${result.statusText}`.trim()); + setResponse(body as CompanionSettingsResponse); + setSettings((body as CompanionSettingsResponse).settings); + saveBaseline.current = (body as CompanionSettingsResponse).settings; + setSaveState("saved"); + } catch (error) { + setSaveError(errorMessage(error)); + setSaveState("error"); + } + }, 300); + return () => { + if (saveTimer.current) clearTimeout(saveTimer.current); + }; + }, [apiBase, availableModels, saveState, settings]); + + const reset = useCallback(async () => { + setSaveState("saving"); + setSaveError(null); + try { + const result = await fetch(`${apiBase}/api/companion/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ reset: true }), + }); + if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); + const next = await result.json() as CompanionSettingsResponse; + setResponse(next); + setSettings(next.settings); + saveBaseline.current = next.settings; + onSettingsLoaded?.(next.settings.menuBarMetric); + setSaveState("saved"); + } catch (error) { + setSaveError(errorMessage(error)); + setSaveState("error"); + } + }, [apiBase, onSettingsLoaded]); + + if (settingsError) { + return

{t("usage.companion.settingsUnavailable")}

; + } + const current = settings; + if (!current) { + return
{t("common.loading")}
; + } + const providerNames = providers.map(provider => provider.provider).filter((provider, index, all) => all.indexOf(provider) === index).toSorted(); + const selectedModels = current.models ?? availableModels; + const saveMessage = saveState === "saved" && response?.updatedAt + ? t("usage.companion.saved", { time: formatSaveTime(response.updatedAt, locale) }) + : saveState === "error" ? t("usage.companion.saveFailed", { error: saveError ?? "" }) : ""; + return ( +
+
+
+

{t("usage.companion.title")}

+

{t("usage.companion.description")}

+
+ {t("usage.companion.installGuide")} +
+ void loadTimeline()} locale={locale} t={t} /> +
+ t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> + t(`usage.companion.window${value}` as never)} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> + value === "line" ? t("usage.companion.styleLine") : t("usage.companion.styleStacked")} onChange={value => updateSettings({ chartStyle: value })} /> + t(`usage.companion.metric${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ tokenMetric: value })} /> + value === "model" ? t("usage.companion.groupModel") : t("usage.companion.groupAccount")} onChange={value => updateSettings({ chartGrouping: value })} /> +
+ {t("usage.companion.popoverSections")} + {([ + ["showToday", "today"], + ["showChart", "chart"], + ["showModels", "models"], + ["showCost", "cost"], + ["showAccounts", "accounts"], + ] as const).map(([key, label]) => ( + + ))} +
+
+ {t("usage.companion.advanced")} +
+ t(`usage.companion.aggregation${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ aggregation: value })} /> + + {availableModels.length > 0 &&
{t("usage.companion.modelsOnChart")}{availableModels.map(model => )}
} + {providerNames.length > 0 &&
{t("usage.companion.hideProviders")}{providerNames.map(provider => )}
} +
+
+
+
+ {saveMessage || "\u00a0"} + {saveState === "error" && } +
+ +

{t("usage.companion.footer")}

+
+ ); +} diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts new file mode 100644 index 0000000000..26c265b356 --- /dev/null +++ b/gui/src/pages/usage-companion-utils.ts @@ -0,0 +1,144 @@ +export type TimelineMetric = "total" | "input" | "output" | "cached"; +export type TimelineAggregation = "sum" | "average" | "max"; +export type TimelineGrouping = "model" | "modelAccount"; +export type CompanionMenuBarMetric = "requests" | "tokens" | "cost" | "quota" | "none"; +export type CompanionChartStyle = "line" | "stackedBar"; +export type ChartHours = 6 | 24 | 72 | 168; + +export interface CompanionSettings { + menuBarMetric: CompanionMenuBarMetric; + menuBarTemplate: string | null; + showToday: boolean; + showChart: boolean; + showModels: boolean; + showCost: boolean; + showAccounts: boolean; + chartHours: ChartHours; + bucketMinutes: number; + chartStyle: CompanionChartStyle; + tokenMetric: TimelineMetric; + aggregation: TimelineAggregation; + chartGrouping: TimelineGrouping; + models: string[] | null; + hiddenProviders: string[]; +} + +export interface TimelineSeries { + id: string; + provider: string; + model: string; + accountLogLabel?: string; + total: number; + points: number[]; +} + +export interface UsageTimeline { + start: number; + end: number; + bucketSeconds: number; + buckets: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + series: TimelineSeries[]; + availableModels: string[]; + missingMeasurements: number; + truncated: boolean; +} + +export interface CompanionSettingsResponse { + settings: CompanionSettings; + updatedAt: number | null; + defaults: CompanionSettings; +} + +export const CHART_BUCKET_MINUTES: Record = { + 6: 15, + 24: 60, + 72: 180, + 168: 360, +}; + +export function bucketMinutesForWindow(hours: ChartHours): number { + return CHART_BUCKET_MINUTES[hours]; +} + +export function buildCompanionSettingsPatch( + patch: Partial, + availableModels: readonly string[] = [], +): Partial { + const next = { ...patch }; + if (typeof next.menuBarTemplate === "string" && next.menuBarTemplate.trim() === "") { + next.menuBarTemplate = null; + } + if (next.models !== undefined && availableModels.length > 0) { + const selected = next.models ?? []; + const allSelected = selected.length === availableModels.length + && availableModels.every(model => selected.includes(model)); + if (allSelected) next.models = null; + } + return next; +} + +export function chartPolylinePoints( + points: readonly number[], + width: number, + height: number, + maxValue: number, + padding = 8, +): string { + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const divisor = Math.max(points.length - 1, 1); + return points.map((value, index) => { + const x = padding + plotWidth * index / divisor; + const y = padding + plotHeight * (1 - Math.max(0, value) / denominator); + return `${x},${y}`; + }).join(" "); +} + +export interface StackedBarRect { + x: number; + y: number; + width: number; + height: number; + seriesIndex: number; + bucketIndex: number; +} + +export function chartStackedBarRects( + series: readonly Pick[], + width: number, + height: number, + maxValue: number, + padding = 8, +): StackedBarRect[] { + const buckets = series[0]?.points.length ?? 0; + if (buckets === 0) return []; + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const gap = Math.min(3, plotWidth / Math.max(buckets * 8, 1)); + const barWidth = Math.max(0, plotWidth / buckets - gap); + const rects: StackedBarRect[] = []; + for (let bucketIndex = 0; bucketIndex < buckets; bucketIndex += 1) { + let offset = 0; + for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) { + const value = Math.max(0, series[seriesIndex]?.points[bucketIndex] ?? 0); + const barHeight = plotHeight * value / denominator; + if (barHeight > 0) { + rects.push({ + x: padding + bucketIndex * (plotWidth / buckets) + gap / 2, + y: padding + plotHeight - offset - barHeight, + width: barWidth, + height: barHeight, + seriesIndex, + bucketIndex, + }); + } + offset += barHeight; + } + } + return rects; +} diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index aa9e3bb45c..453e1a2c6b 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -214,6 +214,60 @@ gap: 6px; } +.usage-companion-panel { + display: grid; + gap: 16px; + padding-top: 4px; +} +.usage-companion-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.usage-companion-header .panel-title { margin: 0; } +.usage-companion-header .card-sub { margin: 4px 0 0; } +.usage-companion-chart { min-width: 0; } +.usage-companion-chart svg { display: block; width: 100%; height: 160px; overflow: visible; } +.usage-companion-axis { stroke: var(--border); stroke-width: 1; } +.usage-companion-axis-label { fill: var(--muted); font-size: 10px; } +.usage-companion-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 8px; } +.usage-companion-legend-item { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 11px; } +.usage-companion-swatch { width: 8px; height: 8px; border-radius: 50%; } +.usage-companion-chart-skeleton { + height: 160px; + border: 1px solid var(--border-soft); + background: var(--surface); + animation: pulse 1.2s ease-in-out infinite alternate; +} +.usage-companion-chart-state { display: flex; align-items: center; gap: 10px; min-height: 160px; color: var(--muted); } +.usage-companion-controls { display: grid; gap: 14px; } +.usage-companion-control { display: grid; gap: 6px; min-width: 0; } +.usage-companion-control > select, .usage-companion-control > input { + min-height: 34px; width: 100%; padding: 6px 9px; + border: 1px solid var(--border); border-radius: var(--radius-xs); + background: var(--raised); color: var(--text); font: inherit; +} +.usage-companion-control > .usage-segmented { width: fit-content; max-width: 100%; } +.field-label { color: var(--muted); font-size: 11.5px; font-weight: 550; } +.usage-companion-switches, .usage-companion-check-list { + display: grid; gap: 8px; border: 0; padding: 0; margin: 0; +} +.usage-companion-switches legend { padding: 0; margin-bottom: 2px; } +.usage-companion-switch { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text); font-size: 12px; } +.usage-companion-switch .toggle { flex: 0 0 auto; } +.usage-companion-advanced { border-top: 1px solid var(--border-soft); padding-top: 12px; } +.usage-companion-advanced summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-advanced-body { display: grid; gap: 14px; padding-top: 12px; } +.usage-companion-check-list label { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 12px; } +.usage-companion-save-status { display: flex; align-items: center; gap: 8px; min-height: 26px; color: var(--muted); font-size: 11.5px; } +.usage-companion-save-status.is-error { color: var(--red); } +.usage-companion-loading { min-height: 160px; color: var(--muted); } + @media (max-width: 640px) { .usage-source-row { align-items: flex-start; flex-direction: column; } + .usage-companion-header { align-items: stretch; flex-direction: column; } + .usage-companion-header .btn { align-self: flex-start; } + .usage-companion-control > .usage-segmented { width: 100%; } + .usage-companion-control > .usage-segmented .usage-segmented-btn { flex: 1 1 0; min-width: 0; padding-inline: 6px; } } diff --git a/gui/tests/usage-companion-utils.test.ts b/gui/tests/usage-companion-utils.test.ts new file mode 100644 index 0000000000..88175e0116 --- /dev/null +++ b/gui/tests/usage-companion-utils.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { + bucketMinutesForWindow, + buildCompanionSettingsPatch, + chartPolylinePoints, + chartStackedBarRects, +} from "../src/pages/usage-companion-utils"; + +describe("usage companion utilities", () => { + test("maps chart windows to bounded buckets", () => { + expect([6, 24, 72, 168].map(bucketMinutesForWindow)).toEqual([15, 60, 180, 360]); + }); + + test("normalizes empty templates and all-selected models", () => { + expect(buildCompanionSettingsPatch({ + menuBarTemplate: " ", + models: ["openai/gpt-5", "anthropic/claude"], + }, ["openai/gpt-5", "anthropic/claude"])).toEqual({ + menuBarTemplate: null, + models: null, + }); + expect(buildCompanionSettingsPatch({ models: ["openai/gpt-5"] }, ["openai/gpt-5", "anthropic/claude"])).toEqual({ + models: ["openai/gpt-5"], + }); + }); + + test("creates line and stacked bar geometry", () => { + expect(chartPolylinePoints([0, 5, 10], 100, 50, 10)).toBe("8,42 50,25 92,8"); + expect(chartStackedBarRects([ + { points: [5] }, + { points: [5] }, + ], 100, 50, 10)).toEqual([ + { x: 9.5, y: 25, width: 81, height: 17, seriesIndex: 0, bucketIndex: 0 }, + { x: 9.5, y: 8, width: 81, height: 17, seriesIndex: 1, bucketIndex: 0 }, + ]); + }); +}); diff --git a/src/server/management/usage-timeline-routes.ts b/src/server/management/usage-timeline-routes.ts index 2ae627f17c..68c34cb770 100644 --- a/src/server/management/usage-timeline-routes.ts +++ b/src/server/management/usage-timeline-routes.ts @@ -1,4 +1,4 @@ -import { scanUsageLedgerCooperatively } from "../../usage/ledger-scanner"; +import { readUsageSnapshotForManagement } from "../../usage/log"; import { createTimelineAccumulator, parseTimelineQuery } from "../../usage/timeline"; import { jsonResponse } from "../auth-cors"; import type { ManagementContext } from "./context"; @@ -16,12 +16,17 @@ export async function handleUsageTimelineRoutes(ctx: ManagementContext): Promise const key = JSON.stringify({ ...query, now: roundedNow }); const current = Date.now(); const cached = cache.get(key); - if (cached && cached.expiresAt > current) return jsonResponse(await cached.promise); + if (cached && cached.expiresAt > current) return jsonResponse(await cached.promise, 200, req, ctx.config); let promise: Promise["finish"]>>; promise = (async () => { const accumulator = createTimelineAccumulator(query); - await scanUsageLedgerCooperatively({ signal: req.signal, onEntry: entry => accumulator.add(entry) }); - return accumulator.finish(); + const snapshot = await readUsageSnapshotForManagement(ctx.config.managementUsageMaxReadBytes); + if (req.signal.aborted) throw req.signal.reason ?? new Error("usage timeline request aborted"); + for (const entry of snapshot.entries) accumulator.add(entry); + return { + ...accumulator.finish(), + truncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, + }; })().catch(error => { const entry = cache.get(key); if (entry?.promise === promise) cache.delete(key); diff --git a/src/usage/timeline.ts b/src/usage/timeline.ts index bd514ea047..cedcebc9f1 100644 --- a/src/usage/timeline.ts +++ b/src/usage/timeline.ts @@ -37,6 +37,7 @@ export interface UsageTimeline { series: TimelineSeries[]; availableModels: string[]; missingMeasurements: number; + truncated: boolean; } const METRICS: readonly TimelineMetric[] = ["total", "input", "output", "cached"]; @@ -195,6 +196,7 @@ export function createTimelineAccumulator(query: TimelineQuery): { add(entry: Pe series: kept, availableModels: [...availableModels].sort(), missingMeasurements, + truncated: false, }; } From cd019e475feb7e986025db4160da48049850443b Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 11:00:57 -0700 Subject: [PATCH 40/61] fix(companion): timeline cache isolation, other-fold aggregation, ocx companion set/reset Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/test-layout/layout.json | 1 + .../ocx/references/01_management_surface.md | 3 + src/cli/capabilities.ts | 4 ++ src/cli/companion.ts | 56 +++++++++++++++++++ src/cli/dispatch.ts | 12 +--- src/cli/registry.ts | 9 ++- src/companion/settings.ts | 6 +- src/server/management/companion-routes.ts | 7 ++- .../management/usage-timeline-routes.ts | 6 +- src/usage/timeline.ts | 37 ++++++++---- tests/cli/cli-companion.test.ts | 28 ++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/server/companion-settings.test.ts | 13 ++++- tests/usage/usage-timeline.test.ts | 18 ++++++ 14 files changed, 170 insertions(+), 31 deletions(-) create mode 100644 src/cli/companion.ts create mode 100644 tests/cli/cli-companion.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index dd6c816f0d..f1e6f40d99 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -186,6 +186,7 @@ "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 0082405e2f..4918176da3 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -553,6 +553,9 @@ Inspect and configure menu-bar and widget companion usage settings. JSON mode: `payload`. +- `show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults. +- Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly. + ### `ocx account main reauth` Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 8491d74ff7..f651183d05 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -325,6 +325,10 @@ export const CAPABILITIES: readonly Capability[] = [ flags: [{ name: "--json", value: "boolean", summary: "Emit companion settings as JSON." }], mutates: true, json: "payload", + details: [ + "`show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults.", + "Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.", + ], }, { command: ["account", "history"], diff --git a/src/cli/companion.ts b/src/cli/companion.ts new file mode 100644 index 0000000000..2b24258fcc --- /dev/null +++ b/src/cli/companion.ts @@ -0,0 +1,56 @@ +import { CliUsageError, printData, rejectArgs, runCliAction, runtimeRequest, takeFlag, type RuntimeApiDeps } from "./runtime-api"; + +const USAGE = `Usage: + ocx companion [show] [--json] + ocx companion set = [...] [--json] + ocx companion reset [--json]`; + +function parseValue(raw: string): unknown { + if (raw === "null") return null; + try { return JSON.parse(raw); } catch { return raw; } +} + +async function show(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + printData(await runtimeRequest("/api/companion/settings", {}, deps), wantsJson); +} + +async function set(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + if (args.length === 0) throw new CliUsageError("companion set requires key=value assignments", USAGE); + const patch: Record = {}; + for (const assignment of args) { + const separator = assignment.indexOf("="); + if (separator <= 0) throw new CliUsageError(`invalid companion setting "${assignment}"; use key=value`, USAGE); + patch[assignment.slice(0, separator)] = parseValue(assignment.slice(separator + 1)); + } + printData(await runtimeRequest("/api/companion/settings", { + method: "PUT", + body: JSON.stringify({ settings: patch }), + }, deps), wantsJson, ["Companion settings saved."]); +} + +async function reset(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + printData(await runtimeRequest("/api/companion/settings", { + method: "PUT", + body: JSON.stringify({ reset: true }), + }, deps), wantsJson, ["Companion settings reset."]); +} + +export async function handleCompanionCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const [sub = "show", ...rest] = argv; + if (sub === "show") await show(rest, deps); + else if (sub === "set") await set(rest, deps); + else if (sub === "reset") await reset(rest, deps); + else throw new CliUsageError(`unknown companion command ${sub}`, USAGE); + }); +} + +export const COMPANION_USAGE = USAGE; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 700c49ebde..9cfeae652b 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -783,16 +783,8 @@ const commandRunners: Record = { return await handleComboCommand(deps.args.slice(1)); }, companion: async deps => { - const { printData, runtimeRequest, takeFlag } = await import("./runtime-api"); - const args = deps.args.slice(1); - const wantsJson = takeFlag(args, "--json"); - if (args.length) { - console.error("Usage: ocx companion [--json]"); - return 64; - } - const payload = await runtimeRequest("/api/companion/settings"); - printData(payload, wantsJson); - return 0; + const { handleCompanionCommand } = await import("./companion"); + return await handleCompanionCommand(deps.args.slice(1)); }, route: async deps => { if (deps.args[1] !== "combo" && deps.args[1] !== "policy") { diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 8ec80587bd..85c2d66031 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -300,8 +300,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ }, { name: "companion", - usage: "ocx companion [--json]", - summary: "Inspect menu-bar and widget companion usage settings.", + usage: "ocx companion ...", + summary: "Inspect and configure menu-bar and widget companion usage settings.", + details: [ + "ocx companion and ocx companion show read settings; use --json for machine-readable output.", + "ocx companion set accepts one or more key=value assignments; values are parsed as JSON when possible.", + "ocx companion reset restores the default settings.", + ], }, { name: "combo", diff --git a/src/companion/settings.ts b/src/companion/settings.ts index 5429884776..5c90acb91c 100644 --- a/src/companion/settings.ts +++ b/src/companion/settings.ts @@ -106,16 +106,16 @@ export function applyCompanionSettingsPatch( return { ...current, ...values } as CompanionSettings; } -export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null } { +export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } { const path = companionSettingsPath(); if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; try { const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed); - if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; + if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true }; return { settings, updatedAt: statSync(path).mtimeMs }; } catch { - return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; + return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true }; } } diff --git a/src/server/management/companion-routes.ts b/src/server/management/companion-routes.ts index c4d63fb25f..aebcc247fa 100644 --- a/src/server/management/companion-routes.ts +++ b/src/server/management/companion-routes.ts @@ -10,7 +10,12 @@ import type { ManagementContext } from "./context"; function response(): Response { const loaded = loadCompanionSettings(); - return jsonResponse({ settings: loaded.settings, updatedAt: loaded.updatedAt, defaults: DEFAULT_COMPANION_SETTINGS }); + return jsonResponse({ + settings: loaded.settings, + updatedAt: loaded.updatedAt, + defaults: DEFAULT_COMPANION_SETTINGS, + ...(loaded.corrupt ? { corrupt: true } : {}), + }); } export async function handleCompanionRoutes(ctx: ManagementContext): Promise { diff --git a/src/server/management/usage-timeline-routes.ts b/src/server/management/usage-timeline-routes.ts index 68c34cb770..d4f04c3718 100644 --- a/src/server/management/usage-timeline-routes.ts +++ b/src/server/management/usage-timeline-routes.ts @@ -13,15 +13,15 @@ export async function handleUsageTimelineRoutes(ctx: ManagementContext): Promise if ("error" in query) return jsonResponse(query, 400, req, ctx.config); const bucketMs = query.bucketMinutes * 60_000; const roundedNow = Math.floor(query.now / bucketMs) * bucketMs; - const key = JSON.stringify({ ...query, now: roundedNow }); + const normalized = { ...query, now: roundedNow }; + const key = JSON.stringify(normalized); const current = Date.now(); const cached = cache.get(key); if (cached && cached.expiresAt > current) return jsonResponse(await cached.promise, 200, req, ctx.config); let promise: Promise["finish"]>>; promise = (async () => { - const accumulator = createTimelineAccumulator(query); + const accumulator = createTimelineAccumulator(normalized); const snapshot = await readUsageSnapshotForManagement(ctx.config.managementUsageMaxReadBytes); - if (req.signal.aborted) throw req.signal.reason ?? new Error("usage timeline request aborted"); for (const entry of snapshot.entries) accumulator.add(entry); return { ...accumulator.finish(), diff --git a/src/usage/timeline.ts b/src/usage/timeline.ts index cedcebc9f1..af44587f51 100644 --- a/src/usage/timeline.ts +++ b/src/usage/timeline.ts @@ -158,7 +158,7 @@ export function createTimelineAccumulator(query: TimelineQuery): { add(entry: Pe } function finish(): UsageTimeline { - const rows = [...series].map(([id, state]): TimelineSeries => { + const rows = [...series].map(([id, state]): { row: TimelineSeries; state: SeriesState } => { if (query.aggregation !== "sum") { for (const [bucket, requests] of state.requests) { const values = [...requests.values()]; @@ -169,19 +169,34 @@ export function createTimelineAccumulator(query: TimelineQuery): { add(entry: Pe } const total = state.points.reduce((sum, value) => sum + value, 0); return { - id, - provider: state.provider, - model: state.model, - ...(state.accountLogLabel !== undefined ? { accountLogLabel: state.accountLogLabel } : {}), - total, - points: state.points, + row: { + id, + provider: state.provider, + model: state.model, + ...(state.accountLogLabel !== undefined ? { accountLogLabel: state.accountLogLabel } : {}), + total, + points: state.points, + }, + state, }; - }).sort((left, right) => right.total - left.total || left.id.localeCompare(right.id)); - const kept = rows.length > 24 ? rows.slice(0, 23) : rows; + }).sort((left, right) => right.row.total - left.row.total || left.row.id.localeCompare(right.row.id)); + const kept = (rows.length > 24 ? rows.slice(0, 23) : rows).map(({ row }) => row); if (rows.length > 24) { const otherPoints = Array(buckets).fill(0); - for (const row of rows.slice(23)) { - for (let index = 0; index < buckets; index += 1) otherPoints[index] = (otherPoints[index] ?? 0) + (row.points[index] ?? 0); + const folded = rows.slice(23); + if (query.aggregation === "sum") { + for (const { row } of folded) { + for (let index = 0; index < buckets; index += 1) otherPoints[index] = (otherPoints[index] ?? 0) + (row.points[index] ?? 0); + } + } else { + for (let index = 0; index < buckets; index += 1) { + const values = folded.flatMap(({ state }) => [...(state.requests.get(index)?.values() ?? [])]); + if (values.length > 0) { + otherPoints[index] = query.aggregation === "max" + ? Math.max(...values) + : values.reduce((sum, value) => sum + value, 0) / values.length; + } + } } kept.push({ id: "other", provider: "", model: "other", total: otherPoints.reduce((sum, value) => sum + value, 0), points: otherPoints }); } diff --git a/tests/cli/cli-companion.test.ts b/tests/cli/cli-companion.test.ts new file mode 100644 index 0000000000..2f3d361bac --- /dev/null +++ b/tests/cli/cli-companion.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { handleCompanionCommand } from "../../src/cli/companion"; + +describe("ocx companion", () => { + test("set parses JSON values and reset sends the matching management payload", async () => { + const requests: Array<{ path: string; method: string; body: unknown }> = []; + const deps = { + baseUrl: "http://proxy.test", + fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) : null; + requests.push({ path: new URL(String(input)).pathname, method: init?.method ?? "GET", body }); + return Response.json({ settings: {}, defaults: {}, updatedAt: null }); + }, + }; + expect(await handleCompanionCommand(["set", "showChart=false", "chartHours=6", "menuBarTemplate=null", "--json"], deps)).toBe(0); + expect(requests[0]).toEqual({ + path: "/api/companion/settings", + method: "PUT", + body: { settings: { showChart: false, chartHours: 6, menuBarTemplate: null } }, + }); + expect(await handleCompanionCommand(["reset"], deps)).toBe(0); + expect(requests[1]).toEqual({ + path: "/api/companion/settings", + method: "PUT", + body: { reset: true }, + }); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 1b22b77979..494d9326de 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -18,6 +18,7 @@ "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", diff --git a/tests/server/companion-settings.test.ts b/tests/server/companion-settings.test.ts index 687a970e91..020370d351 100644 --- a/tests/server/companion-settings.test.ts +++ b/tests/server/companion-settings.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -38,6 +38,7 @@ describe("companion settings", () => { expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); writeFileSync(join(home, "companion.json"), "{"); expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); + expect(loadCompanionSettings().corrupt).toBe(true); expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { unknown: true })).toEqual({ error: expect.any(String) }); expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { menuBarTemplate: "x".repeat(201) })).toEqual({ error: expect.any(String) }); const updated = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { showChart: false }); @@ -55,4 +56,14 @@ describe("companion settings", () => { expect((await call("PUT", { settings: { bad: true } })).status).toBe(400); }); }); + + test("GET reports corrupt persisted settings without overwriting them", async () => { + await withHome(async home => { + writeFileSync(join(home, "companion.json"), "{"); + const result = await call("GET"); + expect(result.status).toBe(200); + expect(result.body.corrupt).toBe(true); + expect(readFileSync(join(home, "companion.json"), "utf8")).toBe("{"); + }); + }); }); diff --git a/tests/usage/usage-timeline.test.ts b/tests/usage/usage-timeline.test.ts index 6f8c8c19f6..ee395007e1 100644 --- a/tests/usage/usage-timeline.test.ts +++ b/tests/usage/usage-timeline.test.ts @@ -96,4 +96,22 @@ describe("usage timeline", () => { expect(result.series).toHaveLength(24); expect(result.series.at(-1)?.id).toBe("other"); }); + + test("folds other rows with request-level max and average", () => { + const make = (aggregation: "average" | "max") => { + const query = parseTimelineQuery(new URLSearchParams(`hours=6&aggregation=${aggregation}`), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + for (let index = 0; index < 25; index += 1) { + acc.add(entry({ + requestId: `request-${index}`, + model: `model-${index}`, + totalTokens: index < 23 ? 100 + index : index - 22, + })); + } + return acc.finish().series.at(-1); + }; + expect(make("max")?.points.at(-1)).toBe(2); + expect(make("average")?.points.at(-1)).toBe(1.5); + }); }); From c034c992b39637649d6f0f0080ff53605058750b Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 11:08:11 -0700 Subject: [PATCH 41/61] feat(app): settings-driven menu bar title, today metrics, timeline chart, widget snapshot export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../MenuBarCore/CompanionSettings.swift | 118 ++++++++++++++ app/Sources/MenuBarCore/MenuBarTitle.swift | 38 +++++ .../MenuBarCore/PollingCoordinator.swift | 60 ++++--- app/Sources/MenuBarCore/ProxyClient.swift | 18 +++ app/Sources/MenuBarCore/ProxyModels.swift | 19 +++ app/Sources/MenuBarCore/ProxySnapshot.swift | 25 +++ app/Sources/MenuBarCore/UsageTimeline.swift | 39 +++++ app/Sources/MenuBarCore/WidgetSnapshot.swift | 147 ++++++++++++++++++ .../CompanionSettingsSuite.swift | 27 ++++ .../MenuBarCoreTests/MenuBarTitleSuite.swift | 36 +++++ .../MenuBarCoreTests/ModelDecodingSuite.swift | 2 +- .../MenuBarCoreTests/PollingSuite.swift | 4 +- .../TimelineDecodingSuite.swift | 15 ++ .../MenuBarCoreTests/TransportSuite.swift | 16 ++ .../WidgetSnapshotSuite.swift | 37 +++++ app/Sources/MenuBarCoreTests/main.swift | 4 + app/Sources/MenuBarUI/AppDelegate.swift | 13 ++ app/Sources/MenuBarUI/CompanionViews.swift | 74 +++++++++ .../MenuBarUI/PopoverViewController.swift | 19 ++- app/Sources/MenuBarUI/ProviderListView.swift | 3 +- app/Sources/MenuBarUI/TimelineChartView.swift | 95 +++++++++++ app/Sources/MenuBarUI/Views.swift | 51 +----- app/Sources/MenuBarUITests/main.swift | 33 ++++ app/Sources/UIProbe/main.swift | 13 +- 24 files changed, 824 insertions(+), 82 deletions(-) create mode 100644 app/Sources/MenuBarCore/CompanionSettings.swift create mode 100644 app/Sources/MenuBarCore/MenuBarTitle.swift create mode 100644 app/Sources/MenuBarCore/UsageTimeline.swift create mode 100644 app/Sources/MenuBarCore/WidgetSnapshot.swift create mode 100644 app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift create mode 100644 app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift create mode 100644 app/Sources/MenuBarUI/CompanionViews.swift create mode 100644 app/Sources/MenuBarUI/TimelineChartView.swift diff --git a/app/Sources/MenuBarCore/CompanionSettings.swift b/app/Sources/MenuBarCore/CompanionSettings.swift new file mode 100644 index 0000000000..acc4979097 --- /dev/null +++ b/app/Sources/MenuBarCore/CompanionSettings.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct CompanionSettings: Decodable, Equatable, Sendable { + public enum MenuBarMetric: String, Sendable { + case requests, tokens, cost, quota, none + } + + public enum ChartStyle: String, Sendable { + case line, stackedBar + } + + public enum TokenMetric: String, Sendable { + case total, input, output, cached + } + + public enum Aggregation: String, Sendable { + case sum, average, max + } + + public enum ChartGrouping: String, Sendable { + case model, modelAccount + } + + public let menuBarMetric: MenuBarMetric + public let menuBarTemplate: String? + public let showToday: Bool + public let showChart: Bool + public let showModels: Bool + public let showCost: Bool + public let showAccounts: Bool + public let chartHours: Int + public let bucketMinutes: Int + public let chartStyle: ChartStyle + public let tokenMetric: TokenMetric + public let aggregation: Aggregation + public let chartGrouping: ChartGrouping + public let models: [String]? + public let hiddenProviders: [String] + + public static let defaults = CompanionSettings( + menuBarMetric: .requests, menuBarTemplate: nil, + showToday: true, showChart: true, showModels: true, showCost: true, showAccounts: true, + chartHours: 24, bucketMinutes: 60, chartStyle: .line, tokenMetric: .total, + aggregation: .sum, chartGrouping: .model, models: nil, hiddenProviders: [] + ) + + public init( + menuBarMetric: MenuBarMetric = .requests, + menuBarTemplate: String? = nil, + showToday: Bool = true, + showChart: Bool = true, + showModels: Bool = true, + showCost: Bool = true, + showAccounts: Bool = true, + chartHours: Int = 24, + bucketMinutes: Int = 60, + chartStyle: ChartStyle = .line, + tokenMetric: TokenMetric = .total, + aggregation: Aggregation = .sum, + chartGrouping: ChartGrouping = .model, + models: [String]? = nil, + hiddenProviders: [String] = [] + ) { + self.menuBarMetric = menuBarMetric + self.menuBarTemplate = menuBarTemplate + self.showToday = showToday + self.showChart = showChart + self.showModels = showModels + self.showCost = showCost + self.showAccounts = showAccounts + self.chartHours = chartHours + self.bucketMinutes = bucketMinutes + self.chartStyle = chartStyle + self.tokenMetric = tokenMetric + self.aggregation = aggregation + self.chartGrouping = chartGrouping + self.models = models + self.hiddenProviders = hiddenProviders + } + + private enum CodingKeys: String, CodingKey { + case menuBarMetric, menuBarTemplate, showToday, showChart, showModels, showCost, showAccounts + case chartHours, bucketMinutes, chartStyle, tokenMetric, aggregation, chartGrouping, models, hiddenProviders + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + menuBarMetric: Self.enumValue(MenuBarMetric.self, try c.decodeIfPresent(String.self, forKey: .menuBarMetric), default: .requests), + menuBarTemplate: try c.decodeIfPresent(String.self, forKey: .menuBarTemplate), + showToday: try c.decodeIfPresent(Bool.self, forKey: .showToday) ?? true, + showChart: try c.decodeIfPresent(Bool.self, forKey: .showChart) ?? true, + showModels: try c.decodeIfPresent(Bool.self, forKey: .showModels) ?? true, + showCost: try c.decodeIfPresent(Bool.self, forKey: .showCost) ?? true, + showAccounts: try c.decodeIfPresent(Bool.self, forKey: .showAccounts) ?? true, + chartHours: try c.decodeIfPresent(Int.self, forKey: .chartHours) ?? 24, + bucketMinutes: try c.decodeIfPresent(Int.self, forKey: .bucketMinutes) ?? 60, + chartStyle: Self.enumValue(ChartStyle.self, try c.decodeIfPresent(String.self, forKey: .chartStyle), default: .line), + tokenMetric: Self.enumValue(TokenMetric.self, try c.decodeIfPresent(String.self, forKey: .tokenMetric), default: .total), + aggregation: Self.enumValue(Aggregation.self, try c.decodeIfPresent(String.self, forKey: .aggregation), default: .sum), + chartGrouping: Self.enumValue(ChartGrouping.self, try c.decodeIfPresent(String.self, forKey: .chartGrouping), default: .model), + models: try c.decodeIfPresent([String].self, forKey: .models), + hiddenProviders: try c.decodeIfPresent([String].self, forKey: .hiddenProviders) ?? [] + ) + } + + private static func enumValue( + _ type: T.Type, _ raw: String?, default value: T + ) -> T where T.RawValue == String { + raw.flatMap(T.init(rawValue:)) ?? value + } +} + +public struct CompanionSettingsResponse: Decodable, Equatable, Sendable { + public let settings: CompanionSettings + public let updatedAt: Double? + public let corrupt: Bool? +} diff --git a/app/Sources/MenuBarCore/MenuBarTitle.swift b/app/Sources/MenuBarCore/MenuBarTitle.swift new file mode 100644 index 0000000000..c17613e3c8 --- /dev/null +++ b/app/Sources/MenuBarCore/MenuBarTitle.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum MenuBarTitle { + public static func render( + settings: CompanionSettings, + today: UsageReport?, + quotas: [NormalizedQuota] + ) -> String? { + let summary = today?.summary + let values: [String: String] = [ + "requests": Format.count(summary?.requests), + "totalTokens": Format.tokens(summary?.totalTokens), + "inputTokens": Format.tokens(summary?.inputTokens), + "outputTokens": Format.tokens(summary?.outputTokens), + "costUsd": Format.cost(summary?.estimatedCostUsd), + "quotaPercent": Format.percent(quotas.compactMap(\.percent).min()), + ] + let rendered: String + if let template = settings.menuBarTemplate?.trimmingCharacters(in: .whitespacesAndNewlines), + !template.isEmpty { + rendered = values.reduce(template) { text, item in + text.replacingOccurrences(of: "{\(item.key)}", with: item.value) + } + } else { + switch settings.menuBarMetric { + case .requests: rendered = Format.count(summary?.requests) + case .tokens: rendered = Format.tokens(summary?.totalTokens) + case .cost: rendered = Format.cost(summary?.estimatedCostUsd) + case .quota: rendered = Format.percent(quotas.compactMap(\.percent).min()) + case .none: return nil + } + } + let text = rendered.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if text.count <= 24 { return text } + return String(text.prefix(23)) + "…" + } +} diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift index e0a08e5535..4125657a62 100644 --- a/app/Sources/MenuBarCore/PollingCoordinator.swift +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -116,20 +116,18 @@ public actor PollingCoordinator { return } - if popoverOpen { - // Only on an actual open or manual refresh. Running these on every liveness - // tick turned two rarely-changing endpoints into 5-second pollers. - if includeHeavy { await refreshOnOpen(cycle: cycle) } - - // Rate-limit on ATTEMPT, not success: gating on success alone meant one - // persistently failing endpoint re-fetched its healthy sibling every 5s. - let aggregationDue = lastAggregationAttempt.map { - Date().timeIntervalSince($0) >= Self.heavyInterval - } ?? true - if aggregationDue, isCurrent(cycle) { - lastAggregationAttempt = Date() - _ = await refreshAggregation(cycle: cycle) - } + if popoverOpen, includeHeavy { + await refreshOnOpen(cycle: cycle) + } + + // Settings and today metrics also drive the menu-bar title, so aggregation runs + // on the normal cadence even while the popover is closed. + let aggregationDue = lastAggregationAttempt.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true + if aggregationDue, isCurrentCycle(cycle) { + lastAggregationAttempt = Date() + _ = await refreshAggregation(cycle: cycle) } if cycle == generation { publish() } @@ -202,28 +200,48 @@ public actor PollingCoordinator { /// Still the newest cycle, and still worth doing. private func isCurrent(_ cycle: Int) -> Bool { cycle == generation && popoverOpen } + private func isCurrentCycle(_ cycle: Int) -> Bool { cycle == generation } /// The expensive aggregation reads. Returns whether every read landed, so a partial /// failure does not masquerade as a completed refresh. private func refreshAggregation(cycle: Int) async -> Bool { - guard isCurrent(cycle) else { return false } + guard isCurrentCycle(cycle) else { return false } var complete = true // Each read is independent: one failing endpoint must not blank the others. - if let usage = try? await client.usage(range: .sevenDays) { - guard isCurrent(cycle) else { return false } - snapshot.usage = usage + if let response = try? await client.companionSettings() { + guard isCurrentCycle(cycle) else { return false } + snapshot.settings = response.settings + snapshot.settingsLoaded = true + } else { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if let today = try? await client.usage(range: .today) { + guard isCurrentCycle(cycle) else { return false } + snapshot.today = today + snapshot.usage = today snapshot.usageUpdated = Date() } else { complete = false } - guard isCurrent(cycle) else { return false } - if let quotas = try? await client.quotas() { + guard isCurrentCycle(cycle) else { return false } + if snapshot.settings.showChart, let timeline = try? await client.timeline(snapshot.settings) { + guard isCurrentCycle(cycle) else { return false } + snapshot.timeline = timeline + snapshot.timelineUpdated = Date() + } else if snapshot.settings.showChart { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if (popoverOpen || snapshot.settings.menuBarMetric == .quota), let quotas = try? await client.quotas() { guard isCurrent(cycle) else { return false } snapshot.quotas = quotas snapshot.quotasLoaded = true - } else { + } else if popoverOpen || snapshot.settings.menuBarMetric == .quota { complete = false } diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index 602ef55ff5..f6f494aaae 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -91,6 +91,24 @@ public actor ProxyClient { try await get("api/usage", query: [URLQueryItem(name: "range", value: range.rawValue)]) } + public func companionSettings() async throws -> CompanionSettingsResponse { + try await get("api/companion/settings") + } + + public func timeline(_ settings: CompanionSettings) async throws -> UsageTimeline { + var query = [ + URLQueryItem(name: "hours", value: String(settings.chartHours)), + URLQueryItem(name: "bucketMinutes", value: String(settings.bucketMinutes)), + URLQueryItem(name: "metric", value: settings.tokenMetric.rawValue), + URLQueryItem(name: "aggregation", value: settings.aggregation.rawValue), + URLQueryItem(name: "grouping", value: settings.chartGrouping.rawValue), + ] + if let models = settings.models, !models.isEmpty { + query.append(URLQueryItem(name: "models", value: models.joined(separator: ","))) + } + return try await get("api/usage/timeline", query: query) + } + public func quotas() async throws -> [QuotaReport] { let envelope: QuotaEnvelope = try await get("api/provider-quotas") return envelope.reports ?? [] diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift index 765a2b1bb8..b79034e394 100644 --- a/app/Sources/MenuBarCore/ProxyModels.swift +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -80,6 +80,7 @@ public struct ProxyConfigSummary: Decodable, Equatable, Sendable { /// stringly-typed range would let a caller ask for `24h`, receive thirty days of data, /// and label it wrongly. public enum UsageRange: String, Sendable, CaseIterable { + case today = "today" case sevenDays = "7d" case thirtyDays = "30d" case all @@ -110,6 +111,8 @@ public struct UsageReport: Decodable, Equatable, Sendable { public let generatedAt: Double? public let summary: UsageSummary? public let days: [UsageDay]? + public let models: [UsageModelRow]? + public let accounts: [UsageAccountRow]? /// The range the server actually applied, which is not always the one requested. public var effectiveRange: UsageRange? { @@ -119,6 +122,7 @@ public struct UsageReport: Decodable, Equatable, Sendable { /// Header text driven by the response, never by the request. public var rangeLabel: String { switch effectiveRange { + case .today: return "TODAY" case .sevenDays: return "LAST 7 DAYS" case .thirtyDays: return "LAST 30 DAYS" case .all: return "ALL TIME" @@ -139,6 +143,21 @@ public struct UsageReport: Decodable, Equatable, Sendable { } } +public struct UsageModelRow: Decodable, Equatable, Sendable { + public let provider: String? + public let model: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + +public struct UsageAccountRow: Decodable, Equatable, Sendable { + public let accountLogLabel: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + public struct QuotaWindow: Decodable, Equatable, Sendable { public let label: String? public let percent: Double? diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift index d8867d814c..30416a5a7f 100644 --- a/app/Sources/MenuBarCore/ProxySnapshot.swift +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -78,6 +78,11 @@ public struct ProxySnapshot: Equatable, Sendable { public var state: ProxyState public var endpoint: ProxyEndpoint public var usage: UsageReport? + public var settings: CompanionSettings + public var settingsLoaded: Bool + public var today: UsageReport? + public var timeline: UsageTimeline? + public var timelineUpdated: Date? public var quotas: [QuotaReport] public var providers: [ProviderSummary] public var defaultProvider: String? @@ -102,6 +107,11 @@ public struct ProxySnapshot: Equatable, Sendable { state: ProxyState = .loading, endpoint: ProxyEndpoint, usage: UsageReport? = nil, + settings: CompanionSettings = .defaults, + settingsLoaded: Bool = false, + today: UsageReport? = nil, + timeline: UsageTimeline? = nil, + timelineUpdated: Date? = nil, quotas: [QuotaReport] = [], providers: [ProviderSummary] = [], defaultProvider: String? = nil, @@ -116,6 +126,11 @@ public struct ProxySnapshot: Equatable, Sendable { self.state = state self.endpoint = endpoint self.usage = usage + self.settings = settings + self.settingsLoaded = settingsLoaded + self.today = today + self.timeline = timeline + self.timelineUpdated = timelineUpdated self.quotas = quotas self.providers = providers self.defaultProvider = defaultProvider @@ -166,6 +181,16 @@ public struct ProxySnapshot: Equatable, Sendable { quotas.map { $0.normalized() } } + public var visibleProviders: [ProviderSummary] { + providers.filter { !settings.hiddenProviders.contains($0.name) } + } + + public var menuBarTitle: String? { + MenuBarTitle.render(settings: settings, today: today ?? usage, quotas: quotaRows) + } + + public var todayRows: [UsageModelRow] { today?.models ?? [] } + /// Whether the metrics section should render its empty copy. `nil` means unknown, /// which renders em dashes instead. public var usageIsEmpty: Bool? { usage?.isEmptyOrUnknown } diff --git a/app/Sources/MenuBarCore/UsageTimeline.swift b/app/Sources/MenuBarCore/UsageTimeline.swift new file mode 100644 index 0000000000..6291272bcc --- /dev/null +++ b/app/Sources/MenuBarCore/UsageTimeline.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct TimelineSeries: Decodable, Equatable, Sendable { + public let id: String + public let provider: String + public let model: String + public let accountLogLabel: String? + public let total: Double + public let points: [Double] +} + +public struct UsageTimeline: Decodable, Equatable, Sendable { + public let start: Double + public let end: Double + public let bucketSeconds: Int + public let buckets: Int + public let metric: String + public let aggregation: String + public let grouping: String + public let series: [TimelineSeries] + public let availableModels: [String] + public let missingMeasurements: Int + public let truncated: Bool? + + public var maxPoint: Double { + series.flatMap(\.points).max() ?? 0 + } + + public var stackedMax: Double { + guard buckets > 0 else { return 0 } + return (0.. WidgetSnapshot { + let state: String + switch snapshot.state { + case .loading: state = "loading" + case .running: state = "running" + case .unreachable: state = "unreachable" + case .unauthorized: state = "unauthorized" + case .degraded: state = "degraded" + } + let report = snapshot.today ?? snapshot.usage + let today = report?.summary.map { + Today(requests: $0.requests, totalTokens: $0.totalTokens, estimatedCostUsd: $0.estimatedCostUsd) + } + let quotas = snapshot.quotaRows.map { + Quota(providerLabel: $0.providerLabel, windowLabel: $0.windowLabel, percent: $0.percent, resetAt: $0.resetAt?.timeIntervalSince1970) + } + let chart = snapshot.timeline.map { + Chart( + start: $0.start, bucketSeconds: $0.bucketSeconds, style: snapshot.settings.chartStyle.rawValue, + series: Array($0.series.prefix(6)).map { Chart.Series(id: $0.id, points: $0.points) } + ) + } + return WidgetSnapshot( + schemaVersion: 1, generatedAt: now.timeIntervalSince1970, + state: state, stateTitle: snapshot.state.title, detail: snapshot.state.detail, + endpointDisplay: snapshot.endpoint.display, menuTitle: snapshot.menuBarTitle, + today: today, quotas: quotas, chart: chart, + lastUpdated: (snapshot.timelineUpdated ?? snapshot.usageUpdated)?.timeIntervalSince1970 + ) + } +} + +public final class WidgetSnapshotStore: @unchecked Sendable { + private let fileManager: FileManager + private let homeDirectory: URL + private let widgetBundleID: String + private let lock = NSLock() + private var lastWritten: WidgetSnapshot? + private let logger = Logger(subsystem: "ai.opencodex.menubar", category: "widget-snapshot") + private var loggedFailures = Set() + + public init( + widgetBundleID: String = "com.opencodex.menubar.widget", + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + self.widgetBundleID = widgetBundleID + self.fileManager = fileManager + self.homeDirectory = homeDirectory + } + + public var url: URL { + homeDirectory + .appendingPathComponent("Library/Containers/\(widgetBundleID)/Data/Library/Application Support/OpenCodex", isDirectory: true) + .appendingPathComponent("snapshot.json") + } + + public func write(_ snapshot: WidgetSnapshot) throws { + let directory = url.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(snapshot) + let temporary = directory.appendingPathComponent(".snapshot-\(UUID().uuidString).tmp") + try data.write(to: temporary, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } + try fileManager.moveItem(at: temporary, to: url) + Self.reloadTimelines() + } + + public func writeIfChanged(_ snapshot: WidgetSnapshot) { + lock.lock() + let previous = lastWritten + if previous?.withoutGeneratedAt == snapshot.withoutGeneratedAt { + lock.unlock() + return + } + do { + try write(snapshot) + lastWritten = snapshot + lock.unlock() + } catch { + let key = String(describing: type(of: error)) + if loggedFailures.insert(key).inserted { logger.error("Widget snapshot write failed: \(key, privacy: .public)") } + lock.unlock() + } + } + + public static func reloadTimelines() { + #if canImport(WidgetKit) + if #available(macOS 14, *) { WidgetCenter.shared.reloadAllTimelines() } + #endif + } +} + +private extension WidgetSnapshot { + var withoutGeneratedAt: WidgetSnapshot { + WidgetSnapshot( + schemaVersion: schemaVersion, generatedAt: 0, state: state, stateTitle: stateTitle, + detail: detail, endpointDisplay: endpointDisplay, menuTitle: menuTitle, today: today, + quotas: quotas, chart: chart, lastUpdated: lastUpdated + ) + } +} diff --git a/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift new file mode 100644 index 0000000000..0a927a880d --- /dev/null +++ b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift @@ -0,0 +1,27 @@ +import Foundation +import MenuBarCore + +enum CompanionSettingsSuite { + static func run(_ t: TestRunner) { + let decoder = JSONDecoder() + t.test("companion settings: empty JSON uses defaults") { + let settings = try decoder.decode(CompanionSettings.self, from: Data("{}".utf8)) + t.equal(settings, .defaults) + } + t.test("companion settings: unknown enum uses its default") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"future","chartStyle":"future","tokenMetric":"future","aggregation":"future","chartGrouping":"future"}"#.utf8)) + t.equal(settings.menuBarMetric, .requests) + t.equal(settings.chartStyle, .line) + t.equal(settings.tokenMetric, .total) + t.equal(settings.aggregation, .sum) + t.equal(settings.chartGrouping, .model) + } + t.test("companion settings: full payload decodes") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"quota","menuBarTemplate":"{requests}","showToday":false,"showChart":false,"showModels":false,"showCost":false,"showAccounts":false,"chartHours":72,"bucketMinutes":180,"chartStyle":"stackedBar","tokenMetric":"cached","aggregation":"max","chartGrouping":"modelAccount","models":["openai/gpt"],"hiddenProviders":["openai"]}"#.utf8)) + t.equal(settings.chartHours, 72) + t.equal(settings.chartStyle, .stackedBar) + t.equal(settings.models, ["openai/gpt"]) + t.equal(settings.hiddenProviders, ["openai"]) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift new file mode 100644 index 0000000000..53a928f726 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift @@ -0,0 +1,36 @@ +import Foundation +import MenuBarCore + +enum MenuBarTitleSuite { + private static let reportJSON = #"{"range":"today","summary":{"requests":12,"totalTokens":3456,"inputTokens":1000,"outputTokens":2000,"estimatedCostUsd":1.25}}"# + + static func run(_ t: TestRunner) { + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(reportJSON.utf8)) + for metric in [CompanionSettings.MenuBarMetric.requests, .tokens, .cost] { + t.test("menu title: \(metric.rawValue) metric") { + let settings = CompanionSettings(menuBarMetric: metric) + t.expect(MenuBarTitle.render(settings: settings, today: report, quotas: []) != nil, "title") + } + } + t.test("menu title: quota picks the lowest percent") { + let settings = CompanionSettings(menuBarMetric: .quota) + let quotas = try! JSONDecoder().decode([QuotaReport].self, from: Data(#"[{"provider":"a","quota":{"weeklyPercent":80}},{"provider":"b","quota":{"weeklyPercent":20}}]"#.utf8)).map { $0.normalized() } + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: quotas), "20%") + } + t.test("menu title: template replaces placeholders") { + let settings = CompanionSettings(menuBarTemplate: "{requests}/{totalTokens}/{costUsd}") + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: []), "12/3.46K/$1.25") + } + t.test("menu title: none is nil and unknowns are em dashes") { + t.isNil(MenuBarTitle.render(settings: CompanionSettings(menuBarMetric: .none), today: report, quotas: []), "none") + let settings = CompanionSettings(menuBarTemplate: "{inputTokens}") + t.equal(MenuBarTitle.render(settings: settings, today: nil, quotas: []), "—") + } + t.test("menu title: long output is truncated") { + let settings = CompanionSettings(menuBarTemplate: "012345678901234567890123456789") + let title = MenuBarTitle.render(settings: settings, today: report, quotas: []) + t.equal(title?.count, 24) + t.expect(title?.hasSuffix("…") == true, "ellipsis") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift index 5368100a2f..0ec0012e62 100644 --- a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -120,7 +120,7 @@ enum ModelDecodingSuite { t.test("usage: the range enum is closed") { t.isNil(UsageRange(rawValue: "24h"), "UsageRange(24h)") - t.equal(UsageRange.allCases.map(\.rawValue), ["7d", "30d", "all"]) + t.equal(UsageRange.allCases.map(\.rawValue), ["today", "7d", "30d", "all"]) } // The decisive trap: openai sends weeklyResetAt in SECONDS (1785258443) while diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift index f57a9e1d9a..838249be4c 100644 --- a/app/Sources/MenuBarCoreTests/PollingSuite.swift +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -65,7 +65,7 @@ enum PollingSuite { StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) let coordinator = makeCoordinator() sync { await coordinator.refresh() } - t.equal(paths(), ["/api/startup-health"]) + t.equal(paths(), ["/api/startup-health", "/api/companion/settings", "/api/usage", "/api/usage/timeline"]) } t.test("polling: opening the popover fetches on-open and aggregation reads") { @@ -233,7 +233,7 @@ enum PollingSuite { await coordinator.refresh() } t.equal(paths().filter { $0 == "/api/providers" }.count, 0) - t.equal(paths().filter { $0 == "/api/usage" }.count, 0) + t.equal(paths().filter { $0 == "/api/usage" }.count, 1) } // A failing quota endpoint must not drag its healthy sibling into the 5s tick. diff --git a/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift new file mode 100644 index 0000000000..eb2b2b5ca0 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift @@ -0,0 +1,15 @@ +import Foundation +import MenuBarCore + +enum TimelineDecodingSuite { + static func run(_ t: TestRunner) { + t.test("timeline: decodes series and derived maxima") { + let json = #"{"start":0,"end":3600,"bucketSeconds":1800,"buckets":2,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"a","provider":"p","model":"m","total":3,"points":[1,2]},{"id":"b","provider":"p","model":"n","total":4,"points":[4,0]}],"availableModels":["p/m","p/n"],"missingMeasurements":1,"truncated":true}"# + let timeline = try JSONDecoder().decode(UsageTimeline.self, from: Data(json.utf8)) + t.equal(timeline.maxPoint, 4) + t.equal(timeline.stackedMax, 5) + t.equal(timeline.isEmpty, false) + t.equal(timeline.truncated, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index ad32b2c804..9ee8f3f6ed 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -78,6 +78,22 @@ final class StubProtocol: URLProtocol, @unchecked Sendable { Self.gateEntered.signal() gate.wait() } + if request.url?.path == "/api/companion/settings" { + let body = #"{"settings":{"menuBarMetric":"requests","showToday":true,"showChart":true,"showModels":true,"showCost":true,"showAccounts":true,"chartHours":24,"bucketMinutes":60,"chartStyle":"line","tokenMetric":"total","aggregation":"sum","chartGrouping":"model","hiddenProviders":[]}}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } + if request.url?.path == "/api/usage/timeline" { + let body = #"{"start":0,"end":3600,"bucketSeconds":3600,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } guard let response = Self.next() else { client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) return diff --git a/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift new file mode 100644 index 0000000000..66f25176b6 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift @@ -0,0 +1,37 @@ +import Foundation +import MenuBarCore + +enum WidgetSnapshotSuite { + static func run(_ t: TestRunner) { + t.test("widget snapshot: maps today and caps chart series") { + var series: [String] = [] + for index in 0..<7 { + series.append(#"{"id":"s\#(index)","provider":"p","model":"m\#(index)","total":1,"points":[1]}"#) + } + let timelineJSON = #"{"start":1,"end":2,"bucketSeconds":60,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[\#(series.joined(separator: ","))],"availableModels":[],"missingMeasurements":0}"# + let timeline = try! JSONDecoder().decode(UsageTimeline.self, from: Data(timelineJSON.utf8)) + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(#"{"range":"today","summary":{"requests":2,"totalTokens":3,"estimatedCostUsd":4}}"#.utf8)) + var snapshot = ProxySnapshot(endpoint: .default, usage: report, today: report, timeline: timeline) + snapshot.state = .running(try! JSONDecoder().decode(StartupHealth.self, from: Data(#"{"status":"protected"}"#.utf8))) + let widget = WidgetSnapshot.make(from: snapshot, now: Date(timeIntervalSince1970: 100)) + t.equal(widget.schemaVersion, 1) + t.equal(widget.today?.requests, 2) + t.equal(widget.chart?.series.count, 6) + } + t.test("widget snapshot: encoded payload contains no credentials") { + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + let data = try! JSONEncoder().encode(snapshot) + let text = String(decoding: data, as: UTF8.self) + t.expect(!text.contains("apiKey") && !text.contains("x-opencodex"), "privacy") + } + t.test("widget snapshot: store writes to injected home") { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = WidgetSnapshotStore(homeDirectory: home) + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + store.writeIfChanged(snapshot) + t.expect(FileManager.default.fileExists(atPath: store.url.path), "snapshot file") + let mode = (try? FileManager.default.attributesOfItem(atPath: store.url.path)[.posixPermissions] as? NSNumber)?.intValue + t.equal(mode, 0o600) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift index 8db237c4c5..41928f17a1 100644 --- a/app/Sources/MenuBarCoreTests/main.swift +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -8,6 +8,10 @@ let runner = TestRunner() DiscoverySuite.run(runner) ModelDecodingSuite.run(runner) FormattingSuite.run(runner) +CompanionSettingsSuite.run(runner) +TimelineDecodingSuite.run(runner) +MenuBarTitleSuite.run(runner) +WidgetSnapshotSuite.run(runner) TransportSuite.run(runner) SnapshotStateSuite.run(runner) PollingSuite.run(runner) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift index 3d8b3da75c..7ca5c1d8e2 100644 --- a/app/Sources/MenuBarUI/AppDelegate.swift +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -16,6 +16,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private var coordinator: PollingCoordinator? private var actions: ActionCoordinator? private var client: ProxyClient? + private let widgetStore = WidgetSnapshotStore() /// The snapshot the UI is currently showing, for decisions that need context /// (the start command to display, the default provider to protect). private var latest: ProxySnapshot? @@ -44,6 +45,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { statusItem = item controller.onDashboard = { [weak self] in self?.openDashboard() } + controller.onCompanionSettings = { [weak self] in self?.openCompanionSettings() } controller.onStop = { [weak self] in self?.stopProxy() } controller.onRefresh = { [weak self] in self?.refreshNow() } controller.onAddKey = { [weak self] in self?.openDashboard() } @@ -97,9 +99,15 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { @MainActor fileprivate func render(_ snapshot: ProxySnapshot) { latest = snapshot + let title = snapshot.menuBarTitle ?? "" + statusItem?.button?.title = title + statusItem?.button?.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium) + statusItem?.button?.imagePosition = title.isEmpty ? .imageOnly : .imageLeading statusItem?.button?.image = StatusIcon.image(for: snapshot.state) statusItem?.button?.toolTip = "OpenCodex — \(snapshot.state.title) (\(snapshot.endpoint.display))" controller.apply(snapshot) + let widgetSnapshot = WidgetSnapshot.make(from: snapshot) + Task.detached { [widgetStore] in widgetStore.writeIfChanged(widgetSnapshot) } } // MARK: - Actions @@ -155,6 +163,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { NSWorkspace.shared.open(endpoint.baseURL) } + private func openCompanionSettings() { + guard let url = URL(string: "\(endpoint.baseURL.absoluteString)/#/usage#usage-section-companion") else { return } + NSWorkspace.shared.open(url) + } + /// Stopping is destructive: it interrupts in-flight requests and stops the launchd /// service, so nothing restarts the proxy. It always confirms first. private func stopProxy() { diff --git a/app/Sources/MenuBarUI/CompanionViews.swift b/app/Sources/MenuBarUI/CompanionViews.swift new file mode 100644 index 0000000000..677c0cf94f --- /dev/null +++ b/app/Sources/MenuBarUI/CompanionViews.swift @@ -0,0 +1,74 @@ +import AppKit +import MenuBarCore + +final class ModelsListView: NSView { + private let stack = NSStackView() + + init() { + super.init(frame: .zero) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = Theme.tightGap + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + clear() + let rows = snapshot.todayRows.sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) }.prefix(5) + isHidden = !snapshot.settings.showModels || rows.isEmpty + for row in rows { + let model = [row.provider, row.model].compactMap { $0 }.joined(separator: "/") + let cost = snapshot.settings.showCost ? " · \(Format.cost(row.estimatedCostUsd))" : "" + stack.addArrangedSubview(makeLabel( + "\(model) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))\(cost)", + font: Theme.caption, color: Theme.text + )) + } + } + + private func clear() { + for view in stack.arrangedSubviews { stack.removeArrangedSubview(view); view.removeFromSuperview() } + } +} + +final class AccountsListView: NSView { + private let stack = NSStackView() + + init() { + super.init(frame: .zero) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = Theme.tightGap + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: topAnchor), stack.leadingAnchor.constraint(equalTo: leadingAnchor), + stack.trailingAnchor.constraint(equalTo: trailingAnchor), stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { nil } + + func apply(_ snapshot: ProxySnapshot) { + clear() + let rows = (snapshot.today?.accounts ?? []).sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) } + isHidden = !snapshot.settings.showAccounts || rows.isEmpty + for row in rows { + stack.addArrangedSubview(makeLabel( + "\(row.accountLogLabel ?? Format.unknown) · \(Format.count(row.requests)) · \(Format.tokens(row.totalTokens))", + font: Theme.caption, color: Theme.text + )) + } + } + + private func clear() { + for view in stack.arrangedSubviews { stack.removeArrangedSubview(view); view.removeFromSuperview() } + } +} diff --git a/app/Sources/MenuBarUI/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift index 502bd50783..1cc8dd51ae 100644 --- a/app/Sources/MenuBarUI/PopoverViewController.swift +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -31,7 +31,9 @@ public final class PopoverViewController: NSViewController { private let scrollView = NSScrollView() private let body = NSStackView() private let metrics = MetricsView() - private let sparkline = SparklineView() + private let timelineChart = TimelineChartView() + private let models = ModelsListView() + private let accounts = AccountsListView() private let quotaStack = NSStackView() private let quotaEmpty = makeLabel("No provider quota sources connected.", font: Theme.caption, color: Theme.muted) private let providers = ProviderListView() @@ -53,6 +55,7 @@ public final class PopoverViewController: NSViewController { private let quotaSeparator = makeSeparator() public var onDashboard: (() -> Void)? + public var onCompanionSettings: (() -> Void)? public var onStop: (() -> Void)? public var onQuit: (() -> Void)? public var onRefresh: (() -> Void)? @@ -82,8 +85,8 @@ public final class PopoverViewController: NSViewController { body.alignment = .leading body.spacing = Theme.rowGap body.setViews( - [skeleton, metrics, sparkline, metricsSeparator, quotaStack, quotaEmpty, - providers, quotaSeparator, resultBanner, guidanceLabel, commandField], + [skeleton, metrics, timelineChart, metricsSeparator, models, quotaStack, quotaEmpty, + accounts, providers, quotaSeparator, resultBanner, guidanceLabel, commandField], in: .top ) body.translatesAutoresizingMaskIntoConstraints = false @@ -190,11 +193,15 @@ public final class PopoverViewController: NSViewController { quotaSeparator.isHidden = !showsData if showsData { metrics.apply(snapshot) - sparkline.apply(snapshot) + timelineChart.apply(snapshot) + models.apply(snapshot) + accounts.apply(snapshot) applyQuotas(snapshot) providers.apply(snapshot) } else { - sparkline.isHidden = true + timelineChart.isHidden = true + models.isHidden = true + accounts.isHidden = true quotaStack.isHidden = true quotaEmpty.isHidden = true providers.isHidden = true @@ -337,10 +344,12 @@ public final class PopoverViewController: NSViewController { let menu = NSMenu() menu.addItem(withTitle: "Refresh", action: #selector(refreshTapped), keyEquivalent: "r").target = self menu.addItem(withTitle: "Open dashboard", action: #selector(dashboardTapped), keyEquivalent: "").target = self + menu.addItem(withTitle: "Companion settings…", action: #selector(companionSettingsTapped), keyEquivalent: "").target = self menu.addItem(.separator()) menu.addItem(withTitle: "Quit OpenCodex", action: #selector(quitTapped), keyEquivalent: "q").target = self menu.popUp(positioning: nil, at: NSPoint(x: 0, y: overflowButton.bounds.height + 4), in: overflowButton) } + @objc private func companionSettingsTapped() { onCompanionSettings?() } /// AppKit routes Escape here for the whole responder chain, which `keyDown` does not /// reliably receive inside a popover. diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift index 5166a8334b..c0d5b6dc0b 100644 --- a/app/Sources/MenuBarUI/ProviderListView.swift +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -86,7 +86,7 @@ public final class ProviderListView: NSView { view.removeFromSuperview() } - for provider in snapshot.providers.sorted(by: { $0.name < $1.name }) { + for provider in snapshot.visibleProviders.sorted(by: { $0.name < $1.name }) { let isDefault = provider.name == snapshot.defaultProvider let row = ProviderRowView( provider: provider, @@ -239,6 +239,7 @@ package extension ProviderListView { func isToggleOn(_ name: String) -> Bool? { row(name)?.isOn } func isToggleEnabled(_ name: String) -> Bool? { row(name)?.isToggleEnabled } + func hasProviderForTesting(_ name: String) -> Bool { row(name) != nil } private func row(_ name: String) -> ProviderRowView? { for case let row as ProviderRowView in providerRows where row.providerName == name { diff --git a/app/Sources/MenuBarUI/TimelineChartView.swift b/app/Sources/MenuBarUI/TimelineChartView.swift new file mode 100644 index 0000000000..a3738207be --- /dev/null +++ b/app/Sources/MenuBarUI/TimelineChartView.swift @@ -0,0 +1,95 @@ +import AppKit +import MenuBarCore + +public final class TimelineChartView: NSView { + private var timeline: UsageTimeline? + private var settings = CompanionSettings.defaults + private let colors = [0x0A84FF, 0xFF9F0A, 0x30D158, 0xBF5AF2, 0xFF453A, 0x64D2FF] + + public override var intrinsicContentSize: NSSize { + NSSize(width: NSView.noIntrinsicMetric, height: 104) + } + + public func apply(_ snapshot: ProxySnapshot) { + settings = snapshot.settings + timeline = snapshot.timeline + isHidden = !settings.showChart || timeline == nil + setAccessibilityLabel("Usage timeline") + needsDisplay = true + } + + public override func draw(_ dirtyRect: NSRect) { + guard let timeline, !timeline.isEmpty else { + if settings.showChart { + drawText("No token usage in this window.", in: NSRect(x: 0, y: 36, width: bounds.width, height: 16), font: Theme.caption, color: Theme.muted) + } + return + } + let chartHeight: CGFloat = 72 + let maxValue = settings.chartStyle == .stackedBar ? timeline.stackedMax : timeline.maxPoint + drawText(Format.tokens(Int(maxValue.rounded())), in: NSRect(x: 0, y: chartHeight + 8, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted, alignment: .right) + let window = timeline.buckets * timeline.bucketSeconds / 3600 + let windowLabel = window >= 24 ? "\(window / 24)d" : "\(window)h" + drawText(windowLabel, in: NSRect(x: 0, y: chartHeight + 8, width: 40, height: 14), font: Theme.micro, color: Theme.muted) + + let plot = NSRect(x: 0, y: 20, width: bounds.width, height: chartHeight) + Theme.muted.setStroke() + let baseline = NSBezierPath() + baseline.move(to: NSPoint(x: plot.minX, y: plot.minY)) + baseline.line(to: NSPoint(x: plot.maxX, y: plot.minY)) + baseline.lineWidth = 0.5 + baseline.stroke() + + if settings.chartStyle == .stackedBar { + drawBars(timeline, in: plot, maxValue: maxValue) + } else { + drawLines(timeline, in: plot, maxValue: maxValue) + } + + let legend = timeline.series.prefix(4).enumerated().map { "\($0.offset + 1). \($0.element.id)" }.joined(separator: " ") + let extra = max(0, timeline.series.count - 4) + let legendText = extra > 0 ? "\(legend) +\(extra) more" : legend + drawText(legendText, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted) + } + + private func drawText( + _ text: String, in rect: NSRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left + ) { + let style = NSMutableParagraphStyle() + style.alignment = alignment + NSAttributedString( + string: text, + attributes: [.font: font, .foregroundColor: color, .paragraphStyle: style] + ).draw(in: rect) + } + + private func drawLines(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { + guard timeline.buckets > 1, maxValue > 0 else { return } + for (seriesIndex, series) in timeline.series.enumerated() { + let path = NSBezierPath() + for (index, value) in series.points.enumerated() { + let x = plot.minX + plot.width * CGFloat(index) / CGFloat(max(timeline.buckets - 1, 1)) + let y = plot.minY + plot.height * CGFloat(value / maxValue) + if index == 0 { path.move(to: NSPoint(x: x, y: y)) } else { path.line(to: NSPoint(x: x, y: y)) } + } + NSColor(hex: colors[seriesIndex % colors.count]).setStroke() + path.lineWidth = 1.5 + path.stroke() + } + } + + private func drawBars(_ timeline: UsageTimeline, in plot: NSRect, maxValue: Double) { + guard timeline.buckets > 0, maxValue > 0 else { return } + let width = max(1, plot.width / CGFloat(timeline.buckets) - 1) + for bucket in 0.. 0 else { return } - // Narrow bars with generous gaps read as a chart; wide slabs read as a progress - // bar. Cap the width so a short series does not stretch into blocks. - let count = CGFloat(values.count) - let gap: CGFloat = 4 - let available = bounds.width - gap * (count - 1) - let barWidth = min(14, max(2, available / count)) - // Left-aligned so the trend sits under the metric columns it belongs to. - // Centering it would float the chart away from its own labels. - let originX: CGFloat = 0 - - for (index, value) in values.enumerated() { - // A floor of 2pt keeps a low-but-nonzero day visible; a true zero draws - // nothing, so "quiet" and "none" stay distinguishable. - let ratio = CGFloat(value) / CGFloat(peak) - guard value > 0 else { continue } - let height = max(2, bounds.height * ratio) - let rect = NSRect( - x: originX + CGFloat(index) * (barWidth + gap), - y: 0, - width: barWidth, - height: height - ) - // The most recent day is the one being asked about, so it carries full - // weight while history recedes. - let isLatest = index == values.count - 1 - (isLatest ? Theme.muted : Theme.graphMark).setFill() - NSBezierPath(roundedRect: rect, xRadius: 1.5, yRadius: 1.5).fill() - } - } -} - // MARK: - Quotas /// `OpenAI ▓▓▓▓▓░░░░░ 44%` diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift index 64604e4383..daf7e872b2 100644 --- a/app/Sources/MenuBarUITests/main.swift +++ b/app/Sources/MenuBarUITests/main.swift @@ -129,4 +129,37 @@ runner.test("ui: providers are hidden until they have actually been read") { runner.equal(list.isHidden, false, "an empty result renders its own copy") } +runner.test("ui: hidden providers do not create rows") { + let list = ProviderListView() + var current = snapshot(providers: [provider("openai"), provider("anthropic")]) + current.settings = CompanionSettings(hiddenProviders: ["openai"]) + list.apply(current) + list.expandForTesting() + runner.equal(list.hasProviderForTesting("openai"), false) + runner.equal(list.hasProviderForTesting("anthropic"), true) +} + +runner.test("ui: chart setting hides the timeline view") { + let chart = TimelineChartView() + var current = snapshot(providers: []) + current.timeline = try! JSONDecoder().decode( + UsageTimeline.self, + from: Data(#"{"start":0,"end":1,"bucketSeconds":1,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"#.utf8) + ) + current.settings = CompanionSettings(showChart: false) + chart.apply(current) + runner.equal(chart.isHidden, true) +} + +runner.test("ui: menu title renders from a companion template") { + let report = try! JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"today","summary":{"requests":3}}"#.utf8) + ) + var current = snapshot(providers: []) + current.today = report + current.settings = CompanionSettings(menuBarTemplate: "req {requests}") + runner.equal(current.menuBarTitle, "req 3") +} + exit(runner.summarize()) diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift index f1059c4238..02eca1592b 100644 --- a/app/Sources/UIProbe/main.swift +++ b/app/Sources/UIProbe/main.swift @@ -84,16 +84,21 @@ final class ProbeDelegate: NSObject, NSApplicationDelegate { let quotas = (try? JSONDecoder().decode([QuotaReport].self, from: Data("[\(many)]".utf8))) ?? [] let usage = try? JSONDecoder().decode( UsageReport.self, - from: Data(#"{"range":"7d","summary":{"requests":100},"days":[{"date":"d","requests":100}]}"#.utf8)) + from: Data(#"{"range":"today","summary":{"requests":100,"totalTokens":1200},"models":[{"provider":"p","model":"m","requests":100,"totalTokens":1200}]}"#.utf8)) + let timeline = try? JSONDecoder().decode( + UsageTimeline.self, + from: Data(#"{"start":0,"end":3600,"bucketSeconds":900,"buckets":4,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"p/m","provider":"p","model":"m","total":1200,"points":[100,200,300,600]}],"availableModels":["p/m"],"missingMeasurements":0}"#.utf8)) snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, quotas: quotas, + endpoint: endpoint, usage: usage, settings: CompanionSettings(menuBarMetric: .tokens), + today: usage, timeline: timeline, + quotas: quotas, quotasLoaded: true) case "empty": let usage = try? JSONDecoder().decode( UsageReport.self, - from: Data(#"{"range":"7d","summary":{"requests":0},"days":[]}"#.utf8)) + from: Data(#"{"range":"today","summary":{"requests":0},"models":[],"accounts":[]}"#.utf8)) snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")), - endpoint: endpoint, usage: usage, quotas: [], providers: [], + endpoint: endpoint, usage: usage, today: usage, quotas: [], providers: [], providersLoaded: true, quotasLoaded: true) default: await coordinator.setPopoverOpen(true) From 7788bc54a304ad1d25ca2fc1233114e64b647159 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 11:14:05 -0700 Subject: [PATCH 42/61] feat(widget): WidgetKit extension with small/medium/large families, packaged into OpenCodex.app Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 2 + app/Package.swift | 6 + app/Sources/MenuBarCore/WidgetSnapshot.swift | 43 ++++ app/Sources/OpenCodexWidget/Provider.swift | 49 ++++ .../OpenCodexWidget/SnapshotReader.swift | 31 +++ app/Sources/OpenCodexWidget/Views.swift | 242 ++++++++++++++++++ app/Sources/OpenCodexWidget/main.swift | 6 + app/Widget-Info.plist | 20 ++ app/Widget.entitlements | 8 + .../src/content/docs/guides/macos-menu-bar.md | 8 + .../content/docs/ja/guides/macos-menu-bar.md | 7 + .../content/docs/ko/guides/macos-menu-bar.md | 6 + .../content/docs/ru/guides/macos-menu-bar.md | 7 + .../docs/zh-cn/guides/macos-menu-bar.md | 6 + scripts/build-macos-app.sh | 25 ++ tests/gui/macos-build-script.test.ts | 18 +- 16 files changed, 482 insertions(+), 2 deletions(-) create mode 100644 app/Sources/OpenCodexWidget/Provider.swift create mode 100644 app/Sources/OpenCodexWidget/SnapshotReader.swift create mode 100644 app/Sources/OpenCodexWidget/Views.swift create mode 100644 app/Sources/OpenCodexWidget/main.swift create mode 100644 app/Widget-Info.plist create mode 100644 app/Widget.entitlements diff --git a/README.md b/README.md index 2edc34129b..88a6438b3d 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,8 @@ The first launch needs a right-click → Open, because the app is ad-hoc signed than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) for the full explanation. +The app also includes a macOS 14+ widget for proxy status, today's usage, and quotas. + It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex diff --git a/app/Package.swift b/app/Package.swift index 7ce1361dea..73602c9421 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -6,6 +6,7 @@ let package = Package( platforms: [.macOS(.v13)], products: [ .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), + .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), .executable(name: "MenuBarUITests", targets: ["MenuBarUITests"]), .executable(name: "UIProbe", targets: ["UIProbe"]), @@ -21,6 +22,11 @@ let package = Package( dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/MenuBarApp" ), + .executableTarget( + name: "OpenCodexWidget", + dependencies: ["MenuBarCore"], + path: "Sources/OpenCodexWidget" + ), // An executable rather than a .testTarget: Xcode Command Line Tools ships // neither a usable XCTest module nor the swift-testing runtime, so a test bundle // cannot run without a full Xcode install. See Sources/MenuBarCoreTests/Harness.swift. diff --git a/app/Sources/MenuBarCore/WidgetSnapshot.swift b/app/Sources/MenuBarCore/WidgetSnapshot.swift index c6b0240c18..3dc4f9cd98 100644 --- a/app/Sources/MenuBarCore/WidgetSnapshot.swift +++ b/app/Sources/MenuBarCore/WidgetSnapshot.swift @@ -9,6 +9,12 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public let requests: Int? public let totalTokens: Int? public let estimatedCostUsd: Double? + + public init(requests: Int?, totalTokens: Int?, estimatedCostUsd: Double?) { + self.requests = requests + self.totalTokens = totalTokens + self.estimatedCostUsd = estimatedCostUsd + } } public struct Quota: Codable, Equatable, Sendable { @@ -16,6 +22,13 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public let windowLabel: String public let percent: Double? public let resetAt: Double? + + public init(providerLabel: String, windowLabel: String, percent: Double?, resetAt: Double?) { + self.providerLabel = providerLabel + self.windowLabel = windowLabel + self.percent = percent + self.resetAt = resetAt + } } public struct Chart: Codable, Equatable, Sendable { @@ -24,9 +37,21 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public let style: String public let series: [Series] + public init(start: Double, bucketSeconds: Int, style: String, series: [Series]) { + self.start = start + self.bucketSeconds = bucketSeconds + self.style = style + self.series = series + } + public struct Series: Codable, Equatable, Sendable { public let id: String public let points: [Double] + + public init(id: String, points: [Double]) { + self.id = id + self.points = points + } } } @@ -42,6 +67,24 @@ public struct WidgetSnapshot: Codable, Equatable, Sendable { public let chart: Chart? public let lastUpdated: Double? + public init( + schemaVersion: Int, generatedAt: Double, state: String, stateTitle: String, detail: String?, + endpointDisplay: String, menuTitle: String?, today: Today?, quotas: [Quota], + chart: Chart?, lastUpdated: Double? + ) { + self.schemaVersion = schemaVersion + self.generatedAt = generatedAt + self.state = state + self.stateTitle = stateTitle + self.detail = detail + self.endpointDisplay = endpointDisplay + self.menuTitle = menuTitle + self.today = today + self.quotas = quotas + self.chart = chart + self.lastUpdated = lastUpdated + } + public static func make(from snapshot: ProxySnapshot, now: Date = Date()) -> WidgetSnapshot { let state: String switch snapshot.state { diff --git a/app/Sources/OpenCodexWidget/Provider.swift b/app/Sources/OpenCodexWidget/Provider.swift new file mode 100644 index 0000000000..55d983bf5c --- /dev/null +++ b/app/Sources/OpenCodexWidget/Provider.swift @@ -0,0 +1,49 @@ +import Foundation +import WidgetKit +import MenuBarCore + +@available(macOS 14, *) +public struct SnapshotEntry: TimelineEntry { + public let date: Date + public let snapshot: WidgetSnapshot? + public let failure: ReadFailure? + public let stale: Bool +} + +@available(macOS 14, *) +public struct SnapshotProvider: TimelineProvider { + private let reader = SnapshotReader() + + public init() {} + + public func placeholder(in context: Context) -> SnapshotEntry { + SnapshotEntry(date: Date(), snapshot: Self.sample, failure: nil, stale: false) + } + + public func getSnapshot(in context: Context, completion: @escaping (SnapshotEntry) -> Void) { + completion(readEntry()) + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + completion(Timeline(entries: [readEntry(now: now)], policy: .after(now.addingTimeInterval(300)))) + } + + private func readEntry(now: Date = Date()) -> SnapshotEntry { + switch reader.read() { + case .failure(let failure): + return SnapshotEntry(date: now, snapshot: nil, failure: failure, stale: false) + case .success(let snapshot): + return SnapshotEntry(date: now, snapshot: snapshot, failure: nil, stale: snapshot.isStale(now: now)) + } + } + + private static let sample = WidgetSnapshot( + schemaVersion: 1, generatedAt: Date().timeIntervalSince1970, + state: "running", stateTitle: "Running", detail: "protected", + endpointDisplay: "127.0.0.1:10100", menuTitle: "12", + today: .init(requests: 12, totalTokens: 4_200, estimatedCostUsd: 0.12), + quotas: [.init(providerLabel: "OpenAI", windowLabel: "week", percent: 42, resetAt: Date().addingTimeInterval(86_400).timeIntervalSince1970)], + chart: nil, lastUpdated: Date().timeIntervalSince1970 + ) +} diff --git a/app/Sources/OpenCodexWidget/SnapshotReader.swift b/app/Sources/OpenCodexWidget/SnapshotReader.swift new file mode 100644 index 0000000000..0c3b8fc66b --- /dev/null +++ b/app/Sources/OpenCodexWidget/SnapshotReader.swift @@ -0,0 +1,31 @@ +import Foundation +import MenuBarCore + +public enum ReadFailure: String, Error, Equatable, Sendable { + case missing + case corrupt +} + +public extension WidgetSnapshot { + func isStale(now: Date = Date()) -> Bool { + now.timeIntervalSince1970 - generatedAt > 600 + } +} + +public struct SnapshotReader: Sendable { + public init() {} + + public func read() -> Result { + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let url = directory.appendingPathComponent("OpenCodex/snapshot.json") + guard let data = try? Data(contentsOf: url) else { return .failure(.missing) } + guard let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .failure(.corrupt) + } + return .success(snapshot) + } + + public func isStale(_ snapshot: WidgetSnapshot, now: Date = Date()) -> Bool { + snapshot.isStale(now: now) + } +} diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift new file mode 100644 index 0000000000..e453394ac7 --- /dev/null +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -0,0 +1,242 @@ +import SwiftUI +import WidgetKit +import MenuBarCore + +@available(macOS 14, *) +struct OpenCodexWidgetView: View { + let entry: SnapshotEntry + @Environment(\.widgetFamily) private var family + + var body: some View { + Group { + if let failure = entry.failure { + failureView(failure) + } else if let snapshot = entry.snapshot { + content(snapshot) + } else { + failureView(.missing) + } + } + .containerBackground(.background, for: .widget) + .widgetURL(widgetURL) + } + + private var widgetURL: URL? { + guard let display = entry.snapshot?.endpointDisplay, + let endpoint = URL(string: "http://\(display)"), + endpoint.host != nil, endpoint.port != nil + else { return nil } + return URL(string: "http://\(display)/#/usage") + } + + @ViewBuilder + private func content(_ snapshot: WidgetSnapshot) -> some View { + switch family { + case .systemSmall: + small(snapshot) + case .systemLarge: + large(snapshot) + default: + medium(snapshot) + } + } + + private func tone(_ snapshot: WidgetSnapshot) -> Color { + switch snapshot.state { + case "running": return .green + case "degraded": return .orange + case "unreachable", "unauthorized": return .red + default: return .secondary + } + } + + private func small(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text("OpenCodex").font(.caption).foregroundStyle(.secondary) + } + Text(Format.count(snapshot.today?.requests)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .lineLimit(1) + if let title = snapshot.menuTitle { + Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } else { + Text("requests today").font(.caption).foregroundStyle(.secondary) + } + updated(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func medium(_ snapshot: WidgetSnapshot) -> some View { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + status(snapshot) + metric("Requests", Format.count(snapshot.today?.requests)) + metric("Tokens", Format.tokens(snapshot.today?.totalTokens)) + if let cost = snapshot.today?.estimatedCostUsd { metric("Cost", Format.cost(cost)) } + updated(snapshot) + } + Divider() + quotaView(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func large(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 10) { + medium(snapshot) + if let chart = snapshot.chart { chartView(chart) } + ForEach(Array(snapshot.quotas.prefix(4).enumerated()), id: \.offset) { _, quota in + quotaRow(quota) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func status(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text(([snapshot.stateTitle, snapshot.detail].compactMap { $0?.isEmpty == false ? $0 : nil }).joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private func metric(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Text(value).font(.system(.body, design: .monospaced)) + } + } + + private func quotaView(_ snapshot: WidgetSnapshot) -> some View { + Group { + if let quota = snapshot.quotas.compactMap({ $0.percent == nil ? nil : $0 }).min(by: { ($0.percent ?? 100) < ($1.percent ?? 100) }) { + VStack(alignment: .leading, spacing: 5) { + Text(quota.providerLabel).font(.caption).lineLimit(1) + ProgressView(value: (quota.percent ?? 0) / 100) + .tint((quota.percent ?? 0) > 80 ? .orange : .green) + Text("\(quota.windowLabel) · \(resets(in: quota.resetAt))") + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } else { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func quotaRow(_ quota: WidgetSnapshot.Quota) -> some View { + HStack { + Text(quota.providerLabel).lineLimit(1) + Spacer() + Text("\(Format.percent(quota.percent)) · \(quota.windowLabel)") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func chartView(_ chart: WidgetSnapshot.Chart) -> some View { + GeometryReader { geometry in + if chart.style == "stackedBar" { + stackedBars(chart, in: geometry.size) + } else { + lineChart(chart, in: geometry.size) + } + } + .frame(height: 72) + } + + private func lineChart(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + ZStack { + ForEach(Array(chart.series.enumerated()), id: \.offset) { index, series in + Path { path in + let maxValue = maxPoint(chart.series.flatMap(\.points)) + for pointIndex in series.points.indices { + let x = series.points.count > 1 + ? size.width * CGFloat(pointIndex) / CGFloat(series.points.count - 1) : 0 + let y = size.height * (1 - CGFloat(series.points[pointIndex] / maxValue)) + if pointIndex == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + } + .stroke(palette[index % palette.count], lineWidth: 1.5) + } + } + } + + private func stackedBars(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + let count = chart.series.map(\.points.count).max() ?? 0 + let maxValue = maxPoint((0.. Double { max(points.max() ?? 1, 1) } + + private func resets(in timestamp: Double?) -> String { + Format.resetsIn(timestamp.map(Date.init(timeIntervalSince1970:))) + } + + private func updated(_ snapshot: WidgetSnapshot) -> some View { + let text = snapshot.lastUpdated.map { "Updated \(Format.age(Date(timeIntervalSince1970: $0)))" } ?? "Not updated" + return Text(text).font(.caption2).foregroundStyle(entry.stale ? .orange : .secondary).lineLimit(1) + } + + private func failureView(_ failure: ReadFailure) -> some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: failure == .missing ? "rectangle.on.rectangle" : "exclamationmark.triangle") + .font(.title2) + Text(failure == .missing + ? "Open the OpenCodex menu bar app to start sharing usage." + : "Snapshot unreadable — refresh from the menu bar app.") + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +@available(macOS 14, *) +struct OpenCodexWidgetBundle: WidgetBundle { + var body: some Widget { + OpenCodexWidget() + } +} + +@available(macOS 14, *) +struct OpenCodexWidget: Widget { + let kind = "OpenCodexWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SnapshotProvider()) { entry in + OpenCodexWidgetView(entry: entry) + } + .configurationDisplayName("OpenCodex") + .description("Proxy status, today's usage, and quota at a glance.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/app/Sources/OpenCodexWidget/main.swift b/app/Sources/OpenCodexWidget/main.swift new file mode 100644 index 0000000000..8050e93e0c --- /dev/null +++ b/app/Sources/OpenCodexWidget/main.swift @@ -0,0 +1,6 @@ +import SwiftUI +import WidgetKit + +if #available(macOS 14, *) { + OpenCodexWidgetBundle.main() +} diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist new file mode 100644 index 0000000000..360a159103 --- /dev/null +++ b/app/Widget-Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableOpenCodexWidget + CFBundleIdentifiercom.opencodex.menubar.widget + CFBundleInfoDictionaryVersion6.0 + CFBundleNameOpenCodex + CFBundlePackageTypeXPC! + CFBundleShortVersionString0.0.0 + CFBundleVersion0.0.0 + LSMinimumSystemVersion14.0 + NSHumanReadableCopyrightMIT — opencodex contributors + NSExtension + + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + + diff --git a/app/Widget.entitlements b/app/Widget.entitlements new file mode 100644 index 0000000000..1b44cd3cd2 --- /dev/null +++ b/app/Widget.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 58f558a376..4cfa58c719 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -95,6 +95,14 @@ different default in the dashboard first. Everything else — accounts, model configuration, storage — stays in the dashboard. +## Widget + +Add the widget from the desktop: right-click, choose **Edit Widgets**, then add +**OpenCodex**. It shows proxy status, today's usage, quota pressure, and the same +privacy-safe usage snapshot as the menu bar app. The widget refreshes when the app polls. +It requires macOS 14 or later and reads only the privacy-safe snapshot written by the +OpenCodex app; it does not receive API keys or raw account data. + ## Connecting to the proxy The app finds the proxy automatically. It reads `~/.opencodex/runtime-port.json` (or diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index e7d584c681..d4c5dc5fa4 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -93,6 +93,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app アカウント、モデル設定、ストレージなどはダッシュボードで操作します。 +## ウィジェット + +デスクトップを右クリックして **ウィジェットを編集** を選び、**OpenCodex** を追加します。 +プロキシの状態、今日の使用量、クォータを表示し、メニューバーアプリと同じプライバシー保護済み +スナップショットを使います。アプリのポーリング時に更新されます。macOS 14 以降が必要で、 +API キーや生のアカウント情報は受け取りません。 + ## プロキシへの接続 アプリが自動で見つけます。`~/.opencodex/runtime-port.json`(または diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index f913268ac1..17126a9408 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -92,6 +92,12 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app 계정, 모델 설정, 저장소 관리 같은 나머지는 대시보드에서 합니다. +## 위젯 + +바탕화면을 우클릭하고 **위젯 편집**을 선택한 다음 **OpenCodex**를 추가하세요. 프록시 상태, +오늘의 사용량과 쿼터를 표시하며 메뉴바 앱과 동일한 개인정보 보호 스냅샷을 사용합니다. 앱이 +폴링할 때 새로 고침됩니다. macOS 14 이상이 필요하고 API 키나 원시 계정 정보는 전달하지 않습니다. + ## 프록시 연결 앱이 알아서 찾습니다. `~/.opencodex/runtime-port.json`(또는 diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md index a0ed399171..a67ddae712 100644 --- a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -95,6 +95,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app Всё остальное — аккаунты, настройка моделей, хранилище — остаётся в панели управления. +## Виджет + +Щёлкните правой кнопкой по рабочему столу, выберите **Изменить виджеты** и добавьте +**OpenCodex**. Он показывает состояние прокси, расход за сегодня и квоты, используя тот же +конфиденциальный снимок, что и приложение в строке меню. Виджет обновляется при опросе приложения. +Требуется macOS 14 или новее; API-ключи и необработанные данные аккаунтов не передаются. + ## Подключение к прокси Приложение находит прокси само. Оно читает `~/.opencodex/runtime-port.json` (или diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 421d394664..9a63a6b0bc 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -82,6 +82,12 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app 账号、模型配置、存储等其余操作仍在仪表板中完成。 +## 小组件 + +在桌面上右键点击,选择**编辑小组件**,然后添加 **OpenCodex**。它显示代理状态、今日用量和 +配额,并使用与菜单栏应用相同的隐私安全快照。应用轮询时小组件会刷新。需要 macOS 14 或更高 +版本;它不会接收 API 密钥或原始账户信息。 + ## 连接到代理 应用会自动查找。它读取 `~/.opencodex/runtime-port.json`(或 diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 12074e8122..8499b07caf 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -118,6 +118,7 @@ esac mkdir -p "$output_root" swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) +widget_swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexWidget) if [[ "${UNIVERSAL:-0}" == "1" ]]; then developer_dir="$(xcode-select -p 2>/dev/null || true)" @@ -129,17 +130,25 @@ if [[ "${UNIVERSAL:-0}" == "1" ]]; then exit 1 fi swift_args+=(--arch arm64 --arch x86_64) + widget_swift_args+=(--arch arm64 --arch x86_64) fi echo "==> Building ($configuration)…" swift build "${swift_args[@]}" +swift build "${widget_swift_args[@]}" bin_dir="$(swift build "${swift_args[@]}" --show-bin-path)" executable="$bin_dir/OpenCodexMenuBar" +widget_bin_dir="$(swift build "${widget_swift_args[@]}" --show-bin-path)" +widget_executable="$widget_bin_dir/OpenCodexWidget" if [[ ! -x "$executable" ]]; then echo "Build did not produce an executable at $executable" >&2 exit 1 fi +if [[ ! -x "$widget_executable" ]]; then + echo "Build did not produce an executable at $widget_executable" >&2 + exit 1 +fi staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" staged_app="$staging_root/OpenCodex.app" @@ -150,6 +159,10 @@ trap cleanup EXIT mkdir -p "$staged_app/Contents/MacOS" "$staged_app/Contents/Resources" cp "$executable" "$staged_app/Contents/MacOS/OpenCodexMenuBar" cp "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" +appex="$staged_app/Contents/PlugIns/OpenCodexWidget.appex" +mkdir -p "$appex/Contents/MacOS" +cp "$widget_executable" "$appex/Contents/MacOS/OpenCodexWidget" +cp "$package_dir/Widget-Info.plist" "$appex/Contents/Info.plist" # The app version comes from package.json, so it can never claim a version the release # did not ship. @@ -192,6 +205,8 @@ fi plutil -replace CFBundleShortVersionString -string "$version_core" "$staged_app/Contents/Info.plist" plutil -replace CFBundleVersion -string "$build_version" "$staged_app/Contents/Info.plist" +plutil -replace CFBundleShortVersionString -string "$version_core" "$appex/Contents/Info.plist" +plutil -replace CFBundleVersion -string "$build_version" "$appex/Contents/Info.plist" # Icon: reuse the dashboard favicon rather than adding another binary asset to the repo. icon_source="$repo_root/gui/public/favicon.png" @@ -222,11 +237,20 @@ iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" # be verified". The project has no Developer ID certificate today, so ad-hoc is what # ships and the docs must carry the right-click-Open path rather than pretend # otherwise. +# +# The widget reads the host snapshot through its bundle container fallback path. +# App Groups are intentionally not used because ad-hoc signatures fail the team-ID +# requirement on this machine. if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then + codesign --force --options runtime --timestamp \ + --entitlements "$package_dir/Widget.entitlements" \ + --sign "$MACOS_SIGN_IDENTITY" "$appex" codesign --force --deep --options runtime --timestamp \ --sign "$MACOS_SIGN_IDENTITY" "$staged_app" echo "==> Signed with $MACOS_SIGN_IDENTITY (hardened runtime)" else + codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \ + --timestamp=none "$appex" codesign --force --sign - --timestamp=none "$staged_app" echo "==> Ad-hoc signed (no MACOS_SIGN_IDENTITY): Gatekeeper will require the" >&2 echo " right-click-Open path on first launch." >&2 @@ -241,3 +265,4 @@ mv "$staged_app" "$app_bundle" echo "==> Built $app_bundle (release $version, short $version_core, build $build_version)" lipo -archs "$app_bundle/Contents/MacOS/OpenCodexMenuBar" +lipo -archs "$app_bundle/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" diff --git a/tests/gui/macos-build-script.test.ts b/tests/gui/macos-build-script.test.ts index 44f47c8011..7998ecfee7 100644 --- a/tests/gui/macos-build-script.test.ts +++ b/tests/gui/macos-build-script.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { repoPath, repoRoot as findRepoRoot } from "../helpers/repo-root"; // The macOS build script deletes whatever sits at its destination, so its containment // check is a safety boundary rather than a convenience. These run the real script. @@ -14,10 +15,12 @@ import { join, resolve } from "node:path"; // - resolving physically BEFORE normalising let `..` reveal a symlink that was then // never followed. -const repoRoot = resolve(import.meta.dir, ".."); -const script = join(repoRoot, "scripts", "build-macos-app.sh"); +const repoRoot = findRepoRoot(); +const script = repoPath("scripts", "build-macos-app.sh"); const isMacOS = process.platform === "darwin"; +const scriptText = await Bun.file(script).text(); + async function runScript(outputDir: string, cwd: string = repoRoot) { const proc = Bun.spawn(["bash", script], { cwd, @@ -183,3 +186,14 @@ describe.skipIf(!isMacOS)("macOS build script containment", () => { }); }, 300_000); }); + +describe("macOS widget packaging", () => { + test("stages, signs, and validates the WidgetKit appex", () => { + expect(scriptText).toContain("--product OpenCodexWidget"); + expect(scriptText).toContain("Contents/PlugIns/OpenCodexWidget.appex"); + expect(scriptText).toContain("Widget-Info.plist"); + expect(scriptText).toContain("Widget.entitlements"); + expect(scriptText).toContain("Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget"); + expect(scriptText).toContain("container fallback path"); + }); +}); From 32a9223ca6694a6d46786e054fb50493b91e90f5 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 11:31:40 -0700 Subject: [PATCH 43/61] chore: structure/docs/ci parity for the macOS companion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../051_feature_summary.md | 10 ++++++++++ readme/README.fr.md | 13 +++++++++++++ readme/README.ja.md | 12 ++++++++++++ readme/README.ko.md | 12 ++++++++++++ readme/README.ru.md | 12 ++++++++++++ readme/README.tr.md | 12 ++++++++++++ readme/README.zh-CN.md | 11 +++++++++++ readme/README.zh-TW.md | 11 +++++++++++ readme/i18n-manifest.json | 14 +++++++------- scripts/build-macos-app.sh | 6 +++--- structure/INDEX.md | 2 ++ structure/gui-and-management-api.md | 2 +- structure/manifest.json | 2 ++ tests/ci-workflows/ci-structure-gate.test.ts | 11 ++++++++++- tests/ci-workflows/ci-workflows.test.ts | 1 + 15 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 devlog/_fin/260725_macos_menubar_app/051_feature_summary.md diff --git a/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md new file mode 100644 index 0000000000..1e40fc5201 --- /dev/null +++ b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md @@ -0,0 +1,10 @@ +# 051 — Feature summary + +The macOS companion now shares the proxy's canonical usage accounting across the menu bar +app, widget, and dashboard Usage companion section. The proxy owns the +`/api/usage/timeline` and `/api/companion/settings` contracts; `ocx companion` provides +matching read/write controls with `show`, `set`, and `reset` subcommands. + +The menu bar app renders a settings-driven title, today metrics, model/account/provider +sections, and a timeline chart. It writes a privacy-safe snapshot for the WidgetKit +companion, which supports small, medium, and large families and links back to Usage. diff --git a/readme/README.fr.md b/readme/README.fr.md index 160acfb908..411a27e759 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -92,6 +92,19 @@ Ouvrez **http://localhost:10100** et configurez tout dans le tableau de bord web fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison compatible OpenAI), choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. +### Application macOS dans la barre des menus + +Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fournisseurs sans ouvrir +le tableau de bord. Le code source se trouve dans [`app/`](../app) (Swift + AppKit, sans dépendance +tierce). Téléchargez-le depuis la +[page des releases](https://github.com/lidge-jun/opencodex/releases) ou compilez-le localement avec +`bun run build:macos`. + +Le premier lancement nécessite un clic droit → Ouvrir, car l’application est signée ad hoc et non +notarisée. Consultez le [guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +pour l’explication complète. + +L’application inclut également un widget macOS 14+ affichant l’état du proxy, l’utilisation du jour et les quotas. Il peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs comptes ChatGPT / Codex et actualisez leurs quotas 5 h / hebdomadaires / 30 j dans le tableau de bord. Avec le routage par quota, les nouvelles sessions peuvent utiliser le compte opérationnel le moins sollicité ; diff --git a/readme/README.ja.md b/readme/README.ja.md index 415d5c9674..ee8a7e6adc 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -99,6 +99,18 @@ Codex 認証用の **ChatGPT アカウントプール**も管理できます。C は使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、アカウント に選択順を指定してください。 +### macOS メニューバーアプリ + +ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ +コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。 +[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 +`bun run build:macos` でローカルビルドできます。 + +アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。 +詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。 + +macOS 14 以降では、プロキシの状態、今日の使用量、クォータを表示するウィジェットも利用できます。 + ### スポンサー アップストリームのプロトコルが変わるたびに opencodex を追随させているのはスポンサーの支援です。 diff --git a/readme/README.ko.md b/readme/README.ko.md index f4c44f121c..f93936bc5a 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -96,6 +96,18 @@ round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레 계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. Codex Desktop 로그인처럼 다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면, 계정에 선택 순서를 지정하세요. +### macOS 메뉴 막대 앱 + +대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다. +소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다. +[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 +`bun run build:macos`로 직접 빌드할 수 있습니다. + +앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요. +자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요. + +macOS 14 이상에서는 프록시 상태, 오늘의 사용량과 쿼터를 보여 주는 위젯도 포함됩니다. + ### 스폰서 업스트림 프로토콜이 바뀔 때마다 opencodex가 따라갈 수 있는 건 스폰서 덕분입니다. 관심이 있으면 diff --git a/readme/README.ru.md b/readme/README.ru.md index e654f571a9..d2df43cd07 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -103,6 +103,18 @@ ocx start # прокси + панель управлен них — обычно вход Codex Desktop — должен использоваться только после того, как остальные исчерпаны. +### Приложение macOS в строке меню + +Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели. +Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей). +Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или +соберите локально командой `bun run build:macos`. + +При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc, +но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/). + +Приложение также включает виджет для macOS 14+, показывающий состояние прокси, расход за сегодня и квоты. + ### Спонсоры Спонсоры позволяют поддерживать opencodex при каждом изменении вышестоящих протоколов. Интересно? diff --git a/readme/README.tr.md b/readme/README.tr.md index 998338d876..511a42b0a1 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -99,6 +99,18 @@ değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığ 429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde kullanılmasını istediğiniz bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim sırası verin. +### macOS menü çubuğu uygulaması + +Panoyu açmadan proxy durumunu, kullanımı ve sağlayıcı kotalarını gösteren yerel yardımcı uygulama. +Kaynak kodu [`app/`](../app) konumundadır (Swift + AppKit, üçüncü taraf bağımlılığı yoktur). +[Sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin veya +`bun run build:macos` ile yerel olarak derleyin. + +Uygulama noter tasdikli olmadığından ve ad hoc imzalandığından ilk açılışta sağ tıklayıp Aç'ı seçin. +Ayrıntılar için [macOS menü çubuğu uygulaması kılavuzuna](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) bakın. + +Uygulama ayrıca proxy durumunu, bugünkü kullanımı ve kotaları gösteren macOS 14+ widget'ını içerir. + ### Sponsorlar Her yukarı akış protokol değişiminde opencodex'in bakımını sürdürebilmesi sponsorlar sayesinde. diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 79392708a1..cc2e57271f 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -97,6 +97,17 @@ ocx start # 代理 + 仪表板:localhost:10100 401/403 与 429 恢复,仍可能重新绑定。给账户设定选择顺序,以便其中某个账户 —— 通常是你的 Codex Desktop 登录 —— 只在其他账户耗尽后才被选中。 +### macOS 菜单栏应用 + +无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于 +[`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases) +下载,或使用 `bun run build:macos` 在本地构建。 + +应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅 +[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 + +应用还包含适用于 macOS 14 及更高版本的小组件,可显示代理状态、今日用量和配额。 + ### 赞助商 赞助商支撑 opencodex 跟上每一次上游协议变更。有兴趣? diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 6a25aae4d1..adb8bf5201 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -95,6 +95,17 @@ ocx start # 代理 + 儀表板位於 localhost:10100 行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 +### macOS 選單列應用程式 + +無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於 +[`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases) +下載,或使用 `bun run build:macos` 在本機建置。 + +應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱 +[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 + +應用程式也包含 macOS 14 以上的小工具,可顯示代理狀態、今日用量與配額。 + ### 贊助 贊助讓 opencodex 能跟上每一次上游協議變更。有興趣? diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index d64870cad2..569d3612db 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" } } } diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 8499b07caf..8dcac03ee1 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -238,9 +238,9 @@ iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" # ships and the docs must carry the right-click-Open path rather than pretend # otherwise. # -# The widget reads the host snapshot through its bundle container fallback path. -# App Groups are intentionally not used because ad-hoc signatures fail the team-ID -# requirement on this machine. +# The widget reads the host snapshot through its own bundle container fallback path. +# App Groups require a team-ID-prefixed group and a Developer ID / team-signed extension; +# ad-hoc signatures cannot satisfy that requirement, so the widget uses its own container. if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then codesign --force --options runtime --timestamp \ --entitlements "$package_dir/Widget.entitlements" \ diff --git a/structure/INDEX.md b/structure/INDEX.md index af2f601a68..40c82166e6 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -90,6 +90,7 @@ A source area can be described by more than one doc, because these docs are orga | Source path | Described by | | --- | --- | | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `app/` | [`overview.md`](overview.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `gui/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`design-methodology.md`](design-methodology.md) | @@ -133,6 +134,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/update/` | [`runtime.md`](runtime.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | +| `src/usage/timeline.ts` | [`gui-and-management-api.md`](gui-and-management-api.md) | | `src/vision/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | | `src/web-search/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 55825d854c..a5faae7d44 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -2,7 +2,7 @@ The companion settings contract in `src/companion/` persists menu-bar and widget display preferences, while `src/server/management/companion-routes.ts` exposes those settings and the -usage timeline to local clients. +usage timeline assembled by `src/usage/timeline.ts` to local clients. Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation is a local CLI surface, not a management API or GUI update permission. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). diff --git a/structure/manifest.json b/structure/manifest.json index 54ab432b85..20e69a13f3 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -49,6 +49,7 @@ "title": "Overview", "scope": "Product boundary, local state ownership, and the non-negotiable invariants index.", "documents": [ + "app/", "gui/", "src/companion/", "scripts/", @@ -332,6 +333,7 @@ "src/lib/", "src/server/", "src/usage/", + "src/usage/timeline.ts", "src/vision/" ] }, diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts index b6f2100909..0cda8de533 100644 --- a/tests/ci-workflows/ci-structure-gate.test.ts +++ b/tests/ci-workflows/ci-structure-gate.test.ts @@ -67,8 +67,17 @@ test("the aggregate gate expects the job instead of ignoring it", () => { // job missing from `expected_for` reads as `undeclared`, not as skipped. const gate = workflow.jobs?.ci; expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("structure-gate"); + expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("macos-app"); const script = (gate?.steps ?? []).map(step => step.run ?? "").join("\n"); expect(script).toContain("structure-gate) echo \"$structure\" ;;"); - expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate\""); + expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate macos-app\""); + expect(script).toContain("|macos-app)"); expect(script).toContain("CHANGES_STRUCTURE"); }); + +test("app changes select the macOS app job", () => { + expect(filters.ci).toContain("app/**"); + const macosApp = workflow.jobs?.["macos-app"]; + expect(macosApp?.if).toContain("needs.changes.outputs.ci == 'true'"); + expect(Array.isArray(macosApp?.needs) ? macosApp?.needs : []).toContain("changes"); +}); diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 22b02edb14..658bd37834 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -495,6 +495,7 @@ describe("GitHub Actions hardening", () => { "Dockerfile", "LICENSE", "README.md", + "app/**", "assets/**", "bin/**", "bun.lock", From 3396ca0775ef6f0731cc3612d9e76a0c6e5de6cb Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 11:38:24 -0700 Subject: [PATCH 44/61] feat(widget): NSExtensionMain entry point, family-specific layouts, popover legend/captions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Package.swift | 8 +- app/Sources/MenuBarUI/CompanionViews.swift | 16 +-- app/Sources/MenuBarUI/TimelineChartView.swift | 50 ++++++++- app/Sources/OpenCodexWidget/Views.swift | 101 +++++++++++++++--- tests/gui/macos-build-script.test.ts | 2 + 5 files changed, 152 insertions(+), 25 deletions(-) diff --git a/app/Package.swift b/app/Package.swift index 73602c9421..9e5f137275 100644 --- a/app/Package.swift +++ b/app/Package.swift @@ -25,7 +25,13 @@ let package = Package( .executableTarget( name: "OpenCodexWidget", dependencies: ["MenuBarCore"], - path: "Sources/OpenCodexWidget" + path: "Sources/OpenCodexWidget", + linkerSettings: [ + // Widget extensions must enter through NSExtensionMain or chronod tears down + // the process before the WidgetBundle connects. + .linkedFramework("Foundation"), + .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), + ] ), // An executable rather than a .testTarget: Xcode Command Line Tools ships // neither a usable XCTest module nor the swift-testing runtime, so a test bundle diff --git a/app/Sources/MenuBarUI/CompanionViews.swift b/app/Sources/MenuBarUI/CompanionViews.swift index 677c0cf94f..a23c4bf42c 100644 --- a/app/Sources/MenuBarUI/CompanionViews.swift +++ b/app/Sources/MenuBarUI/CompanionViews.swift @@ -3,12 +3,14 @@ import MenuBarCore final class ModelsListView: NSView { private let stack = NSStackView() + private let caption = makeLabel("MODELS", font: Theme.micro, color: Theme.faint) init() { super.init(frame: .zero) stack.orientation = .vertical stack.alignment = .leading stack.spacing = Theme.tightGap + stack.addArrangedSubview(caption) stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) NSLayoutConstraint.activate([ @@ -20,7 +22,7 @@ final class ModelsListView: NSView { required init?(coder: NSCoder) { nil } func apply(_ snapshot: ProxySnapshot) { - clear() + clearRows() let rows = snapshot.todayRows.sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) }.prefix(5) isHidden = !snapshot.settings.showModels || rows.isEmpty for row in rows { @@ -33,19 +35,21 @@ final class ModelsListView: NSView { } } - private func clear() { - for view in stack.arrangedSubviews { stack.removeArrangedSubview(view); view.removeFromSuperview() } + private func clearRows() { + for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } } } final class AccountsListView: NSView { private let stack = NSStackView() + private let caption = makeLabel("ACCOUNTS", font: Theme.micro, color: Theme.faint) init() { super.init(frame: .zero) stack.orientation = .vertical stack.alignment = .leading stack.spacing = Theme.tightGap + stack.addArrangedSubview(caption) stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) NSLayoutConstraint.activate([ @@ -57,7 +61,7 @@ final class AccountsListView: NSView { required init?(coder: NSCoder) { nil } func apply(_ snapshot: ProxySnapshot) { - clear() + clearRows() let rows = (snapshot.today?.accounts ?? []).sorted { ($0.totalTokens ?? 0) > ($1.totalTokens ?? 0) } isHidden = !snapshot.settings.showAccounts || rows.isEmpty for row in rows { @@ -68,7 +72,7 @@ final class AccountsListView: NSView { } } - private func clear() { - for view in stack.arrangedSubviews { stack.removeArrangedSubview(view); view.removeFromSuperview() } + private func clearRows() { + for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() } } } diff --git a/app/Sources/MenuBarUI/TimelineChartView.swift b/app/Sources/MenuBarUI/TimelineChartView.swift index a3738207be..56a8a6288f 100644 --- a/app/Sources/MenuBarUI/TimelineChartView.swift +++ b/app/Sources/MenuBarUI/TimelineChartView.swift @@ -29,7 +29,12 @@ public final class TimelineChartView: NSView { let maxValue = settings.chartStyle == .stackedBar ? timeline.stackedMax : timeline.maxPoint drawText(Format.tokens(Int(maxValue.rounded())), in: NSRect(x: 0, y: chartHeight + 8, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted, alignment: .right) let window = timeline.buckets * timeline.bucketSeconds / 3600 - let windowLabel = window >= 24 ? "\(window / 24)d" : "\(window)h" + let windowLabel: String + if window < 48 { + windowLabel = "\(window)h" + } else { + windowLabel = "\(window / 24)d" + } drawText(windowLabel, in: NSRect(x: 0, y: chartHeight + 8, width: 40, height: 14), font: Theme.micro, color: Theme.muted) let plot = NSRect(x: 0, y: 20, width: bounds.width, height: chartHeight) @@ -46,10 +51,7 @@ public final class TimelineChartView: NSView { drawLines(timeline, in: plot, maxValue: maxValue) } - let legend = timeline.series.prefix(4).enumerated().map { "\($0.offset + 1). \($0.element.id)" }.joined(separator: " ") - let extra = max(0, timeline.series.count - 4) - let legendText = extra > 0 ? "\(legend) +\(extra) more" : legend - drawText(legendText, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14), font: Theme.micro, color: Theme.muted) + drawLegend(timeline, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14)) } private func drawText( @@ -92,4 +94,42 @@ public final class TimelineChartView: NSView { } } } + + private func drawLegend(_ timeline: UsageTimeline, in rect: NSRect) { + let attributes: [NSAttributedString.Key: Any] = [ + .font: Theme.micro, + .foregroundColor: Theme.muted, + ] + let separator: CGFloat = 10 + let dotSize: CGFloat = 6 + let entries = timeline.series.enumerated().map { index, series in + (index, NSAttributedString(string: "\(index + 1). \(series.id)", attributes: attributes)) + } + var visible = entries.count + while visible > 0 { + let extra = entries.count - visible + let suffixWidth = extra > 0 + ? NSAttributedString(string: "+\(extra) more", attributes: attributes).size().width + separator + : 0 + let entryWidth = entries.prefix(visible).reduce(CGFloat.zero) { width, entry in + width + dotSize + 4 + entry.1.size().width + separator + } + if entryWidth + suffixWidth <= rect.width || visible == 0 { break } + visible -= 1 + } + let extra = entries.count - visible + var x = rect.minX + for (index, text) in entries.prefix(visible) { + let dot = NSRect(x: x, y: rect.midY - dotSize / 2, width: dotSize, height: dotSize) + NSColor(hex: colors[index % colors.count]).setFill() + NSBezierPath(ovalIn: dot).fill() + x += dotSize + 4 + text.draw(at: NSPoint(x: x, y: rect.minY)) + x += text.size().width + separator + } + if extra > 0 { + NSAttributedString(string: "+\(extra) more", attributes: attributes) + .draw(at: NSPoint(x: x, y: rect.minY)) + } + } } diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index e453394ac7..d88ee9c564 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -59,11 +59,17 @@ struct OpenCodexWidgetView: View { Text(Format.count(snapshot.today?.requests)) .font(.system(size: 28, weight: .semibold, design: .rounded)) .lineLimit(1) - if let title = snapshot.menuTitle { - Text(title).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } else { - Text("requests today").font(.caption).foregroundStyle(.secondary) + Text("requests today").font(.caption).foregroundStyle(.secondary) + HStack(spacing: 4) { + Text(Format.tokens(snapshot.today?.totalTokens)) + if let cost = snapshot.today?.estimatedCostUsd { + Text("·") + Text(Format.cost(cost)) + } } + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) updated(snapshot) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -79,18 +85,43 @@ struct OpenCodexWidgetView: View { updated(snapshot) } Divider() - quotaView(snapshot) + if hasQuota(snapshot) { + quotaView(snapshot) + } else if let chart = snapshot.chart { + VStack(alignment: .leading, spacing: 5) { + Text("Last \(windowLabel(chart))").font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: false) + } + } else { + VStack(alignment: .leading, spacing: 4) { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + Text("Quota appears for providers that report limits") + .font(.caption2).foregroundStyle(.secondary).lineLimit(2) + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func large(_ snapshot: WidgetSnapshot) -> some View { VStack(alignment: .leading, spacing: 10) { - medium(snapshot) - if let chart = snapshot.chart { chartView(chart) } - ForEach(Array(snapshot.quotas.prefix(4).enumerated()), id: \.offset) { _, quota in - quotaRow(quota) + status(snapshot) + metricsRow(snapshot) + if !snapshot.quotas.isEmpty { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(snapshot.quotas.prefix(4).enumerated()), id: \.offset) { _, quota in + quotaRow(quota) + } + } + } + if let chart = snapshot.chart { + Text("Last \(windowLabel(chart)) · \(chart.series.count) models") + .font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: true) + .frame(maxHeight: .infinity) + legend(chart) } + updated(snapshot) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } @@ -113,6 +144,26 @@ struct OpenCodexWidgetView: View { } } + private func metricsRow(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 10) { + metricColumn("REQUESTS", Format.count(snapshot.today?.requests)) + metricColumn("TOKENS", Format.tokens(snapshot.today?.totalTokens)) + metricColumn("COST", Format.cost(snapshot.today?.estimatedCostUsd)) + } + } + + private func metricColumn(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + Text(value).font(.system(.body, design: .monospaced)).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func hasQuota(_ snapshot: WidgetSnapshot) -> Bool { + snapshot.quotas.contains { $0.percent != nil } + } + private func quotaView(_ snapshot: WidgetSnapshot) -> some View { Group { if let quota = snapshot.quotas.compactMap({ $0.percent == nil ? nil : $0 }).min(by: { ($0.percent ?? 100) < ($1.percent ?? 100) }) { @@ -139,7 +190,7 @@ struct OpenCodexWidgetView: View { } } - private func chartView(_ chart: WidgetSnapshot.Chart) -> some View { + private func chartView(_ chart: WidgetSnapshot.Chart, flexible: Bool) -> some View { GeometryReader { geometry in if chart.style == "stackedBar" { stackedBars(chart, in: geometry.size) @@ -147,7 +198,21 @@ struct OpenCodexWidgetView: View { lineChart(chart, in: geometry.size) } } - .frame(height: 72) + .frame(minHeight: 72, maxHeight: flexible ? .infinity : 72) + } + + private func legend(_ chart: WidgetSnapshot.Chart) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 4) { + ForEach(Array(chart.series.prefix(5).enumerated()), id: \.offset) { index, series in + HStack(spacing: 4) { + Circle().fill(seriesColor(index)).frame(width: 6, height: 6) + Text(series.id) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + } + } + } } private func lineChart(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { @@ -163,7 +228,7 @@ struct OpenCodexWidgetView: View { else { path.addLine(to: CGPoint(x: x, y: y)) } } } - .stroke(palette[index % palette.count], lineWidth: 1.5) + .stroke(seriesColor(index), lineWidth: 1.5) } } } @@ -179,7 +244,7 @@ struct OpenCodexWidgetView: View { ForEach(Array(chart.series.enumerated()), id: \.offset) { seriesIndex, series in let value = series.points.indices.contains(index) ? series.points[index] : 0 Rectangle() - .fill(palette[seriesIndex % palette.count]) + .fill(seriesColor(seriesIndex)) .frame(height: max(0, size.height * value / maxValue)) } } @@ -196,6 +261,16 @@ struct OpenCodexWidgetView: View { Color(red: 100 / 255, green: 210 / 255, blue: 1) ] + private func seriesColor(_ index: Int) -> Color { + palette[index % palette.count] + } + + private func windowLabel(_ chart: WidgetSnapshot.Chart) -> String { + let hours = chart.bucketSeconds * (chart.series.map(\.points.count).max() ?? 0) / 3600 + if hours < 48 { return "\(hours)h" } + return "\(hours / 24)d" + } + private func maxPoint(_ points: [Double]) -> Double { max(points.max() ?? 1, 1) } private func resets(in timestamp: Double?) -> String { diff --git a/tests/gui/macos-build-script.test.ts b/tests/gui/macos-build-script.test.ts index 7998ecfee7..70d4a018e7 100644 --- a/tests/gui/macos-build-script.test.ts +++ b/tests/gui/macos-build-script.test.ts @@ -20,6 +20,7 @@ const script = repoPath("scripts", "build-macos-app.sh"); const isMacOS = process.platform === "darwin"; const scriptText = await Bun.file(script).text(); +const packageText = await Bun.file(repoPath("app", "Package.swift")).text(); async function runScript(outputDir: string, cwd: string = repoRoot) { const proc = Bun.spawn(["bash", script], { @@ -195,5 +196,6 @@ describe("macOS widget packaging", () => { expect(scriptText).toContain("Widget.entitlements"); expect(scriptText).toContain("Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget"); expect(scriptText).toContain("container fallback path"); + expect(packageText).toContain("_NSExtensionMain"); }); }); From b92b05899f8174302b6696de10027e92aa247181 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 11:43:13 -0700 Subject: [PATCH 45/61] fix(cli): include companion in help banner Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/cli/help.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/help.ts b/src/cli/help.ts index 0be199f1a5..da5450880b 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -89,6 +89,7 @@ Usage: ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection ocx config Validated configuration show/get/set/import/export + ocx companion Menu-bar and widget companion usage settings ocx lab Read-only Compatibility Lab projection inspection ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile From 520466f6b8dff0adc156ca089a06c074e9f57915 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 12:04:50 -0700 Subject: [PATCH 46/61] fix(ci): cover companion parity and GUI doctor findings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/pages/usage-companion-panel.tsx | 12 +++++++----- gui/src/pages/usage-companion-utils.ts | 3 ++- tests/cli/cli-headless-parity.test.ts | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index d49e9e390a..118a6fdeaf 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -249,6 +249,8 @@ export default function UsageCompanionPanel({ } const providerNames = providers.map(provider => provider.provider).filter((provider, index, all) => all.indexOf(provider) === index).toSorted(); const selectedModels = current.models ?? availableModels; + const selectedModelSet = new Set(selectedModels); + const hiddenProviderSet = new Set(current.hiddenProviders); const saveMessage = saveState === "saved" && response?.updatedAt ? t("usage.companion.saved", { time: formatSaveTime(response.updatedAt, locale) }) : saveState === "error" ? t("usage.companion.saveFailed", { error: saveError ?? "" }) : ""; @@ -277,10 +279,10 @@ export default function UsageCompanionPanel({ ["showCost", "cost"], ["showAccounts", "accounts"], ] as const).map(([key, label]) => ( -
diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts index 26c265b356..0b668b6df9 100644 --- a/gui/src/pages/usage-companion-utils.ts +++ b/gui/src/pages/usage-companion-utils.ts @@ -73,8 +73,9 @@ export function buildCompanionSettingsPatch( } if (next.models !== undefined && availableModels.length > 0) { const selected = next.models ?? []; + const selectedSet = new Set(selected); const allSelected = selected.length === availableModels.length - && availableModels.every(model => selected.includes(model)); + && availableModels.every(model => selectedSet.has(model)); if (allSelected) next.models = null; } return next; diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index c8b7257a8d..27e9f657ff 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -404,6 +404,7 @@ describe("headless GUI parity CLI", () => { ["/api/logs", "ocx observe"], ["/api/lab", "ocx lab"], ["/api/config", "ocx config"], + ["/api/companion", "ocx companion"], // The client machine plane. These are served by the connected client's own loopback // listener rather than the hub, and each one mirrors a connect-family command: // status/clients -> `ocx connect status`, sync -> `ocx sync`, shim -> the client From 71119b8623e48f1e0d7e8065a52549c9126f6735 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 12:27:42 -0700 Subject: [PATCH 47/61] fix(gui): defer companion loading and translate French labels Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/i18n/fr.ts | 6 +++--- gui/src/pages/usage-companion-panel.tsx | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index c004f8e0a5..a4670be556 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1018,18 +1018,18 @@ export const fr: Record = { "usage.companion.menuRequests": "Requêtes", "usage.companion.menuTokens": "Jetons", "usage.companion.menuCost": "Coût", - "usage.companion.menuQuota": "Quota", + "usage.companion.menuQuota": "Limite", "usage.companion.menuNone": "Icône uniquement", "usage.companion.window": "Période", "usage.companion.window6": "6 h", "usage.companion.window24": "24 h", "usage.companion.window72": "3 j", "usage.companion.window168": "7 j", - "usage.companion.style": "Style", + "usage.companion.style": "Présentation", "usage.companion.styleLine": "Courbe", "usage.companion.styleStacked": "Empilé", "usage.companion.metric": "Métrique", - "usage.companion.metricTotal": "Total", + "usage.companion.metricTotal": "Total général", "usage.companion.metricInput": "Entrée", "usage.companion.metricOutput": "Sortie", "usage.companion.metricCached": "En cache", diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index 118a6fdeaf..ffe15838e1 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -80,7 +80,7 @@ function SelectControl({ } function useVisible(ref: RefObject): boolean { - const [visible, setVisible] = useState(typeof IntersectionObserver === "undefined"); + const [visible, setVisible] = useState(false); useEffect(() => { if (visible || !ref.current || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver(entries => { From 3ce7061cbb12489a3c2859f94e88a203f04b6a5b Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 13:03:52 -0700 Subject: [PATCH 48/61] fix(gui): surface corrupt companion settings and correct the widget copy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/i18n/de.ts | 4 +++- gui/src/i18n/en.ts | 4 +++- gui/src/i18n/fr.ts | 4 +++- gui/src/i18n/ja.ts | 4 +++- gui/src/i18n/ko.ts | 4 +++- gui/src/i18n/ru.ts | 4 +++- gui/src/i18n/tr.ts | 4 +++- gui/src/i18n/vi.ts | 4 +++- gui/src/i18n/zh-TW.ts | 4 +++- gui/src/i18n/zh.ts | 4 +++- gui/src/pages/usage-companion-panel.tsx | 23 ++++++++++++----------- gui/src/pages/usage-companion-utils.ts | 1 + 12 files changed, 43 insertions(+), 21 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8607d0d5ac..9929e32a9a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -980,10 +980,12 @@ export const de: Record = { "usage.companion.chartLabel": "Nutzungszeitachse", "usage.companion.olderRecordsSkipped": "Ältere Einträge wurden übersprungen", "usage.companion.settingsUnavailable": "Begleiteinstellungen nicht verfügbar", + "usage.companion.corrupt": "Die Datei mit den Begleiteinstellungen ist beschädigt. Die Steuerelemente zeigen Standardwerte; das Speichern ist pausiert, bis Sie die Datei ersetzen.", + "usage.companion.corruptReset": "Durch Standardwerte ersetzen", "usage.companion.saved": "Gespeichert · {time}", "usage.companion.saveFailed": "Speichern fehlgeschlagen: {error}", "usage.companion.reset": "Auf Standardwerte zurücksetzen", - "usage.companion.footer": "Das Widget übernimmt die Kennzahl der Menüleiste und wird beim Abruf der App aktualisiert.", + "usage.companion.footer": "Das Widget zeigt die heutigen Anfragen, Tokens und Kosten sowie das hier konfigurierte Diagramm und wird beim Abruf der App aktualisiert.", "usage.companion.menuBarShows": "Menüleiste zeigt", "usage.companion.menuRequests": "Anfragen", "usage.companion.menuTokens": "Token", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b5a8871bca..3d8eccfd4f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1033,10 +1033,12 @@ export const en = { "usage.companion.chartLabel": "Usage timeline", "usage.companion.olderRecordsSkipped": "Older records were skipped", "usage.companion.settingsUnavailable": "Companion settings unavailable", + "usage.companion.corrupt": "The companion settings file is corrupt. Controls show defaults; saving is paused until you replace the file.", + "usage.companion.corruptReset": "Replace with defaults", "usage.companion.saved": "Saved · {time}", "usage.companion.saveFailed": "Couldn’t save: {error}", "usage.companion.reset": "Reset to defaults", - "usage.companion.footer": "The widget mirrors the menu bar metric and refreshes when the app polls.", + "usage.companion.footer": "The widget shows today's requests, tokens and cost plus the chart configured here, and refreshes when the app polls.", "usage.companion.menuBarShows": "Menu bar shows", "usage.companion.menuRequests": "Requests", "usage.companion.menuTokens": "Tokens", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a4670be556..e7d5a51cab 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1010,10 +1010,12 @@ export const fr: Record = { "usage.companion.chartLabel": "Chronologie de l’utilisation", "usage.companion.olderRecordsSkipped": "Les enregistrements plus anciens ont été ignorés", "usage.companion.settingsUnavailable": "Réglages du compagnon indisponibles", + "usage.companion.corrupt": "Le fichier de réglages du compagnon est corrompu. Les contrôles affichent les valeurs par défaut ; l’enregistrement est suspendu jusqu’au remplacement du fichier.", + "usage.companion.corruptReset": "Remplacer par les valeurs par défaut", "usage.companion.saved": "Enregistré · {time}", "usage.companion.saveFailed": "Échec de l’enregistrement : {error}", "usage.companion.reset": "Rétablir les valeurs par défaut", - "usage.companion.footer": "Le widget reprend la métrique de la barre des menus et s’actualise quand l’app interroge le proxy.", + "usage.companion.footer": "Le widget affiche les requêtes, les jetons et le coût du jour, ainsi que le graphique configuré ici, et s’actualise quand l’app interroge le proxy.", "usage.companion.menuBarShows": "La barre des menus affiche", "usage.companion.menuRequests": "Requêtes", "usage.companion.menuTokens": "Jetons", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index cf8261fa79..59894c5692 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -945,10 +945,12 @@ export const ja: Record = { "usage.companion.chartLabel": "使用量タイムライン", "usage.companion.olderRecordsSkipped": "古い記録はスキップされました", "usage.companion.settingsUnavailable": "コンパニオン設定を利用できません", + "usage.companion.corrupt": "コンパニオン設定ファイルが破損しています。コントロールにはデフォルト値が表示され、ファイルを置き換えるまで保存は一時停止されます。", + "usage.companion.corruptReset": "デフォルト値に置き換える", "usage.companion.saved": "保存済み · {time}", "usage.companion.saveFailed": "保存できませんでした: {error}", "usage.companion.reset": "既定値に戻す", - "usage.companion.footer": "ウィジェットはメニューバーの指標を使用し、アプリのポーリング時に更新されます。", + "usage.companion.footer": "ウィジェットには今日のリクエスト数、トークン数、コストと、ここで設定したグラフが表示され、アプリのポーリング時に更新されます。", "usage.companion.menuBarShows": "メニューバーに表示", "usage.companion.menuRequests": "リクエスト", "usage.companion.menuTokens": "トークン", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 9ccbfce0c6..2183552a9b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1014,10 +1014,12 @@ export const ko: Record = { "usage.companion.chartLabel": "사용량 타임라인", "usage.companion.olderRecordsSkipped": "오래된 기록을 건너뛰었습니다", "usage.companion.settingsUnavailable": "컴패니언 설정을 사용할 수 없습니다", + "usage.companion.corrupt": "컴패니언 설정 파일이 손상되었습니다. 컨트롤에는 기본값이 표시되며 파일을 교체할 때까지 저장이 일시 중지됩니다.", + "usage.companion.corruptReset": "기본값으로 교체", "usage.companion.saved": "저장됨 · {time}", "usage.companion.saveFailed": "저장하지 못했습니다: {error}", "usage.companion.reset": "기본값으로 재설정", - "usage.companion.footer": "위젯은 메뉴 막대 지표를 따르며 앱이 폴링할 때 새로 고쳐집니다.", + "usage.companion.footer": "위젯에는 오늘의 요청, 토큰, 비용과 여기에서 구성한 차트가 표시되며 앱이 폴링할 때 새로 고쳐집니다.", "usage.companion.menuBarShows": "메뉴 막대 표시", "usage.companion.menuRequests": "요청", "usage.companion.menuTokens": "토큰", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 33ac1e9628..35c25940cd 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1001,10 +1001,12 @@ export const ru: Record = { "usage.companion.chartLabel": "Временная шкала использования", "usage.companion.olderRecordsSkipped": "Старые записи пропущены", "usage.companion.settingsUnavailable": "Настройки компаньона недоступны", + "usage.companion.corrupt": "Файл настроек компаньона повреждён. В элементах управления показаны значения по умолчанию; сохранение приостановлено, пока файл не будет заменён.", + "usage.companion.corruptReset": "Заменить значениями по умолчанию", "usage.companion.saved": "Сохранено · {time}", "usage.companion.saveFailed": "Не удалось сохранить: {error}", "usage.companion.reset": "Сбросить настройки", - "usage.companion.footer": "Виджет повторяет метрику строки меню и обновляется при опросе приложения.", + "usage.companion.footer": "Виджет показывает сегодняшние запросы, токены и стоимость, а также настроенный здесь график, и обновляется при опросе приложения.", "usage.companion.menuBarShows": "В строке меню", "usage.companion.menuRequests": "Запросы", "usage.companion.menuTokens": "Токены", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f37a2e9dc7..400be275de 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1020,10 +1020,12 @@ export const tr: Record = { "usage.companion.chartLabel": "Kullanım zaman çizelgesi", "usage.companion.olderRecordsSkipped": "Eski kayıtlar atlandı", "usage.companion.settingsUnavailable": "Yardımcı ayarları kullanılamıyor", + "usage.companion.corrupt": "Yardımcı ayarları dosyası bozuk. Denetimler varsayılan değerleri gösteriyor; dosyayı değiştirene kadar kaydetme duraklatıldı.", + "usage.companion.corruptReset": "Varsayılanlarla değiştir", "usage.companion.saved": "Kaydedildi · {time}", "usage.companion.saveFailed": "Kaydedilemedi: {error}", "usage.companion.reset": "Varsayılanlara sıfırla", - "usage.companion.footer": "Widget, menü çubuğu metriğini yansıtır ve uygulama yoklama yaptığında yenilenir.", + "usage.companion.footer": "Widget, bugünkü istekleri, belirteçleri ve maliyeti ve burada yapılandırılan grafiği gösterir; uygulama yoklama yaptığında yenilenir.", "usage.companion.menuBarShows": "Menü çubuğunda göster", "usage.companion.menuRequests": "İstekler", "usage.companion.menuTokens": "Tokenlar", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a8ebe7baf1..77bcd283e9 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1003,10 +1003,12 @@ export const vi: Record = { "usage.companion.chartLabel": "Dòng thời gian sử dụng", "usage.companion.olderRecordsSkipped": "Đã bỏ qua các bản ghi cũ hơn", "usage.companion.settingsUnavailable": "Không có cài đặt companion", + "usage.companion.corrupt": "Tệp cài đặt companion bị hỏng. Các điều khiển hiển thị giá trị mặc định; việc lưu bị tạm dừng cho đến khi bạn thay thế tệp.", + "usage.companion.corruptReset": "Thay thế bằng mặc định", "usage.companion.saved": "Đã lưu · {time}", "usage.companion.saveFailed": "Không thể lưu: {error}", "usage.companion.reset": "Đặt lại mặc định", - "usage.companion.footer": "Widget phản chiếu chỉ số thanh menu và làm mới khi ứng dụng thăm dò.", + "usage.companion.footer": "Widget hiển thị số yêu cầu, token và chi phí hôm nay cùng biểu đồ được cấu hình ở đây, rồi làm mới khi ứng dụng thăm dò.", "usage.companion.menuBarShows": "Thanh menu hiển thị", "usage.companion.menuRequests": "Yêu cầu", "usage.companion.menuTokens": "Token", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index aa6a01b6cb..2e31aba078 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -822,10 +822,12 @@ export const zhTW: Record = { "usage.companion.chartLabel": "使用量時間軸", "usage.companion.olderRecordsSkipped": "已略過較早記錄", "usage.companion.settingsUnavailable": "伴隨設定無法使用", + "usage.companion.corrupt": "伴隨設定檔已損毀。控制項顯示預設值;在替換檔案前將暫停儲存。", + "usage.companion.corruptReset": "替換為預設值", "usage.companion.saved": "已儲存 · {time}", "usage.companion.saveFailed": "無法儲存:{error}", "usage.companion.reset": "重設為預設值", - "usage.companion.footer": "小工具會反映選單列指標,並在 App 輪詢時重新整理。", + "usage.companion.footer": "小工具會顯示今天的請求、權杖和費用,以及此處設定的圖表,並在 App 輪詢時重新整理。", "usage.companion.menuBarShows": "選單列顯示", "usage.companion.menuRequests": "要求", "usage.companion.menuTokens": "權杖", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 7e99e21ae6..5b338159f4 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -995,10 +995,12 @@ export const zh: Record = { "usage.companion.chartLabel": "使用量时间线", "usage.companion.olderRecordsSkipped": "已跳过较早记录", "usage.companion.settingsUnavailable": "伴侣设置不可用", + "usage.companion.corrupt": "伴侣设置文件已损坏。控件显示默认值;替换文件前将暂停保存。", + "usage.companion.corruptReset": "替换为默认值", "usage.companion.saved": "已保存 · {time}", "usage.companion.saveFailed": "保存失败:{error}", "usage.companion.reset": "恢复默认设置", - "usage.companion.footer": "小组件显示菜单栏指标,并在应用轮询时刷新。", + "usage.companion.footer": "小组件显示今天的请求数、令牌数和费用,以及此处配置的图表,并在应用轮询时刷新。", "usage.companion.menuBarShows": "菜单栏显示", "usage.companion.menuRequests": "请求", "usage.companion.menuTokens": "令牌", diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index ffe15838e1..e515b9c93d 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -186,13 +186,14 @@ export default function UsageCompanionPanel({ }, [chartQuery, loadTimeline, visible]); const updateSettings = useCallback((patch: Partial) => { + if (response?.corrupt) return; setSettings(current => current ? { ...current, ...patch } : current); setSaveState("saving"); setSaveError(null); - }, []); + }, [response?.corrupt]); useEffect(() => { - if (!settings || !saveBaseline.current || saveBaseline.current === settings || saveState !== "saving") return; + if (response?.corrupt || !settings || !saveBaseline.current || saveBaseline.current === settings || saveState !== "saving") return; if (saveTimer.current) clearTimeout(saveTimer.current); saveTimer.current = setTimeout(async () => { try { @@ -216,7 +217,7 @@ export default function UsageCompanionPanel({ return () => { if (saveTimer.current) clearTimeout(saveTimer.current); }; - }, [apiBase, availableModels, saveState, settings]); + }, [apiBase, availableModels, response?.corrupt, saveState, settings]); const reset = useCallback(async () => { setSaveState("saving"); @@ -228,17 +229,13 @@ export default function UsageCompanionPanel({ body: JSON.stringify({ reset: true }), }); if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); - const next = await result.json() as CompanionSettingsResponse; - setResponse(next); - setSettings(next.settings); - saveBaseline.current = next.settings; - onSettingsLoaded?.(next.settings.menuBarMetric); + await loadSettings(); setSaveState("saved"); } catch (error) { setSaveError(errorMessage(error)); setSaveState("error"); } - }, [apiBase, onSettingsLoaded]); + }, [apiBase, loadSettings]); if (settingsError) { return

{t("usage.companion.settingsUnavailable")}

; @@ -256,6 +253,10 @@ export default function UsageCompanionPanel({ : saveState === "error" ? t("usage.companion.saveFailed", { error: saveError ?? "" }) : ""; return (
+ {response?.corrupt &&
+ {t("usage.companion.corrupt")} + +
}

{t("usage.companion.title")}

@@ -264,7 +265,7 @@ export default function UsageCompanionPanel({ {t("usage.companion.installGuide")}
void loadTimeline()} locale={locale} t={t} /> -
+
t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> t(`usage.companion.window${value}` as never)} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> value === "line" ? t("usage.companion.styleLine") : t("usage.companion.styleStacked")} onChange={value => updateSettings({ chartStyle: value })} /> @@ -298,7 +299,7 @@ export default function UsageCompanionPanel({ {providerNames.length > 0 &&
{t("usage.companion.hideProviders")}{providerNames.map(provider => )}
}
-
+
{saveMessage || "\u00a0"} {saveState === "error" && } diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts index 0b668b6df9..492c53cd28 100644 --- a/gui/src/pages/usage-companion-utils.ts +++ b/gui/src/pages/usage-companion-utils.ts @@ -50,6 +50,7 @@ export interface CompanionSettingsResponse { settings: CompanionSettings; updatedAt: number | null; defaults: CompanionSettings; + corrupt?: boolean; } export const CHART_BUCKET_MINUTES: Record = { From d53ba8e469956555533c1f55ddd46144e0dd5e97 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 13:04:39 -0700 Subject: [PATCH 49/61] fix(gui): reset fieldset chrome on the companion controls Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/styles-usage-workspace.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 453e1a2c6b..4886d3ea69 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -241,7 +241,7 @@ animation: pulse 1.2s ease-in-out infinite alternate; } .usage-companion-chart-state { display: flex; align-items: center; gap: 10px; min-height: 160px; color: var(--muted); } -.usage-companion-controls { display: grid; gap: 14px; } +.usage-companion-controls { display: grid; gap: 14px; border: 0; padding: 0; margin: 0; min-width: 0; } .usage-companion-control { display: grid; gap: 6px; min-width: 0; } .usage-companion-control > select, .usage-companion-control > input { min-height: 34px; width: 100%; padding: 6px 9px; From 7a172c7c4c04c9c57eb77a9622d1fc2da3f3aef2 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:09:31 -0700 Subject: [PATCH 50/61] feat(companion): default the menu bar headline to tokens Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/MenuBarCore/CompanionSettings.swift | 6 +++--- app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift | 2 +- devlog/_fin/260725_macos_menubar_app/051_feature_summary.md | 2 ++ docs-site/src/content/docs/guides/macos-menu-bar.md | 3 +++ src/companion/settings.ts | 2 +- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/Sources/MenuBarCore/CompanionSettings.swift b/app/Sources/MenuBarCore/CompanionSettings.swift index acc4979097..2e3b0333b1 100644 --- a/app/Sources/MenuBarCore/CompanionSettings.swift +++ b/app/Sources/MenuBarCore/CompanionSettings.swift @@ -38,14 +38,14 @@ public struct CompanionSettings: Decodable, Equatable, Sendable { public let hiddenProviders: [String] public static let defaults = CompanionSettings( - menuBarMetric: .requests, menuBarTemplate: nil, + menuBarMetric: .tokens, menuBarTemplate: nil, showToday: true, showChart: true, showModels: true, showCost: true, showAccounts: true, chartHours: 24, bucketMinutes: 60, chartStyle: .line, tokenMetric: .total, aggregation: .sum, chartGrouping: .model, models: nil, hiddenProviders: [] ) public init( - menuBarMetric: MenuBarMetric = .requests, + menuBarMetric: MenuBarMetric = .tokens, menuBarTemplate: String? = nil, showToday: Bool = true, showChart: Bool = true, @@ -86,7 +86,7 @@ public struct CompanionSettings: Decodable, Equatable, Sendable { public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.init( - menuBarMetric: Self.enumValue(MenuBarMetric.self, try c.decodeIfPresent(String.self, forKey: .menuBarMetric), default: .requests), + menuBarMetric: Self.enumValue(MenuBarMetric.self, try c.decodeIfPresent(String.self, forKey: .menuBarMetric), default: .tokens), menuBarTemplate: try c.decodeIfPresent(String.self, forKey: .menuBarTemplate), showToday: try c.decodeIfPresent(Bool.self, forKey: .showToday) ?? true, showChart: try c.decodeIfPresent(Bool.self, forKey: .showChart) ?? true, diff --git a/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift index 0a927a880d..bed663b8e2 100644 --- a/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift +++ b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift @@ -10,7 +10,7 @@ enum CompanionSettingsSuite { } t.test("companion settings: unknown enum uses its default") { let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"future","chartStyle":"future","tokenMetric":"future","aggregation":"future","chartGrouping":"future"}"#.utf8)) - t.equal(settings.menuBarMetric, .requests) + t.equal(settings.menuBarMetric, .tokens) t.equal(settings.chartStyle, .line) t.equal(settings.tokenMetric, .total) t.equal(settings.aggregation, .sum) diff --git a/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md index 1e40fc5201..3de9b2b95e 100644 --- a/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md +++ b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md @@ -8,3 +8,5 @@ matching read/write controls with `show`, `set`, and `reset` subcommands. The menu bar app renders a settings-driven title, today metrics, model/account/provider sections, and a timeline chart. It writes a privacy-safe snapshot for the WidgetKit companion, which supports small, medium, and large families and links back to Usage. +The default menu bar headline is total tokens; the dashboard can switch it to requests, +cost, quota, or icon-only display. diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index 4cfa58c719..ff798e5502 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -76,6 +76,9 @@ you. trend. A `~` after the request count means part of it is estimated rather than reported by the provider. +By default, the menu bar headline shows total tokens; change the headline metric in the +dashboard Usage companion settings when you prefer requests, cost, quota, or an icon only. + **Quotas** — one row per provider, showing the window under the most pressure. A provider at 99% of a five-hour limit and 10% of its monthly limit shows the five-hour figure, because that is the one currently blocking you. The window name is printed under diff --git a/src/companion/settings.ts b/src/companion/settings.ts index 5c90acb91c..c838285b6f 100644 --- a/src/companion/settings.ts +++ b/src/companion/settings.ts @@ -27,7 +27,7 @@ export interface CompanionSettings { } export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = { - menuBarMetric: "requests", + menuBarMetric: "tokens", menuBarTemplate: null, showToday: true, showChart: true, From 90830318e5ba3e8b4d1eba8d7c82405447eb80fd Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:09:53 -0700 Subject: [PATCH 51/61] feat(companion): integer token abbreviation (K/M/B, no decimals) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/MenuBarCore/Formatting.swift | 15 ++++++++------- .../MenuBarCoreTests/FormattingSuite.swift | 14 +++++++------- gui/src/pages/usage-companion-chart.tsx | 4 ++-- gui/src/pages/usage-companion-utils.ts | 14 ++++++++++++++ gui/tests/usage-companion-utils.test.ts | 7 +++++++ 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/app/Sources/MenuBarCore/Formatting.swift b/app/Sources/MenuBarCore/Formatting.swift index f273fc8711..4008f53f34 100644 --- a/app/Sources/MenuBarCore/Formatting.swift +++ b/app/Sources/MenuBarCore/Formatting.swift @@ -29,7 +29,7 @@ public enum Format { public static func tokens(_ value: Int?) -> String { guard let value else { return unknown } if value < 1_000 { return String(value) } - return abbreviate(Double(value)) + return abbreviate(Double(value), integer: true) } public static func cost(_ value: Double?) -> String { @@ -71,7 +71,7 @@ public enum Format { return "\(seconds / 86_400)d ago" } - private static func abbreviate(_ value: Double) -> String { + private static func abbreviate(_ value: Double, integer: Bool = false) -> String { let units: [(threshold: Double, suffix: String)] = [ (1_000_000_000_000, "T"), (1_000_000_000, "B"), @@ -82,23 +82,24 @@ public enum Format { let ascending = units.reversed().map { $0 } for (index, unit) in ascending.enumerated() where value < (unit.threshold * 1000) { - let rendered = render(value / unit.threshold, suffix: unit.suffix) + let rendered = render(value / unit.threshold, suffix: unit.suffix, integer: integer) // Rounding can push a value across its own boundary: 999_999 scales to // 999.999K, which would render "1000K" instead of promoting to "1.00M". guard rendered.hasPrefix("1000"), index + 1 < ascending.count else { return rendered } let larger = ascending[index + 1] - return render(value / larger.threshold, suffix: larger.suffix) + return render(value / larger.threshold, suffix: larger.suffix, integer: integer) } // Beyond the largest unit, stay in that unit rather than inventing a suffix. if let largest = ascending.last, value >= largest.threshold { - return render(value / largest.threshold, suffix: largest.suffix) + return render(value / largest.threshold, suffix: largest.suffix, integer: integer) } return String(format: "%.0f", value) } - /// 3 significant figures: 36.5B, 1.20M, 233K. - private static func render(_ scaled: Double, suffix: String) -> String { + /// Render an abbreviated value with either integer or 3-significant-figure precision. + private static func render(_ scaled: Double, suffix: String, integer: Bool = false) -> String { + if integer { return String(format: "%.0f%@", scaled, suffix) } let decimals = scaled >= 100 ? 0 : (scaled >= 10 ? 1 : 2) return String(format: "%.\(decimals)f%@", scaled, suffix) } diff --git a/app/Sources/MenuBarCoreTests/FormattingSuite.swift b/app/Sources/MenuBarCoreTests/FormattingSuite.swift index 26f066515e..00b816cdeb 100644 --- a/app/Sources/MenuBarCoreTests/FormattingSuite.swift +++ b/app/Sources/MenuBarCoreTests/FormattingSuite.swift @@ -17,21 +17,21 @@ enum FormattingSuite { t.test("format: values promote at suffix rollover boundaries") { t.equal(Format.count(999_999), "1.00M") t.equal(Format.count(999_499), "999K") - t.equal(Format.tokens(999_999_999), "1.00B") - t.equal(Format.tokens(999_999_999_999), "1.00T") + t.equal(Format.tokens(999_999_999), "1B") + t.equal(Format.tokens(999_999_999_999), "1T") t.equal(Format.cost(999_999), "$1.00M") } t.test("format: exact unit thresholds render as the new unit") { - t.equal(Format.tokens(1_000), "1.00K") - t.equal(Format.tokens(1_000_000), "1.00M") - t.equal(Format.tokens(1_000_000_000), "1.00B") + t.equal(Format.tokens(1_000), "1K") + t.equal(Format.tokens(1_000_000), "1M") + t.equal(Format.tokens(1_000_000_000), "1B") } t.test("format: tokens are suffixed at scale") { t.equal(Format.tokens(999), "999") - t.equal(Format.tokens(12_400_000), "12.4M") - t.equal(Format.tokens(36_536_664_705), "36.5B") + t.equal(Format.tokens(12_400_000), "12M") + t.equal(Format.tokens(36_536_664_705), "37B") } t.test("format: cost switches to a suffix above one thousand") { diff --git a/gui/src/pages/usage-companion-chart.tsx b/gui/src/pages/usage-companion-chart.tsx index f6c50a8db7..0cf5505efb 100644 --- a/gui/src/pages/usage-companion-chart.tsx +++ b/gui/src/pages/usage-companion-chart.tsx @@ -1,8 +1,8 @@ -import { formatTokens } from "../format-tokens"; import type { Locale, TFn } from "../i18n/shared"; import { chartPolylinePoints, chartStackedBarRects, + formatCompanionTokens, type UsageTimeline, } from "./usage-companion-utils"; @@ -101,7 +101,7 @@ export function UsageCompanionChart({ - {formatTokens(max, locale)} + {formatCompanionTokens(max)} {marks} {xLabels} diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts index 492c53cd28..cc4053f931 100644 --- a/gui/src/pages/usage-companion-utils.ts +++ b/gui/src/pages/usage-companion-utils.ts @@ -64,6 +64,20 @@ export function bucketMinutesForWindow(hours: ChartHours): number { return CHART_BUCKET_MINUTES[hours]; } +export function formatCompanionTokens(value: number): string { + if (value < 1_000) return String(Math.round(value)); + const units = [ + [1_000_000_000_000, "T"], + [1_000_000_000, "B"], + [1_000_000, "M"], + [1_000, "K"], + ] as const; + for (const [threshold, suffix] of units) { + if (value >= threshold) return `${Math.round(value / threshold)}${suffix}`; + } + return String(Math.round(value)); +} + export function buildCompanionSettingsPatch( patch: Partial, availableModels: readonly string[] = [], diff --git a/gui/tests/usage-companion-utils.test.ts b/gui/tests/usage-companion-utils.test.ts index 88175e0116..746ee226a3 100644 --- a/gui/tests/usage-companion-utils.test.ts +++ b/gui/tests/usage-companion-utils.test.ts @@ -4,6 +4,7 @@ import { buildCompanionSettingsPatch, chartPolylinePoints, chartStackedBarRects, + formatCompanionTokens, } from "../src/pages/usage-companion-utils"; describe("usage companion utilities", () => { @@ -34,4 +35,10 @@ describe("usage companion utilities", () => { { x: 9.5, y: 8, width: 81, height: 17, seriesIndex: 1, bucketIndex: 0 }, ]); }); + + test("formats companion token values as integer SI units", () => { + expect([999, 1_000, 999_600, 1_634_303, 333_400_000, 12_300_000_000].map(formatCompanionTokens)).toEqual([ + "999", "1K", "1M", "2M", "333M", "12B", + ]); + }); }); From ca6c4014ba59b70d656866bb802095cbcc32b257 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:11:24 -0700 Subject: [PATCH 52/61] feat(gui): companion install card driven by app presence Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/MenuBarCore/ProxyClient.swift | 2 ++ .../MenuBarCoreTests/TransportSuite.swift | 1 + gui/src/i18n/de.ts | 10 ++++++ gui/src/i18n/en.ts | 10 ++++++ gui/src/i18n/fr.ts | 10 ++++++ gui/src/i18n/ja.ts | 10 ++++++ gui/src/i18n/ko.ts | 10 ++++++ gui/src/i18n/ru.ts | 10 ++++++ gui/src/i18n/tr.ts | 10 ++++++ gui/src/i18n/vi.ts | 10 ++++++ gui/src/i18n/zh-TW.ts | 10 ++++++ gui/src/i18n/zh.ts | 10 ++++++ gui/src/pages/usage-companion-panel.tsx | 31 +++++++++++++++++++ gui/src/pages/usage-companion-utils.ts | 3 ++ gui/src/styles-usage-workspace.css | 15 +++++++++ src/server/management/companion-routes.ts | 12 ++++++- tests/server/companion-settings.test.ts | 21 +++++++++++-- 17 files changed, 182 insertions(+), 3 deletions(-) diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift index f6f494aaae..5318827814 100644 --- a/app/Sources/MenuBarCore/ProxyClient.swift +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -274,6 +274,8 @@ public actor ProxyClient { var request = URLRequest(url: url) request.httpMethod = method request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + request.setValue("OpenCodexMenuBar/\(version)", forHTTPHeaderField: "User-Agent") if let credential = key ?? apiKey { request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") } diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift index 9ee8f3f6ed..570e10bdea 100644 --- a/app/Sources/MenuBarCoreTests/TransportSuite.swift +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -283,6 +283,7 @@ enum TransportSuite { let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" t.expect(url.contains("range=7d"), "expected range=7d in \(url)") t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") + t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexMenuBar/dev") } t.test("requests: the provider patch sends exactly {\"disabled\":true}") { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9929e32a9a..15b016721e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -982,6 +982,14 @@ export const de: Record = { "usage.companion.settingsUnavailable": "Begleiteinstellungen nicht verfügbar", "usage.companion.corrupt": "Die Datei mit den Begleiteinstellungen ist beschädigt. Die Steuerelemente zeigen Standardwerte; das Speichern ist pausiert, bis Sie die Datei ersetzen.", "usage.companion.corruptReset": "Durch Standardwerte ersetzen", + "usage.companion.connected": "Menüleisten-App verbunden · {age}", + "usage.companion.installTitle": "Menüleisten-App installieren", + "usage.companion.installStep1": "Laden Sie OpenCodex--macos-universal.zip aus der neuesten Veröffentlichung herunter und ziehen Sie OpenCodex.app in Programme.", + "usage.companion.installStep2": "Erster Start: Klicken Sie mit der rechten Maustaste auf OpenCodex.app → Öffnen (die App ist nur ad-hoc signiert, daher fragt Gatekeeper einmal).", + "usage.companion.installStep3": "Die App findet diesen Proxy selbst; das Widget erscheint in der Widget-Galerie, sobald die App ausgeführt wurde.", + "usage.companion.notConnected": "Noch keine Menüleisten-App hat sich mit diesem Proxy verbunden.", + "usage.companion.lastSeen": "Zuletzt gesehen {age}", + "usage.companion.installAnother": "Auf einem anderen Mac installieren", "usage.companion.saved": "Gespeichert · {time}", "usage.companion.saveFailed": "Speichern fehlgeschlagen: {error}", "usage.companion.reset": "Auf Standardwerte zurücksetzen", @@ -1022,6 +1030,8 @@ export const de: Record = { "usage.companion.menuText": "Menüleistentext", "usage.companion.placeholders": "Platzhalter:", "usage.companion.modelsOnChart": "Modelle im Diagramm", + "usage.companion.modelsCount": "{selected} von {total} im Diagramm", + "usage.companion.modelsShowAll": "Alle anzeigen", "usage.companion.hideProviders": "Provider ausblenden", "usage.workspace.report": "Nutzungsbericht", "usage.workspace.sections": "Nutzungsabschnitte", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 3d8eccfd4f..e0dd89ac54 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1035,6 +1035,14 @@ export const en = { "usage.companion.settingsUnavailable": "Companion settings unavailable", "usage.companion.corrupt": "The companion settings file is corrupt. Controls show defaults; saving is paused until you replace the file.", "usage.companion.corruptReset": "Replace with defaults", + "usage.companion.connected": "Menu bar app connected · {age}", + "usage.companion.installTitle": "Install the menu bar app", + "usage.companion.installStep1": "Download OpenCodex--macos-universal.zip from the latest release and drag OpenCodex.app to Applications.", + "usage.companion.installStep2": "First launch: right-click OpenCodex.app → Open (the app is ad-hoc signed, so Gatekeeper asks once).", + "usage.companion.installStep3": "The app finds this proxy on its own; the widget appears in the widget gallery once the app has run.", + "usage.companion.notConnected": "No menu bar app has connected to this proxy yet.", + "usage.companion.lastSeen": "Last seen {age}", + "usage.companion.installAnother": "Install on another Mac", "usage.companion.saved": "Saved · {time}", "usage.companion.saveFailed": "Couldn’t save: {error}", "usage.companion.reset": "Reset to defaults", @@ -1075,6 +1083,8 @@ export const en = { "usage.companion.menuText": "Menu bar text", "usage.companion.placeholders": "Placeholders:", "usage.companion.modelsOnChart": "Models on chart", + "usage.companion.modelsCount": "{selected} of {total} on chart", + "usage.companion.modelsShowAll": "Show all", "usage.companion.hideProviders": "Hide providers", "usage.workspace.report": "Usage report", "usage.workspace.sections": "Usage sections", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e7d5a51cab..b387b6258e 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1052,6 +1052,16 @@ export const fr: Record = { "usage.companion.menuText": "Texte de la barre des menus", "usage.companion.placeholders": "Paramètres substituables :", "usage.companion.modelsOnChart": "Modèles du graphique", + "usage.companion.connected": "App de barre des menus connectée · {age}", + "usage.companion.installTitle": "Installer l’app de barre des menus", + "usage.companion.installStep1": "Téléchargez OpenCodex--macos-universal.zip depuis la dernière version et faites glisser OpenCodex.app dans Applications.", + "usage.companion.installStep2": "Premier lancement : faites un clic droit sur OpenCodex.app → Ouvrir (l’app est signée ad hoc, Gatekeeper ne demande donc qu’une confirmation).", + "usage.companion.installStep3": "L’app trouve ce proxy automatiquement ; le widget apparaît dans la galerie de widgets après le lancement de l’app.", + "usage.companion.notConnected": "Aucune app de barre des menus ne s’est encore connectée à ce proxy.", + "usage.companion.lastSeen": "Dernière connexion {age}", + "usage.companion.installAnother": "Installer sur un autre Mac", + "usage.companion.modelsCount": "{selected} sur {total} dans le graphique", + "usage.companion.modelsShowAll": "Tout afficher", "usage.companion.hideProviders": "Masquer les fournisseurs", "usage.workspace.report": "Rapport d’utilisation", "usage.workspace.sections": "Sections d’utilisation", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 59894c5692..d80fc757ca 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -987,6 +987,16 @@ export const ja: Record = { "usage.companion.menuText": "メニューバーのテキスト", "usage.companion.placeholders": "プレースホルダー:", "usage.companion.modelsOnChart": "グラフのモデル", + "usage.companion.connected": "メニューバーアプリ接続済み · {age}", + "usage.companion.installTitle": "メニューバーアプリをインストール", + "usage.companion.installStep1": "最新リリースから OpenCodex--macos-universal.zip をダウンロードし、OpenCodex.app をアプリケーションに移動します。", + "usage.companion.installStep2": "初回起動:OpenCodex.app を右クリックして「開く」を選択します(アドホック署名のため、Gatekeeper の確認は一度だけです)。", + "usage.companion.installStep3": "アプリはこのプロキシを自動検出します。アプリを一度起動するとウィジェットギャラリーに表示されます。", + "usage.companion.notConnected": "このプロキシに接続したメニューバーアプリはまだありません。", + "usage.companion.lastSeen": "最終接続 {age}", + "usage.companion.installAnother": "別の Mac にインストール", + "usage.companion.modelsCount": "{selected} / {total} がグラフに表示中", + "usage.companion.modelsShowAll": "すべて表示", "usage.companion.hideProviders": "プロバイダーを非表示", "usage.workspace.report": "使用量レポート", "usage.workspace.sections": "使用量セクション", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 2183552a9b..40956c984b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1056,6 +1056,16 @@ export const ko: Record = { "usage.companion.menuText": "메뉴 막대 텍스트", "usage.companion.placeholders": "자리표시자:", "usage.companion.modelsOnChart": "차트의 모델", + "usage.companion.connected": "메뉴 막대 앱 연결됨 · {age}", + "usage.companion.installTitle": "메뉴 막대 앱 설치", + "usage.companion.installStep1": "최신 릴리스에서 OpenCodex--macos-universal.zip을 다운로드하고 OpenCodex.app을 응용 프로그램으로 드래그하세요.", + "usage.companion.installStep2": "첫 실행: OpenCodex.app을 마우스 오른쪽 버튼으로 클릭하고 열기를 선택하세요(앱이 애드혹 서명되어 Gatekeeper가 한 번 확인합니다).", + "usage.companion.installStep3": "앱이 이 프록시를 자동으로 찾습니다. 앱을 실행하면 위젯 갤러리에 위젯이 표시됩니다.", + "usage.companion.notConnected": "아직 이 프록시에 연결한 메뉴 막대 앱이 없습니다.", + "usage.companion.lastSeen": "마지막 연결 {age}", + "usage.companion.installAnother": "다른 Mac에 설치", + "usage.companion.modelsCount": "{selected} / {total}개가 차트에 표시됨", + "usage.companion.modelsShowAll": "모두 표시", "usage.companion.hideProviders": "공급자 숨기기", "usage.workspace.report": "사용량 보고서", "usage.workspace.sections": "사용량 섹션", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 35c25940cd..f76c73b940 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1043,6 +1043,16 @@ export const ru: Record = { "usage.companion.menuText": "Текст строки меню", "usage.companion.placeholders": "Заполнители:", "usage.companion.modelsOnChart": "Модели на графике", + "usage.companion.connected": "Приложение в строке меню подключено · {age}", + "usage.companion.installTitle": "Установить приложение в строке меню", + "usage.companion.installStep1": "Скачайте OpenCodex--macos-universal.zip из последнего релиза и перетащите OpenCodex.app в Программы.", + "usage.companion.installStep2": "Первый запуск: нажмите OpenCodex.app правой кнопкой и выберите «Открыть» (приложение подписано ad-hoc, поэтому Gatekeeper спросит один раз).", + "usage.companion.installStep3": "Приложение само найдёт этот прокси; виджет появится в галерее виджетов после запуска приложения.", + "usage.companion.notConnected": "К этому прокси ещё не подключалось приложение из строки меню.", + "usage.companion.lastSeen": "Последнее подключение: {age}", + "usage.companion.installAnother": "Установить на другом Mac", + "usage.companion.modelsCount": "{selected} из {total} на графике", + "usage.companion.modelsShowAll": "Показать все", "usage.companion.hideProviders": "Скрыть провайдеров", "usage.workspace.report": "Отчёт об использовании", "usage.workspace.sections": "Разделы использования", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 400be275de..79c3cce662 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1062,6 +1062,16 @@ export const tr: Record = { "usage.companion.menuText": "Menü çubuğu metni", "usage.companion.placeholders": "Yer tutucular:", "usage.companion.modelsOnChart": "Grafikteki modeller", + "usage.companion.connected": "Menü çubuğu uygulaması bağlı · {age}", + "usage.companion.installTitle": "Menü çubuğu uygulamasını yükle", + "usage.companion.installStep1": "En son sürümden OpenCodex--macos-universal.zip dosyasını indirin ve OpenCodex.app'i Uygulamalar'a sürükleyin.", + "usage.companion.installStep2": "İlk çalıştırma: OpenCodex.app'e sağ tıklayıp Aç'ı seçin (uygulama ad-hoc imzalıdır; Gatekeeper bir kez sorar).", + "usage.companion.installStep3": "Uygulama bu proxy'yi kendisi bulur; uygulama çalıştıktan sonra widget, widget galerisinde görünür.", + "usage.companion.notConnected": "Bu proxy'ye henüz hiçbir menü çubuğu uygulaması bağlanmadı.", + "usage.companion.lastSeen": "Son görülme {age}", + "usage.companion.installAnother": "Başka bir Mac'e yükle", + "usage.companion.modelsCount": "Grafikte {selected}/{total}", + "usage.companion.modelsShowAll": "Tümünü göster", "usage.companion.hideProviders": "Sağlayıcıları gizle", "usage.workspace.report": "Kullanım raporu", "usage.workspace.sections": "Kullanım bölümleri", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 77bcd283e9..63645b29aa 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1045,6 +1045,16 @@ export const vi: Record = { "usage.companion.menuText": "Văn bản thanh menu", "usage.companion.placeholders": "Trình giữ chỗ:", "usage.companion.modelsOnChart": "Mô hình trên biểu đồ", + "usage.companion.connected": "Ứng dụng trên thanh menu đã kết nối · {age}", + "usage.companion.installTitle": "Cài đặt ứng dụng trên thanh menu", + "usage.companion.installStep1": "Tải OpenCodex--macos-universal.zip từ bản phát hành mới nhất và kéo OpenCodex.app vào Applications.", + "usage.companion.installStep2": "Lần đầu mở: nhấp chuột phải vào OpenCodex.app → Mở (ứng dụng được ký ad-hoc nên Gatekeeper chỉ hỏi một lần).", + "usage.companion.installStep3": "Ứng dụng tự tìm proxy này; widget sẽ xuất hiện trong thư viện widget sau khi ứng dụng chạy.", + "usage.companion.notConnected": "Chưa có ứng dụng trên thanh menu nào kết nối với proxy này.", + "usage.companion.lastSeen": "Lần kết nối gần nhất {age}", + "usage.companion.installAnother": "Cài đặt trên máy Mac khác", + "usage.companion.modelsCount": "{selected}/{total} trên biểu đồ", + "usage.companion.modelsShowAll": "Hiện tất cả", "usage.companion.hideProviders": "Ẩn nhà cung cấp", "usage.workspace.report": "Báo cáo sử dụng", "usage.workspace.sections": "Các phần sử dụng", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2e31aba078..a12292fb5e 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -864,6 +864,16 @@ export const zhTW: Record = { "usage.companion.menuText": "選單列文字", "usage.companion.placeholders": "預留位置:", "usage.companion.modelsOnChart": "圖表中的模型", + "usage.companion.connected": "選單列 App 已連線 · {age}", + "usage.companion.installTitle": "安裝選單列 App", + "usage.companion.installStep1": "從最新版本下載 OpenCodex--macos-universal.zip,並將 OpenCodex.app 拖到應用程式。", + "usage.companion.installStep2": "首次啟動:在 OpenCodex.app 上按右鍵並選擇「打開」(App 使用臨時簽章,因此 Gatekeeper 只會詢問一次)。", + "usage.companion.installStep3": "App 會自動找到此 Proxy;App 執行後,Widget 會出現在 Widget 圖庫中。", + "usage.companion.notConnected": "尚未有選單列 App 連線到此 Proxy。", + "usage.companion.lastSeen": "上次連線 {age}", + "usage.companion.installAnother": "在另一台 Mac 上安裝", + "usage.companion.modelsCount": "圖表顯示 {selected}/{total}", + "usage.companion.modelsShowAll": "顯示全部", "usage.companion.hideProviders": "隱藏提供者", "usage.coverage.measured": "已計量", "usage.coverage.reported": "供應商上報", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 5b338159f4..85e0b6f2ff 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1037,6 +1037,16 @@ export const zh: Record = { "usage.companion.menuText": "菜单栏文本", "usage.companion.placeholders": "占位符:", "usage.companion.modelsOnChart": "图表中的模型", + "usage.companion.connected": "菜单栏应用已连接 · {age}", + "usage.companion.installTitle": "安装菜单栏应用", + "usage.companion.installStep1": "从最新版本下载 OpenCodex--macos-universal.zip,并将 OpenCodex.app 拖到应用程序。", + "usage.companion.installStep2": "首次启动:右键点击 OpenCodex.app 并选择“打开”(应用使用临时签名,因此 Gatekeeper 只会询问一次)。", + "usage.companion.installStep3": "应用会自动找到此代理;应用运行后,小组件会出现在小组件图库中。", + "usage.companion.notConnected": "尚未有菜单栏应用连接到此代理。", + "usage.companion.lastSeen": "上次连接 {age}", + "usage.companion.installAnother": "在另一台 Mac 上安装", + "usage.companion.modelsCount": "图表显示 {selected}/{total}", + "usage.companion.modelsShowAll": "显示全部", "usage.companion.hideProviders": "隐藏提供商", "usage.workspace.report": "用量报告", "usage.workspace.sections": "用量分区", diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index e515b9c93d..f8bf08d4b6 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useI18n } from "../i18n/shared"; +import { relativeTimeLabelsFromT, formatRelativeTime } from "../provider-workspace/usage"; import { UsageCompanionChart } from "./usage-companion-chart"; import { bucketMinutesForWindow, @@ -264,6 +265,36 @@ export default function UsageCompanionPanel({
{t("usage.companion.installGuide")} + {(() => { + const lastSeenAt = response?.companion?.lastSeenAt ?? null; + const connected = lastSeenAt !== null && Date.now() - lastSeenAt <= 10 * 60 * 1000; + const age = lastSeenAt === null ? null : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t)); + const steps = ( +
    +
  1. {t("usage.companion.installStep1")} {t("common.github")}
  2. +
  3. {t("usage.companion.installStep2")}
  4. +
  5. {t("usage.companion.installStep3")}
  6. +
+ ); + return connected ? ( +
+
+
+ {t("usage.companion.installAnother")} + {steps} + xattr -d com.apple.quarantine /Applications/OpenCodex.app +
+
+ ) : ( +
+ {t("usage.companion.installTitle")} + {lastSeenAt !== null &&

{t("usage.companion.lastSeen", { age })}

} + {lastSeenAt === null &&

{t("usage.companion.notConnected")}

} + {steps} + xattr -d com.apple.quarantine /Applications/OpenCodex.app +
+ ); + })()} void loadTimeline()} locale={locale} t={t} />
t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts index cc4053f931..eef53d931f 100644 --- a/gui/src/pages/usage-companion-utils.ts +++ b/gui/src/pages/usage-companion-utils.ts @@ -51,6 +51,9 @@ export interface CompanionSettingsResponse { updatedAt: number | null; defaults: CompanionSettings; corrupt?: boolean; + companion?: { + lastSeenAt: number | null; + }; } export const CHART_BUCKET_MINUTES: Record = { diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 4886d3ea69..281edf0da2 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -227,6 +227,21 @@ } .usage-companion-header .panel-title { margin: 0; } .usage-companion-header .card-sub { margin: 4px 0 0; } +.usage-companion-install { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} +.usage-companion-install summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-install-status { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; } +.usage-companion-install-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); } +.usage-companion-install-steps { display: grid; gap: 8px; margin: 0; padding-left: 20px; color: var(--muted); font-size: 12px; } +.usage-companion-install-steps .btn { margin-left: 6px; } +.usage-companion-install-last-seen { margin: 0; } +.usage-companion-install-command { display: block; overflow-x: auto; padding: 7px 9px; border-radius: var(--radius-xs); background: var(--raised); color: var(--text); font-size: 11px; } .usage-companion-chart { min-width: 0; } .usage-companion-chart svg { display: block; width: 100%; height: 160px; overflow: visible; } .usage-companion-axis { stroke: var(--border); stroke-width: 1; } diff --git a/src/server/management/companion-routes.ts b/src/server/management/companion-routes.ts index aebcc247fa..df0fd8ab6d 100644 --- a/src/server/management/companion-routes.ts +++ b/src/server/management/companion-routes.ts @@ -8,18 +8,28 @@ import { jsonResponse } from "../auth-cors"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; +let companionLastSeenAt: number | null = null; + +export function resetCompanionPresenceForTests(): void { + companionLastSeenAt = null; +} + function response(): Response { const loaded = loadCompanionSettings(); return jsonResponse({ settings: loaded.settings, updatedAt: loaded.updatedAt, defaults: DEFAULT_COMPANION_SETTINGS, + companion: { lastSeenAt: companionLastSeenAt }, ...(loaded.corrupt ? { corrupt: true } : {}), }); } export async function handleCompanionRoutes(ctx: ManagementContext): Promise { - if (ctx.url.pathname === "/api/companion/settings" && ctx.req.method === "GET") return response(); + if (ctx.url.pathname === "/api/companion/settings" && ctx.req.method === "GET") { + if (ctx.req.headers.get("user-agent")?.startsWith("OpenCodexMenuBar/")) companionLastSeenAt = Date.now(); + return response(); + } if (ctx.url.pathname !== "/api/companion/settings" || ctx.req.method !== "PUT") return null; let body: unknown; try { diff --git a/tests/server/companion-settings.test.ts b/tests/server/companion-settings.test.ts index 020370d351..de0058dc86 100644 --- a/tests/server/companion-settings.test.ts +++ b/tests/server/companion-settings.test.ts @@ -8,6 +8,7 @@ import { loadCompanionSettings, saveCompanionSettings, } from "../../src/companion/settings"; +import { resetCompanionPresenceForTests } from "../../src/server/management/companion-routes"; import { handleManagementAPI } from "../../src/server/management-api"; import type { OcxConfig } from "../../src/types"; @@ -21,11 +22,15 @@ async function withHome(run: (home: string) => Promise | T): Promise { rmSync(home, { recursive: true, force: true }); } } -async function call(method: string, body?: unknown): Promise<{ status: number; body: any }> { +async function call(method: string, body?: unknown, userAgent?: string): Promise<{ status: number; body: any }> { const url = new URL("http://127.0.0.1:10100/api/companion/settings"); const req = new Request(url, { method, - headers: { host: "127.0.0.1:10100", ...(body === undefined ? {} : { "content-type": "application/json" }) }, + headers: { + host: "127.0.0.1:10100", + ...(userAgent ? { "user-agent": userAgent } : {}), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, body: body === undefined ? undefined : JSON.stringify(body), }); const response = await handleManagementAPI(req, url, config, {}, "admin-token"); @@ -66,4 +71,16 @@ describe("companion settings", () => { expect(readFileSync(join(home, "companion.json"), "utf8")).toBe("{"); }); }); + + test("GET records menu bar presence only for the companion user agent", async () => { + await withHome(async () => { + resetCompanionPresenceForTests(); + const initial = await call("GET"); + expect(initial.body.companion.lastSeenAt).toBeNull(); + const ordinary = await call("GET", undefined, "Mozilla/5.0"); + expect(ordinary.body.companion.lastSeenAt).toBeNull(); + const companion = await call("GET", undefined, "OpenCodexMenuBar/2.60.0"); + expect(companion.body.companion.lastSeenAt).toBeNumber(); + }); + }); }); From dd19552d6dc2b1fd1e9b6cdc54de462a77206708 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:12:15 -0700 Subject: [PATCH 53/61] feat(gui): scrollable model list with switches for the companion chart Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- gui/src/pages/usage-companion-panel.tsx | 53 ++++++++++++++++++++++++- gui/src/pages/usage-companion-utils.ts | 43 ++++++++++++++++++++ gui/src/styles-usage-workspace.css | 15 +++++++ gui/tests/usage-companion-utils.test.ts | 23 +++++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index f8bf08d4b6..d3e2e0f35e 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -1,10 +1,14 @@ import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useI18n } from "../i18n/shared"; import { relativeTimeLabelsFromT, formatRelativeTime } from "../provider-workspace/usage"; +import { Switch } from "../ui"; import { UsageCompanionChart } from "./usage-companion-chart"; import { bucketMinutesForWindow, buildCompanionSettingsPatch, + formatCompanionTokens, + groupCompanionModels, + toggleCompanionModels, type CompanionSettings, type CompanionSettingsResponse, type UsageTimeline, @@ -248,6 +252,11 @@ export default function UsageCompanionPanel({ const providerNames = providers.map(provider => provider.provider).filter((provider, index, all) => all.indexOf(provider) === index).toSorted(); const selectedModels = current.models ?? availableModels; const selectedModelSet = new Set(selectedModels); + const modelTotals = new Map(); + for (const series of timeline?.series ?? []) { + modelTotals.set(series.id, (modelTotals.get(series.id) ?? 0) + series.total); + } + const modelGroups = groupCompanionModels(availableModels, modelTotals); const hiddenProviderSet = new Set(current.hiddenProviders); const saveMessage = saveState === "saved" && response?.updatedAt ? t("usage.companion.saved", { time: formatSaveTime(response.updatedAt, locale) }) @@ -296,6 +305,49 @@ export default function UsageCompanionPanel({ ); })()} void loadTimeline()} locale={locale} t={t} /> + {modelGroups.length > 0 &&
+
+
+ {t("usage.companion.modelsOnChart")} + {t("usage.companion.modelsCount", { selected: selectedModels.length, total: availableModels.length })} +
+ {current.models !== null && } +
+
+ {modelGroups.map(group => { + const selectedCount = group.models.filter(model => selectedModelSet.has(model.id)).length; + const groupOn = selectedCount === group.models.length; + return
+
+ {group.provider} + {group.models.length} + 0 && !groupOn} + onClick={() => updateSettings({ models: toggleCompanionModels(current.models, availableModels, group.models.map(model => model.id), !groupOn) })} + disabled={response?.corrupt} + label={group.provider} + title={group.provider} + /> +
+ {group.models.map(model => { + const on = selectedModelSet.has(model.id); + return
+ updateSettings({ models: toggleCompanionModels(current.models, availableModels, [model.id], !on) })} + disabled={response?.corrupt} + label={model.id} + title={model.id} + /> + {model.id} + {modelTotals.has(model.id) ? formatCompanionTokens(model.total) : "—"} +
; + })} +
; + })} +
+
}
t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> t(`usage.companion.window${value}` as never)} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> @@ -326,7 +378,6 @@ export default function UsageCompanionPanel({ updateSettings({ menuBarTemplate: event.target.value })} maxLength={200} /> {t("usage.companion.placeholders")} {"{requests} {totalTokens} {inputTokens} {outputTokens} {costUsd} {quotaPercent}"} - {availableModels.length > 0 &&
{t("usage.companion.modelsOnChart")}{availableModels.map(model => )}
} {providerNames.length > 0 &&
{t("usage.companion.hideProviders")}{providerNames.map(provider => )}
} diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts index eef53d931f..2ccbf61f06 100644 --- a/gui/src/pages/usage-companion-utils.ts +++ b/gui/src/pages/usage-companion-utils.ts @@ -81,6 +81,49 @@ export function formatCompanionTokens(value: number): string { return String(Math.round(value)); } +export interface CompanionModelGroup { + provider: string; + models: { id: string; total: number }[]; + total: number; +} + +export function groupCompanionModels( + available: string[], + totals: Map, +): CompanionModelGroup[] { + const groups = new Map(); + for (const id of available) { + const provider = id.includes("/") ? id.slice(0, id.indexOf("/")) : id; + const group = groups.get(provider) ?? { provider, models: [], total: 0 }; + const total = totals.get(id) ?? 0; + group.models.push({ id, total }); + group.total += total; + groups.set(provider, group); + } + return Array.from(groups.values()) + .map(group => ({ + ...group, + models: group.models.toSorted((a, b) => b.total - a.total || a.id.localeCompare(b.id)), + })) + .toSorted((a, b) => b.total - a.total || a.provider.localeCompare(b.provider)); +} + +export function toggleCompanionModels( + selected: string[] | null, + available: string[], + ids: string[], + on: boolean, +): string[] | null { + const availableSet = new Set(available); + const next = new Set((selected ?? available).filter(id => availableSet.has(id))); + for (const id of ids) { + if (on) next.add(id); + else next.delete(id); + } + if (available.length > 0 && available.every(id => next.has(id))) return null; + return available.filter(id => next.has(id)); +} + export function buildCompanionSettingsPatch( patch: Partial, availableModels: readonly string[] = [], diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index 281edf0da2..6f8d4a2af7 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -242,6 +242,21 @@ .usage-companion-install-steps .btn { margin-left: 6px; } .usage-companion-install-last-seen { margin: 0; } .usage-companion-install-command { display: block; overflow-x: auto; padding: 7px 9px; border-radius: var(--radius-xs); background: var(--raised); color: var(--text); font-size: 11px; } +.usage-companion-models { display: grid; gap: 8px; } +.usage-companion-models-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.usage-companion-models-header > div { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.usage-companion-models-count { white-space: nowrap; } +.usage-companion-models-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } +.usage-companion-model-group + .usage-companion-model-group { border-top: 1px solid var(--border-soft); } +.usage-companion-model-group-header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 7px; padding: 8px 10px; background: color-mix(in srgb, var(--surface) 92%, var(--raised)); } +.usage-companion-model-provider { flex: 1; min-width: 0; overflow: hidden; color: var(--text); font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-chip { padding: 2px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); } +.usage-companion-model-group-header .switch { flex: 0 0 auto; } +.usage-companion-model-row { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 10px 7px 18px; } +.usage-companion-model-row .switch { flex: 0 0 auto; } +.usage-companion-model-row code { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-row.is-off code { color: var(--faint); text-decoration: line-through; } +.usage-companion-model-total { flex: 0 0 auto; margin-left: auto; font-variant-numeric: tabular-nums; } .usage-companion-chart { min-width: 0; } .usage-companion-chart svg { display: block; width: 100%; height: 160px; overflow: visible; } .usage-companion-axis { stroke: var(--border); stroke-width: 1; } diff --git a/gui/tests/usage-companion-utils.test.ts b/gui/tests/usage-companion-utils.test.ts index 746ee226a3..b46ed0396f 100644 --- a/gui/tests/usage-companion-utils.test.ts +++ b/gui/tests/usage-companion-utils.test.ts @@ -5,6 +5,8 @@ import { chartPolylinePoints, chartStackedBarRects, formatCompanionTokens, + groupCompanionModels, + toggleCompanionModels, } from "../src/pages/usage-companion-utils"; describe("usage companion utilities", () => { @@ -41,4 +43,25 @@ describe("usage companion utilities", () => { "999", "1K", "1M", "2M", "333M", "12B", ]); }); + + test("groups companion models by descending totals with alphabetical ties", () => { + expect(groupCompanionModels( + ["openai/gpt-4", "anthropic/claude", "openai/gpt-5", "local"], + new Map([ + ["openai/gpt-4", 5], + ["anthropic/claude", 10], + ["openai/gpt-5", 5], + ]), + )).toEqual([ + { provider: "anthropic", models: [{ id: "anthropic/claude", total: 10 }], total: 10 }, + { provider: "openai", models: [{ id: "openai/gpt-4", total: 5 }, { id: "openai/gpt-5", total: 5 }], total: 10 }, + { provider: "local", models: [{ id: "local", total: 0 }], total: 0 }, + ]); + }); + + test("toggles mixed groups and collapses all-selected state to null", () => { + const available = ["openai/gpt-4", "openai/gpt-5", "anthropic/claude"]; + expect(toggleCompanionModels(["openai/gpt-4"], available, ["openai/gpt-5"], true)).toEqual(["openai/gpt-4", "openai/gpt-5"]); + expect(toggleCompanionModels(["openai/gpt-4", "openai/gpt-5"], available, ["anthropic/claude"], true)).toBeNull(); + }); }); From c5aed46e9e9625bcc376b2926fdb83a41e63fabb Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:12:45 -0700 Subject: [PATCH 54/61] feat(app): Liquid Glass surfaces on macOS 26 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/MenuBarUI/PopoverPanel.swift | 65 ++++++++++++------- app/Sources/OpenCodexWidget/Views.swift | 10 ++- .../src/content/docs/guides/macos-menu-bar.md | 2 + 3 files changed, 50 insertions(+), 27 deletions(-) diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift index c5fcc2ea0f..05e3484d1d 100644 --- a/app/Sources/MenuBarUI/PopoverPanel.swift +++ b/app/Sources/MenuBarUI/PopoverPanel.swift @@ -43,31 +43,7 @@ public final class PopoverPanel: NSPanel { public override var contentViewController: NSViewController? { didSet { guard let content = contentViewController?.view else { return } - let effect = NSVisualEffectView() - effect.material = .popover - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 10 - effect.layer?.masksToBounds = true - effect.translatesAutoresizingMaskIntoConstraints = false - - let host = NSView() - host.addSubview(effect) - effect.addSubview(content) - content.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - effect.topAnchor.constraint(equalTo: host.topAnchor), - effect.leadingAnchor.constraint(equalTo: host.leadingAnchor), - effect.trailingAnchor.constraint(equalTo: host.trailingAnchor), - effect.bottomAnchor.constraint(equalTo: host.bottomAnchor), - content.topAnchor.constraint(equalTo: effect.topAnchor), - content.leadingAnchor.constraint(equalTo: effect.leadingAnchor), - content.trailingAnchor.constraint(equalTo: effect.trailingAnchor), - content.bottomAnchor.constraint(equalTo: effect.bottomAnchor), - ]) - contentView = host + contentView = PopoverSurface.make(content: content) } } @@ -145,3 +121,42 @@ public final class PopoverPanel: NSPanel { contentViewController?.view.layoutSubtreeIfNeeded() } } + +private enum PopoverSurface { + static func make(content: NSView) -> NSView { + let surface: NSView + if #available(macOS 26, *) { + let glass = NSGlassEffectView() + glass.cornerRadius = 16 + glass.style = .regular + glass.contentView = content + surface = glass + } else { + let effect = NSVisualEffectView() + effect.material = .popover + effect.blendingMode = .behindWindow + effect.state = .active + effect.wantsLayer = true + effect.layer?.cornerRadius = 10 + effect.layer?.masksToBounds = true + effect.addSubview(content) + surface = effect + } + + let host = NSView() + host.addSubview(surface) + surface.translatesAutoresizingMaskIntoConstraints = false + content.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + surface.topAnchor.constraint(equalTo: host.topAnchor), + surface.leadingAnchor.constraint(equalTo: host.leadingAnchor), + surface.trailingAnchor.constraint(equalTo: host.trailingAnchor), + surface.bottomAnchor.constraint(equalTo: host.bottomAnchor), + content.topAnchor.constraint(equalTo: surface.topAnchor), + content.leadingAnchor.constraint(equalTo: surface.leadingAnchor), + content.trailingAnchor.constraint(equalTo: surface.trailingAnchor), + content.bottomAnchor.constraint(equalTo: surface.bottomAnchor), + ]) + return host + } +} diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index d88ee9c564..00f94a90e4 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -6,6 +6,7 @@ import MenuBarCore struct OpenCodexWidgetView: View { let entry: SnapshotEntry @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode var body: some View { Group { @@ -59,6 +60,7 @@ struct OpenCodexWidgetView: View { Text(Format.count(snapshot.today?.requests)) .font(.system(size: 28, weight: .semibold, design: .rounded)) .lineLimit(1) + .widgetAccentable() Text("requests today").font(.caption).foregroundStyle(.secondary) HStack(spacing: 4) { Text(Format.tokens(snapshot.today?.totalTokens)) @@ -90,7 +92,7 @@ struct OpenCodexWidgetView: View { } else if let chart = snapshot.chart { VStack(alignment: .leading, spacing: 5) { Text("Last \(windowLabel(chart))").font(.caption).foregroundStyle(.secondary) - chartView(chart, flexible: false) + chartView(chart, flexible: false).widgetAccentable() } } else { VStack(alignment: .leading, spacing: 4) { @@ -119,6 +121,7 @@ struct OpenCodexWidgetView: View { .font(.caption).foregroundStyle(.secondary) chartView(chart, flexible: true) .frame(maxHeight: .infinity) + .widgetAccentable() legend(chart) } updated(snapshot) @@ -262,7 +265,10 @@ struct OpenCodexWidgetView: View { ] private func seriesColor(_ index: Int) -> Color { - palette[index % palette.count] + if renderingMode == .accented { + return .primary.opacity([1, 0.8, 0.6, 0.45, 0.3, 0.2][index % 6]) + } + return palette[index % palette.count] } private func windowLabel(_ chart: WidgetSnapshot.Chart) -> String { diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md index ff798e5502..209efa7566 100644 --- a/docs-site/src/content/docs/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -78,6 +78,8 @@ by the provider. By default, the menu bar headline shows total tokens; change the headline metric in the dashboard Usage companion settings when you prefer requests, cost, quota, or an icon only. +On macOS 26, the popover and widgets adopt Liquid Glass; earlier macOS versions use the +standard popover material. **Quotas** — one row per provider, showing the window under the most pressure. A provider at 99% of a five-hour limit and 10% of its monthly limit shows the five-hour From 8a07050781588a143a9dc147d961bf4cb293ac49 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:13:55 -0700 Subject: [PATCH 55/61] fix(companion): round integer token abbreviations and panel presence age Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift | 2 +- gui/src/pages/usage-companion-panel.tsx | 5 +++-- gui/src/pages/usage-companion-utils.ts | 12 ++++++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift index 53a928f726..286502ac91 100644 --- a/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift +++ b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift @@ -19,7 +19,7 @@ enum MenuBarTitleSuite { } t.test("menu title: template replaces placeholders") { let settings = CompanionSettings(menuBarTemplate: "{requests}/{totalTokens}/{costUsd}") - t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: []), "12/3.46K/$1.25") + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: []), "12/3K/$1.25") } t.test("menu title: none is nil and unknowns are em dashes") { t.isNil(MenuBarTitle.render(settings: CompanionSettings(menuBarMetric: .none), today: report, quotas: []), "none") diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index d3e2e0f35e..f2283e7a9a 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -120,6 +120,7 @@ export default function UsageCompanionPanel({ const [timelineError, setTimelineError] = useState(null); const [timelineLoading, setTimelineLoading] = useState(false); const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); + const [renderedAt] = useState(() => Date.now()); const [saveError, setSaveError] = useState(null); const saveTimer = useRef | null>(null); const saveBaseline = useRef(null); @@ -276,8 +277,8 @@ export default function UsageCompanionPanel({ {(() => { const lastSeenAt = response?.companion?.lastSeenAt ?? null; - const connected = lastSeenAt !== null && Date.now() - lastSeenAt <= 10 * 60 * 1000; - const age = lastSeenAt === null ? null : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t)); + const connected = lastSeenAt !== null && renderedAt - lastSeenAt <= 10 * 60 * 1000; + const age = lastSeenAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), renderedAt); const steps = (
  1. {t("usage.companion.installStep1")} {t("common.github")}
  2. diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts index 2ccbf61f06..9f68b323a5 100644 --- a/gui/src/pages/usage-companion-utils.ts +++ b/gui/src/pages/usage-companion-utils.ts @@ -75,8 +75,16 @@ export function formatCompanionTokens(value: number): string { [1_000_000, "M"], [1_000, "K"], ] as const; - for (const [threshold, suffix] of units) { - if (value >= threshold) return `${Math.round(value / threshold)}${suffix}`; + for (let index = 0; index < units.length; index += 1) { + const [threshold, suffix] = units[index]!; + if (value >= threshold) { + const rounded = Math.round(value / threshold); + if (rounded >= 1000 && index > 0) { + const [largerThreshold, largerSuffix] = units[index - 1]!; + return `${Math.round(value / largerThreshold)}${largerSuffix}`; + } + return `${rounded}${suffix}`; + } } return String(Math.round(value)); } From ead9538a38e7e72fc8a56a370373c3ae5a4b9b8f Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:14:15 -0700 Subject: [PATCH 56/61] docs(companion): update integer token examples Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- devlog/_fin/260725_macos_menubar_app/003_design_read.md | 4 ++-- devlog/_fin/260725_macos_menubar_app/010_phase1_core.md | 2 +- devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/devlog/_fin/260725_macos_menubar_app/003_design_read.md b/devlog/_fin/260725_macos_menubar_app/003_design_read.md index f3ec2d3385..0a6d480248 100644 --- a/devlog/_fin/260725_macos_menubar_app/003_design_read.md +++ b/devlog/_fin/260725_macos_menubar_app/003_design_read.md @@ -113,7 +113,7 @@ column. ├──────────────────────────────────────┤ │ LAST 7 DAYS │ range echoed from the response │ REQUESTS TOKENS COST │ micro labels, 10px, letterspaced -│ 1,746 12.4M $8.21 │ tabular-nums, 13px +│ 1,746 12M $8.21 │ tabular-nums, 13px │ ▁▂▃▅▂▁▃ │ 7d usage trend from usage.days[] ├──────────────────────────────────────┤ │ OpenAI ▓▓▓▓▓░░░░░ 44% │ quota rows, one per provider @@ -170,7 +170,7 @@ Live data reaches `requests: 232507`, `totalTokens: 36536664705`, `estimatedCostUsd: 34018.25`. Rules: - Counts: `1,746` → `12.4K` → `1.2M` (3 significant figures, SI suffix at 10 000). -- Tokens: always suffixed (`12.4M`, `36.5B`). +- Tokens: always suffixed with integer values (`12M`, `37B`). - Cost: `$8.21` below 1 000, `$34.0K` above. - All numerics use `tabular-nums` so digits do not reflow while polling. - Timestamps normalize by magnitude: values below `1e12` are seconds, at or above are diff --git a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md index f6f8c854cb..1dded9edf2 100644 --- a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md @@ -399,7 +399,7 @@ seconds and anthropic milliseconds both resolve to sane 2026 dates · `ProxySett decodes without a `defaultProvider` field and `ProxyConfigSummary` supplies it. `FormattingTests`: the `002` magnitudes (`232507`, `36536664705`, `34018.25`) render as -`232K`, `36.5B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. +`232K`, `37B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. ## `app/.gitignore` diff --git a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md index 28fbf34614..95126dceb3 100644 --- a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md @@ -184,7 +184,7 @@ shown as selectable text — displayed, never executed (`002` §3). Three columns from `/api/usage?range=7d`: REQUESTS, TOKENS, COST. Labels in `Theme.micro` uppercase with 0.5pt tracking; values in `Theme.numeric`. All values -through `Format` (`010`), so `36536664705` becomes `36.5B` and `nil` becomes `—`. +through `Format` (`010`), so `36536664705` becomes `37B` and `nil` becomes `—`. **The range label is rendered from the response, not the request.** `002` §3 records that `parseRange` silently falls back to `30d` for any unrecognized value, so a UI that From d1893d15d449a90236ffb206d4b56932694c7d94 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:16:00 -0700 Subject: [PATCH 57/61] fix(companion): live presence refresh, tokens headline in the small widget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/OpenCodexWidget/Views.swift | 10 +++++----- gui/src/pages/usage-companion-panel.tsx | 22 +++++++++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift index 00f94a90e4..b130d1fda5 100644 --- a/app/Sources/OpenCodexWidget/Views.swift +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -57,13 +57,13 @@ struct OpenCodexWidgetView: View { Circle().fill(tone(snapshot)).frame(width: 7, height: 7) Text("OpenCodex").font(.caption).foregroundStyle(.secondary) } - Text(Format.count(snapshot.today?.requests)) + Text(Format.tokens(snapshot.today?.totalTokens)) .font(.system(size: 28, weight: .semibold, design: .rounded)) .lineLimit(1) .widgetAccentable() - Text("requests today").font(.caption).foregroundStyle(.secondary) + Text("tokens today").font(.caption).foregroundStyle(.secondary) HStack(spacing: 4) { - Text(Format.tokens(snapshot.today?.totalTokens)) + Text("\(Format.count(snapshot.today?.requests)) req") if let cost = snapshot.today?.estimatedCostUsd { Text("·") Text(Format.cost(cost)) @@ -81,8 +81,8 @@ struct OpenCodexWidgetView: View { HStack(alignment: .top, spacing: 14) { VStack(alignment: .leading, spacing: 5) { status(snapshot) - metric("Requests", Format.count(snapshot.today?.requests)) metric("Tokens", Format.tokens(snapshot.today?.totalTokens)) + metric("Requests", Format.count(snapshot.today?.requests)) if let cost = snapshot.today?.estimatedCostUsd { metric("Cost", Format.cost(cost)) } updated(snapshot) } @@ -149,8 +149,8 @@ struct OpenCodexWidgetView: View { private func metricsRow(_ snapshot: WidgetSnapshot) -> some View { HStack(spacing: 10) { - metricColumn("REQUESTS", Format.count(snapshot.today?.requests)) metricColumn("TOKENS", Format.tokens(snapshot.today?.totalTokens)) + metricColumn("REQUESTS", Format.count(snapshot.today?.requests)) metricColumn("COST", Format.cost(snapshot.today?.estimatedCostUsd)) } } diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index f2283e7a9a..041e470ab5 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -120,11 +120,16 @@ export default function UsageCompanionPanel({ const [timelineError, setTimelineError] = useState(null); const [timelineLoading, setTimelineLoading] = useState(false); const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle"); - const [renderedAt] = useState(() => Date.now()); + const [fetchedAt, setFetchedAt] = useState(null); const [saveError, setSaveError] = useState(null); const saveTimer = useRef | null>(null); const saveBaseline = useRef(null); const timelineRequest = useRef(null); + const saveStateRef = useRef(saveState); + + useEffect(() => { + saveStateRef.current = saveState; + }, [saveState]); const loadSettings = useCallback(async () => { setSettingsError(null); @@ -133,6 +138,7 @@ export default function UsageCompanionPanel({ if (!result.ok) throw new Error(`${result.status} ${result.statusText}`.trim()); const next = await result.json() as CompanionSettingsResponse; setResponse(next); + setFetchedAt(Date.now()); setSettings(next.settings); saveBaseline.current = next.settings; onSettingsLoaded?.(next.settings.menuBarMetric); @@ -147,6 +153,15 @@ export default function UsageCompanionPanel({ return () => clearTimeout(timer); }, [loadSettings, response, visible]); + useEffect(() => { + if (!visible) return; + const interval = setInterval(() => { + if (saveStateRef.current === "saving") return; + void loadSettings(); + }, 60_000); + return () => clearInterval(interval); + }, [loadSettings, visible]); + const chartQuery = useMemo(() => { if (!settings) return null; const query = new URLSearchParams({ @@ -212,6 +227,7 @@ export default function UsageCompanionPanel({ const body = await result.json() as CompanionSettingsResponse | { error?: string }; if (!result.ok) throw new Error(body && "error" in body && body.error ? body.error : `${result.status} ${result.statusText}`.trim()); setResponse(body as CompanionSettingsResponse); + setFetchedAt(Date.now()); setSettings((body as CompanionSettingsResponse).settings); saveBaseline.current = (body as CompanionSettingsResponse).settings; setSaveState("saved"); @@ -277,8 +293,8 @@ export default function UsageCompanionPanel({ {(() => { const lastSeenAt = response?.companion?.lastSeenAt ?? null; - const connected = lastSeenAt !== null && renderedAt - lastSeenAt <= 10 * 60 * 1000; - const age = lastSeenAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), renderedAt); + const connected = lastSeenAt !== null && fetchedAt !== null && fetchedAt - lastSeenAt <= 10 * 60 * 1000; + const age = lastSeenAt === null || fetchedAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), fetchedAt); const steps = (
    1. {t("usage.companion.installStep1")} {t("common.github")}
    2. From b22b88f8ff3528befb6afb3fb74532f5148e9663 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:23:16 -0700 Subject: [PATCH 58/61] fix(companion): stable model ordering, tokens-first today row Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/MenuBarUI/Views.swift | 6 +++--- gui/src/pages/usage-companion-panel.tsx | 24 ++++++++++++++++++------ gui/tests/usage-companion-utils.test.ts | 10 ++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index feea90cbea..e844ecd645 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -114,7 +114,7 @@ final class MetricsView: NSView { private let columnsRow: NSStackView init() { - let captions = ["REQUESTS", "TOKENS", "COST"] + let captions = ["TOKENS", "REQUESTS", "COST"] columns = captions.map { caption in (makeLabel(caption, font: Theme.micro, color: Theme.faint), makeLabel(Format.unknown, font: Theme.numeric, color: Theme.text)) @@ -167,8 +167,8 @@ final class MetricsView: NSView { emptyLabel.isHidden = true let summary = usage?.summary let requests = Format.count(summary?.requests) - columns[0].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests - columns[1].value.stringValue = Format.tokens(summary?.totalTokens) + columns[0].value.stringValue = Format.tokens(summary?.totalTokens) + columns[1].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests columns[2].value.stringValue = Format.cost(summary?.estimatedCostUsd) columns[0].value.setAccessibilityLabel( (summary?.hasEstimates ?? false) diff --git a/gui/src/pages/usage-companion-panel.tsx b/gui/src/pages/usage-companion-panel.tsx index 041e470ab5..f3af2082de 100644 --- a/gui/src/pages/usage-companion-panel.tsx +++ b/gui/src/pages/usage-companion-panel.tsx @@ -126,11 +126,18 @@ export default function UsageCompanionPanel({ const saveBaseline = useRef(null); const timelineRequest = useRef(null); const saveStateRef = useRef(saveState); + const settingsRef = useRef(settings); + const knownTotalsRef = useRef(new Map()); + const [knownTotals, setKnownTotals] = useState>(new Map()); useEffect(() => { saveStateRef.current = saveState; }, [saveState]); + useEffect(() => { + settingsRef.current = settings; + }, [settings]); + const loadSettings = useCallback(async () => { setSettingsError(null); try { @@ -157,6 +164,7 @@ export default function UsageCompanionPanel({ if (!visible) return; const interval = setInterval(() => { if (saveStateRef.current === "saving") return; + if (settingsRef.current && saveBaseline.current !== settingsRef.current) return; void loadSettings(); }, 60_000); return () => clearInterval(interval); @@ -188,6 +196,14 @@ export default function UsageCompanionPanel({ const next = await result.json() as UsageTimeline; setTimeline(next); setAvailableModels(next.availableModels); + const currentTotals = new Map(); + for (const series of next.series) { + currentTotals.set(series.id, (currentTotals.get(series.id) ?? 0) + series.total); + } + for (const [id, total] of currentTotals) { + knownTotalsRef.current.set(id, total); + } + setKnownTotals(new Map(knownTotalsRef.current)); } catch (error) { if (!controller.signal.aborted) setTimelineError(errorMessage(error)); } finally { @@ -269,11 +285,7 @@ export default function UsageCompanionPanel({ const providerNames = providers.map(provider => provider.provider).filter((provider, index, all) => all.indexOf(provider) === index).toSorted(); const selectedModels = current.models ?? availableModels; const selectedModelSet = new Set(selectedModels); - const modelTotals = new Map(); - for (const series of timeline?.series ?? []) { - modelTotals.set(series.id, (modelTotals.get(series.id) ?? 0) + series.total); - } - const modelGroups = groupCompanionModels(availableModels, modelTotals); + const modelGroups = groupCompanionModels(availableModels, knownTotals); const hiddenProviderSet = new Set(current.hiddenProviders); const saveMessage = saveState === "saved" && response?.updatedAt ? t("usage.companion.saved", { time: formatSaveTime(response.updatedAt, locale) }) @@ -358,7 +370,7 @@ export default function UsageCompanionPanel({ title={model.id} /> {model.id} - {modelTotals.has(model.id) ? formatCompanionTokens(model.total) : "—"} + {knownTotals.has(model.id) ? formatCompanionTokens(model.total) : "—"} ; })} ; diff --git a/gui/tests/usage-companion-utils.test.ts b/gui/tests/usage-companion-utils.test.ts index b46ed0396f..2fd02c7fd9 100644 --- a/gui/tests/usage-companion-utils.test.ts +++ b/gui/tests/usage-companion-utils.test.ts @@ -59,6 +59,16 @@ describe("usage companion utilities", () => { ]); }); + test("keeps known totals for models absent from the current timeline", () => { + expect(groupCompanionModels( + ["openai/gpt-4", "anthropic/claude"], + new Map([["openai/gpt-4", 10]]), + )).toEqual([ + { provider: "openai", models: [{ id: "openai/gpt-4", total: 10 }], total: 10 }, + { provider: "anthropic", models: [{ id: "anthropic/claude", total: 0 }], total: 0 }, + ]); + }); + test("toggles mixed groups and collapses all-selected state to null", () => { const available = ["openai/gpt-4", "openai/gpt-5", "anthropic/claude"]; expect(toggleCompanionModels(["openai/gpt-4"], available, ["openai/gpt-5"], true)).toEqual(["openai/gpt-4", "openai/gpt-5"]); From 90ae1d0cb180d16845cbb7f8411cf7b5b08e2187 Mon Sep 17 00:00:00 2001 From: jun Date: Sat, 19 Sep 2026 19:34:58 -0700 Subject: [PATCH 59/61] fix(companion): address macOS widget review findings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/Sources/MenuBarUI/PopoverPanel.swift | 26 ++++++++++++------- app/Sources/MenuBarUI/Views.swift | 6 +++++ app/Sources/OpenCodexWidget/main.swift | 8 ++---- .../content/docs/ja/guides/macos-menu-bar.md | 5 ++++ .../content/docs/ko/guides/macos-menu-bar.md | 3 +++ .../content/docs/ru/guides/macos-menu-bar.md | 3 +++ .../docs/zh-cn/guides/macos-menu-bar.md | 3 +++ 7 files changed, 39 insertions(+), 15 deletions(-) diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift index 05e3484d1d..5c579f88be 100644 --- a/app/Sources/MenuBarUI/PopoverPanel.swift +++ b/app/Sources/MenuBarUI/PopoverPanel.swift @@ -125,6 +125,7 @@ public final class PopoverPanel: NSPanel { private enum PopoverSurface { static func make(content: NSView) -> NSView { let surface: NSView +#if compiler(>=6.2) if #available(macOS 26, *) { let glass = NSGlassEffectView() glass.cornerRadius = 16 @@ -132,16 +133,11 @@ private enum PopoverSurface { glass.contentView = content surface = glass } else { - let effect = NSVisualEffectView() - effect.material = .popover - effect.blendingMode = .behindWindow - effect.state = .active - effect.wantsLayer = true - effect.layer?.cornerRadius = 10 - effect.layer?.masksToBounds = true - effect.addSubview(content) - surface = effect + surface = makeMaterialSurface(content: content) } +#else + surface = makeMaterialSurface(content: content) +#endif let host = NSView() host.addSubview(surface) @@ -159,4 +155,16 @@ private enum PopoverSurface { ]) return host } + + private static func makeMaterialSurface(content: NSView) -> NSView { + let effect = NSVisualEffectView() + effect.material = .popover + effect.blendingMode = .behindWindow + effect.state = .active + effect.wantsLayer = true + effect.layer?.cornerRadius = 10 + effect.layer?.masksToBounds = true + effect.addSubview(content) + return effect + } } diff --git a/app/Sources/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift index e844ecd645..70c229b685 100644 --- a/app/Sources/MenuBarUI/Views.swift +++ b/app/Sources/MenuBarUI/Views.swift @@ -171,10 +171,16 @@ final class MetricsView: NSView { columns[1].value.stringValue = (summary?.hasEstimates ?? false) ? requests + "~" : requests columns[2].value.stringValue = Format.cost(summary?.estimatedCostUsd) columns[0].value.setAccessibilityLabel( + "\(Format.tokens(summary?.totalTokens)) tokens" + ) + columns[1].value.setAccessibilityLabel( (summary?.hasEstimates ?? false) ? "\(requests) requests, partly estimated" : "\(requests) requests" ) + columns[2].value.setAccessibilityLabel( + "\(Format.cost(summary?.estimatedCostUsd)) estimated cost" + ) } } } diff --git a/app/Sources/OpenCodexWidget/main.swift b/app/Sources/OpenCodexWidget/main.swift index 8050e93e0c..7eeda56386 100644 --- a/app/Sources/OpenCodexWidget/main.swift +++ b/app/Sources/OpenCodexWidget/main.swift @@ -1,6 +1,2 @@ -import SwiftUI -import WidgetKit - -if #available(macOS 14, *) { - OpenCodexWidgetBundle.main() -} +// WidgetKit enters through _NSExtensionMain; this file keeps the executable target's +// source directory populated without adding a competing Swift-generated main. diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md index d4c5dc5fa4..ff67a25ccc 100644 --- a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -74,6 +74,11 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app **使用量** — 直近 7 日間のリクエスト数、トークン、推定コストと日別の推移。リクエスト数の後ろの `~` は、一部がプロバイダー報告値ではなく推定値であることを示します。 +デフォルトでは、メニューバーのヘッドラインに合計トークン数が表示されます。リクエスト数、コスト、クォータ、 +またはアイコンだけを表示したい場合は、ダッシュボードの Usage コンパニオン設定でヘッドライン指標を変更できます。 +macOS 26 ではポップオーバーとウィジェットに Liquid Glass が採用され、それ以前の macOS バージョンでは標準の +ポップオーバーマテリアルが使われます。 + **クォータ** — プロバイダーごとに 1 行、最も逼迫しているウィンドウを表示します。5 時間枠を 99%、月間枠を 10% 使っているプロバイダーなら 5 時間枠の数値を出します。いま実際に制限に かかっているのはそちらだからです。ウィンドウ名を併記するため、`API usage の 42%` と diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md index 17126a9408..53159b4589 100644 --- a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -73,6 +73,9 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app **사용량** — 최근 7일간 요청 수, 토큰, 예상 비용과 일자별 추이입니다. 요청 수 뒤의 `~`는 일부가 프로바이더 보고값이 아니라 추정치라는 표시입니다. +기본적으로 메뉴 막대 헤드라인은 총 토큰 수를 표시합니다. 요청 수, 비용, 할당량 또는 아이콘만 보고 싶다면 대시보드 Usage의 컴패니언 설정에서 헤드라인 지표를 바꿀 수 있습니다. +macOS 26에서는 팝오버와 위젯이 Liquid Glass를 사용하며, 이전 macOS 버전은 기본 팝오버 머티리얼을 사용합니다. + **쿼터** — 프로바이더마다 한 줄씩, 가장 압박이 큰 창을 보여줍니다. 5시간 한도를 99% 쓰고 월 한도는 10%만 쓴 프로바이더라면 5시간 수치를 표시합니다. 지금 막고 있는 쪽이 그것이기 때문입니다. 창 이름을 아래에 적어두어 `API usage의 42%`와 `한 달의 42%`를 헷갈릴 일이 diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md index a67ddae712..30f54ad983 100644 --- a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -76,6 +76,9 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app динамикой. Знак `~` после числа запросов означает, что часть значения оценочная, а не сообщённая провайдером. +По умолчанию в заголовке строки меню отображается общее число токенов; если нужны запросы, стоимость, квота или только значок, измените метрику заголовка в настройках Companion раздела Usage на дашборде. +В macOS 26 всплывающее окно и виджеты используют Liquid Glass; в более ранних версиях macOS используется стандартный материал всплывающего окна. + **Квоты** — по строке на провайдера, показывается окно под наибольшим давлением. Если провайдер израсходовал 99% пятичасового лимита и 10% месячного, показывается пятичасовое значение — именно оно сейчас блокирует работу. Название окна печатается под провайдером, diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md index 9a63a6b0bc..50058524bf 100644 --- a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -66,6 +66,9 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app **用量** — 最近 7 天的请求数、令牌数和预估成本,以及每日趋势。请求数后的 `~` 表示其中一部分 是估算值,而非提供商上报的数据。 +默认情况下,菜单栏标题显示令牌总数;如果您更想查看请求数、成本、配额,或只显示图标,可在控制台 Usage 的 Companion 设置中更改标题指标。 +在 macOS 26 中,弹出面板和小组件采用 Liquid Glass;更早版本的 macOS 使用标准弹出面板材质。 + **配额** — 每个提供商一行,显示压力最大的那个窗口。如果某个提供商 5 小时额度用了 99%、月度 额度只用了 10%,会显示 5 小时的数值,因为真正卡住你的是它。窗口名称标注在提供商下方,因此 `API usage 的 42%` 和`一个月的 42%` 不会混淆。 From 72063f739a29d143ea79d597072b63ecd532dcec Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 12:44:43 +0900 Subject: [PATCH 60/61] docs(devlog): drop the duplicate _plan copy of the closed macOS unit The branch opened this unit under devlog/_plan/ before dev published the same unit as closed at devlog/_fin/260725_macos_menubar_app/ in 8ae52e4291. The rebase replayed the _plan addition on top of that publication, so the tree carried both copies: nine files, 2,605 lines, six byte-identical to their _fin counterparts. The three that differ are worse than redundant. 003_design_read.md, 010_phase1_core.md and 020_phase2_ui.md keep the decimal token text (12.4M, 36.5B) that this same pull request corrects to integers in the _fin copies, so the duplicate contradicted the corrected record two directories over. AGENTS.md defines _plan as units still open and _fin as closed work, and nothing in CI reads devlog/ - the file-size scanner excludes it - so no gate would have caught this. The _fin copies, including the 051_feature_summary.md this PR adds, remain the record. --- .../260725_macos_menubar_app/000_plan.md | 124 ----- .../260725_macos_menubar_app/001_pr_survey.md | 219 --------- .../002_api_surface.md | 263 ---------- .../003_design_read.md | 187 ------- .../010_phase1_core.md | 459 ------------------ .../260725_macos_menubar_app/020_phase2_ui.md | 431 ---------------- .../030_phase3_actions.md | 320 ------------ .../040_phase4_release.md | 459 ------------------ .../050_phase5_handoff.md | 143 ------ 9 files changed, 2605 deletions(-) delete mode 100644 devlog/_plan/260725_macos_menubar_app/000_plan.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/001_pr_survey.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/002_api_surface.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/003_design_read.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/010_phase1_core.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/040_phase4_release.md delete mode 100644 devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md diff --git a/devlog/_plan/260725_macos_menubar_app/000_plan.md b/devlog/_plan/260725_macos_menubar_app/000_plan.md deleted file mode 100644 index 6cea70fa89..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/000_plan.md +++ /dev/null @@ -1,124 +0,0 @@ -# 260725 — macOS menu bar companion app (`app/`) - -**Unit:** `devlog/_plan/260725_macos_menubar_app/` -**Branch:** `feat/macos-app` (dedicated worktree `/opencodex-macos-app`, based on `origin/dev` @ `dbed8c15`) -**Work class:** C4 (new shippable surface + release/CI wiring) -**Mode:** HOTL multi-cycle PABCD under `cxc-loop`. This document is the Phase-0 roadmap lock. - -## Objective - -Ship one maintainer-owned macOS menu bar companion for OpenCodex, replacing the two -competing community PRs (#387 Swift/SwiftUI, #421 Tauri/React) with a single -implementation that lives in `app/`, builds a real distributable `.app` bundle, and -attaches release assets through the existing release workflow. - -## Why this unit exists - -Two contributors independently built a menu bar companion within 24 hours of each -other. They cannot both merge: they occupy different directories (`apps/macos-menu-bar/` -vs `menubar/`), use different runtimes (Swift Package Manager vs Tauri v2 + Rust + -React), and use different transports to reach the proxy (`ocx` CLI subprocess vs HTTP -management API). Merging either as-is would (a) leave the other contributor's work -stranded, and (b) commit the repository to a runtime choice that was never audited -against the release pipeline the project already has. - -The user's decision (2026-07-25) is to build the maintainer version, take the strongest -ideas from both, and close both PRs with credit. - -## Constraints - -| Constraint | Source | Consequence | -| --- | --- | --- | -| No `src/` proxy runtime changes | User scope | The app consumes only endpoints that already exist | -| No new management API endpoints | User scope | Any missing data must be derived from existing responses | -| Bun-native repo, no compile step for the proxy | `AGENTS.md` | The app cannot introduce a build step into the proxy's path | -| `bun run typecheck` / `test` / `privacy:scan` must stay green | `AGENTS.md` CI | `app/` must be excluded from the root `tsconfig` or be type-clean under it | -| Release flow is `scripts/release.ts` + `.github/workflows/release.yml` | `AGENTS.md` | macOS packaging attaches to the existing job graph, it does not fork it | -| Security-sensitive workflow edits require review | `AGENTS.md` | Workflow changes stay minimal, pinned, and least-privilege | -| Branch targets `dev` | `.github/workflows/enforce-pr-target.yml` | `feat/macos-app` is pushed, not PR'd, in this unit | - -## Evidence gathered at P (live, 2026-07-25) - -Local toolchain: - -```text -xcode-select -p -> /Library/Developer/CommandLineTools -swift --version -> Apple Swift 6.4, target arm64-apple-macosx27.0.0 -cargo -> present at ~/.cargo/bin/cargo -sw_vers -> macOS 27.0 (26A5378n) -``` - -Universal-build probe (decisive — see `001_pr_survey.md` §4): - -```text -swift build --arch arm64 --arch x86_64 -c release - -> ld: symbol(s) not found for architecture x86_64 - -> warning: The x86_64 architecture is deprecated for your deployment target (macOS 27.0) -swift build --arch arm64 -c release - -> Build complete! (10.39 sec) -``` - -Live proxy surface (`127.0.0.1:10100`, verified by `curl`): `/api/settings`, -`/api/startup-health`, `/api/usage`, `/api/provider-quotas`, `/api/providers`, -`/api/stop`. Full payload shapes in `002_api_surface.md`. - -Audit-corrected surface facts (see `002` for evidence): - -- `defaultProvider` is served by `/api/config`, **not** `/api/settings`. -- `/api/usage` supports only `7d` / `30d` / `all`; `24h` silently degrades to `30d`. -- `/api/stop` calls `stopServiceIfInstalled()` before responding, so nothing restarts the - proxy and no start endpoint exists. -- `/api/logs` exists and would serve per-request activity; it is deliberately excluded - from v1. - -## Work-phase map (dependency-ordered, PHASE-SPLIT-01) - -Ordering is build-order, not effort: the transport contract must exist before the UI -can render truth, the UI must exist before actions can report their result, and the -bundle must exist before packaging can wrap it. - -| Phase | Doc | Delivers | Independently verifiable by | -| --- | --- | --- | --- | -| 0 | `000`-`003` | Research, API inventory, design lock, this roadmap | Docs exist, audit passes | -| 1 | `010` | `app/` skeleton, proxy discovery, typed API client | `swift test` + `swift build` green | -| 2 | `020` | Menu bar item + popover UI, all states | Screenshot via `swift run` | -| 3 | `030` | Write actions on existing endpoints | Live action against running proxy | -| 4 | `040` | Universal build, packaging, CI/release wiring | `lipo -archs`, workflow syntax | -| 5 | `050` | Docs, PR closure, push | `gh pr view`, `git ls-remote` | - -Phases 1-3 close on `swift test` / `swift build` / `swift run` — never on a bundle. -`scripts/build-macos-app.sh` and the first `.app` belong entirely to Phase 4, so no phase -is verified by a later phase's output. - -## Scope boundary - -**IN:** `app/**`, `scripts/build-macos-app.sh`, `scripts/package-macos-release.sh`, -`.github/workflows/ci.yml`, `.github/workflows/release.yml`, `package.json` script -entries, `docs-site/` companion pages, this devlog unit. - -**OUT:** `src/**` (proxy runtime), new API endpoints, Windows/Linux companions, merging -to `dev`/`main`, the six Haydern provider PRs, `gui/**` beyond required asset reuse. - -## Accept criteria (mirrored into the goalplan) - -1. `app/` produces a launchable `.app` bundle from a repo script (Phase 4). -2. Proxy discovery honours `~/.opencodex/runtime-port.json` and falls back to 10100. -3. The popover renders health, usage trend, quotas, and providers from live data. - ("Activity" is the day-granular usage trend; per-request logs are out of scope for v1.) -4. Every state renders a meaningful surface; error, unauthorized, unreachable, and empty - states each name a next action. `loading` is exempt — there is nothing to act on yet. -5. Write actions call only pre-existing endpoints, and the app never spawns a process. -6. Release build is universal (arm64 + x86_64) **in CI**; local arm64-only is accepted - and documented (see `001` §4). -7. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. -8. No build artifacts committed, and no developer-absolute home path in **any file this - unit adds or modifies** (including its `devlog/` docs, which `privacy:scan` excludes). - Pre-existing paths in unrelated historical devlogs are out of scope. -9. PRs #387 and #421 closed with English maintainer comments crediting both authors, each - written against the PR's head commit at the time of posting. -10. `feat/macos-app` pushed to origin. - -## Terminal outcomes - -`DONE` on all ten. `BLOCKED` only if no `.app` bundle can be produced after documented -attempts. `NEEDS_HUMAN` if a scope decision beyond the user's delegation appears. diff --git a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md b/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md deleted file mode 100644 index 538c5b04cf..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/001_pr_survey.md +++ /dev/null @@ -1,219 +0,0 @@ -# 001 — Survey: PR #387 vs PR #421, and the stack decision - -Research document. No diffs here (LEXICO-SPLIT-01); implementation lives in the decade docs. - -## 1. PR #387 — `feat: ship packaged macOS menu bar companion` (jaycho46) - -**Branch:** `feat/menubar-app` · **Directory:** `apps/macos-menu-bar/` · 16 commits · +1656/-32 - -Architecture (read from the branch, not from the PR body): - -```text -Package.swift swift-tools-version 5.9, .macOS(.v12) - OpenCodexMenuBarCore OcxClient, OcxLocator, StatusModels (library, tested) - OpenCodexMenuBar main.swift, MenuText, StatusBarIcon (executable) - Tests OpenCodexMenuBarCoreTests -``` - -**Transport: `ocx` CLI subprocess.** `OcxClient.fetchStatus` locates the `ocx` -executable via `OcxLocator`, runs `ocx status --json`, then brace-slices the stdout -(`output.firstIndex(of: "{")` … `lastIndex(of: "}")`) and decodes it. Write actions run -through `commandPlan(for:status:)`, which emits further `ocx` argument vectors. - -To make that transport work, the PR also **extends `src/cli/status.ts`** with -`proxy.health.version` and `proxy.health.uptimeSeconds`, and adds -`tests/cli-status-json.test.ts`. - -Packaging (the genuinely strong part): - -- `scripts/build-macos-app.sh` — assembles `OpenCodex.app` by hand: `Contents/MacOS`, - `Contents/Resources`, `Info.plist`, an `.iconset` built from `gui/public/favicon.png`, - and a refusal guard on unexpected bundle paths. -- `scripts/package-macos-release.sh` — `codesign --verify --deep --strict`, - `lipo -archs` assertion for both arches, `ditto -c -k --sequesterRsrc --keepParent`, - archive content assertion (`unzip -Z1` must contain the executable), `shasum -a 256`. -- `.github/workflows/release.yml` — new `package-macos` job on `macos-latest`, artifact - upload, and Release asset attachment. Also scopes Trusted Publishing OIDC to the - publish job (commit `fbc9c844`), which is an unrelated but correct hardening. -- `.github/workflows/ci.yml` — `test:macos` and `build:macos` steps gated on - `runner.os == 'macOS'`. - -Review history: no maintainer review. Its own author left 10 self-review comments and -CodeRabbit iterated ~14 rounds; the commit tail (`1454a925` bound CLI runs with a -timeout and concurrent pipe drain, `dcf4fea0` treated stale launchd services as -repairable, `0ebbb6a7` waited for pipe drain before reading buffers) shows real defect -repair, not cosmetic churn. - -## 2. PR #421 — `feat(menubar): redesign as macOS status widget` (genglintong) - -**Branch:** `feat/menubar-status-widget` · **Directory:** `menubar/` · 5 commits · +14532/-0 -**Surveyed at head `049ef2ac`** (re-verified after the Phase-0 audit; an earlier draft of -this document described an older head and was factually wrong — see §2.1). - -Architecture: - -```text -menubar/src-tauri/ Rust: tray.rs, keychain.rs, discover.rs, api.rs (~170 lines) -menubar/src/ React 19 + TS: App, sections/{Usage,Health,Status,Setup,Activity} -menubar/scripts/ build-app.sh, check-version.sh -``` - -**Transport: HTTP management API.** `discover.rs` reads -`~/.opencodex/runtime-port.json`; `api.rs` proxies WebView `invoke("api_request")` calls -through Rust `reqwest`, with the key sourced from the macOS Keychain. Zero proxy-side -changes — it consumes only endpoints that already exist. - -The PR body claims the token "never crosses to WebView JS". **That is not what the code -does at head `049ef2ac`** — `menubar/src/api.ts:12-13` receives it directly: - -```ts -const discovery = await invoke<{ url: string; token: string | null; found: boolean }>("discover_proxy"); -proxyConfig = { url: discovery.url, token: discovery.token }; -``` - -The token is returned to the renderer and cached in module state. The Rust IPC layer is -still a reasonable shape, but the isolation claim does not hold, so this plan does not -credit it and does not repeat it in the closing comment. - -Design: four-tab segmented widget (Usage / Health / Status / Activity), Apple-style -white theme, tabular-nums stats, `macOSPrivateApi: true` for a transparent rounded -popover with shadow. The submitted screenshot is the more polished of the two. - -Distribution: `menubar/scripts/build-app.sh` runs `cargo tauri build` and produces both -`OpenCodex Menubar.app` and a `.dmg`. **But `.github/` is untouched** — no CI job, no -release job, no artifact attached to any GitHub Release. A user still needs `rustup` plus -a frontend toolchain and must build from source. - -### 2.1 Correction: the committed-artifacts defect is FIXED at the current head - -An earlier draft of this survey stated that `menubar/src-tauri/target/**` was committed -and that `bun run privacy:scan` fails on the tree. **That was true of the head the Codex -reviewer saw, and the contributor has since fixed it.** Verified directly against -`049ef2ac`: - -```text -gh pr view 421 --json files --jq '[.files[].path | select(test("src-tauri/target"))] | length' - -> 0 - -gh api repos/genglintong/opencodex/contents/menubar/src-tauri?ref=049ef2ac - -> .gitignore, Cargo.lock, Cargo.toml, build.rs, capabilities, gen, icons, src, tauri.conf.json -``` - -Commit `049ef2ac` is titled "fix(menubar): address all Codex review findings (5 P1 + 14 -P2)". The contributor responded to review properly and the tree is clean. Any closing -comment must say so; repeating the stale defect would be both wrong and unfair. - -## 3. Head-to-head - -| Axis | #387 (Swift) | #421 (Tauri) | -| --- | --- | --- | -| Runtime deps to build | Swift toolchain (Xcode CLT) | Rust + Node + Tauri CLI | -| Runtime deps to run | none (native binary) | none (bundled WebView) | -| Bundle size class | ~single-MB native | tens of MB (WebView shell + Rust) | -| Transport | `ocx` CLI subprocess | HTTP management API | -| Requires proxy source change | yes (`src/cli/status.ts`) | no | -| Distribution to users | zip + SHA-256 attached to Release | `.app` + `.dmg`, build from source only | -| CI coverage | macOS test + build steps | none | -| Committed artifacts | none | none (fixed at `049ef2ac`) | -| UI polish (as submitted) | functional menu | higher — segmented tabs, tuned spacing | -| Data breadth | proxy status + control | usage, health, status, activity, quotas | - -## 4. Stack decision — Swift + AppKit, transport over HTTP - -**Decision: build in Swift (SwiftPM + AppKit), and talk to the proxy over the HTTP -management API.** This is a hybrid: #387's runtime and packaging discipline, #421's -transport and information architecture. - -Rationale, in order of weight: - -1. **Distribution is the whole point of the user's question.** #421 can build an `.app` - and a `.dmg` locally, but nothing in the repository builds or publishes one: `.github/` - is untouched, so no user can download a build. #387 already proves the full path — - packaged, checksummed, and attached to a GitHub Release. -2. **HTTP beats CLI subprocess for a polling UI.** Spawning `ocx` every refresh cycle - costs a process launch plus Bun startup per tick, requires the brace-slicing hack to - survive incidental stdout, and — decisively — needs `src/cli/status.ts` to grow new - fields. The user put `src/` out of scope. The management API already returns richer - data (`/api/usage`, `/api/provider-quotas`) with no proxy change at all. -3. **Dependency weight.** Swift + AppKit ships zero third-party dependencies. Tauri adds - a Rust toolchain, a Cargo lockfile, generated ACL schemas, and a WebView runtime to a - repository whose entire premise is a single Bun process. -4. **`macOSPrivateApi: true` is a liability.** #421 enables it for rounded corners. - Private API usage is a documented App Store rejection vector and a notarization risk; - AppKit's `NSPopover` gives the same visual result through public API. - -**What is explicitly NOT part of the rationale** (each was in an earlier draft and each -is now known to be wrong or unfair): - -- Not "committed build artifacts" — fixed at `049ef2ac` (§2.1). -- Not "no bundle at all" — `build-app.sh` produces both `.app` and `.dmg`. -- Not "packaging must be rebuilt from scratch" — the gap is repository CI/release - *attachment*, not the ability to produce a bundle locally. - -The rejection of Tauri rests on exactly three facts: no repository CI or release -attachment, a materially heavier build stack for a project whose premise is one Bun -process, and the private-API dependency. - -### 4.1 The universal-binary finding (must be honoured by Phase 4) - -Probed live on this machine: - -```text -swift build --arch arm64 --arch x86_64 -c release - -> ld: symbol(s) not found for architecture x86_64 -swift build --arch arm64 -c release - -> Build complete! (10.39 sec) -``` - -Command Line Tools ships only current-architecture Swift compatibility libraries, and -macOS 27 additionally deprecates x86_64 for this deployment target. #387's build script -already detects this and refuses `UNIVERSAL=1` under CLT with a clear message — that -guard is correct and is inherited. - -**Consequence for the plan:** local verification is arm64-only and that is expected, not -a failure. The universal assertion belongs in CI, where `macos-latest` runners carry a -full Xcode. Phase 4 must therefore keep `UNIVERSAL` opt-in with the CLT guard, and the -`lipo` both-arch assertion must run in the CI job rather than gating local builds. - -### 4.2 What the HTTP transport decision costs, honestly - -Choosing HTTP over the CLI is not free. `/api/stop` stops launchd on purpose -(`src/server/management-api.ts:136-147`), and there is no start endpoint — so the app can -stop the proxy but can never start it. PR #387's CLI transport *could* run `ocx start`. - -This is accepted rather than worked around: the app ships **Stop proxy**, not Restart, and -shows the start command for the user to run. Spawning processes from a menu bar app to -paper over a missing endpoint is worse than being honest about the capability. See `030`. - -## 5. What is salvaged from each PR - -From **#387 (jaycho46)** — packaging architecture: manual bundle assembly, the -unexpected-bundle-path refusal guard, `codesign --verify --deep --strict`, `lipo` -assertion, `ditto` archiving with archive-content verification, SHA-256 sidecar, the -`package-macos` release job shape, the CLT/universal guard, and the Gatekeeper -first-launch documentation angle. - -From **#421 (genglintong)** — product architecture: HTTP management-API transport, -`runtime-port.json` discovery with a 10100 fallback, Keychain-backed key storage, the -usage / health / status information set, tabular-numeral stat treatment, and skipping auth -entirely when the proxy has no `apiKeys` configured. - -Two things from that branch are deliberately NOT carried over: renderer-side token -isolation (§2 shows the token does reach renderer memory at `049ef2ac`, so there is -nothing to adopt), and the per-request activity surface (`002` §3 records why it is -excluded from v1). - -The contributor's review-response discipline at `049ef2ac` also directly improved this -plan: the audit that caught this document's own stale claims used that head as evidence. - -## 6. Rejected alternatives - -- **Merge #387, then re-skin later.** Rejected: it lands the `src/cli/status.ts` change - the user excluded, and the CLI transport would have to be replaced anyway. -- **Merge #421, then add packaging.** Rejected on the current head's remaining facts: - the Rust + Node + Tauri toolchain is a large addition to a single-Bun-process project, - and `macOSPrivateApi: true` keeps a notarization and App-Store-rejection risk that - `NSPopover` avoids. (The committed-artifact defect is fixed — §2.1 — and is explicitly - NOT a reason.) -- **Ask the contributors to converge.** Rejected: the user asked for the maintainer - version now; a two-way contributor negotiation is slower and leaves both PRs open. diff --git a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md b/devlog/_plan/260725_macos_menubar_app/002_api_surface.md deleted file mode 100644 index f6f789dce2..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/002_api_surface.md +++ /dev/null @@ -1,263 +0,0 @@ -# 002 — Management API surface the companion consumes - -Research document: what already exists, verified live against `127.0.0.1:10100` on -2026-07-25 and cross-read in `src/`. No proxy change is required by anything here. - -## 1. Discovery - -`src/config.ts:324` — `resolveRuntimePortPath()` returns `/runtime-port.json`, -where the config dir defaults to `~/.opencodex` (overridable by `OPENCODEX_HOME`). - -Live content: - -```json -{ "pid": 14582, "port": 10100 } -``` - -Resolution order the app implements: - -1. `OPENCODEX_HOME` if set, else `~/.opencodex`. -2. Read `runtime-port.json`; use `port` when it parses and is in `1..65535`. -3. Fall back to `10100`. -4. Host is always loopback (`127.0.0.1`). - -`pid` is present and could be liveness-checked, but the app treats a failed HTTP probe -as the authoritative "not running" signal — simpler, and it matches what the user sees. - -## 2. Authentication - -`src/server/auth-cors.ts:120` — `isApiAuthRequired(config)` returns -`!isLoopbackHostname(config.hostname)`. **On a loopback bind (the default), management -requests need no credential at all.** - -When required, `hasValidApiAuth` (`auth-cors.ts:161`) accepts any of: - -- `x-opencodex-api-key: ` -- `authorization: Bearer ` -- `x-api-key: ` - -validated against `OPENCODEX_API_AUTH_TOKEN` or `config.apiKeys[].key` with -`timingSafeEqual`. - -App behaviour: attempt unauthenticated first. On `401`, read the token from the macOS -Keychain and retry with `x-opencodex-api-key`. Never log the token, never write it to -`UserDefaults`, never include it in error strings surfaced to the UI. - -## 3. Read endpoints - -### `GET /api/settings` - -Bind/runtime configuration plus an embedded `startupHealth`. **Exact live key set** -(enumerated, because an earlier draft of this plan assumed a field that does not exist): - -```text -codexAutoStart · port · hostname · streamMode · startupHealth · codexRuntime -``` - -Used for: the port/hostname the app displays, and as the cheapest liveness probe. - -**`defaultProvider` is NOT in this response.** It lives in `GET /api/config` (below). - -### `GET /api/config` - -The safe config DTO (`src/server/auth-cors.ts:287-337` builds it; secrets are stripped). -Live key set: - -```text -port · hostname · defaultProvider · codexAutoStart · websockets · providers -``` - -Live value: `"defaultProvider": "openai"`. - -This is the **only** source for `defaultProvider`, which Phase 3 needs to disable the -toggle on the provider that cannot be disabled (§4). `/api/providers` does not mark the -default. - -### `GET /api/startup-health` - -```json -{ - "routingKind": "opencodex-local", - "autostartEnabled": false, - "serviceInstalled": true, "serviceViable": true, "serviceEnabled": true, - "serviceRunning": true, "serviceStale": false, "serviceConflict": false, - "serviceSupported": true, - "shimInstalled": false, "shimHealthy": false, - "platform": "darwin", - "routingInjected": true, "localRoutingDependency": true, - "status": "at-risk", - "rebootSafe": false, - "protection": "none", - "shimCoverage": "none", - "recommendedCommand": "ocx service install", - "commands": { "installService": "...", "installShim": "...", "restoreNative": "..." } -} -``` - -`status` is the single field the menu bar icon derives its state from. Observed values -include `protected` and `at-risk`; the app must treat the field as an open string and -degrade unknown values to a neutral state rather than crashing. - -`recommendedCommand` is a **string to display**, never a command the app executes -silently. - -### `GET /api/usage` - -Accepts `?range=` and `?surface=`. - -**Supported ranges are exactly `7d`, `30d`, and `all`** — `src/usage/summary.ts:95-98`: - -```ts -export function parseRange(input: string | null | undefined): UsageRange { - if (input === "7d" || input === "30d" || input === "all") return input; - return "30d"; -} -``` - -Unrecognized values silently fall back to `30d`. Verified live: requesting -`?range=24h` returned `"range": "30d"` with 30 daily buckets. **There is no 24-hour -contract and no hourly bucketing.** `rangeWindow()` (`summary.ts:105-108`) only ever -produces day-granular windows. Adding an hourly range would require a `src/` change, -which is out of scope, so the UI uses `7d` and labels it truthfully. - -```json -{ - "range": "30d", "surface": "all", "since": 1782323333603, "generatedAt": 1784915333603, - "summary": { - "requests": 232507, "measuredRequests": 225380, "estimatedRequests": 14618, - "inputTokens": 33521662469, "outputTokens": 127401110, - "cachedInputTokens": 31920236280, "reasoningOutputTokens": 25395837, - "totalTokens": 36536664705, "coverageRatio": 0.969, "estimatedCostUsd": 34018.25 - }, - "days": [ { "date": "2026-06-28", "requests": 1746, "totalTokens": 0, "models": [] } ] -} -``` - -Note the magnitudes: request counts reach six figures, token counts reach 3.6e10, and -cost reaches five figures. **Every numeric in the UI must be abbreviated and use tabular -figures**; naive rendering destroys the layout. This is a hard design input, recorded in -`003`. - -`days[]` is day-granular and is the source for the **usage trend** sparkline. It is not -"recent activity" — see `/api/logs` below for that distinction. - -### `GET /api/logs` - -`src/server/management/logs-usage-routes.ts:66-69` — returns recent request log entries -through `requestLogDto`, filterable by query params. Each entry carries request time, -model, provider, status, latency, and token counts. - -This is the real "recent activity" source, and PR #421 used it. **Decision: not consumed -in v1.** Per-request rows carry model names and timing for a user's actual traffic; a -menu bar popover that is always one click from view is the wrong surface for that, and -the dashboard already renders it with proper filtering. The popover shows aggregate -trend only. This is a deliberate exclusion, not an oversight, and the endpoint stays -available if the requirement changes. - -### `GET /api/provider-quotas` - -```json -{ - "generatedAt": 1784915336899, - "reports": [ - { "provider": "openai", "label": "OpenAI (Codex login)", "source": "chatgpt:wham", - "quota": { "weeklyPercent": 44, "weeklyResetAt": 1785258443, "resetCredits": 3 } }, - { "provider": "anthropic", "label": "Anthropic Claude", "source": "anthropic:oauth-usage", - "quota": { "weeklyPercent": 58, "weeklyResetAt": 1785265199718, - "customWindows": [ { "label": "5h", "percent": 1, "resetAt": 1784928599718 } ] } }, - { "provider": "xai", "label": "xAI Grok", "source": "xai:grok-billing", - "quota": { "monthlyPercent": 86.83, "monthlyResetAt": 1785542400000 } } - ] -} -``` - -Traps the app must handle: - -- The window key differs per provider: `weeklyPercent`, `monthlyPercent`, or only - `customWindows[]`. There is no single canonical percent field. -- `weeklyResetAt` is **seconds** for `openai` (`1785258443`) but **milliseconds** for - `anthropic` (`1785265199718`). Timestamps must be normalized by magnitude, not by - assuming a unit. -- `quota` may be absent entirely for a provider with no usage source. - -### `GET /api/providers` - -```json -[ { "name": "openai", "adapter": "openai-responses", - "baseUrl": "https://chatgpt.com/backend-api/codex", - "hasApiKey": false, "liveModels": true, "models": [], - "authMode": "forward", "disabled": false, "codexAccountMode": "pool" } ] -``` - -`hasApiKey` is a boolean presence flag — the key itself is never returned. `disabled` -drives the toggle in Phase 3. - -## 4. Write endpoints - -### `POST /api/stop` - -`src/server/management-api.ts:136-147`. The full body matters: - -```ts -stopServiceIfInstalled(); -const restore = restoreNativeCodex(); -setTimeout(async () => { await drainAndShutdown(...); process.exit(0); }, 200); -return jsonResponse(restore.success - ? { success: true, message: "Proxy stopping, native Codex restored." } - : { success: false, message: "Proxy stopping, but native Codex restore failed: … Run `ocx restore`." }); -``` - -The response body carries a `success` boolean: `false` when `restoreNativeCodex()` -failed, in which case the proxy still exits but native Codex is left pointing at a port -that is about to close. Clients should decode the boolean and tell the user to run -`ocx restore`; the accompanying `message` is a server-formatted string and should not be -surfaced verbatim. - -Three consequences, all load-bearing: - -1. **It answers `200` before draining.** The app treats `200` as "stop accepted", not - "stopped", and re-probes until the port stops answering. -2. **A 200 does not mean the restore succeeded.** See the `success` flag above. -3. **It calls `stopServiceIfInstalled()` first — deliberately stopping launchd so the - supervisor cannot respawn the proxy.** A service-managed proxy therefore stays down. - **There is no automatic restart, and no start endpoint exists.** Any UI that says - "Restart" would be lying. See `030` for the corrected action design. - -### `PATCH /api/providers?name=` - -`src/server/management/provider-routes.ts:127`. Body must be a plain object. - -For the disabled toggle the body is exactly `{ "disabled": true|false }`: - -- `provider-routes.ts:177` — non-boolean `disabled` is `400`. -- `provider-routes.ts:178` — disabling `config.defaultProvider` is rejected `400` with - `"cannot disable the default provider; set another default first"`. **The app must - disable the toggle for the default provider and explain why, rather than firing a - request that is guaranteed to fail.** -- `provider-routes.ts:239` — a `disabled`-only patch skips the heavier merged-shape - validators, so the toggle stays a cheap, low-risk call. -- `codexAccountMode` is mutually exclusive with every other field - (`provider-routes.ts:139`) and is **out of scope** for this app. - -Unknown provider names return `404`. - -## 5. Endpoints deliberately not consumed - -`/api/oauth/*` (account operations are a Non-goal), `/api/update/*` (self-update is the -dashboard's job), `/api/debug/*` (verbose, privacy-sensitive), `/api/storage`, -`/api/combos`, `/api/models`, `/api/keys`. Adding them later does not require a proxy -change, so the surface stays extensible. - -## 6. Polling contract - -| Data | Endpoint | Interval | Rationale | -| --- | --- | --- | --- | -| Liveness + health | `/api/startup-health` | 5 s | Cheap, drives the icon | -| Usage summary | `/api/usage?range=7d` | 60 s | Aggregation is expensive; `7d` is a real range | -| Quotas | `/api/provider-quotas` | 60 s | Upstream-rate-limited | -| Providers | `/api/providers` | on popover open | Changes rarely | -| Config (`defaultProvider`) | `/api/config` | on popover open | Changes rarely | - -Polling pauses entirely while the popover is closed except for the 5 s liveness tick, and -backs off to 30 s after three consecutive failures. This keeps an idle menu bar app from -behaving like a load generator against the user's own proxy. diff --git a/devlog/_plan/260725_macos_menubar_app/003_design_read.md b/devlog/_plan/260725_macos_menubar_app/003_design_read.md deleted file mode 100644 index f3ec2d3385..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/003_design_read.md +++ /dev/null @@ -1,187 +0,0 @@ -# 003 — Design Read + dial lock - -Design authority delegated by the user ("그냥 브랜치 너의 미감대로"). Produced under -`cxc-dev-uiux-design` before any UI code, per UX-CONCEPT-GEN-01. Implementation rules -are enforced from `cxc-dev-frontend`. - -## 1. Existing design system detection (MANDATORY, ran first) - -The repository already has a governing token system: `gui/src/styles.css`. It is not a -starter theme — it is deliberate, and the companion inherits it rather than inventing a -parallel aesthetic. - -```css ---bg: light-dark(#ffffff, #212121); ---surface: light-dark(#ffffff, #262626); ---raised: light-dark(#f4f4f4, #303030); ---border: light-dark(#e6e6e6, #3d3d3d); ---text: light-dark(#0d0d0d, #ececec); ---muted: light-dark(#6e6e6e, #a6a6a6); ---accent: light-dark(#0d0d0d, #ececec); /* ink, not a hue */ ---green: light-dark(#0a7d5c, #4ecb9d); ---amber: light-dark(#9a4a08, #fbbf24); ---red: light-dark(#b91c1c, #f87171); ---radius: 12px; --radius-sm: 8px; --radius-pill: 999px; ---text-micro: 10px; --text-caption: 11px; --text-label: 12px; --text-control: 13px; -``` - -Three properties of this system are load-bearing and are carried over verbatim: - -1. **The accent is ink, not a hue.** `--accent` is near-black in light mode and near-white - in dark. Colour is reserved for *state* (green/amber/red), never for decoration. This - is already the correct answer for a developer tool and it sidesteps the - purple-gradient tell without any further thought. -2. **`light-dark()` rather than a class toggle.** The OS decides. A menu bar app that - fought the system appearance would be immediately wrong on macOS. -3. **Small type ladder (10-13px).** Confirms the intended density is high. - -**Consequence:** this is a *derivation*, not a redesign. A separate palette would make -the companion look like a third-party utility rather than part of OpenCodex. - -## 2. Design Read - -```yaml ---- -name: opencodex-menubar -colors: - primary: "#0d0d0d" # ink accent, inverts to #ececec in dark - accent: "#0a7d5c" # state green only; amber #9a4a08, red #b91c1c - background: "#ffffff" # inverts to #212121 in dark -typography: - heading: { fontFamily: "SF Pro Text", fontSize: 12, weight: 600 } - body: { fontFamily: "SF Pro Text", fontSize: 11 } - numeric: { fontFamily: "SF Pro Text", feature: "tabular-nums", fontSize: 13 } -iconography: - system: "SF Symbols" - weight: "regular" - domain: "library-subset" ---- -``` - -Reading this as: **a glanceable operations readout for a local proxy the user already -runs**, in the visual language of the existing OpenCodex dashboard, compressed to a -340pt popover. - -The reference is not another menu bar app — it is an **instrument panel**: Activity -Monitor's CPU popover and Little Snitch's network monitor, where the whole point is that -one glance answers "is it fine?" and a second glance answers "what specifically". - -**Do's:** inherit the dashboard's ink-accent restraint; state colour only for state; -tabular numerals everywhere a number can change; one row = one fact; dense but not -cramped. - -**Don'ts:** no hero anything; no marketing copy; no gradients; no emoji; no segmented -tab bar that hides the answer behind a click; no colour that means nothing. - -### Font choice - -**SF Pro (via `NSFont.systemFont`), not the dashboard's OpenAI Sans.** The dashboard is a -web surface where a brand font is appropriate. A menu bar popover sits 4pt from macOS -chrome, and a non-system font there reads as a foreign object. SF Symbols are used for -iconography for the same reason — this is the one place where "use the platform default" -is the sophisticated choice rather than the lazy one, because the platform *is* the -context. - -## 3. Dial lock - -```text -DESIGN_VARIANCE: 2 -MOTION_INTENSITY: 1 -Product density profile: D7 (finance/ops class — high information density, restrained) -``` - -Reasoning: this is a repeated-glance operations surface for a developer tool. Per the -`cxc-dev-uiux-design` preset table, "Finance / ops" is `2 / 1 / D6-D7` and that is exactly -the right shape here — the user opens this to read numbers, not to be delighted. -MOTION_INTENSITY 1 means feedback-only: the popover's own open/close animation is -AppKit's, and the only in-app motion is a state-change crossfade on the status dot. -Scroll-driven motion is zero. Per FE-MOTION-HONESTY-01, declaring 1 obliges me to ship no -decorative motion, which is the intent. - -## 4. Information architecture - -PR #421 used four segmented tabs (Usage / Health / Status / Activity). **Rejected**, for a -specific reason: a menu bar popover is a glance surface, and tabs mean the answer to "is -it fine?" is one click away three times out of four. UX-LAZY-01 step 1 — can a correct -default remove this decision? Yes: show everything, ordered by urgency, in one scroll-free -column. - -```text -┌──────────────────────────────────────┐ -│ ● Running 127.0.0.1:10100 │ status line — the answer -│ protected · service │ qualifier, muted, 11px -├──────────────────────────────────────┤ -│ LAST 7 DAYS │ range echoed from the response -│ REQUESTS TOKENS COST │ micro labels, 10px, letterspaced -│ 1,746 12.4M $8.21 │ tabular-nums, 13px -│ ▁▂▃▅▂▁▃ │ 7d usage trend from usage.days[] -├──────────────────────────────────────┤ -│ OpenAI ▓▓▓▓▓░░░░░ 44% │ quota rows, one per provider -│ Anthropic ▓▓▓▓▓▓░░░░ 58% │ -│ xAI ▓▓▓▓▓▓▓▓▓░ 87% │ amber >80, red >95 -├──────────────────────────────────────┤ -│ Dashboard Stop proxy ··· │ actions -└──────────────────────────────────────┘ -``` - -Vertical order is urgency order: liveness first (the reason the app exists), then -throughput, then quota pressure, then actions. Providers move to a disclosure row rather -than occupying primary space, since toggling one is rare compared to reading status. - -Target width 340pt. Height is content-driven, capped at 480pt with the provider list -scrolling if a user runs many providers. - -## 5. The one signature moment - -**The menu bar icon itself.** It is a template image so macOS inverts it correctly, and it -carries state without colour: - -| State | Glyph treatment | -| --- | --- | -| Running, protected | Solid mark | -| Running, at-risk | Solid mark + a single-pixel notch | -| Stopped | Outlined mark | -| Unreachable | Outlined mark at 40% opacity | - -Colour is deliberately not used in the menu bar. macOS menu bar template images are -monochrome by convention, and a coloured dot up there is the tell of an app that does not -respect the platform. The coloured status dot lives *inside* the popover, where it has a -label next to it and does not encode meaning by colour alone (WCAG 1.4.1). - -## 6. Anti-slop pre-registration - -Committed to before implementation, so Phase 2's audit can check them: - -- No emoji anywhere in the UI (STRICT). SF Symbols only. -- No gradients. Zero, not "one per viewport" — a 340pt utility popover has no room for - ambient decoration. -- No one-note theme: neutral surfaces, state colour only. -- No oversized display type: the largest text in the app is 13px numeric. -- No self-describing meta copy: no "Your proxy at a glance" style header. The window is - the product; it does not narrate itself. -- No fake data. If a value is unknown, the row shows an em dash, never a plausible zero. - `/api/usage` distinguishes `measuredRequests` from `estimatedRequests`, so an estimate - is marked as one. -- No colour-only meaning: every state colour is paired with a word or a glyph. - -## 7. Numeric formatting (hard requirement from `002`) - -Live data reaches `requests: 232507`, `totalTokens: 36536664705`, -`estimatedCostUsd: 34018.25`. Rules: - -- Counts: `1,746` → `12.4K` → `1.2M` (3 significant figures, SI suffix at 10 000). -- Tokens: always suffixed (`12.4M`, `36.5B`). -- Cost: `$8.21` below 1 000, `$34.0K` above. -- All numerics use `tabular-nums` so digits do not reflow while polling. -- Timestamps normalize by magnitude: values below `1e12` are seconds, at or above are - milliseconds (`002` §3 documents `openai` sending seconds and `anthropic` milliseconds - in the same array). - -## 8. Accessibility gates - -- Every icon-only control carries an `accessibilityLabel`. -- The popover is fully keyboard operable; Escape closes it. -- Quota bars expose their percentage as accessible text, not only as a filled width. -- `NSWorkspace.shared.accessibilityDisplayShouldReduceMotion` disables the status-dot - crossfade. -- Contrast is verified against both light and dark rendering, not assumed from tokens. diff --git a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md b/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md deleted file mode 100644 index f6f8c854cb..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/010_phase1_core.md +++ /dev/null @@ -1,459 +0,0 @@ -# 010 — Phase 1: app skeleton, proxy discovery, management API client - -**Depends on:** nothing (foundation phase). -**Independently verifiable by:** `swift run --package-path app MenuBarCoreTests` green and -`swift build --package-path app -c release --arch arm64` succeeding. - -**Bundle scope note (audit correction):** an earlier draft closed this phase on a -`.app` produced by `scripts/build-macos-app.sh`, but that script is a Phase-4 -deliverable — a phase cannot be verified by a later phase's output. Phase 1 therefore -closes on the compiler and the test suite. Phase 2 does its visual QA with `swift run`, -and **Phase 4 owns the bundle end to end**: the builder, the first `.app`, and packaging. - -## File change map - -| Path | Action | -| --- | --- | -| `app/Package.swift` | NEW | -| `app/Info.plist` | NEW | -| `app/Sources/MenuBarCore/Discovery.swift` | NEW | -| `app/Sources/MenuBarCore/ProxyModels.swift` | NEW | -| `app/Sources/MenuBarCore/ProxyClient.swift` | NEW | -| `app/Sources/MenuBarCore/Formatting.swift` | NEW | -| `app/Sources/MenuBarCore/Keychain.swift` | NEW | -| `app/Sources/MenuBarApp/main.swift` | NEW (minimal `NSApplication` entry; UI lands in 020) | -| `app/Sources/MenuBarCoreTests/Harness.swift` | NEW | -| `app/Sources/MenuBarCoreTests/DiscoverySuite.swift` | NEW | -| `app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift` | NEW | -| `app/Sources/MenuBarCoreTests/FormattingSuite.swift` | NEW | -| `app/Sources/MenuBarCoreTests/main.swift` | NEW | -| `app/.gitignore` | NEW | -| `.gitignore` (root) | MODIFY — add `dist/macos/` | - -### Build-time amendment: the test target is an executable, not a `.testTarget` - -Planned as `swift test`. That does not work on this toolchain, and the failure is -environmental rather than incidental — verified during Phase 1 implementation: - -```text -import XCTest - -> error: unable to resolve module dependency: 'XCTest' - -import Testing (swift-testing) - -> compiles, then at run time: - Library not loaded: @rpath/Testing.framework/Versions/A/Testing -``` - -Xcode Command Line Tools ships neither a usable XCTest module nor the swift-testing -runtime; both require a full Xcode install. Requiring Xcode to run the unit tests of a -menu bar companion would put them out of reach of most contributors and of any CI runner -that has not selected Xcode — the same class of constraint `001` §4.1 already found for -universal builds. - -**Resolution:** a ~90-line dependency-free harness (`Harness.swift`) plus an executable -target. Tests run with `swift run --package-path app MenuBarCoreTests`, exit non-zero on -failure, and print one line per case. Migration to swift-testing is mechanical if the -package ever requires full Xcode for other reasons. - -**Two-target split rationale:** `MenuBarCore` is a plain library with no AppKit -dependency, so it is testable under `swift test` on any runner. `MenuBarApp` holds -everything that needs a running `NSApplication`. PR #387 used the same split and it is -the right call. - -## `app/Package.swift` - -```swift -// swift-tools-version: 5.9 -import PackageDescription - -let package = Package( - name: "OpenCodexMenuBar", - platforms: [.macOS(.v13)], - products: [ - .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), - ], - targets: [ - .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), - .executableTarget(name: "MenuBarApp", dependencies: ["MenuBarCore"], path: "Sources/MenuBarApp"), - .testTarget(name: "MenuBarCoreTests", dependencies: ["MenuBarCore"], path: "Tests/MenuBarCoreTests"), - ], - swiftLanguageVersions: [.v5] -) -``` - -`.macOS(.v13)` rather than #387's `.v12`: Ventura is required for -`MenuBarExtra`-adjacent APIs and modern `NSPopover` behaviour, and macOS 12 is out of -Apple's security-update window. Zero third-party dependencies is a hard rule. - -## `app/Info.plist` - -```xml - - - - - CFBundleDevelopmentRegion en - CFBundleExecutable OpenCodexMenuBar - CFBundleIdentifier com.opencodex.menubar - CFBundleInfoDictionaryVersion 6.0 - CFBundleName OpenCodex - CFBundleDisplayName OpenCodex - CFBundlePackageType APPL - CFBundleIconFile OpenCodex - CFBundleShortVersionString 0.0.0 - CFBundleVersion 0.0.0 - LSUIElement - LSMinimumSystemVersion 13.0 - NSHumanReadableCopyright MIT — opencodex contributors - - -``` - -Three keys are load-bearing and an earlier draft omitted all of them, which would have -produced a bundle macOS refuses to launch: - -- `CFBundleExecutable` must equal the binary name the builder copies into - `Contents/MacOS/` — `OpenCodexMenuBar`. -- `CFBundlePackageType` must be `APPL` for the bundle to be treated as an application. -- `CFBundleIconFile` is `OpenCodex` (no extension), matching the `OpenCodex.icns` the - builder writes into `Contents/Resources/`. - -`LSUIElement` is what makes it a menu bar app: no Dock icon, no menu bar menus of its own. -The two version strings are placeholders — the build script overwrites both from -`package.json` (`040`), so the app can never claim a version the release did not ship. - -## `Discovery.swift` - -Implements `002` §1. - -```swift -public struct ProxyEndpoint: Equatable, Sendable { - public let host: String // always loopback - public let port: Int - public var baseURL: URL { URL(string: "http://\(host):\(port)")! } -} - -public enum ProxyDiscovery { - public static let defaultPort = 10100 - - public static func configDirectory(environment: [String: String] = ProcessInfo.processInfo.environment, - home: URL = FileManager.default.homeDirectoryForCurrentUser) -> URL { - if let override = environment["OPENCODEX_HOME"], !override.isEmpty { - return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) - } - return home.appendingPathComponent(".opencodex") - } - - public static func resolve(configDirectory: URL) -> ProxyEndpoint { - let file = configDirectory.appendingPathComponent("runtime-port.json") - guard let data = try? Data(contentsOf: file), - let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), - (1...65535).contains(record.port) - else { return ProxyEndpoint(host: "127.0.0.1", port: defaultPort) } - return ProxyEndpoint(host: "127.0.0.1", port: record.port) - } -} - -struct RuntimePortRecord: Decodable { let pid: Int?; let port: Int } -``` - -Host is hard-coded loopback and never read from the file. A companion that could be -pointed at an arbitrary host by a file write is a needless attack surface; the config -file only supplies a port. - -`pid` is decoded but unused — `002` §1 records that a failed HTTP probe is the -authoritative liveness signal. - -## `ProxyModels.swift` - -Codable mirrors of the payloads in `002` §3. Every field that the proxy may omit is -optional; nothing is force-unwrapped. - -```swift -public struct StartupHealth: Decodable, Equatable, Sendable { - public let status: String? // "protected" | "at-risk" | unknown-tolerant - public let protection: String? - public let platform: String? - public let serviceRunning: Bool? - public let serviceInstalled: Bool? - public let serviceEnabled: Bool? - public let rebootSafe: Bool? - public let recommendedCommand: String? -} - -/// `GET /api/config` — the ONLY source of `defaultProvider` (`002` §3). -/// `/api/settings` does not carry it; the live key set there is exactly -/// codexAutoStart · port · hostname · streamMode · startupHealth · codexRuntime. -public struct ProxyConfigSummary: Decodable, Equatable, Sendable { - public let port: Int? - public let hostname: String? - public let defaultProvider: String? -} - -public struct UsageSummary: Decodable, Equatable, Sendable { - public let requests: Int? - public let measuredRequests: Int? - public let estimatedRequests: Int? - public let totalTokens: Int? - public let inputTokens: Int? - public let outputTokens: Int? - public let estimatedCostUsd: Double? - public let coverageRatio: Double? -} - -public struct UsageDay: Decodable, Equatable, Sendable { - public let date: String - public let requests: Int? - public let totalTokens: Int? -} - -public struct UsageReport: Decodable, Equatable, Sendable { - public let range: String? - public let generatedAt: Double? - public let summary: UsageSummary? - public let days: [UsageDay]? -} - -public struct QuotaWindow: Decodable, Equatable, Sendable { - public let label: String? - public let percent: Double? - public let resetAt: Double? -} - -public struct ProviderQuota: Decodable, Equatable, Sendable { - public let weeklyPercent: Double? - public let monthlyPercent: Double? - public let weeklyResetAt: Double? - public let monthlyResetAt: Double? - public let customWindows: [QuotaWindow]? - public let updatedAt: Double? -} - -public struct QuotaReport: Decodable, Equatable, Sendable { - public let provider: String - public let label: String? - public let source: String? - public let quota: ProviderQuota? -} - -public struct ProviderSummary: Decodable, Equatable, Sendable { - public let name: String - public let adapter: String? - public let authMode: String? - public let hasApiKey: Bool? - public let disabled: Bool? -} - -public struct ProxySettings: Decodable, Equatable, Sendable { - public let port: Int? - public let hostname: String? - public let streamMode: String? -} -``` - -`serviceInstalled` and `serviceEnabled` are decoded because `020`'s status qualifier line -renders them. They deliberately do **not** drive a restart branch — `030` establishes -that `/api/stop` stops launchd on purpose and nothing restarts the proxy automatically. - -### The normalized quota view (the trap from `002` §3) - -```swift -public struct NormalizedQuota: Equatable, Sendable { - public let providerLabel: String - public let percent: Double? - public let windowLabel: String // "week" | "month" | customWindows[].label - public let resetAt: Date? -} - -public extension QuotaReport { - func normalized() -> NormalizedQuota { - if let p = quota?.weeklyPercent { - return .init(providerLabel: label ?? provider, percent: p, windowLabel: "week", - resetAt: Self.date(from: quota?.weeklyResetAt)) - } - if let p = quota?.monthlyPercent { - return .init(providerLabel: label ?? provider, percent: p, windowLabel: "month", - resetAt: Self.date(from: quota?.monthlyResetAt)) - } - if let w = quota?.customWindows?.first { - return .init(providerLabel: label ?? provider, percent: w.percent, - windowLabel: w.label ?? "window", resetAt: Self.date(from: w.resetAt)) - } - return .init(providerLabel: label ?? provider, percent: nil, windowLabel: "—", resetAt: nil) - } - - /// `002` §3: openai sends weeklyResetAt in SECONDS, anthropic in MILLISECONDS. - /// Disambiguate by magnitude — 1e12 is 2001 in ms and year 33658 in s. - static func date(from value: Double?) -> Date? { - guard let v = value, v > 0 else { return nil } - return Date(timeIntervalSince1970: v >= 1_000_000_000_000 ? v / 1000 : v) - } -} -``` - -## `ProxyClient.swift` - -```swift -public enum ProxyError: Error, Equatable { - case unreachable // connection refused → proxy not running - case unauthorized // 401 → needs a key - case http(Int) - case decoding -} - -public actor ProxyClient { - private let session: URLSession - private var endpoint: ProxyEndpoint - private var apiKey: String? - - public init(endpoint: ProxyEndpoint, session: URLSession = .shared) { ... } - - public func health() async throws -> StartupHealth - public func settings() async throws -> ProxySettings - public func config() async throws -> ProxyConfigSummary - public func usage(range: UsageRange = .sevenDays) async throws -> UsageReport - public func quotas() async throws -> [QuotaReport] - public func providers() async throws -> [ProviderSummary] - - private func get(_ path: String) async throws -> T { - var request = URLRequest(url: endpoint.baseURL.appendingPathComponent(path)) - request.timeoutInterval = 4 - if let key = apiKey { request.setValue(key, forHTTPHeaderField: "x-opencodex-api-key") } - do { - let (data, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { throw ProxyError.decoding } - if http.statusCode == 401 { throw ProxyError.unauthorized } - guard (200..<300).contains(http.statusCode) else { throw ProxyError.http(http.statusCode) } - do { return try JSONDecoder().decode(T.self, from: data) } - catch { throw ProxyError.decoding } - } catch let urlError as URLError - where urlError.code == .cannotConnectToHost || urlError.code == .timedOut { - throw ProxyError.unreachable - } - } -} -``` - -`actor` rather than a `DispatchQueue`: the client owns mutable state (`endpoint`, -`apiKey`) touched from both the polling timer and UI actions, and the actor makes that -data-race-free by construction. - -`unauthorized` is a distinct case because it drives a distinct UI state — "add your API -key", not "the proxy is down". `002` §2 records that a loopback bind needs no credential, -so this path only fires for non-loopback setups. - -### `UsageRange` is a closed enum, not a string - -`src/usage/summary.ts:95-98` accepts exactly `7d`, `30d`, `all` and silently falls back -to `30d` for anything else. A stringly-typed range would let a caller ask for `24h`, -receive 30 days of data, and label it wrongly — which is exactly what an earlier draft of -this plan specified. - -```swift -public enum UsageRange: String, Sendable { - case sevenDays = "7d" - case thirtyDays = "30d" - case all -} -``` - -The UI additionally renders the `range` value the response actually returned, never the -one it requested (`020`). - -**Privacy rule:** `ProxyError` carries no response body. Bodies can echo config values, -and `privacy:scan` forbids logging them. - -## `Keychain.swift` - -Thin Security.framework wrapper: `read(account:)` / `write(_:account:)` / -`delete(account:)` against `kSecClassGenericPassword`, service -`com.opencodex.menubar.apikey`. The key is never written to `UserDefaults`, never -included in an error message, and never logged. Read lazily — only after a `401`. - -## `Formatting.swift` - -Implements `003` §7. - -```swift -public enum Format { - public static func count(_ value: Int?) -> String // 1,746 · 12.4K · 1.2M · 36.5B - public static func tokens(_ value: Int?) -> String // always SI-suffixed - public static func cost(_ value: Double?) -> String // $8.21 · $34.0K - public static func relative(_ date: Date?) -> String // "resets in 3d 4h" -} -``` - -Every function returns `"—"` for `nil` — never `"0"`. `003` §6 forbids fake data, and -"unknown" and "zero" are different facts. - -## Tests - -`DiscoveryTests`: valid record honoured · malformed JSON falls back to 10100 · missing -file falls back · out-of-range port (`0`, `70000`) falls back · `OPENCODEX_HOME` honoured -· host is loopback even when the file names another host. - -`ModelDecodingTests`: decode the **verbatim live payloads captured in `002`** (not -hand-written fixtures) for health, usage, quotas, providers, config · unknown `status` -string decodes without throwing · absent `quota` normalizes to `percent: nil` · openai -seconds and anthropic milliseconds both resolve to sane 2026 dates · `ProxySettings` -decodes without a `defaultProvider` field and `ProxyConfigSummary` supplies it. - -`FormattingTests`: the `002` magnitudes (`232507`, `36536664705`, `34018.25`) render as -`232K`, `36.5B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. - -## `app/.gitignore` - -```gitignore -.build/ -.swiftpm/ -*.xcodeproj -DerivedData/ -``` - -Root `.gitignore` gains `dist/macos/`. This is the direct lesson from PR #421's committed -`src-tauri/target/` — the ignore rules land in the same commit as the first build script, -never afterwards. - -## Code-review corrections (round 1, folded before B closed) - -An adversarial review of the first implementation returned FAIL on 10 findings. Each was -verified against the live proxy or Apple documentation before being folded: - -| Finding | Correction | -| --- | --- | -| ATS blocks loopback IP loads on macOS 14+, so the *packaged* app could not reach the proxy at all while `swift run` stayed green | `Info.plist` gains `NSAppTransportSecurity` / `NSAllowsLocalNetworking` | -| The lazy Keychain retry after 401 was never wired; `Keychain` was dead production code | `CredentialStore` protocol injected into `ProxyClient`; one load, exactly one retry, no loop | -| `kSecAttrAccessible` is ignored on macOS without `kSecUseDataProtectionKeychain` | Flag set on every query; class tightened to `…ThisDeviceOnly`; `write` now updates-then-adds so a failed add cannot destroy a valid key | -| Live `kimi` reports `fiveHourPercent`/`fiveHourResetAt`; `cursor` and `google-antigravity` each carry two `customWindows`. `normalized()` discarded all but one | Added the five-hour fields and `normalizedWindows()` returning every window; `normalized()` keeps an explicit longest-horizon precedence for the compact row | -| `(requests ?? 0) == 0` turned unknown into "no usage" | `isEmptyOrUnknown: Bool?` preserves three states | -| Every non-connectivity `URLError` — including `.cancelled` — mapped to `.unreachable` | `.cancelled` propagates as `CancellationError`; other failures map to a new `.transport` case | -| `ProxyEndpoint.baseURL` force-unwrapped a URL the initializer never validated | Failable initializer; the URL is built once and stored | -| Rounding produced `1000K` instead of promoting to `1.00M` | Promotion on rollover, with boundary tests at, below, and above every unit | -| No tests covered transport, auth, or privacy | `TransportSuite`: 14 cases over status mapping, 401 retry, cancellation, request shape, and body redaction | -| The executable-test amendment was not propagated | `020`, `030`, `040` now all reference `swift run --package-path app MenuBarCoreTests` | - -Live re-verification after the fixes covered all six providers, including Kimi's 5h+week -pair and Cursor's three windows. - -### Round 2 (two blockers, both reentrancy/semantics rather than syntax) - -| Finding | Correction | -| --- | --- | -| Concurrent initial 401s: the actor suspends across each request, so two calls could both get 401; the first loaded a key and retried while the second saw the global `didAttemptCredentialLoad` flag and failed with `.unauthorized` despite a usable key now existing | Retry eligibility is decided **per request**, against the key that request actually sent. A caller that started before the load still retries with the newly available key; a caller that already used the current key does not loop | -| `normalized()` preferred the longest horizon, so a provider at 99% of a five-hour limit and 10% monthly rendered as a green 10% row while the user was actually blocked | The compact row now selects the **highest reported usage**, with ties breaking toward the longer horizon. Live proof: Cursor's compact row moved from `month=10%` to `API usage=42%` | - -Regression tests added for both: a gated concurrent-401 case asserting two successes, -one credential load, and four total requests; and pressure-selection cases covering -higher-short-window, tie-break, and unmeasured-window inputs. 51 -> 55 cases. - -## Accept criteria - -1. `swift run --package-path app MenuBarCoreTests` green, with the `002` payloads as - fixtures (see the build-time amendment above). -2. `swift build --package-path app -c release --arch arm64` succeeds. -3. `UsageRange` admits only `7d`/`30d`/`all`; no call site can request `24h`. -4. `ProxyConfigSummary.defaultProvider` decodes from live `/api/config`. -5. `git status` shows no `.build/` or `dist/` entries. -6. `bun run typecheck` and `bun run test` unaffected (no TS added). -7. A live probe against the running proxy resolves the endpoint and decodes health, - config, usage, quotas, and providers — fixtures alone do not prove the transport. diff --git a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md deleted file mode 100644 index 28fbf34614..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/020_phase2_ui.md +++ /dev/null @@ -1,431 +0,0 @@ -# 020 — Phase 2: menu bar surface and popover UI - -**Depends on:** `010` (client + models + formatting must exist). -**Independently verifiable by:** a screenshot of the running app read back with -`view_image`, plus state-coverage tests. - -**No `.app` bundle in this phase.** Visual QA runs the Swift executable directly -(`swift run --package-path app OpenCodexMenuBar`), which registers a menu bar item and -opens the popover exactly like a bundled build. `scripts/build-macos-app.sh` and the first -`.app` are Phase-4 deliverables; an earlier draft moved the "first launchable bundle" here -without moving the builder that produces it. - -Implements the locked direction in `003`. Dials: `DESIGN_VARIANCE 2`, -`MOTION_INTENSITY 1`, density `D7`. - -## File change map - -| Path | Action | -| --- | --- | -| `app/Sources/MenuBarApp/main.swift` | MODIFY — replace the 010 placeholder | -| `app/Sources/MenuBarApp/AppDelegate.swift` | NEW | -| `app/Sources/MenuBarApp/StatusItemController.swift` | NEW | -| `app/Sources/MenuBarApp/StatusIcon.swift` | NEW | -| `app/Sources/MenuBarApp/PopoverViewController.swift` | NEW | -| `app/Sources/MenuBarApp/Views/StatusHeaderView.swift` | NEW | -| `app/Sources/MenuBarApp/Views/MetricsRowView.swift` | NEW | -| `app/Sources/MenuBarApp/Views/SparklineView.swift` | NEW | -| `app/Sources/MenuBarApp/Views/QuotaRowView.swift` | NEW | -| `app/Sources/MenuBarApp/Views/ActionBarView.swift` | NEW | -| `app/Sources/MenuBarApp/Theme.swift` | NEW | -| `app/Sources/MenuBarCore/ProxySnapshot.swift` | NEW | -| `app/Sources/MenuBarCore/PollingCoordinator.swift` | NEW | -| `app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift` | NEW | - -**AppKit, not SwiftUI.** SwiftUI in an `NSPopover` still fights sizing and first-responder -behaviour, and this layout is a fixed-width column of rows — precisely what AppKit stack -views do without ceremony. Zero-dependency and predictable beats idiomatic-but-fussy for -a surface that must render identically every time. - -## `Theme.swift` — token derivation from `gui/src/styles.css` - -`003` §1 established that the dashboard tokens are inherited rather than reinvented. -Where AppKit provides a semantic colour that already tracks the OS appearance, it wins -over a hardcoded hex, because it also handles increased-contrast and vibrancy. - -```swift -enum Theme { - // Surfaces: AppKit semantics track light/dark AND accessibility settings. - static let background = NSColor.windowBackgroundColor - static let raised = NSColor.controlBackgroundColor - static let separator = NSColor.separatorColor - - // Text: mapped from --text / --muted / --faint. - static let text = NSColor.labelColor - static let muted = NSColor.secondaryLabelColor - static let faint = NSColor.tertiaryLabelColor - - // State colours: taken verbatim from styles.css so the companion and the - // dashboard agree on what "healthy" looks like. - static let green = NSColor(light: 0x0A7D5C, dark: 0x4ECB9D) - static let amber = NSColor(light: 0x9A4A08, dark: 0xFBBF24) - static let red = NSColor(light: 0xB91C1C, dark: 0xF87171) - - // Type ladder: --text-micro/caption/label/control. - static let micro = NSFont.systemFont(ofSize: 10, weight: .medium) - static let caption = NSFont.systemFont(ofSize: 11) - static let label = NSFont.systemFont(ofSize: 12, weight: .semibold) - static let numeric = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .medium) - - static let gutter: CGFloat = 12 // --space-3 - static let rowGap: CGFloat = 8 // --space-2 - static let radius: CGFloat = 8 // --radius-sm - static let width: CGFloat = 340 -} -``` - -`monospacedDigitSystemFont` is the AppKit equivalent of `font-variant-numeric: -tabular-nums` and is required by `003` §7 — without it, polling makes digits jitter. - -`NSColor(light:dark:)` is a small `init(name:dynamicProvider:)` helper so state colours -follow the OS appearance the same way `light-dark()` does on the web. - -## `ProxySnapshot.swift` — the state machine - -One value type describes everything the UI can show, so every view is a pure function of -it and no view invents its own loading flag. - -```swift -public enum ProxyState: Equatable, Sendable { - case loading // first fetch in flight, nothing known yet - case running(StartupHealth) - case unreachable // connection refused → not running - case unauthorized // 401 → needs an API key - case degraded(String) // reachable but errored; message is proxy-free text -} - -public struct ProxySnapshot: Equatable, Sendable { - public var state: ProxyState = .loading - public var endpoint: ProxyEndpoint - public var usage: UsageReport? - public var quotas: [NormalizedQuota] = [] - public var providers: [ProviderSummary] = [] - public var lastUpdated: Date? - public var consecutiveFailures: Int = 0 -} -``` - -`003` §6 forbids fake data, so `usage` stays `nil` until it actually arrives; the metrics -row renders em dashes rather than zeros in the meantime. - -## `PollingCoordinator.swift` — implements `002` §6 - -```swift -public actor PollingCoordinator { - // 5s liveness always; 60s heavy data only while the popover is open. - private static let livenessInterval: TimeInterval = 5 - private static let heavyInterval: TimeInterval = 60 - private static let backoffInterval: TimeInterval = 30 // after 3 consecutive failures - - public func setPopoverOpen(_ open: Bool) - public func refreshNow() async - public var snapshots: AsyncStream { get } -} -``` - -Heavy endpoints (`/api/usage`, `/api/provider-quotas`) are skipped entirely while the -popover is closed, and `/api/providers` is fetched only on open. After three consecutive -failures the liveness tick backs off to 30 s so a stopped proxy does not get hammered. -A menu bar app that polls a local server every 5 s forever is a battery complaint waiting -to happen. - -## `StatusIcon.swift` — the signature moment (`003` §5) - -```swift -enum StatusGlyph { - static func image(for state: ProxyState) -> NSImage { - let image: NSImage - switch state { - case .running(let h) where h.status == "protected": image = solidMark() - case .running: image = solidMarkNotched() - case .loading, .degraded: image = outlinedMark() - case .unreachable, .unauthorized: image = outlinedMark(alpha: 0.4) - } - image.isTemplate = true // macOS inverts for light/dark menu bar - return image - } -} -``` - -Drawn as `NSImage(size:flipped:drawingHandler:)` vector paths at 18×18pt — no PNG assets -for the menu bar, so it stays crisp on every scale factor and inverts correctly as a -template image. No colour in the menu bar, per `003` §5. - -## `PopoverViewController.swift` — layout - -`NSStackView`, vertical, 340pt wide, `edgeInsets` of 12pt, spacing 8pt. Children in -urgency order per `003` §4: - -1. `StatusHeaderView` -2. separator -3. `MetricsRowView` + `SparklineView` -4. separator -5. `QuotaRowView` per provider -6. separator -7. `ActionBarView` - -Behaviour: `NSPopover.behavior = .transient` (click-away dismiss), `Escape` closes, -`animates = false` when reduce-motion is set. - -### `StatusHeaderView` - -```text -● Running 127.0.0.1:10100 - protected · service -``` - -Dot 8pt, `Theme.green/amber/red` by state, **always accompanied by the word** ("Running", -"Stopped", "Unreachable", "Needs API key") so meaning is never colour-only (`003` §8). -Endpoint right-aligned in `Theme.caption`/`muted`. Qualifier line renders -`health.protection` and `health.status`, and when `recommendedCommand` is present it is -shown as selectable text — displayed, never executed (`002` §3). - -### `MetricsRowView` - -Three columns from `/api/usage?range=7d`: REQUESTS, TOKENS, COST. Labels in -`Theme.micro` uppercase with 0.5pt tracking; values in `Theme.numeric`. All values -through `Format` (`010`), so `36536664705` becomes `36.5B` and `nil` becomes `—`. - -**The range label is rendered from the response, not the request.** `002` §3 records that -`parseRange` silently falls back to `30d` for any unrecognized value, so a UI that -labelled its own request would lie whenever the server disagreed. The section header -reads `LAST 7 DAYS` only when `response.range == "7d"`. - -When `summary.estimatedRequests > 0`, the requests value carries a trailing `~` with an -`accessibilityLabel` explaining the estimate — `003` §6 requires estimates to be marked. - -### `SparklineView` - -**Usage trend, not "activity".** One bar per element of `usage.days`, which is -day-granular — `002` §3 records that `rangeWindow()` only ever produces daily buckets and -that hourly data does not exist without a `src/` change. With `range=7d` that is 7 bars. -The bar count follows `days.count`; it is never hardcoded. - -Pure `NSBezierPath` fill in `Theme.faint`, 24pt tall, no axes, no labels, no gradient. -Renders nothing (not a flat line) when data is absent. - -Recent per-request activity (`GET /api/logs?tail=N`) is deliberately out of scope for v1 — -`002` §3 records the reasoning: per-request rows expose model and timing detail for the -user's real traffic, and the dashboard already presents it with proper filtering. - -### `QuotaRowView` - -```text -OpenAI ▓▓▓▓▓░░░░░ 44% -``` - -Provider label left, bar centre, percent right in `Theme.numeric`. Bar fill: `green` below -80, `amber` 80-95, `red` above 95. The percentage text is always present, so the colour is -redundant rather than load-bearing. `accessibilityValue` reads -`"44 percent of weekly quota, resets in 3d 4h"` from `NormalizedQuota` (`010`), which -already resolved the seconds/milliseconds trap. - -Rows with `percent == nil` render the label and an em dash — never a zero-width bar that -looks like "0% used". - -### `ActionBarView` - -`Dashboard` (opens `http://127.0.0.1:` in the browser) · `Stop proxy` (wired in `030`) -· `···` overflow menu (Preferences, Quit). Buttons are `.recessed` bezel, 24pt tall, with -`accessibilityLabel` on the icon-only overflow. - -## State coverage (UX-STATE-01 — all four required) - -| State | Header | Body | Action | -| --- | --- | --- | --- | -| `loading` | "Checking…" neutral dot | skeleton rows, em dashes | none | -| `running` | "Running" + green | live metrics, usage trend, quotas | Dashboard · Stop proxy | -| `unreachable` | "Stopped" + red | "The proxy is not running." | start command as selectable text | -| `unauthorized` | "Needs API key" + amber | "This proxy requires a key." | **Add key…** | -| `degraded` | "Degraded" + amber | last known values + staleness age | Retry | - -Corrections from the Phase-0 audit, carried in from `030`: - -- The `running` action is **`Stop proxy`**, never `Restart`. `/api/stop` stops launchd on - purpose and no start endpoint exists. -- The `unreachable` action is **not** a button that starts anything. It displays the - command to run (`ocx start`, or `ocx service start` when a service is installed) as - selectable text, since the app never spawns processes. - -### Empty states (per-section, distinct from `loading`) - -`loading` means "not known yet" and correctly offers no action. **Empty means "known, and -there is nothing"** — a different fact needing different copy. Each data section defines -its own: - -| Section | Empty condition | Copy | Action | -| --- | --- | --- | --- | -| Metrics | `summary` present, `requests == 0` | "No requests in this period." | Dashboard | -| Usage trend | `days` empty or all-zero | bars omitted entirely, no flat line | none | -| Quotas | `reports` empty | "No provider quota sources connected." | Dashboard | -| Providers | `providers` empty | "No providers configured." | Dashboard | - -A zero is rendered as `0` only when the server actually reported zero; unknown stays an em -dash (`003` §6). Conflating the two is the fake-data tell. - -Every non-running state names its next action — `dev-uiux-design` UX-STATE-01 forbids -dead-ending the user. `degraded` deliberately keeps the last known values with an explicit -"as of 2m ago" rather than blanking the popover, since stale-but-labelled beats empty. - -## Tests (`SnapshotStateTests`) - -`ProxyError.unreachable` → `.unreachable` · `401` → `.unauthorized` · `500` → -`.degraded` · health with `status: "protected"` → `.running` and solid glyph · -unknown status string → `.running` with notched glyph, no crash · three failures raise -`consecutiveFailures` and trigger backoff · reduce-motion disables animation. - -## Visual verification (mandatory before this phase closes) - -Build, launch, open the popover, `screencapture` the region, read it back with -`view_image`, and check against `003` §6: no emoji, no gradient, no oversized type, no -colour-only meaning, numbers abbreviated and tabular, dark and light both legible. Fix -what the screenshot shows, then re-verify. Code review alone does not close this phase. - -## Code-review corrections (folded before B closed) - -An adversarial review that rendered every state returned FAIL on 9 findings. Each was -reproduced visually or with a stub before being folded: - -| Finding | Correction | -| --- | --- | -| `Stop proxy` fired an unconfirmed destructive stop | Confirmation sheet naming the concrete consequence, since `/api/stop` also stops launchd | -| Escape did not close the popover; the accessory app never took key focus | `NSApp.activate` on open, explicit first responder, plus a scoped local key monitor installed on open and removed on close | -| Loading, unauthorized, and degraded were not really implemented | Skeleton rows and disabled chrome while loading; an actual `Add key…` button; `Retry` plus a staleness age for degraded, which now retains its last-known data | -| Popover open forced aggregation every time, while periodic refreshes fetched on-open data | Split into on-open reads (providers, config) and interval-gated aggregation (usage, quotas) | -| Overlapping refreshes could interleave, outlive a close, and mark stale data fresh | One in-flight cycle, a generation counter that discards superseded results, close bumps the generation, and only a fully successful aggregation advances the freshness timestamp | -| The at-risk notch did not render at all, so protected and at-risk looked identical | Notch carved with even-odd winding instead of a `.clear` composite that silently did nothing; verified with a rendered glyph sheet | -| `recommendedCommand` was decoded but never shown, and providers had no section | Recommended command shown as selectable text; a provider summary line with its own empty copy | -| Popover height was uncapped with no scroll region | Fixed header and actions with a scrolling body, capped at 480pt, scrollers only when content actually overflows | -| Polling tests asserted four constants and nothing else | `PollingSuite`: gating, cadence, backoff, recovery, degraded retention, and observer delivery against a stubbed transport | - -Also folded: `UIProbe` now captures with `CGWindowListCreateImage` rather than `Process`, -so nothing under `app/` constructs a subprocess (`030` security rule). - -### Round 2 (6 findings) - -| Finding | Correction | -| --- | --- | -| Escape still did not close the popover — activating before presentation left an accessory app without key focus | Activate on the next main-loop turn *after* `show(relativeTo:)`, then set key window and first responder. Verified by synthesizing keycode 53 into the app's own queue: shown `true` before, `false` after | -| Overflowing content opened scrolled to the bottom, hiding the status and metrics | `FlippedClipView` so the scroll origin is top-anchored | -| Close-then-immediate-reopen could drop the reopen's refresh entirely | `pendingOpenRefresh` queued while a cycle holds the lock, drained on every exit path | -| Closing mid-sequence still issued later requests, and a partial aggregation failure re-fetched its healthy sibling every 5s | `isCurrent(cycle)` re-checked before each request; aggregation rate-limited on ATTEMPT, not success | -| "Retry" opened a browser | Separate `onAddKey` and `onRetry` callbacks; Retry only refreshes | -| Degraded claimed a data age derived from the last *health* probe | `healthUpdated` and `usageUpdated` split; `showsData` requires real loaded sections, and the guidance quotes `dataAge` | - -The overflow menu ships `Refresh`, `Open dashboard`, and `Quit` rather than the -originally sketched `Preferences`: there is no preferences surface to open yet, and a -menu item that opens nothing is worse than its absence. - -### Round 3 (3 findings) — and the amendment that resolved Escape - -**`NSPopover` is replaced by a key-capable `NSPanel` (`PopoverPanel`).** This is a spec -amendment, and it was forced by measurement rather than preference. Three rounds of -Escape fixes failed because the premise was wrong. Probing the real delegate from an -accessory process showed: - -```text -popover window in NSApp.windows : absent -canBecomeKey : false -after NSApp.activate : appActive=true, isKey=false -after NSRunningApplication : appActive=true, isKey=false -after raising window level : appActive=true, isKey=false -``` - -macOS will not route key events to a window that cannot become key, so no activation -strategy could have worked. The same probe against `PopoverPanel`: - -```text -shown=1 canBecomeKey=1 isKey=1 appActive=1 -afterEscape shown=0 RESULT=ESCAPE CLOSES PANEL -``` - -`PopoverPanel` keeps the popover contract that matters — transient dismissal on outside -click, dismissal on losing key focus, `nonactivatingPanel` so opening does not steal -focus from the user's editor — while actually being able to receive a keystroke. - -| Other finding | Correction | -| --- | --- | -| The success path's generation guard returned without draining a queued reopen | Every exit path now clears the lock and drains | -| On-open reads ran on every 5s liveness tick | Gated on `includeHeavy`, so they run only on a real open or manual refresh | -| An already-invalid cycle could consume the aggregation window | `isCurrent(cycle)` required before `lastAggregationAttempt` is set | - -Also removed `lastHeavyRefresh` and `healthUpdated`, which were written but never read. -Four new polling tests cover the tick-while-open, closed-popover, partial-failure, and -degraded-without-data cases. 73 -> 77. - -### Round 4 (2 findings) — the cost of the panel amendment - -Replacing `NSPopover` removed two things it had been providing for free: - -| Finding | Correction | -| --- | --- | -| The borderless panel had **no surface at all**: `isOpaque = false` plus a clear background composited the dashboard straight onto whatever app was underneath, so labels collided with the app behind and contrast depended on it | Content is wrapped in an `NSVisualEffectView` with `.popover` material, rounded and clipped — the surface `NSPopover` supplies automatically | -| Presenting the Stop confirmation made the alert key, which tripped `resignKey()` and tore the panel down behind it — a user who chose Cancel was left with nothing | `isPresentingModal` suspends resign-key dismissal; Cancel restores key focus, Confirm dismisses deliberately | - -**Why the probe missed the first one:** `UIProbe` rendered the controller inside an -ordinary `NSWindow`, which supplies its own background. The probe now presents through -the real `PopoverPanel` over a deliberately loud backdrop, so a missing surface is -impossible to miss. This is the second time in this phase that the harness, not the -code, was the thing hiding a defect. - -Also folded: `dismiss()` is now idempotent against a late monitor callback, -`debugTogglePanel()` is `#if DEBUG` only, and `applicationWillTerminate` dismisses the -panel for lifecycle symmetry. - -### Round 5 (2 findings) - -| Finding | Correction | -| --- | --- | -| Escape during the Stop confirmation dismissed the panel and left the alert stranded with no way to cancel | The Escape monitor now returns the event unchanged while `isPresentingModal`, so `NSAlert` handles it as Cancel | -| `tertiaryLabelColor` measured **2.01:1** in light and **2.39:1** in dark against the popover material, far under the 4.5:1 needed for text | All four tiers recalibrated against the rendered material — see the table below | - -The contrast finding is worth naming precisely: AppKit's tertiary tier is intended for -disabled affordances, and it was being used for the range heading, metric captions, and -quota window labels — all information the user actually has to read. "It is a system -semantic colour" is not the same as "it is legible on this material." - -### Round 6: the contrast numbers, measured properly - -My first correction was itself wrong: the sampling picked the darkest pixel in a band, -which is primary `text`, not `faint`. Corrected method — count pixels matching each exact -token value in the rendered PNG, so a tier cannot be measured by sampling a different one. - -Backgrounds as rendered: light `(220,219,218)`, dark `(103,102,102)`. The dark material -is not perfectly flat — the dominant pixel is `(102,101,101)` and adjacent pixels read -`(103,102,102)`. The table below uses the lighter of the two, which is the stricter test; -the 5.81:1 ceiling quoted afterwards is measured against `(102,101,101)`. - -| Token | Light | Dark | Threshold | -| --- | ---: | ---: | ---: | -| `text` | 12.59:1 | 5.72:1 | 4.5 | -| `muted` | 7.86:1 | 5.11:1 | 4.5 | -| `faint` | 5.48:1 | 4.89:1 | 4.5 | -| `graphMark` | 3.58:1 | 3.79:1 | 3.0 (non-text) | - -Every tier passes and `text > muted > faint` holds in both appearances. - -The dark material is the binding constraint: **pure white measures only 5.81:1 against -it**, so the three text tiers have to fit inside a 1.3-point band. That is why the dark -values cluster — there is no room for the airy separation the light palette allows, and -choosing AppKit's semantic tiers instead would silently reintroduce the failure. - -Contrast is measured from the rendered PNG rather than assumed from token names, and the -probe can force an appearance (`PROBE_APPEARANCE=dark`) without touching system settings. - -## Accept criteria - -1. Menu bar icon renders as a template image and changes with state. -2. Popover renders live data from the running proxy at 340pt. -3. All five states reachable; each except `loading` names a next action. -4. Screenshot inspected with `view_image` in both appearances. -5. Keyboard: popover opens, Tab reaches every control, Escape closes. -6. The metrics header renders the range the response returned, verified by forcing a - fallback (`?range=bogus` → server answers `30d` → header must read `LAST 30 DAYS`). - The `UsageRange` enum is closed, so production code cannot issue `?range=bogus`; the - test injects a stubbed response whose `range` differs from the requested value and - asserts the header follows the response. A direct `curl ?range=bogus` is kept only as - server-contract evidence in `002`. -7. Sparkline bar count equals `days.count`, not a hardcoded 24. -8. Each empty state above renders its defined copy, distinct from `loading`. -9. `swift run --package-path app OpenCodexMenuBar` shows the menu bar item and popover. -10. `swift run --package-path app MenuBarCoreTests` green (see `010` build-time amendment). diff --git a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md b/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md deleted file mode 100644 index 3acc60cc83..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/030_phase3_actions.md +++ /dev/null @@ -1,320 +0,0 @@ -# 030 — Phase 3: write actions on existing endpoints - -**Depends on:** `020` (the UI must exist to report a result into). -**Independently verifiable by:** a live provider toggle against the running proxy, plus -the stubbed transport suite for stop. Stopping the developer's own proxy is out of -bounds, and the branches that matter cannot be produced on demand from a healthy one — -see the amended acceptance criterion 1. - -Constraint from the user's scope: **no new proxy endpoints.** Everything here calls -routes inventoried in `002` §4. - -## Stale check at P (what Phase 2 already landed) - -Re-verifying this document against the tree found three items already done, because the -UI phase could not ship a `Stop proxy` button without them: - -- `ProxyClient.stop()` and `setProviderDisabled(_:disabled:)` exist (`010`/`020`). -- The confirmation sheet exists as an `NSAlert` in `AppDelegate.stopProxy()`, including - the `isPresentingModal` guard that keeps the panel alive behind it. -- `ConfirmSheet.swift` is therefore not needed as a separate file. - -What remained, and is what this phase delivers: an `ActionCoordinator` that reports what -actually happened, the provider toggle UI, and result feedback in the popover. - -## File change map - -| Path | Action | -| --- | --- | -| `app/Sources/MenuBarCore/ProxyClient.swift` | MODIFY — three-state liveness, decode the stop `success` flag | -| `app/Sources/MenuBarCore/ActionCoordinator.swift` | NEW | -| `app/Sources/MenuBarUI/ProviderListView.swift` | NEW — disclosure + toggles | -| `app/Sources/MenuBarUI/PopoverViewController.swift` | MODIFY — result banner, provider section | -| `app/Sources/MenuBarUI/AppDelegate.swift` | MODIFY — wire both actions to the coordinator | -| `app/Sources/MenuBarCoreTests/ActionSuite.swift` | NEW | - -## `ProxyClient` additions - -```swift -/// Returns whether the proxy also restored native Codex on the way out. The response -/// carries `success: false` when `restoreNativeCodex()` failed; only the boolean is -/// decoded, never the server-formatted message. -@discardableResult -public func stop() async throws -> Bool { - let data = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) - guard let result = try? JSONDecoder().decode(StopResult.self, from: data) else { return true } - return result.success ?? true -} - -public func setProviderDisabled(_ name: String, disabled: Bool) async throws { - var components = URLComponents(url: endpoint.baseURL.appendingPathComponent("api/providers"), - resolvingAgainstBaseURL: false)! - components.queryItems = [URLQueryItem(name: "name", value: name)] - var request = URLRequest(url: components.url!) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "content-type") - request.httpBody = try JSONEncoder().encode(["disabled": disabled]) - ... -} -``` - -The PATCH body is exactly `{"disabled": }` and nothing else. `002` §4 records -`provider-routes.ts:239`: a `disabled`-only patch skips the heavy merged-shape -validators. Adding any second field would silently change the request class. - -## `ActionCoordinator.swift` - -### There is no restart. There is only stop. - -This was the single biggest correction from the Phase-0 audit, and it is worth stating -plainly because an earlier draft of this document got it wrong. - -`src/server/management-api.ts:136-147` — `/api/stop` calls `stopServiceIfInstalled()` -**before** responding. That call exists precisely so launchd cannot respawn the proxy. -So a service-managed proxy does not come back on its own, and there is no start endpoint -to call. A control labelled "Restart" would therefore be a lie in every configuration. - -**Decision: the app ships `Stop proxy`, never `Restart`.** After a successful stop, the -UI enters the `unreachable` state (`020`) whose next action shows the exact command to -start it again (`ocx start`, or `ocx service start` when a service is installed) as -selectable text. The app does not spawn processes the user did not ask for, and it does -not claim a capability the API does not have. - -This removes the `serviceManaged` computed branch an earlier draft assumed. The -`StartupHealth.serviceInstalled` / `serviceEnabled` fields are still decoded in `010` — -they render the status qualifier line in `020`, they just no longer gate an action. - -### The drain problem - -`002` §4 also records that `/api/stop` answers `200` **before** draining. Treating `200` -as "stopped" would make the UI lie for several seconds. - -```swift -public enum ActionOutcome: Equatable, Sendable { - case succeeded - /// Stop confirmed; the app cannot relaunch it, so it carries the start command. - case requiresManualStart(String) - /// Stopped, but `restoreNativeCodex()` failed — native Codex still points at the - /// closing port, so the user must run `ocx restore` too. - case stoppedWithRestoreFailure(String) - /// User-facing text, never a raw response body. - case failed(String) -} - -public func stop(startCommand: String) async -> ActionOutcome { - let restored: Bool - do { restored = try await client.stop() } - catch let error as ProxyError { return .failed(error.userMessage) } - catch { return .failed("Could not reach the proxy to stop it.") } - - // Poll until the connection is REFUSED. Any HTTP answer — including 500 or an - // undecodable body — proves a server is still listening, and a timeout proves - // nothing at all. - let deadline = now().addingTimeInterval(Self.stopTimeout) - var sawIndeterminate = false - while now() < deadline { - await sleeper(Self.pollInterval) - // Cap each probe to the time left, so the last one cannot overrun the deadline - // by its own timeout. - let remaining = deadline.timeIntervalSince(now()) - guard remaining > 0 else { break } - switch await client.liveness(timeout: min(1.5, remaining)) { - case .refused: - return restored ? .requiresManualStart(startCommand) - : .stoppedWithRestoreFailure(startCommand) - case .reachable: sawIndeterminate = false - case .indeterminate: sawIndeterminate = true - } - } - return .failed(sawIndeterminate - ? "The proxy accepted the stop, but its state could not be confirmed. Check with `ocx status`." - : "The proxy accepted the stop but was still responding after 10 seconds.") -} -``` - -`requiresManualStart` is the honest success case: the stop is confirmed, and the app -says so while telling the user how to bring it back. - -### Provider toggle — the default-provider trap - -`002` §4 records `provider-routes.ts:178`: disabling `config.defaultProvider` returns -`400` with `"cannot disable the default provider; set another default first"`. - -Per `dev-uiux-design` UX-LAZY-01, firing a request guaranteed to fail is not acceptable. -The toggle is disabled up front with an explanatory tooltip: - -```swift -// The proxy guard is `rawBody.disabled && name === defaultProvider`, so only DISABLING -// the default is refused. A default provider that is already off must stay toggleable. -let wouldDisableDefault = isDefault && provider.isEnabled -toggle.isEnabled = !wouldDisableDefault -toggle.toolTip = wouldDisableDefault - ? "This is the default provider. Choose another default in the dashboard first." - : nil -``` - -**`defaultProvider` comes from `GET /api/config`, not `/api/settings`.** The audit -verified the live `/api/settings` key set is exactly `codexAutoStart`, `port`, -`hostname`, `streamMode`, `startupHealth`, `codexRuntime` — no `defaultProvider`. -`/api/config` returns it (`"defaultProvider": "openai"` live). `010` adds a -`ProxyConfigSummary` model and `config()` client method for this. - -Optimistic update with rollback: flip the switch immediately, send the PATCH, and revert -with an inline error on failure. Reverting is the required behaviour — leaving a switch -in a state the server rejected is the "fake state" tell. - -## Confirmation policy - -| Action | Confirmation | Why | -| --- | --- | --- | -| Stop proxy | **Yes** — sheet | Disruptive: kills in-flight requests, and nothing restarts it | -| Provider disable | No — optimistic + undo | Cheap and reversible | -| Provider enable | No | Strictly additive | - -`dev-uiux-design` UX-LAZY-01 exempts destructive actions from magic defaults, and stopping -a proxy mid-request is destructive. Everything else stays frictionless. - -`ConfirmSheet` states the concrete consequence — "In-flight requests will be interrupted, -and OpenCodex will not restart on its own." — not a generic "Are you sure?". - -## Security rules - -- Write requests carry the key in `x-opencodex-api-key`, read from the Keychain lazily - (`010`), and never in a URL query. -- No response body ever reaches a log, an error string, or the UI verbatim. Failures map - to a fixed set of human sentences. -- **No shell execution at all.** The app never spawns `ocx` or any other process; it only - displays the command for the user to run. This is stricter than PR #387, which shelled - out to the CLI, and it removes an entire class of injection and privilege concerns. -- The app never writes to `~/.opencodex/config.json` directly; all mutation goes through - the management API so the proxy's own validation runs. - -## Tests (`ActionTests`) - -Stubbed `URLProtocol`: - -- `stop()` on `200` → `.requiresManualStart` only after reachability actually drops. -- `stop()` where the port keeps answering → `.failed`, never a false success. -- `setProviderDisabled` sends `PATCH /api/providers?name=x` with body exactly - `{"disabled":true}`. -- A `400` response reverts the optimistic toggle. -- The default provider (from `/api/config`) has its toggle disabled before any request is - attempted. -- No code path constructs a `Process` / `NSTask`. -- No error path leaks a response body into `ActionOutcome`. - -## Code-review corrections (folded before B closed) - -### Round 6 - -| Finding | Correction | -| --- | --- | -| The continuation tests could still pass without entering the continuation: `gateEntered` proved cycle 1 reached the gate, but nothing proved the *waiter* had registered before the gate was released. Under starvation the waiter could start afterwards, take the ordinary path, and satisfy every assertion | `PollingCoordinator.waiterCount` is exposed and the tests poll it until registration is observed, then assert it returns to zero. No `Thread.sleep` remains as synchronisation | -| No test drove `MenuBarUI` at all, so the Phase 3 rollback, pending-versus-poll, and default-direction behaviours — every one of them a defect found in an earlier round — had zero regression cover | New `MenuBarUITests` target (7 cases) with read-only inspection hooks on `ProviderListView` | - -**Sabotage-verified.** Both previously-fixed defects were reintroduced and the suite -caught exactly the right two cases: making the default guard direction-insensitive failed -"a disabled default provider can still be switched back on", and dropping the intended -value in `rebuildRows` failed "a stale poll cannot undo an in-flight optimistic change". -The other five stayed green. - -### Round 5 - -| Finding | Correction | -| --- | --- | -| The "queued cycle fails" test never consumed a failure: with the popover closed a cycle takes exactly one health response, and the queue led with three 200s, so it re-tested the success path | The popover is opened first (consuming its own five-response cycle), then one gated 200 followed by refusals. A new `snapshot.state == .unreachable` assertion proves the failure was actually consumed — and it is what caught this | -| The gate was read and written without the stub's lock, and the test inferred "the request reached the gate" from a 200ms sleep | `setGate`/`currentGate` go through the same lock, a `gateEntered` semaphore lets the test wait for the request to actually arrive, and `defer` releases the gate so a mid-test failure cannot wedge the suite | - -### Round 4 - -| Finding | Correction | -| --- | --- | -| Both `refreshAndWait` tests ran with `refreshInFlight == false`, so neither entered `waitForCompletion()`. They would have stayed green if the continuation never resumed — no regression proof for the concurrency fix that closed the round-3 blocker | `StubProtocol` gained a request gate. Two new tests hold a cycle suspended, assert the waiter has NOT returned, then release and assert it does — one for a succeeding queued cycle, one for a failing one | - -**Sabotage-verified, and the sabotage itself needed a second pass.** A test that passes -proves nothing about a path it never takes: - -- Removing `waiter.resume()` entirely makes the suite hang until timeout instead of - passing, so both gate tests genuinely depend on the continuation. -- Removing the signal from only the `ProxyError` exit does NOT fail the suite. A signal - trace (`RELEASE site=…`) showed why: the failing cycle releases at that site, but when - it is muted another exit path still reaches an idle state and releases the waiter. The - waiter is therefore protected by several exits rather than by exactly one, which is - the safer arrangement but means single-site sabotage is not a valid probe here. - -Recording both results because the second one is the kind of thing that quietly -invalidates a "verified" claim. - -### Round 3 - -| Finding | Correction | -| --- | --- | -| `liveness()` went through the generic `send()`, so a 401 with a stored key triggered a credential retry — spending a second full timeout re-asking a question the 401 had already answered, and downgrading a known-reachable result to indeterminate if that retry failed | Liveness now calls `perform()` directly: one attempt, no retry | -| The stop loop always requested a 1.5s probe, so the final one could overrun the 10s deadline | Each probe is capped to `min(1.5, remaining)`, and the loop breaks when no time is left | -| `refreshAndWait()` spun on shared booleans with a 5s bound, which a legitimately slow cycle can exceed — re-enabling the switch against pre-write data, the exact window it was added to close | Waits on a continuation released when no cycle is running or queued | - -### Round 2 - -| Finding | Correction | -| --- | --- | -| `.timedOut` and `.networkConnectionLost` were still mapped to `.unreachable`, so the three-state contract was two states in practice and a timeout could confirm a false stop | New `ProxyError.inconclusive`; only `.cannotConnectToHost` becomes `.refused`. Liveness probes also take a 1.5s timeout so one probe cannot overrun the stop deadline | -| `rebuildRows()` initialised switches from the snapshot, so a poll landing mid-write visibly snapped the switch back despite the row being busy | `pending` now stores the intended value, applied before the row is marked busy | -| The post-write refresh coalesced and returned immediately, so the switch became interactive against pre-write data | `refreshAndWait()` waits for a cycle to actually complete | -| The document still required a live stop at the top and carried pre-review snippets | Verification line and all three snippets updated to what shipped | - -### Round 1 - -| Finding | Correction | -| --- | --- | -| `isReachable()` treated every non-401 error as "gone", so a 500 or a decode failure during polling reported a stop as confirmed while an HTTP server was still listening | Three-state `liveness()`: `reachable` (any HTTP answer, including 401/403/500 and undecodable bodies), `refused` (the only proof), `indeterminate` (timeouts prove nothing) | -| `/api/stop` returns `success: false` when `restoreNativeCodex()` fails — the proxy still exits, but native Codex is left pointing at a closing port. The body was discarded and the app said "Proxy stopped" | Decode only the boolean, never the server's message. New `stoppedWithRestoreFailure` outcome tells the user to run `ocx restore` | -| Two rapid toggles could reach the server out of order, leaving it opposite to the user's last click | One in-flight write per provider in the coordinator, and the row goes inert until its authoritative refresh lands. Pending state survives `rebuildRows`, so a poll cannot resurrect the pre-toggle switch | -| A default provider that was already disabled could never be re-enabled: the switch was inert whenever `isDefault`. The proxy guard is `rawBody.disabled && name === defaultProvider` — only *disabling* is refused | The switch is inert only when it would disable an enabled default | -| The "exact body" test encoded its own dictionary and compared that, so it would pass with no request body at all | `StubProtocol` now drains `httpBodyStream` and the test asserts on the decoded actual body | -| An outcome test built non-empty literals and asserted they were non-empty | Replaced with one that drives three real failure paths and checks the user-visible message, including that no response body leaks | -| Acceptance criterion 1 demanded a live stop while the notes said stop was deliberately not run live | Criterion amended with its reasoning; see below | - -## Implementation notes - -**The stop timeout needed an injectable clock, not just a no-op sleeper.** The first test -for "a proxy that keeps answering is a failure" passed a sleeper that did nothing — and -the test failed, reporting success. The loop is bounded by a wall-clock deadline, so -skipping the sleep without advancing the clock means the deadline never arrives. Both the -sleeper and `now` are injected. - -The same test also exposed a harness trap worth recording: `StubProtocol` falls back to -"connection refused" once its response queue drains, which reads as a successful stop. A -test that queues too few responses will pass for the wrong reason. - -**Live verification** against the running proxy (`ActionProbe`, removed after use): - -```text -default provider: openai -target: anthropic enabled: true -disable -> succeeded proxy now reports enabled: false -re-enable -> succeeded proxy now reports enabled: true -default-provider guard -> failed("openai is the default provider. Choose another default…") -``` - -Proxy state was confirmed restored afterwards: 10 providers, 10 enabled. - -`stop` is covered by the stubbed suite rather than live, per the amended criterion 1 -above. The branches proven there are the ones a healthy proxy cannot demonstrate: -`success: false` from a failed native-Codex restore, a 500 mid-poll, an undecodable 200, -and a proxy that accepts the stop but keeps answering. - -## Accept criteria - -1. Stop behaviour proven deterministically rather than by stopping the user's proxy. - **Amended criterion:** stopping the developer's own running proxy is out of bounds — - it would interrupt their work, and the failure modes that matter (a 200 that never - drains, a 500 during polling, `success: false`, an undecodable body) cannot be - produced on demand from a healthy proxy anyway. The gate is therefore the stubbed - transport suite, which covers every branch, plus a live read confirming the proxy is - still healthy afterwards. -2. Provider disable + re-enable executed live and reflected in `/api/providers`. -3. The default provider's toggle is inert and explains why, using `/api/config`. -4. Failure paths surface a human sentence, never a raw body. -5. No `Process` / `NSTask` usage anywhere in `app/`. -6. `swift run --package-path app MenuBarCoreTests` and - `swift run --package-path app MenuBarUITests` both green. diff --git a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md b/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md deleted file mode 100644 index 8d24fcf0f7..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/040_phase4_release.md +++ /dev/null @@ -1,459 +0,0 @@ -# 040 — Phase 4: universal build, release packaging, CI wiring - -**Depends on:** `010`-`030` (there must be an app worth packaging). Phases 1-3 verify -themselves through `swift test` / `swift build` / `swift run`; **this phase owns the -bundle end to end** — `scripts/build-macos-app.sh` is introduced here and the first `.app` -is produced here. -**Independently verifiable by:** `lipo -archs` on the packaged executable, archive -content assertion, and workflow syntax validation. - -This phase is the direct answer to the user's question — *"메뉴바는 못 넣는 거 아님? 앱을 -만들어야 되는 거 아님?"* The app is only real when a user can download and run it without a -toolchain. Packaging architecture is inherited from PR #387 (`001` §5); it was the -strongest part of either PR and is not re-derived. - -**Security note:** this phase edits `.github/workflows/release.yml`, which -`AGENTS.md` classifies as requiring explicit security review. Changes are therefore -minimal, additive, SHA-pinned, and least-privilege. No secret is introduced. - -## Stale check at P - -Re-verified against the tree: neither script existed, `package.json` had no macOS -entries, and `gui/public/favicon.png` (the icon source) is present. The CI path filter -also lacked `app/**`, so an app-only change would have run no CI at all — added. - -## File change map - -| Path | Action | -| --- | --- | -| `scripts/build-macos-app.sh` | NEW | -| `scripts/package-macos-release.sh` | NEW | -| `package.json` | MODIFY — three script entries | -| `.github/workflows/ci.yml` | MODIFY — path filter + macOS steps | -| `.github/workflows/release.yml` | MODIFY — `package-macos` job + asset attach | -| `.gitignore` | MODIFY — `dist/macos/` (already added in `010`) | - -## `scripts/build-macos-app.sh` - -Assembles the bundle by hand. No Xcode project, so nothing to keep in sync. - -```bash -#!/usr/bin/env bash -set -euo pipefail -repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -package_dir="$repo_root/app" -output_root="${OUTPUT_DIR:-$repo_root/dist/macos}" -configuration="${CONFIGURATION:-release}" - -[[ "$(uname -s)" == "Darwin" ]] || { echo "build:macos requires macOS." >&2; exit 1; } - -# The build DELETES whatever sits at the destination, so containment is a safety -# boundary. It took four attempts to get right, and each failure is why the final shape -# looks the way it does: -# -# 1. comparing $app_bundle against $output_root proved nothing — same variable; -# 2. `cd … && pwd` keeps LOGICAL paths, so a repo-local symlink pointing outside -# satisfied the prefix check; -# 3. resolving physically BEFORE normalising let `..` reveal a symlink that was then -# never followed — and `unset 'stack[-1]'` is a bad subscript in bash 3.2 (what -# macOS ships), so `..` was silently never applied at all; -# 4. a RELATIVE dangling target was joined on without normalising, so -# `link -> ../../outside` became `/../../outside`, passed the `/*` -# check, and escaped during mkdir -p. -# -# resolve_physical therefore normalises lexically first (quoted array iteration, so a -# literal glob is not expanded), then resolves component by component, and refuses any -# symlink that does not resolve to an existing directory. -# -# ABBREVIATED. scripts/build-macos-app.sh is authoritative — in particular resolve_physical -# itself, and the $TMPDIR handling below, which matters because macOS puts TMPDIR under -# /var/folders rather than /tmp. A containment check that allowed only /tmp would reject -# the packaging script's own temporary build root. -output_root="$(resolve_physical "$output_root")" -allowed_root="$(cd "$repo_root" && pwd -P)" -allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd -P || echo "")" -case "$output_root" in - "$allowed_root"/*) ;; - /private/tmp/*|/tmp/*) ;; - *) - if [[ -z "$allowed_tmp" || "$output_root" != "$allowed_tmp"/* ]]; then - echo "Refusing to build into '$output_root'" >&2 - exit 1 - fi - ;; -esac - -# Only NOW create it, so a refused path leaves nothing behind. -mkdir -p "$output_root" -app_bundle="$output_root/OpenCodex.app" - -swift_args=(--package-path "$package_dir" -c "$configuration" --product OpenCodexMenuBar) -if [[ "${UNIVERSAL:-0}" == "1" ]]; then - developer_dir="$(xcode-select -p 2>/dev/null || true)" - if [[ "$developer_dir" == *"CommandLineTools"* ]]; then - echo "UNIVERSAL=1 requires the full Xcode toolchain; Command Line Tools ships only" >&2 - echo "current-architecture Swift compatibility libraries." >&2 - echo "Install Xcode, then: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" >&2 - exit 1 - fi - swift_args+=(--arch arm64 --arch x86_64) -fi - -swift build "${swift_args[@]}" -bin_dir="$(swift build "${swift_args[@]}" --show-bin-path)" -``` - -**Every path is defined before use, and `output_root` exists before `mktemp` targets it.** -An earlier draft of this document called `mktemp` inside a directory it had not created, -used `$iconset` before defining it, and ran `plutil` against an `Info.plist` it never -copied — under `set -u` that script cannot run. The full sequence below is the executable -version. - -**The CLT guard is not optional.** `001` §4.1 records the live probe on this machine: - -```text -swift build --arch arm64 --arch x86_64 -c release - -> ld: symbol(s) not found for architecture x86_64 -swift build --arch arm64 -c release - -> Build complete! (10.39 sec) -``` - -Without the guard, a contributor on Command Line Tools gets a linker error with no -explanation. PR #387 discovered this and its message is kept nearly verbatim. - -Staging, then atomic swap: - -```bash -# See the containment block above: validation happens before any mkdir. -staging_root="$(mktemp -d "$output_root/.OpenCodex-build.XXXXXX")" -staged_app="$staging_root/OpenCodex.app" -iconset="$staging_root/OpenCodex.iconset" -trap 'rm -rf "$staging_root"' EXIT - -mkdir -p "$staged_app/Contents/MacOS" "$staged_app/Contents/Resources" -cp "$bin_dir/OpenCodexMenuBar" "$staged_app/Contents/MacOS/OpenCodexMenuBar" -cp "$package_dir/Info.plist" "$staged_app/Contents/Info.plist" - -# Version comes from package.json — the app can never claim a version the release did not ship. -version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$repo_root/package.json" | head -n 1)" -# Apple constrains both fields, and differently from the npm version string: -# CFBundleShortVersionString - exactly three integers (no prerelease suffix) -# CFBundleVersion - ONE TO THREE integers; a fourth is ignored, so -# appending a run number to a full semver adds nothing -version_core="${version%%-*}" -build_version="${MACOS_BUILD_NUMBER:-$version_core}" -plutil -replace CFBundleShortVersionString -string "$version_core" "$staged_app/Contents/Info.plist" -plutil -replace CFBundleVersion -string "$build_version" "$staged_app/Contents/Info.plist" - -# Icon: reuse the existing dashboard favicon, no new binary asset in the repo. -icon_source="$repo_root/gui/public/favicon.png" -[[ -f "$icon_source" ]] || { echo "Missing icon source: $icon_source" >&2; exit 1; } -mkdir -p "$iconset" -for size in 16 32 128 256 512; do - sips -z "$size" "$size" "$icon_source" --out "$iconset/icon_${size}x${size}.png" >/dev/null - sips -z "$((size*2))" "$((size*2))" "$icon_source" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null -done -iconutil -c icns "$iconset" -o "$staged_app/Contents/Resources/OpenCodex.icns" - -# MACOS_SIGN_IDENTITY is a LOCAL hook (preconfigured keychain). CI deliberately does not -# set it: an identity name alone cannot sign on a hosted runner, because nothing imports -# the certificate and private key. Unset, the bundle is ad-hoc signed and says so. -if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then - codesign --force --deep --options runtime --timestamp --sign "$MACOS_SIGN_IDENTITY" "$staged_app" -else - codesign --force --sign - --timestamp=none "$staged_app" -fi - -# Refuse to delete a symlinked destination. -if [[ -L "$app_bundle" ]]; then - echo "Refusing to replace '$app_bundle': it is a symlink." >&2 - exit 1 -fi - -rm -rf "$app_bundle" && mv "$staged_app" "$app_bundle" -``` - -Building into a temp dir and moving at the end means an interrupted build never leaves a -half-written `.app` that launches and misbehaves. - -## `scripts/package-macos-release.sh` - -Wraps the bundle for distribution. Every step is an assertion, not a hope. - -```bash -RELEASE_VERSION guard # package.json must equal the requested release version -UNIVERSAL=1 CONFIGURATION=release bash scripts/build-macos-app.sh -codesign --verify --deep --strict --verbose=2 "$app_bundle" -lipo -archs "$executable" # must contain arm64 AND x86_64 when UNIVERSAL=1 -ditto -c -k --sequesterRsrc --keepParent "$app_bundle" "$archive_path" -unzip -Z1 "$archive_path" | grep -Fqx 'OpenCodex.app/Contents/MacOS/OpenCodexMenuBar' -shasum -a 256 "$archive_name" > "$checksum_name" -``` - -Output: `OpenCodex--macos-universal.zip` + `.sha256`. - -`ditto` rather than `zip`: it preserves extended attributes and symlinks, so the unpacked -bundle stays launchable. Plain `zip` corrupts code signatures. The `unzip -Z1` assertion -catches the case where the archive is produced but empty. - -## `package.json` - -```json -"build:macos": "bash scripts/build-macos-app.sh", -"package:macos": "bash scripts/package-macos-release.sh", -"test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests" -``` - -## `.github/workflows/ci.yml` - -Path filter gains `"app/**"` in both the `pull_request` and `push` blocks. New steps in -the existing cross-platform job, gated so Linux and Windows runners skip them: - -```yaml -- name: Test macOS menu bar app - if: runner.os == 'macOS' - run: bun run test:macos - -- name: Build macOS menu bar app - if: runner.os == 'macOS' - run: bun run build:macos -``` - -Placed after `privacy:scan` so a credential leak fails before a long Swift build runs. - -## `.github/workflows/release.yml` - -### Current state (read before editing) - -The workflow declares **workflow-level** permissions at lines 32-35: - -```yaml -permissions: - contents: write # create the GitHub Release + tag after npm publish - actions: read # verify the release commit passed Cross-platform CI - id-token: write # OIDC for Trusted Publishing + provenance -``` - -Workflow-level permissions are **inherited by every job**. A `package-macos` job added -without its own `permissions:` block would silently run with `contents: write` and -`id-token: write` — an OIDC-capable token in a job that builds third-party-toolchain -code. An earlier draft of this document claimed the job "needs no `id-token`, no -`contents: write`" while specifying no block that would achieve that. - -### Job graph - -Three jobs, with npm independence preserved by construction: - -```text -publish (existing) package-macos (new) - npm + GitHub Release build + zip + sha256 - \ / - \ / - attach-macos (new, needs: [publish, package-macos]) - upload assets to the existing Release -``` - -`publish` gains no `needs`, so a Swift or packaging failure **cannot** block or fail the -npm publish. `attach-macos` runs only when both succeed. If npm publishes but packaging -fails, the release is still valid and the asset is attached by re-running the workflow's -packaging path — documented in the guide as the retry procedure. - -### The jobs - -```yaml -package-macos: - runs-on: macos-latest - timeout-minutes: 20 - permissions: - contents: read # explicit: drops the inherited write + id-token - outputs: - archive_name: ${{ steps.package.outputs.archive_name }} - checksum_name: ${{ steps.package.outputs.checksum_name }} - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - with: - persist-credentials: false - - id: package - env: - RELEASE_VERSION: ${{ inputs.version }} - UNIVERSAL: "1" - # A valid single-integer CFBundleVersion. Appending a run number to a full - # semver would be a FOURTH component, which Apple ignores. - MACOS_BUILD_NUMBER: ${{ github.run_number }} - run: bash scripts/package-macos-release.sh - - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 - with: - name: macos-release - path: dist/release/ - if-no-files-found: error - retention-days: 7 - -attach-macos: - runs-on: ubuntu-latest - needs: [publish, package-macos] - if: ${{ inputs.dry-run != true }} - timeout-minutes: 10 - permissions: - contents: write # only to attach assets to the existing Release - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: macos-release - path: dist/release - - name: Verify checksum before upload - run: cd dist/release && shasum -a 256 -c *.sha256 - - name: Attach to release - env: - GH_TOKEN: ${{ github.token }} - # Inputs reach shell code through env. Direct interpolation into run: source is - # rejected repo-wide by tests/ci-workflows.test.ts. - RELEASE_VERSION: ${{ inputs.version }} - run: gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber -``` - -`shasum -c` before upload means a corrupted artifact transfer cannot become a published -asset. `if: ${{ inputs.dry-run != true }}` keeps dry runs from touching a real Release. The -input is named `dry-run` with a hyphen (`release.yml:22-26`); `inputs.dry_run` would -resolve to null and the guard would silently pass, which is the exact failure this line -exists to prevent. - -**`UNIVERSAL: "1"` is safe here specifically because `macos-latest` carries a full -Xcode**, the environment `001` §4.1 identified as the only one that can produce both -slices. This is why the universal assertion lives in CI and not in the local gate. - -Constraints honoured: - -- Every action pinned to a full commit SHA, including the two new ones above - (`AGENTS.md` treats mutable third-party refs as a release blocker). -- Each new job declares explicit least-privilege `permissions`, overriding inheritance. -- `persist-credentials: false` on the packaging checkout. -- The npm publish path gains no new dependency. - -## Privacy and artifact hygiene - -`bun run privacy:scan` must pass. Concretely: - -- `app/.gitignore` excludes `.build/`, `.swiftpm/`, `DerivedData/` (landed in `010`). -- Root `.gitignore` excludes `dist/macos/`. -- `git ls-files app/ | grep -E '\.build/|DerivedData/'` must return empty. -- No absolute developer path appears in **any file this unit adds or modifies** — checked - explicitly rather than assumed. Pre-existing paths in unrelated historical devlogs are - out of scope (`000` criterion 8). This mirrors the artifact defect the Codex reviewer - originally raised on PR #421, which that contributor has since fixed (`001` §2.1). - -## Implementation notes - -**A pipeline subtlety cost a real debugging pass.** The archive assertion was originally -`unzip -Z1 "$archive" | grep -Fqx '…'`. Under `set -o pipefail`, `grep -q` exits as soon -as it matches; `unzip` *can* then receive SIGPIPE while still writing, and the pipeline -reports failure even though the match succeeded — which is how a correctly packaged -archive got rejected with "does not contain the OpenCodex executable". It is a race, not -a certainty: a reviewer re-running the old pipeline against the same archive saw it exit -0. That is precisely why it is worth fixing rather than dismissing — an assertion that -fails intermittently on success is worse than one that fails consistently. Capturing the -listing into a variable first and matching against a here-string removes the pipeline. - -**`--sequesterRsrc` adds `__MACOSX/` entries** alongside the real paths, which is -harmless for an exact-match assertion but surprising when reading the listing by eye. - -### Verified locally - -```text -bash scripts/build-macos-app.sh - -> dist/macos/OpenCodex.app (version 2.7.35), arm64 - -> Info.plist: CFBundleExecutable=OpenCodexMenuBar, CFBundlePackageType=APPL, - CFBundleIconFile=OpenCodex, LSUIElement=true, NSAllowsLocalNetworking=true - -> codesign --verify --deep --strict: valid on disk, satisfies its Designated Requirement - -> launched from the bundle: menu bar item appeared, no ATS errors in the log - -UNIVERSAL=0 bash scripts/package-macos-release.sh - -> OpenCodex-2.7.35-macos-arm64.zip (813 KB) + .sha256 - -> shasum -a 256 -c: OK - -> unpacked with ditto -x -k: signature survived, app launched from the unpacked bundle - -UNIVERSAL=1 bash scripts/build-macos-app.sh - -> refused with the Command Line Tools explanation rather than a linker error -``` - -The unpack-and-launch step is the one that matters: it is the path a user actually takes, -and it is the one that would expose a `zip`-corrupted signature. - -## Signing and Gatekeeper: what actually ships - -The asset is **ad-hoc signed**, and `spctl --assess --type execute` rejects it. That is -not an oversight to paper over — Developer ID signing plus notarization requires a paid -Apple Developer account, and this project has no certificate today: - -```text -security find-identity -v -p codesigning | grep -c "Developer ID Application" -> 0 -grep -rn "APPLE_\|NOTARY\|DEVELOPER_ID" .github/workflows/ -> none -``` - -So the scripts are built to be honest about it and ready for the day that changes: - -- `MACOS_SIGN_IDENTITY` (optional) switches `build-macos-app.sh` to - `codesign --options runtime --timestamp --sign "$identity"`, which is what - notarization requires. Unset, it ad-hoc signs and says so on stderr. -- `package-macos-release.sh` runs `spctl --assess` and reports the verdict. An ad-hoc - rejection is expected and non-fatal; a build that claimed a real identity and *still* - fails assessment exits non-zero, because that means notarization is missing. -- `release.yml` deliberately does **not** pass `MACOS_SIGN_IDENTITY`. An identity name - alone cannot sign on a hosted runner: nothing imports the certificate and private key, - so `codesign` fails with "no identity found". Advertising the secret would imply a - capability that does not exist. Real CI signing means a protected P12 import, a - temporary keychain, `notarytool` credentials, and stapling — one security-reviewed - change, not a lone secret. - -**Consequence for Phase 5 docs:** the Gatekeeper section is not optional. Users will see -"cannot be opened because the developer cannot be verified" and need the right-click → -Open path. Documenting that honestly is better than shipping an asset that appears -broken. - -## Accept criteria - -1. `bun run build:macos` produces a launchable `dist/macos/OpenCodex.app`. -2. `bun run package:macos` produces zip + `.sha256`, with the content assertion passing. -3. `lipo -archs` shows `arm64` locally; both arches asserted in CI. - 3a. Both version fields honour Apple's limits: `CFBundleShortVersionString` is - exactly three integers (`2.7.36-preview.1` → `2.7.36`), and `CFBundleVersion` is - one to three integers — `MACOS_BUILD_NUMBER` replaces it outright rather than - appending a fourth component, which Apple ignores. - 3b. `OUTPUT_DIR` outside the repository or temp is refused, since the build deletes - whatever sits at the destination. Covered by `tests/macos-build-script.test.ts`, - **8 cases** — six refusals, each asserting that nothing is created, and two - acceptances: - - 1. a sibling-of-repository path - 2. an unresolved `..` traversal - 3. a symlink revealed by a `..` - 4. a symlink pointing outside the permitted roots - 5. a symlink with a *relative* escaping target - 6. a literal glob, run from a directory containing a matching entry - 7. a repository path (accepted) - 8. a temp path (accepted) - - Three harness details are load-bearing, each learned by getting it wrong: - - - The outside path is a **sibling of the repository**, not anything under `$HOME`. - Other suites replace `HOME` with a temp directory, and temp is a permitted root, - so a `HOME`-derived path made this test pass alone and fail in the full suite. - - The traversal fixture is built by **string concatenation**, never `path.join()`, - which normalises `..` itself — with `join()` the test passed against the broken - resolver. - - The glob case runs the child in a directory that **contains a matching entry**. - With `cwd` at the repository root and the glob under `dist/`, the old unquoted - loop had nothing to expand and the test passed against the broken implementation. -4. `UNIVERSAL=1` under Command Line Tools fails with the explanatory message, not a - linker error. -5. The build script runs end to end on a clean checkout under `set -euo pipefail`, with - every variable defined before use. -6. Workflow YAML parses; all actions SHA-pinned to a full commit SHA. - Note: "build clean" means exit 0, not warning-free — Command Line Tools emits - framework search-path warnings that come from the toolchain, not from this code. -7. **Security review evidence recorded** before this phase closes (`MAINTAINERS.md` - requires it for release automation): the final workflow diff reviewed, effective - per-job permissions enumerated and confirmed least-privilege, every action pin - resolved to an immutable SHA, dry-run behaviour confirmed not to touch a Release, and - the npm-publish path confirmed to have gained no new failure dependency. -8. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green. -9. No build artifacts tracked by git. diff --git a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md b/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md deleted file mode 100644 index 8d337c0556..0000000000 --- a/devlog/_plan/260725_macos_menubar_app/050_phase5_handoff.md +++ /dev/null @@ -1,143 +0,0 @@ -# 050 — Phase 5: docs, PR consolidation, push - -**Depends on:** `040` (nothing is documented or announced until it builds and packages). -**Independently verifiable by:** `gh pr view 387/421` showing `CLOSED` with the posted -comments, and `git ls-remote --heads origin feat/macos-app` matching local `HEAD`. - -## File change map - -| Path | Action | -| --- | --- | -| `docs-site/src/content/docs/guides/macos-menu-bar.md` | NEW (English source) | -| `docs-site/src/content/docs/ko/guides/macos-menu-bar.md` | NEW | -| `docs-site/src/content/docs/ja/guides/macos-menu-bar.md` | NEW | -| `docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md` | NEW | -| `docs-site/src/content/docs/ru/guides/macos-menu-bar.md` | NEW | -| `docs-site/astro.config.mjs` | MODIFY — sidebar entry | -| `README.md` | MODIFY — one line under features | -| `structure/00_overview.md` | MODIFY — `app/` in the layout map (SOT-SYNC-01) | -| `AGENTS.md` | MODIFY — one line in "Repository layout" | - -`AGENTS.md` describes `src/`, `gui/`, `docs-site/`, `structure/`, `scripts/`, `devlog/`. -A new top-level `app/` that is not listed there would be invisible to the next agent. - -## Documentation content - -The guide answers, in order: what it is, how to get it, the Gatekeeper first launch, -what each part of the popover means, and how to build from source. - -**Gatekeeper section is mandatory.** The release zip is ad-hoc signed, not notarized, so -the first launch shows *"OpenCodex.app cannot be opened because the developer cannot be -verified."* Without documentation this reads as a broken download. The guide gives the -right-click → Open path and the `xattr -d com.apple.quarantine` alternative, and states -plainly that notarization requires a paid Apple Developer identity the project does not -currently hold. PR #387 documented this across five locales and that instinct is correct. - -Translated locales must not contradict the English source (`AGENTS.md` docs-sync rule). - -## PR closure - -Both PRs are closed with an English maintainer comment (`AGENTS.md`: always review in -English), naming what was taken from each. Credit is specific, not ceremonial — both -authors shipped work that materially shaped this implementation. - -### To #387 (jaycho46) - -Names what was adopted: the Swift/SwiftPM runtime choice, the two-target core/app split, -manual bundle assembly with the unexpected-path refusal guard, `codesign --verify --deep ---strict`, the `lipo` universal assertion, `ditto` archiving with archive-content -verification, the SHA-256 sidecar, the `package-macos` release job shape, the -Command-Line-Tools universal guard, and the Gatekeeper documentation. - -States plainly what changed and why: the transport moved from `ocx status --json` -subprocess calls to the HTTP management API, because the CLI path required extending -`src/cli/status.ts` and the maintainer scope for this work excluded proxy runtime -changes — and because `/api/usage` and `/api/provider-quotas` already return richer data -with no proxy change at all. - -Also states the cost of that choice honestly (`001` §4.2): the CLI transport could run -`ocx start`, and HTTP cannot. The maintainer app ships **Stop proxy** rather than pretend -to restart. - -### To #421 (genglintong) - -Names what was adopted: HTTP management-API transport, `runtime-port.json` discovery with -the 10100 fallback, Keychain-backed key storage, skipping auth when the proxy has no -`apiKeys` configured, the usage/health/status information set, and tabular-numeral stat -treatment. - -**Do not credit renderer-side token isolation.** `001` §2 shows `menubar/src/api.ts:12-13` -returning the token into renderer memory at head `049ef2ac`, so the PR body's claim does -not hold and repeating it would put a false statement in the record. - -**Must be written against head `049ef2ac`.** CodeRabbit's review is anchored to that same -head, so this is not a "bots reviewed an older tree" situation — the tree simply changed -after the Codex reviewer's P1. The contributor's commit titled "address all Codex review -findings (5 P1 + 14 P2)" removed the -committed `src-tauri/target/**` tree; `001` §2.1 verifies zero matching paths remain. The -comment explicitly acknowledges that fix. Repeating the stale defect would be factually -wrong and would misrepresent a contributor who responded to review properly. - -The three remaining reasons Tauri was not adopted, and nothing else: no repository CI or -release attachment (`.github/` untouched, so no user can download a build), a materially -heavier build stack for a project whose premise is a single Bun process, and -`macOSPrivateApi: true` — a notarization and App-Store-rejection risk that `NSPopover` -avoids through public API. - -The four-tab layout became a single column with a bounded scrolling middle so the primary question — "is it -running?" — is answered without a click. - -Both comments state that the work is not discarded, point at this devlog unit, and invite -review of the maintainer branch. - -**Pre-send check:** re-read both PR heads immediately before posting. A closing comment -that describes a stale head is the one failure mode that cannot be corrected after the -fact, because the PR is closed by the same action. - -## Push - -```bash -git push -u origin feat/macos-app -``` - -Push is pre-approved by the user for this branch only (`cxc-loop` LOOP-GIT-01: push is -ESCALATE by default; the user's instruction "커밋쌓고 두개 클로즈 하고 푸시" is the -approval, scoped to `feat/macos-app`). - -**No PR is opened.** `.github/workflows/enforce-pr-target.yml` rewrites any PR not -targeting `dev` to `[WRONG BRANCH]` draft status. Opening one against `dev` is the -maintainer's call after reviewing the branch, and the user asked for a branch, not a PR. - -## Commit sequence - -One commit per phase, so `git log` reads as the build order: - -```text -docs(devlog): plan macOS menu bar companion (Phase 0 roadmap) -feat(app): add macOS menu bar core — discovery, client, formatting -feat(app): add menu bar status item and popover UI -feat(app): wire proxy control and provider toggles -feat(release): build and package the macOS companion -docs(macos): document the companion and Gatekeeper first launch -``` - -## Devlog path hygiene - -`scripts/privacy-scan.ts` excludes `devlog/`, so these documents are **not** covered by -the credential scan. That is a reason for more care, not less: absolute developer paths -(`/Users//...`) must not appear in tracked docs. Use repo-relative paths, or -`` as a placeholder, and redact home directories when quoting evidence from -another contributor's machine. - -## Accept criteria - -1. Guide source added in five locales, linked from the sidebar, no locale contradictions, - and `docs-site` builds with all five pages present. Public publication follows merge - and a Pages deployment; this phase delivers the branch, not the deploy. -2. `README.md`, `AGENTS.md`, `structure/00_overview.md` mention `app/`. -3. #387 and #421 `CLOSED` with the comments above, each verified against the PR's head - commit at the moment of posting. -4. `feat/macos-app` pushed; remote SHA equals local `HEAD`. -5. `bun run typecheck`, `bun run test`, `bun run privacy:scan` green on the final tree. -6. No absolute developer home path in any file this unit adds or modifies, including its - `devlog/` docs. Pre-existing paths in unrelated historical devlogs are out of scope. From a4f084af4c799b446ffb0e79f8d014ceb020b17f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 12:46:06 +0900 Subject: [PATCH 61/61] ci(release): gate package-macos on dispatch validation and align the artifact pin Two defects in the release jobs this pull request adds, both found in review of the workflow surface. package-macos had no needs:, so a dispatch that validate-dispatch would reject still spun up a macOS runner and packaged an asset. Every other job in the file gates on that validation; this one now does too. The blast radius was bounded - contents: read, no secrets, and the script's own version guard - but running at all on a rejected dispatch is not the design. The upload step pinned actions/upload-artifact at v5.0.0 while ci.yml already pins v7.0.1, leaving the repository with two pins for one action and pairing a v5 upload against the v8 download in attach-macos. Both now use the SHA ci.yml already trusts, which is also the pairing actions/download-artifact v8 expects. --- .github/workflows/release.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aaab5a0872..2c3c200258 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -70,6 +70,7 @@ jobs: } NODE package-macos: + needs: validate-dispatch runs-on: macos-latest timeout-minutes: 20 permissions: @@ -102,7 +103,7 @@ jobs: run: bash scripts/package-macos-release.sh - name: Upload the release asset - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: macos-release path: dist/release/