diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5954a569708..83a4f9c2d0c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1151,8 +1151,8 @@ 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
+ widget:
+ name: macos widget
needs: [changes, gates]
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: macos-latest
@@ -1171,11 +1171,16 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- - name: Test macOS menu bar app
+ - name: Test MenuBarCore
run: bun run test:macos
- - name: Build macOS menu bar app
- run: bun run build:macos
+ - name: Build WidgetKit appex
+ run: bash desktop/scripts/build-widget.sh
+
+ - name: Verify WidgetKit appex
+ run: |
+ test -x desktop/src-tauri/widget/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget
+ codesign -dv desktop/src-tauri/widget/OpenCodexWidget.appex
desktop-shell:
name: desktop shell
@@ -1225,7 +1230,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, macos-app, desktop-shell]
+ 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, widget, desktop-shell]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
@@ -1291,13 +1296,13 @@ 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 macos-app"
+ GATED_JOBS="$GATED_JOBS structure-gate widget"
GATED_JOBS="$GATED_JOBS desktop-shell"
expected_for() {
case "$1" in
changes|select-windows-runner) echo requested ;;
- test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|macos-app)
+ test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|widget)
echo "$scoped" ;;
desktop-shell)
echo "$scoped" ;;
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index a15d56d4042..cb206c437e2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -69,47 +69,6 @@ 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
-
package-standalone:
needs: validate-dispatch
strategy:
@@ -212,20 +171,14 @@ jobs:
if-no-files-found: error
retention-days: 7
- attach-macos:
+ attach-standalone:
runs-on: ubuntu-latest
- needs: [publish, package-macos, package-standalone]
+ needs: [publish, package-standalone]
if: ${{ inputs.dry-run != true }}
timeout-minutes: 10
permissions:
contents: write
steps:
- - name: Download the macOS packaged asset
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- with:
- name: macos-release
- path: dist/release
-
- name: Download standalone packaged assets
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
diff --git a/.gitignore b/.gitignore
index 7973851d2cd..8f9c5f9fd59 100644
--- a/.gitignore
+++ b/.gitignore
@@ -74,5 +74,6 @@ dist/macos/
dist/release/
desktop/src-tauri/binaries/
desktop/src-tauri/resources/
+desktop/src-tauri/widget/
desktop/src-tauri/gen/
desktop/src-tauri/target/
diff --git a/AGENTS.md b/AGENTS.md
index 78a63a33602..5fc447e7c3c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -27,9 +27,8 @@ 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
+- `app/` — native macOS WidgetKit extension bundled into the Tauri desktop app;
+ `MenuBarCore` is its snapshot model/formatting layer. 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.
diff --git a/README.md b/README.md
index 88a6438b3dc..8fb4456fd33 100644
--- a/README.md
+++ b/README.md
@@ -91,13 +91,13 @@ Open **http://localhost:10100** and configure everything in the web dashboard
(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
+### macOS desktop app and widget
-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
+A native desktop app and WidgetKit extension for proxy status, usage, and provider
+quotas without opening the dashboard. The snapshot model lives in [`app/`](./app)
+(`MenuBarCore`). Download it from the
[releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with
-`bun run build:macos`.
+`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`.
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/)
diff --git a/app/Info.plist b/app/Info.plist
deleted file mode 100644
index 3d52873063f..00000000000
--- a/app/Info.plist
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
- 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
-
- NSAppTransportSecurity
-
- NSAllowsLocalNetworking
-
-
- NSHumanReadableCopyright
- MIT — opencodex contributors
-
-
diff --git a/app/Package.swift b/app/Package.swift
index 9e5f1372751..0e14e3bd5b8 100644
--- a/app/Package.swift
+++ b/app/Package.swift
@@ -2,26 +2,14 @@
import PackageDescription
let package = Package(
- name: "OpenCodexMenuBar",
+ name: "OpenCodexWidget",
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"],
@@ -41,15 +29,6 @@ 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"),
],
swiftLanguageVersions: [.v5]
)
diff --git a/app/Sources/IconProbe/main.swift b/app/Sources/IconProbe/main.swift
deleted file mode 100644
index f9a359114d5..00000000000
--- a/app/Sources/IconProbe/main.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-// 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/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift
deleted file mode 100644
index 710ac8c51b9..00000000000
--- a/app/Sources/MenuBarApp/main.swift
+++ /dev/null
@@ -1,11 +0,0 @@
-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()
diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift
index 412a5ff5c0b..23b34e1d0c3 100644
--- a/app/Sources/MenuBarCore/Discovery.swift
+++ b/app/Sources/MenuBarCore/Discovery.swift
@@ -57,7 +57,7 @@ public enum ProxyDiscovery {
/// 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
+ /// the default rather than throwing. A desktop 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")
diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift
index 4125657a62b..5154fd7e835 100644
--- a/app/Sources/MenuBarCore/PollingCoordinator.swift
+++ b/app/Sources/MenuBarCore/PollingCoordinator.swift
@@ -2,7 +2,7 @@ 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
+/// Polling is deliberately conservative. A desktop 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.
diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift
index 53188278140..e9a31984244 100644
--- a/app/Sources/MenuBarCore/ProxyClient.swift
+++ b/app/Sources/MenuBarCore/ProxyClient.swift
@@ -275,7 +275,7 @@ public actor ProxyClient {
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")
+ request.setValue("OpenCodexWidget/\(version)", forHTTPHeaderField: "User-Agent")
if let credential = key ?? apiKey {
request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key")
}
diff --git a/app/Sources/MenuBarCore/WidgetSnapshot.swift b/app/Sources/MenuBarCore/WidgetSnapshot.swift
index 3dc4f9cd989..6ea48c89783 100644
--- a/app/Sources/MenuBarCore/WidgetSnapshot.swift
+++ b/app/Sources/MenuBarCore/WidgetSnapshot.swift
@@ -127,7 +127,7 @@ public final class WidgetSnapshotStore: @unchecked Sendable {
private var loggedFailures = Set()
public init(
- widgetBundleID: String = "com.opencodex.menubar.widget",
+ widgetBundleID: String = "com.opencodex.desktop.widget",
fileManager: FileManager = .default,
homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
) {
diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift
index 570e10bdeab..d46a667e0fe 100644
--- a/app/Sources/MenuBarCoreTests/TransportSuite.swift
+++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift
@@ -283,7 +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.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexWidget/dev")
}
t.test("requests: the provider patch sends exactly {\"disabled\":true}") {
diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift
deleted file mode 100644
index 7ca5c1d8e29..00000000000
--- a/app/Sources/MenuBarUI/AppDelegate.swift
+++ /dev/null
@@ -1,254 +0,0 @@
-import AppKit
-import MenuBarCore
-
-public final class AppDelegate: NSObject, NSApplicationDelegate {
- private var statusItem: NSStatusItem?
- /// 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 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?
- private var endpoint = ProxyEndpoint.default
- private var pollTask: Task?
- /// 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() }
-
- 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
- self.actions = ActionCoordinator(client: client)
-
- 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.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() }
- 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
- 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
- // 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()
- removeEscapeMonitor()
- panel.dismiss()
- }
-
- // 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) {
- 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
-
- #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 }
- if panel.isShown {
- panel.dismiss()
- } else {
- panel.present(from: button)
- installEscapeMonitor()
- Task { [coordinator] in await coordinator?.setPopoverOpen(true) }
- }
- }
-
- /// 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
- // 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
- }
- }
-
-
- private func removeEscapeMonitor() {
- if let monitor = escapeMonitor { NSEvent.removeMonitor(monitor) }
- escapeMonitor = nil
- }
-
- private func openDashboard() {
- 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() {
- 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")
-
- // 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)
- let confirmed = alert.runModal() == .alertFirstButtonReturn
- panel.isPresentingModal = false
-
- guard confirmed else {
- panel.makeKeyAndOrderFront(nil)
- return
- }
-
- 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 .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)
- }
- }
- }
- }
-
- /// 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
- controller.setProviderBusy(name, true, intended: !disable)
-
- 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):
- 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. 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/CompanionViews.swift b/app/Sources/MenuBarUI/CompanionViews.swift
deleted file mode 100644
index a23c4bf42c9..00000000000
--- a/app/Sources/MenuBarUI/CompanionViews.swift
+++ /dev/null
@@ -1,78 +0,0 @@
-import AppKit
-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([
- 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) {
- clearRows()
- 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 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([
- 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) {
- clearRows()
- 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 clearRows() {
- for view in stack.arrangedSubviews.dropFirst() { stack.removeArrangedSubview(view); view.removeFromSuperview() }
- }
-}
diff --git a/app/Sources/MenuBarUI/PopoverPanel.swift b/app/Sources/MenuBarUI/PopoverPanel.swift
deleted file mode 100644
index 5c579f88be9..00000000000
--- a/app/Sources/MenuBarUI/PopoverPanel.swift
+++ /dev/null
@@ -1,170 +0,0 @@
-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
- }
-
- /// 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 }
- contentView = PopoverSurface.make(content: content)
- }
- }
-
- /// 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 }
-
- 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() {
- // Idempotent: a late monitor callback must not re-run teardown.
- guard isVisible else { return }
- 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 — unless we put the focus elsewhere
- // ourselves by presenting a confirmation.
- guard !isPresentingModal else { return }
- if isVisible { dismiss() }
- }
-
- private func layoutContent() {
- contentViewController?.view.layoutSubtreeIfNeeded()
- }
-}
-
-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
- glass.style = .regular
- glass.contentView = content
- surface = glass
- } else {
- surface = makeMaterialSurface(content: content)
- }
-#else
- surface = makeMaterialSurface(content: content)
-#endif
-
- 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
- }
-
- 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/PopoverViewController.swift b/app/Sources/MenuBarUI/PopoverViewController.swift
deleted file mode 100644
index 1cc8dd51ae4..00000000000
--- a/app/Sources/MenuBarUI/PopoverViewController.swift
+++ /dev/null
@@ -1,384 +0,0 @@
-import AppKit
-import MenuBarCore
-
-/// 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 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 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()
- /// 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)
- // 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 metricsSeparator = makeSeparator()
- private let quotaSeparator = makeSeparator()
-
- public var onDashboard: (() -> Void)?
- public var onCompanionSettings: (() -> Void)?
- 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)?
- public var onRetry: (() -> Void)?
-
- 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, timelineChart, metricsSeparator, models, quotaStack, quotaEmpty,
- accounts, providers, quotaSeparator, resultBanner, guidanceLabel, commandField],
- in: .top
- )
- 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
- scrollView.drawsBackground = false
- scrollView.borderType = .noBorder
- scrollView.translatesAutoresizingMaskIntoConstraints = false
-
- 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(), scrollView, 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
-
- 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 heightConstraint = scrollView.heightAnchor.constraint(equalToConstant: 120)
- heightConstraint.isActive = true
- scrollHeight = heightConstraint
-
- view = root
- }
-
- private func configureControls() {
- 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)
-
- 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
- overflowButton.font = Theme.caption
- 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)
-
- 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)
- timelineChart.apply(snapshot)
- models.apply(snapshot)
- accounts.apply(snapshot)
- applyQuotas(snapshot)
- providers.apply(snapshot)
- } else {
- timelineChart.isHidden = true
- models.isHidden = true
- accounts.isHidden = true
- quotaStack.isHidden = true
- quotaEmpty.isHidden = true
- providers.isHidden = true
- }
-
- applyGuidance(snapshot)
- applyActions(snapshot, isLoading: isLoading)
- resize()
- }
-
- private func applyQuotas(_ snapshot: ProxySnapshot) {
- for view in quotaStack.arrangedSubviews {
- quotaStack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- let rows = snapshot.quotaRows
- quotaStack.isHidden = rows.isEmpty
- // "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
- quotaStack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: quotaStack.widthAnchor).isActive = true
- }
- }
-
- /// 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)
- }
-
- public func setProviderBusy(_ name: String, _ busy: Bool, intended: Bool? = nil) {
- providers.setBusy(name, busy, intended: intended)
- }
-
- /// 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) {
- var guidance: String?
- var command: String?
-
- switch snapshot.nextAction {
- case .none:
- // 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:
- guidance = "This proxy is bound to a non-loopback address and needs a key."
- case .retry:
- guidance = snapshot.dataAge.map { "Showing data from \(Format.age($0)). Retrying automatically." }
- ?? "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() {
- switch snapshot?.nextAction {
- case .addAPIKey: onAddKey?()
- case .retry: onRetry?()
- default: break
- }
- }
- @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(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.
- public override func cancelOperation(_ sender: Any?) {
- view.window?.performClose(nil)
- }
-}
-
-/// 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 {
- override var intrinsicContentSize: NSSize {
- NSSize(width: NSView.noIntrinsicMetric, height: 84)
- }
-
- 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
- }
- }
-}
diff --git a/app/Sources/MenuBarUI/ProviderListView.swift b/app/Sources/MenuBarUI/ProviderListView.swift
deleted file mode 100644
index c0d5b6dc0b2..00000000000
--- a/app/Sources/MenuBarUI/ProviderListView.swift
+++ /dev/null
@@ -1,255 +0,0 @@
-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.
-public 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?
- /// 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)`.
- public var onToggle: ((String, Bool) -> Void)?
-
- public override init(frame: NSRect) {
- super.init(frame: frame)
-
- 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),
- ])
- }
-
- public convenience init() { self.init(frame: .zero) }
-
- public required init?(coder: NSCoder) { nil }
-
- public 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.visibleProviders.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)
- }
- // 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
- }
- }
-
- /// 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
- 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.
- 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)
- 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.
- /// `intended` is the state the user selected, retained so a poll landing mid-write
- /// cannot snap the switch back.
- public 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)
- }
- }
-}
-
-public final class ProviderRowView: NSView {
- public 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
- 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 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])
- 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 }
-
- 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
- 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)
- }
-}
-
-
-// MARK: - Test inspection
-
-/// Read-only hooks so the UI suite can assert on rendered control state rather than on
-/// the view's private bookkeeping.
-package 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 }
- 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 {
- return row
- }
- return nil
- }
-}
-
-package extension ProviderRowView {
- var isOn: Bool { toggleState }
- var isToggleEnabled: Bool { toggleIsEnabled }
-}
diff --git a/app/Sources/MenuBarUI/StatusIcon.swift b/app/Sources/MenuBarUI/StatusIcon.swift
deleted file mode 100644
index e7eb2951b17..00000000000
--- a/app/Sources/MenuBarUI/StatusIcon.swift
+++ /dev/null
@@ -1,73 +0,0 @@
-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.
-public enum StatusIcon {
- public static let size = NSSize(width: 17, height: 17)
-
- 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)
- 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 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)
-
- 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()
-
- if filled {
- path.fill()
- } else {
- path.lineWidth = 1.6
- path.stroke()
- }
- return true
- }
- image.isTemplate = true
- return image
- }
-}
diff --git a/app/Sources/MenuBarUI/Theme.swift b/app/Sources/MenuBarUI/Theme.swift
deleted file mode 100644
index b1e8b4241f4..00000000000
--- a/app/Sources/MenuBarUI/Theme.swift
+++ /dev/null
@@ -1,103 +0,0 @@
-import AppKit
-
-/// Tokens derived from `gui/src/styles.css` so the companion and the dashboard agree on
-/// what "healthy" looks like.
-///
-/// 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
- 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.
- /// 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: 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)
- 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/TimelineChartView.swift b/app/Sources/MenuBarUI/TimelineChartView.swift
deleted file mode 100644
index 56a8a6288f1..00000000000
--- a/app/Sources/MenuBarUI/TimelineChartView.swift
+++ /dev/null
@@ -1,135 +0,0 @@
-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: 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)
- 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)
- }
-
- drawLegend(timeline, in: NSRect(x: 0, y: 0, width: bounds.width, height: 14))
- }
-
- 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 {
- 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/MenuBarUI/Views.swift b/app/Sources/MenuBarUI/Views.swift
deleted file mode 100644
index 70c229b6859..00000000000
--- a/app/Sources/MenuBarUI/Views.swift
+++ /dev/null
@@ -1,264 +0,0 @@
-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 = ["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))
- }
-
- 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.today ?? snapshot.usage
- isHidden = !snapshot.settings.showToday
- rangeLabel.stringValue = usage?.rangeLabel ?? "USAGE"
- columnsRow.arrangedSubviews[2].isHidden = !snapshot.settings.showCost
-
- // 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 = 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(
- "\(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"
- )
- }
- }
-}
-
-// 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/MenuBarUITests/Harness.swift b/app/Sources/MenuBarUITests/Harness.swift
deleted file mode 100644
index 0deb1d6ae46..00000000000
--- a/app/Sources/MenuBarUITests/Harness.swift
+++ /dev/null
@@ -1,105 +0,0 @@
-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
deleted file mode 100644
index daf7e872b20..00000000000
--- a/app/Sources/MenuBarUITests/main.swift
+++ /dev/null
@@ -1,165 +0,0 @@
-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")
-}
-
-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/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift
index b130d1fda59..edc91171f44 100644
--- a/app/Sources/OpenCodexWidget/Views.swift
+++ b/app/Sources/OpenCodexWidget/Views.swift
@@ -293,8 +293,8 @@ struct OpenCodexWidgetView: View {
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.")
+ ? "Open the OpenCodex desktop app to start sharing usage."
+ : "Snapshot unreadable — refresh from the desktop app.")
.font(.caption)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift
deleted file mode 100644
index 02eca1592b3..00000000000
--- a/app/Sources/UIProbe/main.swift
+++ /dev/null
@@ -1,165 +0,0 @@
-// Visual-QA harness (not shipped).
-//
-// 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.
-//
-// 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
-import MenuBarUI
-
-// 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?
-
- 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)
-
- // 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)
- 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 {
- 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 "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":"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, settings: CompanionSettings(menuBarMetric: .tokens),
- today: usage, timeline: timeline,
- quotas: quotas,
- quotasLoaded: true)
- case "empty":
- let usage = try? JSONDecoder().decode(
- UsageReport.self,
- from: Data(#"{"range":"today","summary":{"requests":0},"models":[],"accounts":[]}"#.utf8))
- snap = ProxySnapshot(state: .running(StartupHealth(status: "protected", protection: "service")),
- endpoint: endpoint, usage: usage, today: usage, quotas: [], providers: [],
- providersLoaded: true, quotasLoaded: true)
- default:
- await coordinator.setPopoverOpen(true)
- snap = await coordinator.current
- }
- 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
- 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 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"
- // 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)
- 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)
- }
-}
-
-let app = NSApplication.shared
-app.setActivationPolicy(.regular)
-let d = ProbeDelegate()
-app.delegate = d
-app.run()
diff --git a/app/Widget-Info.plist b/app/Widget-Info.plist
index 360a1591031..568e5fce750 100644
--- a/app/Widget-Info.plist
+++ b/app/Widget-Info.plist
@@ -4,7 +4,7 @@
CFBundleDevelopmentRegionen
CFBundleExecutableOpenCodexWidget
- CFBundleIdentifiercom.opencodex.menubar.widget
+ CFBundleIdentifiercom.opencodex.desktop.widget
CFBundleInfoDictionaryVersion6.0
CFBundleNameOpenCodex
CFBundlePackageTypeXPC!
diff --git a/desktop/README.md b/desktop/README.md
index 1367889fe05..e4621ef6e75 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -5,7 +5,8 @@ in the proxy's loopback origin. During development:
```sh
bun run prepare-sidecar
-bun run dev
+bun run prepare-widget
+bunx tauri dev
```
The sidecar is generated from the repository's standalone binary build and is
@@ -15,3 +16,12 @@ The CI desktop-shell job performs Rust-only checks. It creates an empty
platform-named sidecar stub and a placeholder dashboard resource directory
solely for Tauri's external-binary and resource validation; it does not build
or run the standalone binary.
+
+For a macOS release build, prepare the sidecar and WidgetKit extension before invoking
+Tauri:
+
+```sh
+bun run prepare-sidecar
+bun run prepare-widget
+bunx tauri build
+```
diff --git a/desktop/package.json b/desktop/package.json
index 8b1bc197495..09668ce09b7 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -4,7 +4,8 @@
"scripts": {
"dev": "tauri dev",
"build": "tauri build",
- "prepare-sidecar": "bun scripts/prepare-sidecar.ts"
+ "prepare-sidecar": "bun scripts/prepare-sidecar.ts",
+ "prepare-widget": "bash scripts/build-widget.sh"
},
"devDependencies": {
"@tauri-apps/cli": "2.5.0"
diff --git a/desktop/scripts/build-widget.sh b/desktop/scripts/build-widget.sh
new file mode 100755
index 00000000000..59827dcea2a
--- /dev/null
+++ b/desktop/scripts/build-widget.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "$(uname -s)" != "Darwin" ]]; then
+ echo "prepare-widget requires macOS." >&2
+ exit 1
+fi
+
+script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+desktop_dir="$(cd "$script_dir/.." && pwd)"
+repo_root="$(cd "$desktop_dir/.." && pwd)"
+package_dir="$repo_root/app"
+output_dir="$desktop_dir/src-tauri/widget/OpenCodexWidget.appex"
+configuration="${CONFIGURATION:-release}"
+universal="${UNIVERSAL:-1}"
+
+if [[ "$universal" != "0" && "$universal" != "1" ]]; then
+ echo "UNIVERSAL must be 0 or 1." >&2
+ exit 1
+fi
+
+build_root="$(mktemp -d "${TMPDIR:-/tmp}/opencodex-widget.XXXXXX")"
+cleanup() { rm -rf "$build_root"; }
+trap cleanup EXIT
+
+build_widget() {
+ local arch="$1"
+ local scratch="$build_root/$arch"
+ swift build \
+ --package-path "$package_dir" \
+ --scratch-path "$scratch" \
+ -c "$configuration" \
+ --arch "$arch" \
+ --product OpenCodexWidget
+ swift build \
+ --package-path "$package_dir" \
+ --scratch-path "$scratch" \
+ -c "$configuration" \
+ --arch "$arch" \
+ --show-bin-path
+}
+
+if [[ "$universal" == "1" ]]; then
+ arm64_bin="$(build_widget arm64 | tail -n 1)/OpenCodexWidget"
+ x86_64_bin="$(build_widget x86_64 | tail -n 1)/OpenCodexWidget"
+ executable="$build_root/OpenCodexWidget"
+ lipo -create "$arm64_bin" "$x86_64_bin" -output "$executable"
+else
+ executable="$(build_widget "$(uname -m)" | tail -n 1)/OpenCodexWidget"
+fi
+
+[[ -x "$executable" ]] || { echo "Swift build did not produce $executable" >&2; exit 1; }
+
+rm -rf "$output_dir"
+mkdir -p "$output_dir/Contents/MacOS"
+cp "$executable" "$output_dir/Contents/MacOS/OpenCodexWidget"
+cp "$package_dir/Widget-Info.plist" "$output_dir/Contents/Info.plist"
+
+version="$(sed -n 's/^[[:space:]]*"version": "\([^"]*\)",/\1/p' "$desktop_dir/src-tauri/tauri.conf.json" | head -n 1)"
+version_core="${version%%-*}"
+[[ "$version_core" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || {
+ echo "Invalid Tauri version: $version" >&2
+ exit 1
+}
+plutil -replace CFBundleShortVersionString -string "$version_core" "$output_dir/Contents/Info.plist"
+plutil -replace CFBundleVersion -string "$version_core" "$output_dir/Contents/Info.plist"
+
+if [[ -n "${MACOS_SIGN_IDENTITY:-}" ]]; then
+ codesign --force --sign "$MACOS_SIGN_IDENTITY" --entitlements "$package_dir/Widget.entitlements" \
+ --timestamp "$output_dir"
+else
+ codesign --force --sign - --entitlements "$package_dir/Widget.entitlements" \
+ --timestamp=none "$output_dir"
+fi
+
+echo "$output_dir"
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 22861cd63e7..bc5c4cf389b 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -2504,6 +2504,7 @@ dependencies = [
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio",
+ "uuid",
]
[[package]]
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index 9f0c0879c57..49ecab73e5a 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -18,6 +18,7 @@ tauri-build = { version = "=2.6.3", features = [] }
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "=1.0.219", features = ["derive"] }
serde_json = "=1.0.140"
+uuid = { version = "=1.18.1", features = ["v4"] }
tauri = { version = "=2.11.6", features = ["tray-icon", "image-png"] }
tauri-plugin-autostart = "=2.5.0"
tauri-plugin-opener = "=2.5.3"
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index bea1d5d8529..cc1758de3a2 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -4,6 +4,7 @@ mod formatting;
mod proxy;
mod sidecar;
mod tray;
+mod widget;
mod window;
use std::sync::{
diff --git a/desktop/src-tauri/src/proxy.rs b/desktop/src-tauri/src/proxy.rs
index 5101b73d6aa..fc8dc71ec5e 100644
--- a/desktop/src-tauri/src/proxy.rs
+++ b/desktop/src-tauri/src/proxy.rs
@@ -46,10 +46,22 @@ impl ProxyClient {
self.get("/api/usage?range=7d").await
}
+ pub async fn usage_today(&self) -> Result {
+ self.get("/api/usage?range=today").await
+ }
+
+ pub async fn startup_health(&self) -> Result {
+ self.get("/api/startup-health").await
+ }
+
pub async fn quotas(&self) -> Result {
self.get("/api/provider-quotas").await
}
+ pub async fn timeline(&self, query: &str) -> Result {
+ self.get(&format!("/api/usage/timeline?{query}")).await
+ }
+
pub async fn stop(&self) -> Result {
self.request(Method::POST, "/api/stop").await
}
diff --git a/desktop/src-tauri/src/tray.rs b/desktop/src-tauri/src/tray.rs
index 740231d0d48..148b0262175 100644
--- a/desktop/src-tauri/src/tray.rs
+++ b/desktop/src-tauri/src/tray.rs
@@ -1,4 +1,4 @@
-use crate::{formatting, proxy::ProxyClient, window};
+use crate::{formatting, proxy::ProxyClient, widget, window};
use serde_json::Value;
use std::sync::atomic::Ordering;
use tauri::{
@@ -102,11 +102,17 @@ pub fn install(app: &AppHandle, proxy: ProxyClient) -> tauri::Result<()> {
.build(app)?;
refresh_title(&tray, &proxy);
+ widget::refresh(&proxy);
let tray = tray.clone();
tauri::async_runtime::spawn(async move {
+ let mut tick = 0;
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
refresh_title(&tray, &proxy);
+ tick += 1;
+ if tick % 5 == 0 {
+ widget::refresh(&proxy);
+ }
}
});
Ok(())
@@ -129,7 +135,7 @@ fn refresh_title(tray: &tauri::tray::TrayIcon, proxy: &ProxyClient) {
});
}
-fn render_title(settings: &Value, usage: &Value, quotas: &Value) -> Option {
+pub(crate) fn render_title(settings: &Value, usage: &Value, quotas: &Value) -> Option {
let metric = settings
.pointer("/settings/menuBarMetric")
.and_then(Value::as_str)
diff --git a/desktop/src-tauri/src/widget.rs b/desktop/src-tauri/src/widget.rs
new file mode 100644
index 00000000000..cc2c78c0f6f
--- /dev/null
+++ b/desktop/src-tauri/src/widget.rs
@@ -0,0 +1,529 @@
+#[cfg(target_os = "macos")]
+mod macos {
+ use crate::{
+ proxy::{ProxyClient, ProxyError},
+ tray,
+ };
+ use serde::Serialize;
+ use serde_json::{json, Value};
+ use std::{
+ collections::HashSet,
+ fs,
+ path::PathBuf,
+ sync::{Mutex, OnceLock},
+ time::{SystemTime, UNIX_EPOCH},
+ };
+ use uuid::Uuid;
+
+ #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)]
+ #[serde(rename_all = "camelCase")]
+ struct Today {
+ requests: Option,
+ total_tokens: Option,
+ estimated_cost_usd: Option,
+ }
+
+ #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)]
+ #[serde(rename_all = "camelCase")]
+ struct Quota {
+ provider_label: String,
+ window_label: String,
+ percent: Option,
+ reset_at: Option,
+ }
+
+ #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)]
+ #[serde(rename_all = "camelCase")]
+ struct Series {
+ id: String,
+ points: Vec,
+ }
+
+ #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)]
+ #[serde(rename_all = "camelCase")]
+ struct Chart {
+ start: f64,
+ bucket_seconds: i64,
+ style: String,
+ series: Vec,
+ }
+
+ #[derive(Debug, Serialize, serde::Deserialize, Clone, PartialEq)]
+ #[serde(rename_all = "camelCase")]
+ struct Snapshot {
+ schema_version: i64,
+ state: String,
+ state_title: String,
+ detail: Option,
+ endpoint_display: String,
+ menu_title: Option,
+ today: Option,
+ quotas: Vec,
+ chart: Option,
+ last_updated: Option,
+ generated_at: f64,
+ }
+
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
+ enum ErrorKind {
+ Unreachable,
+ Unauthorized,
+ Http,
+ Decode,
+ }
+
+ fn state_for_error(
+ kind: ErrorKind,
+ detail: Option,
+ ) -> (&'static str, &'static str, Option) {
+ match kind {
+ ErrorKind::Unreachable => (
+ "unreachable",
+ "Stopped",
+ Some("The proxy is not running.".into()),
+ ),
+ ErrorKind::Unauthorized => (
+ "unauthorized",
+ "Needs API key",
+ Some("This proxy requires an API key.".into()),
+ ),
+ ErrorKind::Http | ErrorKind::Decode => ("degraded", "Degraded", detail),
+ }
+ }
+
+ fn proxy_error(error: &ProxyError) -> (ErrorKind, Option) {
+ match error {
+ ProxyError::Unreachable => (ErrorKind::Unreachable, None),
+ ProxyError::Unauthorized => (ErrorKind::Unauthorized, None),
+ ProxyError::Http(status) => (ErrorKind::Http, Some(format!("HTTP {status}"))),
+ ProxyError::Decode(error) => (ErrorKind::Decode, Some(error.to_string())),
+ }
+ }
+
+ fn number(value: Option<&Value>) -> Option {
+ value.and_then(Value::as_f64)
+ }
+
+ fn integer(value: Option<&Value>) -> Option {
+ value.and_then(Value::as_i64)
+ }
+
+ fn reset_at(value: Option<&Value>) -> Option {
+ let value = number(value)?;
+ Some(if value >= 1_000_000_000_000.0 {
+ value / 1000.0
+ } else {
+ value
+ })
+ }
+
+ fn quotas(value: &Value) -> Vec {
+ let Some(reports) = value.get("reports").and_then(Value::as_array) else {
+ return Vec::new();
+ };
+ let mut rows = Vec::new();
+ for report in reports {
+ let provider_label = report
+ .get("label")
+ .or_else(|| report.get("provider"))
+ .and_then(Value::as_str)
+ .unwrap_or("unknown")
+ .to_owned();
+ let Some(quota) = report.get("quota") else {
+ continue;
+ };
+ let mut push = |percent: Option<&Value>, window_label: &str, reset: Option<&Value>| {
+ if percent.is_some() || reset.is_some() {
+ rows.push(Quota {
+ provider_label: provider_label.clone(),
+ window_label: window_label.to_owned(),
+ percent: number(percent),
+ reset_at: reset_at(reset),
+ });
+ }
+ };
+ push(
+ quota.get("fiveHourPercent"),
+ "5h",
+ quota.get("fiveHourResetAt"),
+ );
+ push(
+ quota.get("weeklyPercent"),
+ "week",
+ quota.get("weeklyResetAt"),
+ );
+ push(
+ quota.get("monthlyPercent"),
+ "month",
+ quota.get("monthlyResetAt"),
+ );
+ if let Some(windows) = quota.get("customWindows").and_then(Value::as_array) {
+ for window in windows {
+ let label = window
+ .get("label")
+ .and_then(Value::as_str)
+ .unwrap_or("window");
+ push(window.get("percent"), label, window.get("resetAt"));
+ }
+ }
+ }
+ rows
+ }
+
+ fn chart(value: &Value, settings: &Value) -> Option {
+ let start = number(value.get("start"))?;
+ let bucket_seconds = integer(value.get("bucketSeconds"))?;
+ let settings = settings.get("settings").unwrap_or(settings);
+ let style = settings
+ .get("chartStyle")
+ .and_then(Value::as_str)
+ .unwrap_or("line")
+ .to_owned();
+ let series = value
+ .get("series")
+ .and_then(Value::as_array)?
+ .iter()
+ .take(6)
+ .filter_map(|item| {
+ Some(Series {
+ id: item.get("id")?.as_str()?.to_owned(),
+ points: item
+ .get("points")?
+ .as_array()?
+ .iter()
+ .filter_map(Value::as_f64)
+ .collect(),
+ })
+ })
+ .collect();
+ Some(Chart {
+ start,
+ bucket_seconds,
+ style,
+ series,
+ })
+ }
+
+ fn timeline_query(settings: &Value) -> String {
+ let settings = settings.get("settings").unwrap_or(settings);
+ let get = |key: &str, fallback: &str| {
+ settings
+ .get(key)
+ .and_then(Value::as_str)
+ .unwrap_or(fallback)
+ .to_owned()
+ };
+ let hours = settings
+ .get("chartHours")
+ .and_then(Value::as_i64)
+ .unwrap_or(24);
+ let bucket_minutes = settings
+ .get("bucketMinutes")
+ .and_then(Value::as_i64)
+ .unwrap_or(60);
+ let metric = get("tokenMetric", "total");
+ let aggregation = get("aggregation", "sum");
+ let grouping = get("chartGrouping", "model");
+ let mut query = format!(
+ "hours={hours}&bucketMinutes={bucket_minutes}&metric={metric}&aggregation={aggregation}&grouping={grouping}"
+ );
+ if let Some(models) = settings.get("models").and_then(Value::as_array) {
+ let models = models
+ .iter()
+ .filter_map(Value::as_str)
+ .map(percent_encode)
+ .collect::>()
+ .join(",");
+ if !models.is_empty() {
+ query.push_str("&models=");
+ query.push_str(&models);
+ }
+ }
+ query
+ }
+
+ fn percent_encode(value: &str) -> String {
+ let mut encoded = String::with_capacity(value.len());
+ for byte in value.bytes() {
+ match byte {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ encoded.push(byte as char)
+ }
+ _ => encoded.push_str(&format!("%{byte:02X}")),
+ }
+ }
+ encoded
+ }
+
+ fn snapshot_path() -> PathBuf {
+ let home = std::env::var_os("HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from("."));
+ home.join("Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json")
+ }
+
+ fn now_seconds() -> f64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs_f64()
+ }
+
+ fn log_once(kind: &str, message: &str) {
+ static LOGGED: OnceLock>> = OnceLock::new();
+ let logged = LOGGED.get_or_init(|| Mutex::new(HashSet::new()));
+ if let Ok(mut logged) = logged.lock() {
+ if logged.insert(kind.to_owned()) {
+ eprintln!("widget snapshot {kind} failed: {message}");
+ }
+ }
+ }
+
+ fn without_generated_at(snapshot: &Snapshot) -> Snapshot {
+ let mut snapshot = snapshot.clone();
+ snapshot.generated_at = 0.0;
+ snapshot
+ }
+
+ fn write_if_changed(
+ path: &std::path::Path,
+ previous: Option<&Snapshot>,
+ snapshot: &Snapshot,
+ ) -> std::io::Result {
+ if previous.map(without_generated_at).as_ref() == Some(&without_generated_at(snapshot)) {
+ return Ok(false);
+ }
+ let Some(directory) = path.parent() else {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "snapshot path has no parent",
+ ));
+ };
+ fs::create_dir_all(directory)?;
+ let bytes = serde_json::to_vec(snapshot).map_err(std::io::Error::other)?;
+ let temporary = directory.join(format!(".snapshot-{}.tmp", Uuid::new_v4()));
+ fs::write(&temporary, bytes)?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
+ }
+ fs::rename(temporary, path)?;
+ Ok(true)
+ }
+
+ fn make_snapshot(
+ proxy: &ProxyClient,
+ settings: &Value,
+ health: &Value,
+ today: Option<&Value>,
+ quota_value: Option<&Value>,
+ timeline_value: Option<&Value>,
+ ) -> Snapshot {
+ let endpoint = proxy.endpoint();
+ let detail = {
+ let parts = [health.get("status"), health.get("protection")]
+ .into_iter()
+ .filter_map(|value| value.and_then(Value::as_str))
+ .filter(|part| !part.is_empty() && *part != "none")
+ .collect::>();
+ (!parts.is_empty()).then(|| parts.join(" · "))
+ };
+ let today_snapshot = today
+ .and_then(|value| value.get("summary").or(Some(value)))
+ .map(|summary| Today {
+ requests: integer(summary.get("requests")),
+ total_tokens: integer(summary.get("totalTokens")),
+ estimated_cost_usd: number(summary.get("estimatedCostUsd")),
+ });
+ let quotas_value = quota_value.unwrap_or(&Value::Null);
+ let menu_title = tray::render_title(settings, today.unwrap_or(&Value::Null), quotas_value);
+ let chart = timeline_value.and_then(|value| chart(value, settings));
+ Snapshot {
+ schema_version: 1,
+ state: "running".into(),
+ state_title: "Running".into(),
+ detail,
+ endpoint_display: format!("{}:{}", endpoint.host, endpoint.port),
+ menu_title,
+ today: today_snapshot,
+ quotas: quotas(quotas_value),
+ chart,
+ last_updated: timeline_value.map(|_| now_seconds()),
+ generated_at: now_seconds(),
+ }
+ }
+
+ pub async fn write(proxy: ProxyClient) {
+ let health = match proxy.startup_health().await {
+ Ok(value) => value,
+ Err(error) => {
+ let (kind, detail) = proxy_error(&error);
+ let (state, state_title, detail) = state_for_error(kind, detail);
+ let snapshot = Snapshot {
+ schema_version: 1,
+ state: state.into(),
+ state_title: state_title.into(),
+ detail,
+ endpoint_display: format!(
+ "{}:{}",
+ proxy.endpoint().host,
+ proxy.endpoint().port
+ ),
+ menu_title: None,
+ today: None,
+ quotas: Vec::new(),
+ chart: None,
+ last_updated: None,
+ generated_at: now_seconds(),
+ };
+ let path = snapshot_path();
+ let previous = fs::read(&path)
+ .ok()
+ .and_then(|bytes| serde_json::from_slice::(&bytes).ok());
+ if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) {
+ log_once("write", &error.to_string());
+ }
+ log_once("health", state);
+ return;
+ }
+ };
+ let settings = proxy
+ .companion_settings()
+ .await
+ .unwrap_or_else(|_| json!({ "settings": {} }));
+ let today = proxy.usage_today().await.ok();
+ let quota_value = proxy.quotas().await.ok();
+ let timeline_value = proxy.timeline(&timeline_query(&settings)).await.ok();
+ let snapshot = make_snapshot(
+ &proxy,
+ &settings,
+ &health,
+ today.as_ref(),
+ quota_value.as_ref(),
+ timeline_value.as_ref(),
+ );
+ let path = snapshot_path();
+ let previous = fs::read(&path)
+ .ok()
+ .and_then(|bytes| serde_json::from_slice::(&bytes).ok());
+ if let Err(error) = write_if_changed(&path, previous.as_ref(), &snapshot) {
+ log_once("write", &error.to_string());
+ }
+ }
+
+ pub fn refresh(proxy: &ProxyClient) {
+ let proxy = proxy.clone();
+ tauri::async_runtime::spawn(async move { write(proxy).await });
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use super::*;
+
+ #[test]
+ fn serialization_uses_swift_field_names() {
+ let snapshot = Snapshot {
+ schema_version: 1,
+ state: "running".into(),
+ state_title: "Running".into(),
+ detail: Some("ok".into()),
+ endpoint_display: "127.0.0.1:10100".into(),
+ menu_title: Some("2K".into()),
+ today: Some(Today {
+ requests: Some(2),
+ total_tokens: Some(1234),
+ estimated_cost_usd: Some(0.12),
+ }),
+ quotas: vec![Quota {
+ provider_label: "OpenAI".into(),
+ window_label: "week".into(),
+ percent: Some(10.0),
+ reset_at: Some(1.0),
+ }],
+ chart: Some(Chart {
+ start: 1.0,
+ bucket_seconds: 3600,
+ style: "line".into(),
+ series: vec![Series {
+ id: "openai/gpt".into(),
+ points: vec![1.0, 2.0],
+ }],
+ }),
+ last_updated: Some(2.0),
+ generated_at: 3.0,
+ };
+ assert_eq!(
+ serde_json::to_string(&snapshot).unwrap(),
+ r#"{"schemaVersion":1,"state":"running","stateTitle":"Running","detail":"ok","endpointDisplay":"127.0.0.1:10100","menuTitle":"2K","today":{"requests":2,"totalTokens":1234,"estimatedCostUsd":0.12},"quotas":[{"providerLabel":"OpenAI","windowLabel":"week","percent":10.0,"resetAt":1.0}],"chart":{"start":1.0,"bucketSeconds":3600,"style":"line","series":[{"id":"openai/gpt","points":[1.0,2.0]}]},"lastUpdated":2.0,"generatedAt":3.0}"#
+ );
+ }
+
+ #[test]
+ fn error_state_mapping_covers_four_kinds() {
+ assert_eq!(
+ state_for_error(ErrorKind::Unreachable, None).0,
+ "unreachable"
+ );
+ assert_eq!(
+ state_for_error(ErrorKind::Unauthorized, None).0,
+ "unauthorized"
+ );
+ assert_eq!(
+ state_for_error(ErrorKind::Http, Some("HTTP 500".into())).0,
+ "degraded"
+ );
+ assert_eq!(
+ state_for_error(ErrorKind::Decode, Some("bad".into())).0,
+ "degraded"
+ );
+ }
+
+ #[test]
+ fn write_if_changed_ignores_generated_at() {
+ let path = std::env::temp_dir().join(format!("ocx-widget-{}.json", std::process::id()));
+ let snapshot = Snapshot {
+ schema_version: 1,
+ state: "running".into(),
+ state_title: "Running".into(),
+ detail: None,
+ endpoint_display: "127.0.0.1:10100".into(),
+ menu_title: None,
+ today: None,
+ quotas: Vec::new(),
+ chart: None,
+ last_updated: None,
+ generated_at: 1.0,
+ };
+ assert!(write_if_changed(&path, None, &snapshot).unwrap());
+ let mut changed = snapshot.clone();
+ changed.generated_at = 2.0;
+ assert!(!write_if_changed(&path, Some(&snapshot), &changed).unwrap());
+ let _ = fs::remove_file(path);
+ }
+
+ #[test]
+ fn timeline_query_encodes_model_ids() {
+ let query = timeline_query(&json!({
+ "settings": { "models": ["openai/gpt-4.1", "claude 3,5"] }
+ }));
+ assert!(query.ends_with("&models=openai%2Fgpt-4.1,claude%203%2C5"));
+ }
+
+ #[test]
+ fn chart_series_are_truncated_to_six() {
+ let series = (0..8)
+ .map(|index| json!({ "id": index.to_string(), "points": [1] }))
+ .collect::>();
+ let value = json!({ "start": 1, "bucketSeconds": 60, "series": series });
+ let result = chart(&value, &json!({ "settings": { "chartStyle": "line" } })).unwrap();
+ assert_eq!(result.series.len(), 6);
+ }
+ }
+}
+
+#[cfg(target_os = "macos")]
+pub(crate) use macos::refresh;
+
+#[cfg(not(target_os = "macos"))]
+pub(crate) fn refresh(_: &crate::proxy::ProxyClient) {}
diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json
index b5eaa0f9d85..fb5dd306b9e 100644
--- a/desktop/src-tauri/tauri.conf.json
+++ b/desktop/src-tauri/tauri.conf.json
@@ -25,7 +25,10 @@
"icons/icon.png"
],
"macOS": {
- "minimumSystemVersion": "13.0"
+ "minimumSystemVersion": "13.0",
+ "files": {
+ "PlugIns/OpenCodexWidget.appex": "widget/OpenCodexWidget.appex"
+ }
},
"windows": {
"webviewInstallMode": {
diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md
index c134193f422..f59ebe9916b 100644
--- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md
@@ -303,6 +303,7 @@ Utilisez `ocx service` pour maintenir un proxy d’arrière-plan toujours actif,
### `ocx tray [--json] [--no-start]`
Installe et contrôle l’icône OpenCodex dans la zone de notification Windows. Elle démarre à l’ouverture de session et fournit des commandes du proxy accessibles en un clic. `start` et `stop` contrôlent uniquement l’icône ; utilisez son menu pour contrôler le proxy. `--no-start` s’applique à `install` et installe l’icône sans la lancer immédiatement.
+Obsolète : l’application OpenCodex fournit la zone de notification sous Windows, macOS et Linux ; `ocx tray` reste disponible pour les installations sans l’application de bureau.
## Tableau de bord
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 a323e659a78..9591108e6c2 100644
--- a/docs-site/src/content/docs/guides/macos-menu-bar.md
+++ b/docs-site/src/content/docs/guides/macos-menu-bar.md
@@ -111,7 +111,7 @@ Everything else — accounts, model configuration, storage — stays in the dash
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.
+privacy-safe usage snapshot as the desktop 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.
@@ -145,11 +145,13 @@ Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun.
```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
-bun run build:macos
+bun run prepare-sidecar
+bun run prepare-widget
+bunx tauri build
```
-The bundle appears at `dist/macos/OpenCodex.app`. Without Bun you can run the script
-directly: `bash scripts/build-macos-app.sh`.
+The bundle appears in Tauri's release output, with the WidgetKit appex under
+`OpenCodex.app/Contents/PlugIns/`.
Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command
Line Tools ships only current-architecture Swift compatibility libraries, and the build
@@ -159,7 +161,7 @@ If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTIT
sign with the hardened runtime instead of ad-hoc:
```bash
-MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos
+MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget
```
## Uninstall
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 ff67a25ccc7..3561b02216b 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
@@ -9,6 +9,12 @@ description: OpenCodex プロキシの状態、使用量、プロバイダーの
プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは
ローカルの管理 API に接続するクライアントとして動きます。
+## デスクトップアプリ (Tauri)
+
+同じダッシュボードを OpenCodex デスクトップアプリ内で実行できます。Usage コンパニオン
+パネルは OS に合ったインストール手順を表示し、デスクトップシェル内では **ブラウザーで開く**
+を選ぶと現在の画面を通常のブラウザーで開けます。
+
## インストール
[リリースページ](https://github.com/lidge-jun/opencodex/releases)から
@@ -134,11 +140,13 @@ macOS 13 以降、Xcode Command Line Tools、および [Bun](https://bun.sh) が
```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
-bun run build:macos
+bun run prepare-sidecar
+bun run prepare-widget
+bunx tauri build
```
-バンドルは `dist/macos/OpenCodex.app` に生成されます。Bun がない場合はスクリプトを直接
-実行できます: `bash scripts/build-macos-app.sh`。
+バンドルは Tauri のリリース出力に生成され、WidgetKit 拡張は
+`OpenCodex.app/Contents/PlugIns/` に含まれます。
ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には
現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは
@@ -148,7 +156,7 @@ bun run build:macos
ではなく hardened runtime で署名できます。
```bash
-MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos
+MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget
```
## アンインストール
diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md
index bf37bc911b6..877954e9755 100644
--- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md
@@ -272,6 +272,7 @@ OpenCodex の更新後、既存の Windows シムにこの動作を適用する
### `ocx tray [--json] [--no-start]`
Windows ステータス トレイ アイコンをインストールして制御します。 Windows ログイン時に開始され、ワンクリックでプロキシ コントロールを提供します。 `start` および `stop` はアイコンのみを制御します。そのメニューを使用してプロキシを制御します。 `--no-start` は `install` に適用され、トレイをすぐに起動せずにインストールします。
+非推奨: OpenCodex デスクトップアプリは Windows、macOS、Linux のトレイを提供します。`ocx tray` はデスクトップアプリを使わないインストール向けに残っています。
## ダッシュボード
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 53159b4589b..d33c9d45fff 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
@@ -9,6 +9,12 @@ description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를
프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리
API에 붙는 클라이언트입니다.
+## 데스크톱 앱 (Tauri)
+
+같은 대시보드를 OpenCodex 데스크톱 앱에서 실행할 수 있습니다. 사용량 패널은 운영체제에
+맞는 설치 단계를 보여주며, 데스크톱 셸 안에서는 **브라우저에서 열기**를 선택해 현재
+대시보드 화면을 일반 브라우저로 열 수 있습니다.
+
## 설치
[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서
@@ -130,11 +136,13 @@ macOS 13 이상, Xcode Command Line Tools, 그리고 [Bun](https://bun.sh)이
```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
-bun run build:macos
+bun run prepare-sidecar
+bun run prepare-widget
+bunx tauri build
```
-번들은 `dist/macos/OpenCodex.app`에 생깁니다. Bun 없이 쓰려면 스크립트를 직접 실행하세요:
-`bash scripts/build-macos-app.sh`.
+번들은 Tauri 릴리스 출력에 생성되며, WidgetKit 확장은
+`OpenCodex.app/Contents/PlugIns/` 아래에 포함됩니다.
유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools
에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를
@@ -144,7 +152,7 @@ bun run build:macos
런타임으로 서명할 수 있습니다.
```bash
-MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos
+MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget
```
## 삭제
diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
index e4f2913c6f6..97048c3768e 100644
--- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md
@@ -390,6 +390,8 @@ OpenCodex를 업데이트한 뒤 기존 Windows shim에 이 동작을 적용하
Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로그인 시 시작되며, 프록시를 원클릭으로
제어할 수 있습니다. `start`와 `stop`은 아이콘만 제어합니다. 프록시 제어는 메뉴를 사용하세요.
`--no-start`는 `install`에 적용되며, 트레이를 바로 실행하지 않고 설치합니다.
+지원 중단 예정: OpenCodex 데스크톱 앱이 Windows, macOS, Linux에서 트레이를 제공합니다.
+`ocx tray`는 데스크톱 앱이 없는 설치를 위해 계속 사용할 수 있습니다.
## 대시보드
diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md
index e8380c73ca6..07f6ca58537 100644
--- a/docs-site/src/content/docs/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/reference/cli/lifecycle.md
@@ -611,6 +611,8 @@ file is not part of the injected `env_key` contract; the launching process must
Install and control the Windows status tray icon. It starts at Windows login and provides one-click
proxy controls. `start` and `stop` control the icon only; use its menu to control the proxy.
`--no-start` applies to `install` and installs the tray without launching it immediately.
+Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray`
+remains for installs without the desktop app.
## Dashboard
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 30f54ad9835..2519b08b298 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
@@ -9,6 +9,12 @@ description: Нативное приложение, показывающее с
Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню —
клиент, который обращается к локальному management API.
+## Настольное приложение (Tauri)
+
+Ту же панель можно открыть в настольном приложении OpenCodex. Панель компаньона показывает
+шаги установки для выбранной ОС, а пункт **Открыть в браузере** открывает текущий экран
+в обычном браузере, когда панель работает внутри desktop shell.
+
## Установка
Скачайте `OpenCodex-<версия>-macos-universal.zip` со
@@ -136,11 +142,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app
```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
-bun run build:macos
+bun run prepare-sidecar
+bun run prepare-widget
+bunx tauri build
```
-Бандл появится в `dist/macos/OpenCodex.app`. Без Bun скрипт можно запустить напрямую:
-`bash scripts/build-macos-app.sh`.
+Бандл появится в выходных файлах Tauri, а расширение WidgetKit будет включено в
+`OpenCodex.app/Contents/PlugIns/`.
Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть
только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом
@@ -150,7 +158,7 @@ bun run build:macos
подписать с hardened runtime вместо ad-hoc:
```bash
-MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos
+MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget
```
## Удаление
diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md
index f338d75388f..6719e9cf2cb 100644
--- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md
@@ -395,6 +395,8 @@ ocx codex-shim uninstall
one-click управление прокси. `start` и `stop` управляют только иконкой; самим прокси нужно
управлять из её меню. `--no-start` применяется к `install` и устанавливает tray, не запуская её
немедленно.
+Устарело: приложение OpenCodex для рабочего стола предоставляет трей в Windows, macOS и Linux;
+`ocx tray` остаётся для установок без приложения для рабочего стола.
## Дашборд
diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md
index ffa5b19df50..f1c30392609 100644
--- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md
@@ -470,6 +470,8 @@ Windows durum tepsisi simgesini kurun ve kontrol edin. Windows oturum açılış
başlar ve tek tıklamayla proxy kontrolleri sağlar. `start` ve `stop` yalnızca
simgeyi kontrol eder; proxy'yi kontrol etmek için menüsünü kullanın.
`--no-start`, `install` için geçerlidir ve tepsiyi hemen başlatmadan kurar.
+Kullanımdan kaldırıldı: OpenCodex masaüstü uygulaması Windows, macOS ve Linux'ta tepsi sağlar;
+`ocx tray`, masaüstü uygulaması olmayan kurulumlar için kullanılmaya devam eder.
## Kontrol Paneli
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 50058524bf0..bc352dc779e 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
@@ -7,6 +7,11 @@ description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供
它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。
+## 桌面应用(Tauri)
+
+同一个仪表板也可以在 OpenCodex 桌面应用中运行。用量面板会显示匹配操作系统的安装步骤;
+在桌面壳中选择**在浏览器中打开**,即可在普通浏览器中打开当前页面。
+
## 安装
从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载
@@ -117,11 +122,13 @@ xattr -d com.apple.quarantine /Applications/OpenCodex.app
```bash
git clone https://github.com/lidge-jun/opencodex.git
cd opencodex
-bun run build:macos
+bun run prepare-sidecar
+bun run prepare-widget
+bunx tauri build
```
-程序包会生成在 `dist/macos/OpenCodex.app`。若没有 Bun,可以直接运行脚本:
-`bash scripts/build-macos-app.sh`。
+程序包会生成在 Tauri 的发布输出中,WidgetKit 扩展位于
+`OpenCodex.app/Contents/PlugIns/`。
构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift
兼容库,此时构建会给出说明信息,而不是抛出链接器错误。
@@ -130,7 +137,7 @@ bun run build:macos
替代 ad-hoc 签名:
```bash
-MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos
+MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run prepare-widget
```
## 卸载
diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md
index 30687e2b81e..b1fdef059a3 100644
--- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md
@@ -259,6 +259,7 @@ ocx codex-shim uninstall
### `ocx tray [--json] [--no-start]`
安装并控制 Windows 状态托盘图标。它会在 Windows 登录时启动,并提供一键代理控制。`start` 和 `stop` 只控制图标本身;要控制代理,请使用其菜单。`--no-start` 适用于 `install`,会安装托盘但不会立即启动。
+已弃用:OpenCodex 桌面应用在 Windows、macOS 和 Linux 上提供托盘;没有桌面应用的安装仍可使用 `ocx tray`。
## 仪表盘
diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md
index d9c104d3c91..4317ef37b30 100644
--- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md
+++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md
@@ -244,6 +244,7 @@ ocx codex-shim uninstall
### `ocx tray [--json] [--no-start]`
安裝並控制 Windows 狀態列圖示。它在 Windows 登入時啟動並提供一鍵代理控制。`start` 與 `stop` 僅控制圖示;請用其選單控制代理。`--no-start` 適用於 `install`,並在不立即啟動它的情況下安裝 tray。
+已淘汰:OpenCodex 桌面應用程式在 Windows、macOS 與 Linux 提供系統匣;沒有桌面應用程式的安裝仍可使用 `ocx tray`。
## 儀表板
diff --git a/package.json b/package.json
index 4b6912dfa6f..b8671c59b0e 100644
--- a/package.json
+++ b/package.json
@@ -58,10 +58,8 @@
"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",
"build:standalone": "bun scripts/build-standalone.ts",
- "package:macos": "bash scripts/package-macos-release.sh",
- "test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests",
+ "test:macos": "swift run --package-path app MenuBarCoreTests",
"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/readme/README.fr.md b/readme/README.fr.md
index 411a27e7597..5a7f5d79553 100644
--- a/readme/README.fr.md
+++ b/readme/README.fr.md
@@ -98,7 +98,7 @@ Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fo
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`.
+`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`.
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/)
diff --git a/readme/README.ja.md b/readme/README.ja.md
index ee8a7e6adc5..4e65d37511a 100644
--- a/readme/README.ja.md
+++ b/readme/README.ja.md
@@ -104,7 +104,7 @@ Codex 認証用の **ChatGPT アカウントプール**も管理できます。C
ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ
コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。
[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、
-`bun run build:macos` でローカルビルドできます。
+`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` でローカルビルドできます。
アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。
詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。
diff --git a/readme/README.ko.md b/readme/README.ko.md
index f93936bc5a5..f1e8212a301 100644
--- a/readme/README.ko.md
+++ b/readme/README.ko.md
@@ -101,7 +101,7 @@ round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레
대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다.
소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다.
[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나
-`bun run build:macos`로 직접 빌드할 수 있습니다.
+`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`로 직접 빌드할 수 있습니다.
앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요.
자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요.
diff --git a/readme/README.ru.md b/readme/README.ru.md
index d2df43cd07f..c2e04bfa893 100644
--- a/readme/README.ru.md
+++ b/readme/README.ru.md
@@ -108,7 +108,7 @@ ocx start # прокси + панель управлен
Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели.
Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей).
Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или
-соберите локально командой `bun run build:macos`.
+соберите локально командой `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build`.
При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc,
но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/).
diff --git a/readme/README.tr.md b/readme/README.tr.md
index 511a42b0a1c..b69a6737617 100644
--- a/readme/README.tr.md
+++ b/readme/README.tr.md
@@ -104,7 +104,7 @@ bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim
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.
+`bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 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.
diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md
index cc2e57271f3..70030a04dc9 100644
--- a/readme/README.zh-CN.md
+++ b/readme/README.zh-CN.md
@@ -101,7 +101,7 @@ ocx start # 代理 + 仪表板:localhost:10100
无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于
[`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases)
-下载,或使用 `bun run build:macos` 在本地构建。
+下载,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本地构建。
应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅
[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。
diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md
index adb8bf52012..f48a03ac913 100644
--- a/readme/README.zh-TW.md
+++ b/readme/README.zh-TW.md
@@ -99,7 +99,7 @@ ocx start # 代理 + 儀表板位於 localhost:10100
無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於
[`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases)
-下載,或使用 `bun run build:macos` 在本機建置。
+下載,或使用 `bun run prepare-sidecar && bun run prepare-widget && bunx tauri build` 在本機建置。
應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱
[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。
diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json
index 569d3612db8..ffde56df118 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": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd"
+ "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9"
},
"ko": {
"file": "readme/README.ko.md",
"label": "한국어",
"docsPath": "ko",
- "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd"
+ "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9"
},
"zh-CN": {
"file": "readme/README.zh-CN.md",
"label": "简体中文",
"docsPath": "zh-cn",
- "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd"
+ "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9"
},
"zh-TW": {
"file": "readme/README.zh-TW.md",
"label": "繁體中文",
"docsPath": "zh-tw",
- "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd"
+ "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9"
},
"ru": {
"file": "readme/README.ru.md",
"label": "Русский",
"docsPath": "ru",
- "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd"
+ "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9"
},
"ja": {
"file": "readme/README.ja.md",
"label": "日本語",
"docsPath": "ja",
- "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd"
+ "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9"
},
"tr": {
"file": "readme/README.tr.md",
"label": "Türkçe",
"docsPath": "tr",
- "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd"
+ "sourceSha256": "22b1527c1db1d521e7368286949d0093d2ee49a4e8cdd28cecb839efd1f662a9"
}
}
}
diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh
deleted file mode 100755
index 8dcac03ee18..00000000000
--- a/scripts/build-macos-app.sh
+++ /dev/null
@@ -1,268 +0,0 @@
-#!/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
-
-# 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: walk up to the nearest existing ancestor, resolve that, and
-# re-append the parts that do not exist yet.
-resolve_physical() {
- local target="$1" part resolved
- # Absolute-ise relative input against the caller's directory.
- [[ "$target" = /* ]] || target="$PWD/$target"
-
- # 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=/
- read -r -a parts <<< "$target"
- unset IFS
-
- for part in "${parts[@]}"; do
- case "$part" in
- "" | ".") continue ;;
- "..")
- # `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
-
- # 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 [[ -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
- # `cd … && pwd -P` follows the symlink and any chain behind it.
- resolved="$(cd "$candidate" && pwd -P)"
- else
- resolved="${resolved%/}/$part"
- fi
- done
- 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
-# 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="$(cd "$repo_root" && pwd -P)"
-if [[ -n "${TMPDIR:-}" ]]; then
- allowed_tmp="$(cd "${TMPDIR%/}" 2>/dev/null && pwd -P || echo "")"
-else
- allowed_tmp=""
-fi
-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': 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
-
-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)"
- 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)
- 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"
-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"
-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.
-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
-
-# 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%%-*}"
-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="$MACOS_BUILD_NUMBER"
-else
- build_version="$version_core"
-fi
-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_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"
-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"
-
-# Signing.
-#
-# 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.
-#
-# 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.
-#
-# 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" \
- --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
-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 (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/scripts/package-macos-release.sh b/scripts/package-macos-release.sh
deleted file mode 100755
index f9b0b1ebd89..00000000000
--- a/scripts/package-macos-release.sh
+++ /dev/null
@@ -1,113 +0,0 @@
-#!/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"
-
-# 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
- 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"
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 6181c9b7f7e..23631d963b7 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -167,7 +167,6 @@
}
},
"explicit": {
- "macos-build-script.test.ts": "gui",
"gui-desktop-sidecar-script.test.ts": "gui",
"standalone-build-script.test.ts": "gui",
"standalone-service.test.ts": "service",
diff --git a/src/cli/registry.ts b/src/cli/registry.ts
index 85c2d660310..b6df952a156 100644
--- a/src/cli/registry.ts
+++ b/src/cli/registry.ts
@@ -94,9 +94,10 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
{
name: "tray",
usage: "ocx tray [--json] [--no-start]",
- summary: "Install and control the Windows status tray icon.",
+ summary: "Install and control the Windows status tray icon (deprecated in favor of the desktop app).",
details: [
"The tray starts at Windows login and provides one-click proxy controls.",
+ "Deprecated: the OpenCodex desktop app provides the tray on Windows, macOS, and Linux; `ocx tray` remains for installs without the desktop app.",
"Tray start/stop controls the icon only; use its menu to start or stop the proxy.",
"--no-start (install only) installs the tray without launching it immediately.",
],
diff --git a/structure/desktop-shell.md b/structure/desktop-shell.md
index c4fc115c6f3..8440fe115e6 100644
--- a/structure/desktop-shell.md
+++ b/structure/desktop-shell.md
@@ -18,7 +18,16 @@ desktop/src-tauri/resources/ remain ignored.
The management API companion presence check in
`src/server/management/companion-routes.ts` accepts both
-`OpenCodexMenuBar/` and `OpenCodexDesktop/` user agents. This is presence
-telemetry only; management authentication remains in the shared API boundary.
+`OpenCodexMenuBar/` (legacy Swift companion) and `OpenCodexDesktop/` user agents.
+This is presence telemetry only; management
+authentication remains in the shared API boundary.
The desktop webview uses a Mozilla-compatible `OpenCodexDesktop/` user-agent
marker, which the GUI detects to identify the shell without using IPC.
+
+## Widget snapshot
+
+The macOS desktop shell writes the WidgetKit snapshot to
+`~/Library/Containers/com.opencodex.desktop.widget/Data/Library/Application Support/OpenCodex/snapshot.json`.
+The schema version is `1`; the Rust writer refreshes it every five minutes after an
+immediate first write. The WidgetKit appex reads this privacy-safe file and performs no
+network access.
diff --git a/structure/overview.md b/structure/overview.md
index 4ead1823794..cc5df8cf865 100644
--- a/structure/overview.md
+++ b/structure/overview.md
@@ -33,9 +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,
+`app/` contains the native macOS WidgetKit extension bundled into the Tauri desktop app.
+`MenuBarCore` is its snapshot model/formatting layer; the desktop shell writes the
+privacy-safe snapshot that the widget reads without network access. 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.
Its persisted display contract is owned by `src/companion/`.
diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts
index 0cda8de5330..5081034b851 100644
--- a/tests/ci-workflows/ci-structure-gate.test.ts
+++ b/tests/ci-workflows/ci-structure-gate.test.ts
@@ -67,17 +67,18 @@ 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");
+ expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("widget");
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 macos-app\"");
- expect(script).toContain("|macos-app)");
+ expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate widget\"");
+ expect(script).toContain("|widget)");
expect(script).toContain("CHANGES_STRUCTURE");
});
-test("app changes select the macOS app job", () => {
+test("app changes select the macOS widget 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");
+ expect(filters.ci).toContain("desktop/**");
+ const widget = workflow.jobs?.widget;
+ expect(widget?.if).toContain("needs.changes.outputs.ci == 'true'");
+ expect(Array.isArray(widget?.needs) ? widget?.needs : []).toContain("changes");
});
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 2349a61a26f..fd66fd29230 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -1,5 +1,4 @@
{
- "macos-build-script.test.ts": "gui",
"gui-desktop-sidecar-script.test.ts": "gui",
"standalone-build-script.test.ts": "gui",
"standalone-service.test.ts": "service",
diff --git a/tests/gui/macos-build-script.test.ts b/tests/gui/macos-build-script.test.ts
deleted file mode 100644
index 70d4a018e7f..00000000000
--- a/tests/gui/macos-build-script.test.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-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.
-//
-// 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 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 = findRepoRoot();
-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], {
- cwd,
- 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 };
-}
-
-/// 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 () => {
- // 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");
- expect(existsSync(target)).toBe(false);
- }, 120_000);
-
- test("refuses an unresolved .. traversal before creating any directory", async () => {
- 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 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("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 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 });
- }
- });
-
- // 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);
- }, 300_000);
-
- 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");
- expect(existsSync(outside)).toBe(false);
- } finally {
- rmSync(link, { recursive: true, force: true });
- }
- }, 120_000);
-
- // 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, exitCode } = await runScript(link);
-
- expect(exitCode).not.toBe(0);
- expect(stderr).toContain("Refusing to build");
- expect(existsSync(escaped)).toBe(false);
- } finally {
- 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 () => {
- 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);
-});
-
-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");
- expect(packageText).toContain("_NSExtensionMain");
- });
-});