Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
d06cecc
docs(devlog): plan macOS menu bar companion (Phase 0 roadmap)
lidge-jun Jul 24, 2026
c8ea549
docs(devlog): fold 13 audit blockers into the macOS app roadmap
lidge-jun Jul 24, 2026
3a17e39
docs(devlog): fold round-2 audit blockers (design lock, bundle owners…
lidge-jun Jul 24, 2026
7db2d3c
docs(devlog): clear round-3 residual nits
lidge-jun Jul 24, 2026
2a8bc13
feat(app): add macOS menu bar core — discovery, client, formatting
lidge-jun Jul 24, 2026
36cdb13
fix(app): fold code-review blockers into the menu bar core
lidge-jun Jul 24, 2026
a740ec8
fix(app): per-request 401 retry and pressure-based quota selection
lidge-jun Jul 24, 2026
bb2d8be
feat(app): add menu bar status item and popover UI
lidge-jun Jul 24, 2026
18c2b39
fix(app): fold UI review blockers — states, keyboard, polling, glyph
lidge-jun Jul 24, 2026
d9f91b3
fix(app): make Escape work, top-anchor overflow, split data freshness
lidge-jun Jul 24, 2026
33f67d0
fix(app): replace NSPopover with a key-capable panel so Escape works
lidge-jun Jul 24, 2026
ced828b
fix(app): give the panel a real surface and keep it alive behind the …
lidge-jun Jul 24, 2026
4416915
fix(app): let the alert own Escape, and fix measured contrast failures
lidge-jun Jul 24, 2026
5bfeb18
fix(app): recalibrate all four text tiers against the rendered material
lidge-jun Jul 24, 2026
3e5b705
docs(app): note the material pixel variation and the semantic-colour …
lidge-jun Jul 24, 2026
988db7e
feat(app): wire proxy control and provider toggles
lidge-jun Jul 24, 2026
8c8324b
fix(app): distinguish liveness states and serialize provider writes
lidge-jun Jul 24, 2026
a0c6173
fix(app): only a refused connection proves the proxy stopped
lidge-jun Jul 24, 2026
f24fb86
fix(app): single-attempt liveness and a real refresh completion signal
lidge-jun Jul 24, 2026
ef72ab6
test(app): actually exercise the refresh continuation path
lidge-jun Jul 24, 2026
5d95f06
test(app): make the failing-cycle test actually consume a failure
lidge-jun Jul 24, 2026
d2622de
test(app): deterministic waiter registration and a UI test target
lidge-jun Jul 24, 2026
c4abf45
chore(app): narrow test hooks to package visibility
lidge-jun Jul 24, 2026
49ffc07
feat(release): build and package the macOS companion
lidge-jun Jul 24, 2026
8cbfe6d
fix(release): env-pass the release input, fix preview versions, guard…
lidge-jun Jul 24, 2026
32ea9e9
fix(release): honour Apple's actual version limits and drop the phant…
lidge-jun Jul 24, 2026
cdf638f
docs(release): sync the Phase 4 plan with what actually shipped
lidge-jun Jul 24, 2026
9a214ad
fix(release): normalise .. before the containment check, and cover it
lidge-jun Jul 24, 2026
468f3d3
fix(release): normalise before resolving, and stop the test deleting …
lidge-jun Jul 24, 2026
2d4c4cc
fix(release): refuse symlinks that do not resolve to a directory
lidge-jun Jul 24, 2026
de08775
docs(release): make the Phase 4 containment snippet honest and complete
lidge-jun Jul 24, 2026
0a6fcf5
docs(release): correct two counting errors in the Phase 4 criteria
lidge-jun Jul 24, 2026
19585b9
docs(macos): document the companion and the Gatekeeper first launch
lidge-jun Jul 24, 2026
6d9a85d
docs(macos): correct the API-key claim the app does not implement
lidge-jun Jul 24, 2026
7ccf737
docs(macos): stop pointing users at a Keychain item they cannot create
lidge-jun Jul 24, 2026
633f5ab
docs(app): align the Keychain comment with what the guides now say
lidge-jun Jul 24, 2026
af6ef54
ci: declare macos-app in the aggregate gate after the dev rebase
lidge-jun Sep 19, 2026
ffd97fc
feat: add usage timeline companion settings
lidge-jun Sep 19, 2026
2e92b98
feat(gui): companion section in Usage with live timeline preview
lidge-jun Sep 19, 2026
cd019e4
fix(companion): timeline cache isolation, other-fold aggregation, ocx…
lidge-jun Sep 19, 2026
c034c99
feat(app): settings-driven menu bar title, today metrics, timeline ch…
lidge-jun Sep 19, 2026
7788bc5
feat(widget): WidgetKit extension with small/medium/large families, p…
lidge-jun Sep 19, 2026
32a9223
chore: structure/docs/ci parity for the macOS companion
lidge-jun Sep 19, 2026
3396ca0
feat(widget): NSExtensionMain entry point, family-specific layouts, p…
lidge-jun Sep 19, 2026
b92b058
fix(cli): include companion in help banner
lidge-jun Sep 19, 2026
520466f
fix(ci): cover companion parity and GUI doctor findings
lidge-jun Sep 19, 2026
71119b8
fix(gui): defer companion loading and translate French labels
lidge-jun Sep 19, 2026
3ce7061
fix(gui): surface corrupt companion settings and correct the widget copy
lidge-jun Sep 19, 2026
d53ba8e
fix(gui): reset fieldset chrome on the companion controls
lidge-jun Sep 19, 2026
7a172c7
feat(companion): default the menu bar headline to tokens
lidge-jun Sep 20, 2026
9083031
feat(companion): integer token abbreviation (K/M/B, no decimals)
lidge-jun Sep 20, 2026
ca6c401
feat(gui): companion install card driven by app presence
lidge-jun Sep 20, 2026
dd19552
feat(gui): scrollable model list with switches for the companion chart
lidge-jun Sep 20, 2026
c5aed46
feat(app): Liquid Glass surfaces on macOS 26
lidge-jun Sep 20, 2026
8a07050
fix(companion): round integer token abbreviations and panel presence age
lidge-jun Sep 20, 2026
ead9538
docs(companion): update integer token examples
lidge-jun Sep 20, 2026
d1893d1
fix(companion): live presence refresh, tokens headline in the small w…
lidge-jun Sep 20, 2026
b22b88f
fix(companion): stable model ordering, tokens-first today row
lidge-jun Sep 20, 2026
90ae1d0
fix(companion): address macOS widget review findings
lidge-jun Sep 20, 2026
72063f7
docs(devlog): drop the duplicate _plan copy of the closed macOS unit
lidge-jun Sep 20, 2026
a4f084a
ci(release): gate package-macos on dispatch validation and align the …
lidge-jun Sep 20, 2026
a09d7ae
Merge remote-tracking branch 'origin/dev' into feat/macos-app-merge
lidge-jun Sep 20, 2026
9eb033e
Merge origin/dev into feat/macos-app
codex Sep 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ on:
- "bin/**"
- "tests/**"
- "scripts/**"
- "app/**"
- "gui/**"
- "assets/**"
- ".gitattributes"
Expand Down Expand Up @@ -213,6 +214,7 @@ jobs:
- 'bin/**'
- 'tests/**'
- 'scripts/**'
- 'app/**'
- 'gui/**'
- 'assets/**'
- '.gitattributes'
Expand Down Expand Up @@ -1147,14 +1149,40 @@ 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()
# Every producer, including the ones that only feed other jobs. `needs` holds
# 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:
Expand Down Expand Up @@ -1220,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" ;;
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,75 @@ jobs:
process.exit(1);
}
NODE
package-macos:
needs: validate-dispatch
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"
# 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 }}
# 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
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
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 }}
# 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${RELEASE_VERSION}" dist/release/* --clobber

publish:
needs: validate-dispatch
runs-on: ubuntu-latest
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,5 @@ go/

# Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts.
native/**/target/
dist/macos/
dist/release/
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,21 @@ 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. 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.

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
Expand Down
4 changes: 4 additions & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.build/
.swiftpm/
*.xcodeproj
DerivedData/
42 changes: 42 additions & 0 deletions app/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>OpenCodexMenuBar</string>
<key>CFBundleIdentifier</key>
<string>com.opencodex.menubar</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>OpenCodex</string>
<key>CFBundleDisplayName</key>
<string>OpenCodex</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>OpenCodex</string>
<key>CFBundleShortVersionString</key>
<string>0.0.0</string>
<key>CFBundleVersion</key>
<string>0.0.0</string>
<key>LSUIElement</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<!-- macOS 14 stopped allowing IP-address loads under App Transport Security by
default. The proxy is always plain HTTP on loopback, so without this the
packaged bundle cannot reach it at all — while `swift run` stays green because
it does not run under this plist. Local networking only; ATS stays enforced
for every non-local destination. -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHumanReadableCopyright</key>
<string>MIT — opencodex contributors</string>
</dict>
</plist>
55 changes: 55 additions & 0 deletions app/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
name: "OpenCodexMenuBar",
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"]),
.executable(name: "IconProbe", targets: ["IconProbe"]),
],
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", "MenuBarUI"],
path: "Sources/MenuBarApp"
),
.executableTarget(
name: "OpenCodexWidget",
dependencies: ["MenuBarCore"],
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
// cannot run without a full Xcode install. See Sources/MenuBarCoreTests/Harness.swift.
.executableTarget(
name: "MenuBarCoreTests",
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"),
],
swiftLanguageVersions: [.v5]
)
32 changes: 32 additions & 0 deletions app/Sources/IconProbe/main.swift
Original file line number Diff line number Diff line change
@@ -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: ", "))
11 changes: 11 additions & 0 deletions app/Sources/MenuBarApp/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import AppKit
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()
Loading
Loading