diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 003415f3af5..e5eacaa1e9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ on: - "bin/**" - "tests/**" - "scripts/**" + - "app/**" - "gui/**" - "assets/**" - ".gitattributes" @@ -213,6 +214,7 @@ jobs: - 'bin/**' - 'tests/**' - 'scripts/**' + - 'app/**' - 'gui/**' - 'assets/**' - '.gitattributes' @@ -1147,6 +1149,32 @@ jobs: # `if: always()` is load-bearing. Without it, a failed or skipped dependency # skips this job too — and GitHub reports a skipped job as success, so the gate # would go green precisely when something went wrong. + macos-app: + name: macos app + needs: [changes, gates] + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: macos-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Test macOS menu bar app + run: bun run test:macos + + - name: Build macOS menu bar app + run: bun run build:macos + ci: name: ci if: always() @@ -1154,7 +1182,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped`, which is the shape the step below is written to catch. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, macos-app] runs-on: ubuntu-latest timeout-minutes: 5 permissions: @@ -1220,12 +1248,12 @@ jobs: GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates" GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke" GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build" - GATED_JOBS="$GATED_JOBS structure-gate" + GATED_JOBS="$GATED_JOBS structure-gate macos-app" expected_for() { case "$1" in changes|select-windows-runner) echo requested ;; - test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke) + test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|macos-app) echo "$scoped" ;; npm-global-smoke) echo "$packaging" ;; docs-site-build) echo "$docs" ;; diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b565b68003..2c3c2002583 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,6 +69,75 @@ jobs: process.exit(1); } NODE + package-macos: + needs: validate-dispatch + runs-on: macos-latest + timeout-minutes: 20 + permissions: + contents: read + outputs: + archive_name: ${{ steps.package.outputs.archive_name }} + checksum_name: ${{ steps.package.outputs.checksum_name }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Package the macOS companion + id: package + env: + RELEASE_VERSION: ${{ inputs.version }} + UNIVERSAL: "1" + # A monotonic numeric CFBundleVersion. Preview versions carry a suffix that + # Apple does not accept in that field, so the script uses the numeric core + # plus this run number. + MACOS_BUILD_NUMBER: ${{ github.run_number }} + # NOTE: intentionally no MACOS_SIGN_IDENTITY here. The build script honours + # it, but an identity NAME alone cannot sign on a hosted runner — the + # certificate and private key are never imported into a keychain, so codesign + # fails with "no identity found". Real Developer ID signing needs a protected + # P12 import, a temporary keychain, notarytool credentials, and stapling, all + # as one security-reviewed change. Until then the asset is ad-hoc signed and + # the docs carry the Gatekeeper first-launch path. + run: bash scripts/package-macos-release.sh + + - name: Upload the release asset + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-release + path: dist/release/ + if-no-files-found: error + retention-days: 7 + + attach-macos: + runs-on: ubuntu-latest + needs: [publish, package-macos] + if: ${{ inputs.dry-run != true }} + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Download the packaged asset + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: macos-release + path: dist/release + + - name: Verify the checksum before uploading + run: | + cd dist/release + shasum -a 256 -c ./*.sha256 + + - name: Attach to the release + env: + GH_TOKEN: ${{ github.token }} + # Workflow inputs reach shell code through env, never by interpolation into + # run: source. tests/ci-workflows.test.ts enforces this repo-wide. + RELEASE_VERSION: ${{ inputs.version }} + run: | + gh release upload "v${RELEASE_VERSION}" dist/release/* --clobber + publish: needs: validate-dispatch runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 1973231f05b..e6dd44882e5 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ go/ # Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts. native/**/target/ +dist/macos/ +dist/release/ diff --git a/AGENTS.md b/AGENTS.md index 1f621b99b41..469912b5e39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,11 @@ Bun-native TypeScript with no separate server compile step. seeds in `layout.json` place a conventionally named file until then. History: `devlog/_fin/260905_test_modularization_and_windows/`. - `gui/` — React + Vite dashboard; packaged output is served from `gui/dist`. +- `app/` — native macOS menu bar companion (Swift + AppKit, no third-party + dependencies). `MenuBarCore` is the testable transport/model layer, + `MenuBarUI` the AppKit views, `MenuBarApp` the entry point. Its tests are + executables, not XCTest bundles — Command Line Tools ships neither a usable + XCTest module nor the swift-testing runtime. - `docs-site/` — public docs (Astro + Starlight), deployed to GitHub Pages. - `go/` — retired Go native-runtime experiment; kept only where the TypeScript runtime still references it. New work does not go here. diff --git a/README.md b/README.md index d29cd2f43af..88a6438b3dc 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,21 @@ Use `ocx service` to run it in the background. Open **http://localhost:10100** and configure everything in the web dashboard — add providers (40+ built-ins, or any OpenAI-compatible endpoint), pick models, manage accounts. `ocx gui` re-opens the dashboard at any time. + +### macOS menu bar app + +A native companion for proxy status, usage, and provider quotas without opening the +dashboard. The source lives in [`app/`](./app) (Swift + AppKit, no third-party +dependencies). Download it from the +[releases page](https://github.com/lidge-jun/opencodex/releases) or build it locally with +`bun run build:macos`. + +The first launch needs a right-click → Open, because the app is ad-hoc signed rather +than notarized. See the [macOS Menu Bar App guide](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +for the full explanation. + +The app also includes a macOS 14+ widget for proxy status, today's usage, and quotas. + It can also manage a **ChatGPT account pool** for Codex auth. Add multiple ChatGPT / Codex accounts, refresh their 5h / weekly / 30d quota in the dashboard. Under quota routing, new sessions can use the lowest-usage healthy account; round-robin and fill-first use their own policies. Existing Codex diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 00000000000..4629e801bfa --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj +DerivedData/ diff --git a/app/Info.plist b/app/Info.plist new file mode 100644 index 00000000000..3d52873063f --- /dev/null +++ b/app/Info.plist @@ -0,0 +1,42 @@ + + + + + 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 new file mode 100644 index 00000000000..9e5f1372751 --- /dev/null +++ b/app/Package.swift @@ -0,0 +1,55 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "OpenCodexMenuBar", + platforms: [.macOS(.v13)], + products: [ + .executable(name: "OpenCodexMenuBar", targets: ["MenuBarApp"]), + .executable(name: "OpenCodexWidget", targets: ["OpenCodexWidget"]), + .executable(name: "MenuBarCoreTests", targets: ["MenuBarCoreTests"]), + .executable(name: "MenuBarUITests", targets: ["MenuBarUITests"]), + .executable(name: "UIProbe", targets: ["UIProbe"]), + .executable(name: "IconProbe", targets: ["IconProbe"]), + ], + targets: [ + .target(name: "MenuBarCore", path: "Sources/MenuBarCore"), + // AppKit views live in a library so both the app and the visual-QA probe can + // build the same surface. An executable target cannot be imported. + .target(name: "MenuBarUI", dependencies: ["MenuBarCore"], path: "Sources/MenuBarUI"), + .executableTarget( + name: "MenuBarApp", + dependencies: ["MenuBarCore", "MenuBarUI"], + path: "Sources/MenuBarApp" + ), + .executableTarget( + name: "OpenCodexWidget", + dependencies: ["MenuBarCore"], + path: "Sources/OpenCodexWidget", + linkerSettings: [ + // Widget extensions must enter through NSExtensionMain or chronod tears down + // the process before the WidgetBundle connects. + .linkedFramework("Foundation"), + .unsafeFlags(["-Xlinker", "-e", "-Xlinker", "_NSExtensionMain"]), + ] + ), + // An executable rather than a .testTarget: Xcode Command Line Tools ships + // neither a usable XCTest module nor the swift-testing runtime, so a test bundle + // cannot run without a full Xcode install. See Sources/MenuBarCoreTests/Harness.swift. + .executableTarget( + name: "MenuBarCoreTests", + dependencies: ["MenuBarCore"], + path: "Sources/MenuBarCoreTests" + ), + // UI-layer tests need AppKit and an NSApplication, so they are a separate + // executable from the dependency-free core suite. + .executableTarget( + name: "MenuBarUITests", + dependencies: ["MenuBarCore", "MenuBarUI"], + path: "Sources/MenuBarUITests" + ), + .executableTarget(name: "UIProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/UIProbe"), + .executableTarget(name: "IconProbe", dependencies: ["MenuBarCore", "MenuBarUI"], path: "Sources/IconProbe"), + ], + swiftLanguageVersions: [.v5] +) diff --git a/app/Sources/IconProbe/main.swift b/app/Sources/IconProbe/main.swift new file mode 100644 index 00000000000..f9a359114d5 --- /dev/null +++ b/app/Sources/IconProbe/main.swift @@ -0,0 +1,32 @@ +// Renders every menu bar glyph state to one sheet so the state signal can be verified +// visually. The notch previously did not render at all, which made protected and +// at-risk indistinguishable. +import AppKit +import MenuBarCore +import MenuBarUI + +let states: [(String, ProxyState)] = [ + ("protected", .running(StartupHealth(status: "protected"))), + ("at-risk", .running(StartupHealth(status: "at-risk"))), + ("loading", .loading), + ("stopped", .unreachable), +] + +let scale: CGFloat = 6 +let cell = NSSize(width: 17 * scale, height: 17 * scale) +let sheet = NSImage(size: NSSize(width: cell.width * CGFloat(states.count), height: cell.height)) +sheet.lockFocus() +NSColor.white.setFill() +NSRect(origin: .zero, size: sheet.size).fill() +for (i, entry) in states.enumerated() { + let img = StatusIcon.image(for: entry.1) + let rect = NSRect(x: CGFloat(i) * cell.width, y: 0, width: cell.width, height: cell.height) + NSGraphicsContext.current?.imageInterpolation = .none + img.draw(in: rect.insetBy(dx: 8, dy: 8)) +} +sheet.unlockFocus() +if let tiff = sheet.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: "/tmp/glyphs.png")) +} +print("wrote /tmp/glyphs.png:", states.map(\.0).joined(separator: ", ")) diff --git a/app/Sources/MenuBarApp/main.swift b/app/Sources/MenuBarApp/main.swift new file mode 100644 index 00000000000..710ac8c51b9 --- /dev/null +++ b/app/Sources/MenuBarApp/main.swift @@ -0,0 +1,11 @@ +import AppKit +import MenuBarUI + +let app = NSApplication.shared +// .accessory keeps it out of the Dock; LSUIElement in Info.plist does the same for the +// packaged bundle, and this covers `swift run` during development. +app.setActivationPolicy(.accessory) + +let delegate = AppDelegate() +app.delegate = delegate +app.run() diff --git a/app/Sources/MenuBarCore/ActionCoordinator.swift b/app/Sources/MenuBarCore/ActionCoordinator.swift new file mode 100644 index 00000000000..5666ad48c92 --- /dev/null +++ b/app/Sources/MenuBarCore/ActionCoordinator.swift @@ -0,0 +1,123 @@ +import Foundation + +/// The result of a write action, in terms the UI can render directly. +public enum ActionOutcome: Equatable, Sendable { + case succeeded + /// The stop was confirmed, but nothing will restart the proxy — the user has to. + case requiresManualStart(String) + /// The proxy stopped, but it could not restore native Codex on the way out, so the + /// user's Codex config still points at a port that is now closed. + case stoppedWithRestoreFailure(String) + /// A human sentence. Never a response body: bodies can echo configuration. + case failed(String) +} + +/// Executes write actions and reports what actually happened. +/// +/// Split from the UI because the interesting behaviour is timing, not presentation: +/// `/api/stop` answers before it drains, so "the request returned 200" and "the proxy +/// stopped" are different facts and only the second one is worth telling the user. +public actor ActionCoordinator { + /// How long to wait for the port to stop answering before giving up. + public static let stopTimeout: TimeInterval = 10 + public static let pollInterval: TimeInterval = 0.5 + + private let client: ProxyClient + /// One in-flight write per provider. Both this actor and `ProxyClient` are reentrant + /// across network awaits, so two rapid toggles could otherwise reach the server out + /// of order and leave it opposite to the user's last click. + private var inFlight: Set = [] + private let sleeper: @Sendable (TimeInterval) async -> Void + /// Injected so tests can advance time without waiting for it. A no-op sleeper alone + /// is not enough: the loop is bounded by a deadline, so the clock has to move too. + private let now: @Sendable () -> Date + + public init( + client: ProxyClient, + sleeper: @escaping @Sendable (TimeInterval) async -> Void = { seconds in + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.client = client + self.sleeper = sleeper + self.now = now + } + + /// Stops the proxy and waits until it is actually gone. + /// + /// `/api/stop` calls `stopServiceIfInstalled()` and returns before draining, so a + /// 200 means "accepted", not "stopped". Reporting success on the response alone + /// would make the UI claim a state the system has not reached yet. + public func stop(startCommand: String) async -> ActionOutcome { + let restored: Bool + do { + restored = try await client.stop() + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("Could not reach the proxy to stop it.") + } + + let deadline = now().addingTimeInterval(Self.stopTimeout) + var sawIndeterminate = false + while now() < deadline { + await sleeper(Self.pollInterval) + // Cap the probe to whatever time is left, so the last one cannot overrun the + // deadline by its own timeout. + let remaining = deadline.timeIntervalSince(now()) + guard remaining > 0 else { break } + switch await client.liveness(timeout: min(1.5, remaining)) { + case .refused: + // The only proof the proxy is actually gone. + return restored + ? .requiresManualStart(startCommand) + : .stoppedWithRestoreFailure(startCommand) + case .reachable: + sawIndeterminate = false + case .indeterminate: + // A timeout proves nothing; keep polling rather than declaring victory. + sawIndeterminate = true + } + } + + return .failed( + sawIndeterminate + ? "The proxy accepted the stop, but its state could not be confirmed. Check with `ocx status`." + : "The proxy accepted the stop but was still responding after \(Int(Self.stopTimeout)) seconds." + ) + } + + /// Enables or disables a provider. + /// + /// The default provider is rejected before any request is sent: the proxy answers + /// 400 for that case, and firing a request that cannot succeed is worse than not + /// offering it. + public func setProvider( + _ name: String, + disabled: Bool, + defaultProvider: String? + ) async -> ActionOutcome { + if disabled, name == defaultProvider { + return .failed("\(name) is the default provider. Choose another default in the dashboard first.") + } + guard !inFlight.contains(name) else { + return .failed("A change to \(name) is still in progress.") + } + inFlight.insert(name) + defer { inFlight.remove(name) } + + do { + try await client.setProviderDisabled(name, disabled: disabled) + return .succeeded + } catch ProxyError.http(400) { + // The proxy validates more than we can predict; surface its refusal without + // quoting its body. + return .failed("The proxy refused that change. Adjust it in the dashboard.") + } catch let error as ProxyError { + return .failed(error.userMessage) + } catch { + return .failed("That change could not be applied.") + } + } +} diff --git a/app/Sources/MenuBarCore/CompanionSettings.swift b/app/Sources/MenuBarCore/CompanionSettings.swift new file mode 100644 index 00000000000..2e3b0333b1a --- /dev/null +++ b/app/Sources/MenuBarCore/CompanionSettings.swift @@ -0,0 +1,118 @@ +import Foundation + +public struct CompanionSettings: Decodable, Equatable, Sendable { + public enum MenuBarMetric: String, Sendable { + case requests, tokens, cost, quota, none + } + + public enum ChartStyle: String, Sendable { + case line, stackedBar + } + + public enum TokenMetric: String, Sendable { + case total, input, output, cached + } + + public enum Aggregation: String, Sendable { + case sum, average, max + } + + public enum ChartGrouping: String, Sendable { + case model, modelAccount + } + + public let menuBarMetric: MenuBarMetric + public let menuBarTemplate: String? + public let showToday: Bool + public let showChart: Bool + public let showModels: Bool + public let showCost: Bool + public let showAccounts: Bool + public let chartHours: Int + public let bucketMinutes: Int + public let chartStyle: ChartStyle + public let tokenMetric: TokenMetric + public let aggregation: Aggregation + public let chartGrouping: ChartGrouping + public let models: [String]? + public let hiddenProviders: [String] + + public static let defaults = CompanionSettings( + menuBarMetric: .tokens, menuBarTemplate: nil, + showToday: true, showChart: true, showModels: true, showCost: true, showAccounts: true, + chartHours: 24, bucketMinutes: 60, chartStyle: .line, tokenMetric: .total, + aggregation: .sum, chartGrouping: .model, models: nil, hiddenProviders: [] + ) + + public init( + menuBarMetric: MenuBarMetric = .tokens, + menuBarTemplate: String? = nil, + showToday: Bool = true, + showChart: Bool = true, + showModels: Bool = true, + showCost: Bool = true, + showAccounts: Bool = true, + chartHours: Int = 24, + bucketMinutes: Int = 60, + chartStyle: ChartStyle = .line, + tokenMetric: TokenMetric = .total, + aggregation: Aggregation = .sum, + chartGrouping: ChartGrouping = .model, + models: [String]? = nil, + hiddenProviders: [String] = [] + ) { + self.menuBarMetric = menuBarMetric + self.menuBarTemplate = menuBarTemplate + self.showToday = showToday + self.showChart = showChart + self.showModels = showModels + self.showCost = showCost + self.showAccounts = showAccounts + self.chartHours = chartHours + self.bucketMinutes = bucketMinutes + self.chartStyle = chartStyle + self.tokenMetric = tokenMetric + self.aggregation = aggregation + self.chartGrouping = chartGrouping + self.models = models + self.hiddenProviders = hiddenProviders + } + + private enum CodingKeys: String, CodingKey { + case menuBarMetric, menuBarTemplate, showToday, showChart, showModels, showCost, showAccounts + case chartHours, bucketMinutes, chartStyle, tokenMetric, aggregation, chartGrouping, models, hiddenProviders + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.init( + menuBarMetric: Self.enumValue(MenuBarMetric.self, try c.decodeIfPresent(String.self, forKey: .menuBarMetric), default: .tokens), + menuBarTemplate: try c.decodeIfPresent(String.self, forKey: .menuBarTemplate), + showToday: try c.decodeIfPresent(Bool.self, forKey: .showToday) ?? true, + showChart: try c.decodeIfPresent(Bool.self, forKey: .showChart) ?? true, + showModels: try c.decodeIfPresent(Bool.self, forKey: .showModels) ?? true, + showCost: try c.decodeIfPresent(Bool.self, forKey: .showCost) ?? true, + showAccounts: try c.decodeIfPresent(Bool.self, forKey: .showAccounts) ?? true, + chartHours: try c.decodeIfPresent(Int.self, forKey: .chartHours) ?? 24, + bucketMinutes: try c.decodeIfPresent(Int.self, forKey: .bucketMinutes) ?? 60, + chartStyle: Self.enumValue(ChartStyle.self, try c.decodeIfPresent(String.self, forKey: .chartStyle), default: .line), + tokenMetric: Self.enumValue(TokenMetric.self, try c.decodeIfPresent(String.self, forKey: .tokenMetric), default: .total), + aggregation: Self.enumValue(Aggregation.self, try c.decodeIfPresent(String.self, forKey: .aggregation), default: .sum), + chartGrouping: Self.enumValue(ChartGrouping.self, try c.decodeIfPresent(String.self, forKey: .chartGrouping), default: .model), + models: try c.decodeIfPresent([String].self, forKey: .models), + hiddenProviders: try c.decodeIfPresent([String].self, forKey: .hiddenProviders) ?? [] + ) + } + + private static func enumValue( + _ type: T.Type, _ raw: String?, default value: T + ) -> T where T.RawValue == String { + raw.flatMap(T.init(rawValue:)) ?? value + } +} + +public struct CompanionSettingsResponse: Decodable, Equatable, Sendable { + public let settings: CompanionSettings + public let updatedAt: Double? + public let corrupt: Bool? +} diff --git a/app/Sources/MenuBarCore/Discovery.swift b/app/Sources/MenuBarCore/Discovery.swift new file mode 100644 index 00000000000..412a5ff5c0b --- /dev/null +++ b/app/Sources/MenuBarCore/Discovery.swift @@ -0,0 +1,80 @@ +import Foundation + +/// A loopback endpoint for the local OpenCodex proxy. +/// +/// The host is deliberately fixed to loopback and never read from disk: the port record +/// is a convenience, not a redirection mechanism. +public struct ProxyEndpoint: Equatable, Sendable { + public static let loopbackHost = "127.0.0.1" + public static let validPorts = 1...65535 + + public let host: String + public let port: Int + private let resolvedURL: URL + + /// Fails rather than traps on an out-of-range port. `baseURL` is built once here, so + /// no accessor can crash later on a value that was never a valid URL. + public init?(port: Int) { + guard Self.validPorts.contains(port), + let url = URL(string: "http://\(Self.loopbackHost):\(port)") + else { return nil } + self.host = Self.loopbackHost + self.port = port + self.resolvedURL = url + } + + /// The default endpoint, which is known-valid by construction. + public static let `default` = ProxyEndpoint(port: ProxyDiscovery.defaultPort)! + + public var baseURL: URL { resolvedURL } + + public var display: String { "\(host):\(port)" } +} + +struct RuntimePortRecord: Decodable { + let pid: Int? + let port: Int +} + +/// Resolves where the proxy is listening, mirroring `resolveRuntimePortPath()` in +/// `src/config.ts`. +public enum ProxyDiscovery { + public static let defaultPort = 10100 + public static var validPorts: ClosedRange { ProxyEndpoint.validPorts } + + /// `OPENCODEX_HOME` when set and non-empty, else `~/.opencodex`. + public static func configDirectory( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + if let override = environment["OPENCODEX_HOME"]?.trimmingCharacters(in: .whitespaces), + !override.isEmpty { + return URL(fileURLWithPath: (override as NSString).expandingTildeInPath) + } + return home.appendingPathComponent(".opencodex", isDirectory: true) + } + + /// Reads `runtime-port.json`, falling back to the default port on any problem. + /// + /// Every failure mode — missing file, malformed JSON, out-of-range port — resolves to + /// the default rather than throwing. A menu bar app that refuses to start because a + /// cache file is unreadable would be worse than one that probes the usual port. + public static func resolve(configDirectory directory: URL) -> ProxyEndpoint { + let file = directory.appendingPathComponent("runtime-port.json") + guard + let data = try? Data(contentsOf: file), + let record = try? JSONDecoder().decode(RuntimePortRecord.self, from: data), + let endpoint = ProxyEndpoint(port: record.port) + else { + return .default + } + return endpoint + } + + public static func resolve( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> ProxyEndpoint { + resolve(configDirectory: configDirectory(environment: environment, home: home)) + } +} diff --git a/app/Sources/MenuBarCore/Formatting.swift b/app/Sources/MenuBarCore/Formatting.swift new file mode 100644 index 00000000000..4008f53f34f --- /dev/null +++ b/app/Sources/MenuBarCore/Formatting.swift @@ -0,0 +1,106 @@ +import Foundation + +/// Number and date presentation for a 340pt popover. +/// +/// Live data reaches `requests: 232507`, `totalTokens: 36536664705`, and +/// `estimatedCostUsd: 34018.25`. Rendering those verbatim destroys the layout, so every +/// value is abbreviated and every unknown is an em dash — never a plausible-looking zero. +public enum Format { + public static let unknown = "—" + + private static let grouping: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + f.groupingSeparator = "," + f.maximumFractionDigits = 0 + return f + }() + + /// Counts: grouped below 10 000, then SI-suffixed with 3 significant figures. + public static func count(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 10_000 { + return grouping.string(from: NSNumber(value: value)) ?? String(value) + } + return abbreviate(Double(value)) + } + + /// Tokens are always suffixed — they are never small enough to be worth grouping. + public static func tokens(_ value: Int?) -> String { + guard let value else { return unknown } + if value < 1_000 { return String(value) } + return abbreviate(Double(value), integer: true) + } + + public static func cost(_ value: Double?) -> String { + guard let value else { return unknown } + if value < 1_000 { + return String(format: "$%.2f", value) + } + return "$" + abbreviate(value) + } + + public static func percent(_ value: Double?) -> String { + guard let value else { return unknown } + return "\(Int(value.rounded()))%" + } + + /// "resets in 3d 4h" / "resets in 12m". Past dates read as "expired". + public static func resetsIn(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let interval = date.timeIntervalSince(now) + guard interval > 0 else { return "expired" } + + let totalMinutes = Int(interval / 60) + let days = totalMinutes / 1440 + let hours = (totalMinutes % 1440) / 60 + let minutes = totalMinutes % 60 + + if days > 0 { return hours > 0 ? "\(days)d \(hours)h" : "\(days)d" } + if hours > 0 { return minutes > 0 ? "\(hours)h \(minutes)m" : "\(hours)h" } + return "\(max(minutes, 1))m" + } + + /// "2m ago" for staleness labels on the degraded state. + public static func age(_ date: Date?, now: Date = Date()) -> String { + guard let date else { return unknown } + let seconds = Int(now.timeIntervalSince(date)) + if seconds < 60 { return "just now" } + if seconds < 3600 { return "\(seconds / 60)m ago" } + if seconds < 86_400 { return "\(seconds / 3600)h ago" } + return "\(seconds / 86_400)d ago" + } + + private static func abbreviate(_ value: Double, integer: Bool = false) -> String { + let units: [(threshold: Double, suffix: String)] = [ + (1_000_000_000_000, "T"), + (1_000_000_000, "B"), + (1_000_000, "M"), + (1_000, "K"), + ] + // Ascending, so promotion is a simple step to the next entry. + let ascending = units.reversed().map { $0 } + + for (index, unit) in ascending.enumerated() where value < (unit.threshold * 1000) { + let rendered = render(value / unit.threshold, suffix: unit.suffix, integer: integer) + // Rounding can push a value across its own boundary: 999_999 scales to + // 999.999K, which would render "1000K" instead of promoting to "1.00M". + guard rendered.hasPrefix("1000"), index + 1 < ascending.count else { return rendered } + let larger = ascending[index + 1] + return render(value / larger.threshold, suffix: larger.suffix, integer: integer) + } + + // Beyond the largest unit, stay in that unit rather than inventing a suffix. + if let largest = ascending.last, value >= largest.threshold { + return render(value / largest.threshold, suffix: largest.suffix, integer: integer) + } + return String(format: "%.0f", value) + } + + /// Render an abbreviated value with either integer or 3-significant-figure precision. + private static func render(_ scaled: Double, suffix: String, integer: Bool = false) -> String { + if integer { return String(format: "%.0f%@", scaled, suffix) } + let decimals = scaled >= 100 ? 0 : (scaled >= 10 ? 1 : 2) + return String(format: "%.\(decimals)f%@", scaled, suffix) + } +} diff --git a/app/Sources/MenuBarCore/Keychain.swift b/app/Sources/MenuBarCore/Keychain.swift new file mode 100644 index 00000000000..2d67ecb5de3 --- /dev/null +++ b/app/Sources/MenuBarCore/Keychain.swift @@ -0,0 +1,76 @@ +import Foundation +import Security + +/// Generic-password storage for the optional management API key. +/// +/// The key is read lazily — only after a 401 — and is never written to UserDefaults, +/// never logged, and never included in an error surfaced to the UI. +/// +/// **Read-only in practice today, and there is no way to provision the key.** Nothing in +/// the app calls `write`, because there is no key-entry UI yet — and a user cannot fill +/// the gap by hand either: every query sets `kSecUseDataProtectionKeychain`, and +/// Keychain Access does not create data-protection items. So a non-loopback bind is +/// genuinely unsupported rather than merely inconvenient, and the docs say exactly that. +/// +/// `write`/`delete` exist for the native entry flow that is planned. Do not document a +/// manual workaround on top of them: an earlier revision of the guide did, naming a +/// service that was both wrong and unreachable. +/// +/// Every query sets `kSecUseDataProtectionKeychain`. Without it, `kSecAttrAccessible` is +/// ignored on macOS (it applies only to data-protection or synchronizable items), so the +/// declared accessibility class would be decorative. Setting it on *all* operations also +/// matters for correctness: a data-protection item is invisible to a query that omits +/// the flag, so a mixed set of queries would fail to find or delete its own items. +public enum Keychain { + public static let service = "com.opencodex.menubar.apikey" + public static let defaultAccount = "default" + + private static func baseQuery(account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecUseDataProtectionKeychain as String: true, + ] + } + + public static func read(account: String = defaultAccount) -> String? { + var query = baseQuery(account: account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let value = String(data: data, encoding: .utf8), + !value.isEmpty + else { return nil } + return value + } + + @discardableResult + public static func write(_ value: String, account: String = defaultAccount) -> Bool { + let data = Data(value.utf8) + + // Update first, add only when absent. Deleting first would destroy a working key + // whenever the subsequent add failed. + let updateStatus = SecItemUpdate( + baseQuery(account: account) as CFDictionary, + [kSecValueData as String: data] as CFDictionary + ) + if updateStatus == errSecSuccess { return true } + guard updateStatus == errSecItemNotFound else { return false } + + var attributes = baseQuery(account: account) + attributes[kSecValueData as String] = data + // ThisDeviceOnly: the key is a local proxy credential with no reason to migrate + // to another machine via backup or transfer. + attributes[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + return SecItemAdd(attributes as CFDictionary, nil) == errSecSuccess + } + + @discardableResult + public static func delete(account: String = defaultAccount) -> Bool { + let status = SecItemDelete(baseQuery(account: account) as CFDictionary) + return status == errSecSuccess || status == errSecItemNotFound + } +} diff --git a/app/Sources/MenuBarCore/MenuBarTitle.swift b/app/Sources/MenuBarCore/MenuBarTitle.swift new file mode 100644 index 00000000000..c17613e3c85 --- /dev/null +++ b/app/Sources/MenuBarCore/MenuBarTitle.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum MenuBarTitle { + public static func render( + settings: CompanionSettings, + today: UsageReport?, + quotas: [NormalizedQuota] + ) -> String? { + let summary = today?.summary + let values: [String: String] = [ + "requests": Format.count(summary?.requests), + "totalTokens": Format.tokens(summary?.totalTokens), + "inputTokens": Format.tokens(summary?.inputTokens), + "outputTokens": Format.tokens(summary?.outputTokens), + "costUsd": Format.cost(summary?.estimatedCostUsd), + "quotaPercent": Format.percent(quotas.compactMap(\.percent).min()), + ] + let rendered: String + if let template = settings.menuBarTemplate?.trimmingCharacters(in: .whitespacesAndNewlines), + !template.isEmpty { + rendered = values.reduce(template) { text, item in + text.replacingOccurrences(of: "{\(item.key)}", with: item.value) + } + } else { + switch settings.menuBarMetric { + case .requests: rendered = Format.count(summary?.requests) + case .tokens: rendered = Format.tokens(summary?.totalTokens) + case .cost: rendered = Format.cost(summary?.estimatedCostUsd) + case .quota: rendered = Format.percent(quotas.compactMap(\.percent).min()) + case .none: return nil + } + } + let text = rendered.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + if text.count <= 24 { return text } + return String(text.prefix(23)) + "…" + } +} diff --git a/app/Sources/MenuBarCore/PollingCoordinator.swift b/app/Sources/MenuBarCore/PollingCoordinator.swift new file mode 100644 index 00000000000..4125657a62b --- /dev/null +++ b/app/Sources/MenuBarCore/PollingCoordinator.swift @@ -0,0 +1,268 @@ +import Foundation + +/// Owns the refresh schedule and turns transport results into a `ProxySnapshot`. +/// +/// Polling is deliberately conservative. A menu bar app that hits a local server every +/// five seconds forever is a battery complaint waiting to happen, so heavy aggregation +/// endpoints are fetched only while the popover is open, and repeated failures back the +/// liveness tick off rather than hammering a proxy the user has stopped on purpose. +public actor PollingCoordinator { + public static let livenessInterval: TimeInterval = 5 + public static let heavyInterval: TimeInterval = 60 + public static let backoffInterval: TimeInterval = 30 + public static let backoffAfterFailures = 3 + + private let client: ProxyClient + private var snapshot: ProxySnapshot + private var popoverOpen = false + private var observers: [UUID: @Sendable (ProxySnapshot) -> Void] = [:] + /// Rises on every close and on every new refresh, so results from a superseded or + /// abandoned cycle can be discarded instead of overwriting fresher state. + private var generation = 0 + private var refreshInFlight = false + /// A refresh requested while another was in flight. Without this, closing and + /// immediately reopening the popover dropped the reopen's refresh entirely: the old + /// cycle exited on its generation guard and the new one had already been rejected. + private var pendingOpenRefresh = false + /// Continuations waiting for a cycle to publish. Waiting on a real completion signal + /// rather than a bounded spin means a slow-but-legitimate refresh cannot be + /// abandoned early, which would re-enable a control against pre-write state. + private var completionWaiters: [CheckedContinuation] = [] + /// Attempt time, distinct from success time: a persistently failing endpoint must + /// not turn its healthy sibling into a 5-second poller. + private var lastAggregationAttempt: Date? + + public init(client: ProxyClient, endpoint: ProxyEndpoint) { + self.client = client + self.snapshot = ProxySnapshot(endpoint: endpoint) + } + + public var current: ProxySnapshot { snapshot } + + /// Interval until the next liveness tick, widened once failures pile up. + public var currentInterval: TimeInterval { + snapshot.consecutiveFailures >= Self.backoffAfterFailures + ? Self.backoffInterval + : Self.livenessInterval + } + + @discardableResult + public func observe(_ handler: @escaping @Sendable (ProxySnapshot) -> Void) -> UUID { + let token = UUID() + observers[token] = handler + handler(snapshot) + return token + } + + public func removeObserver(_ token: UUID) { observers[token] = nil } + + public func setPopoverOpen(_ open: Bool) async { + popoverOpen = open + if open { + await refresh(includeHeavy: true) + } else { + // Abandon in-flight heavy work: its results are no longer visible and + // must not land as if they were current. + generation &+= 1 + } + } + + /// One refresh cycle. + /// + /// `includeHeavy` marks a popover-open refresh: on-open reads (providers, config) + /// always run, while the expensive aggregation reads (usage, quotas) still respect + /// the 60s interval so reopening the popover repeatedly does not hammer the proxy. + public func refresh(includeHeavy: Bool = false) async { + // Overlapping cycles publish interleaved state and double the request rate. + guard !refreshInFlight else { + if includeHeavy { pendingOpenRefresh = true } + return + } + refreshInFlight = true + generation &+= 1 + let cycle = generation + defer { refreshInFlight = false } + + do { + let health = try await client.health() + guard cycle == generation else { + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } + snapshot.state = .running(health) + snapshot.lastKnownStartCommand = health.manualStartCommand + snapshot.recommendedCommand = health.recommendedCommand + snapshot.consecutiveFailures = 0 + snapshot.lastUpdated = Date() + } catch is CancellationError { + // The popover closed mid-flight. Not a proxy failure; leave state untouched. + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } catch let error as ProxyError { + if cycle == generation { apply(error); publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } catch { + if cycle == generation { apply(.transport); publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + return + } + + if popoverOpen, includeHeavy { + await refreshOnOpen(cycle: cycle) + } + + // Settings and today metrics also drive the menu-bar title, so aggregation runs + // on the normal cadence even while the popover is closed. + let aggregationDue = lastAggregationAttempt.map { + Date().timeIntervalSince($0) >= Self.heavyInterval + } ?? true + if aggregationDue, isCurrentCycle(cycle) { + lastAggregationAttempt = Date() + _ = await refreshAggregation(cycle: cycle) + } + + if cycle == generation { publish() } + refreshInFlight = false + await drainPendingRefresh() + signalCompletionIfIdle() + } + + /// Refreshes and does not return until a cycle has actually published. + /// + /// `refresh()` coalesces: if another cycle holds the lock it queues and returns + /// immediately. A caller that needs authoritative state afterwards — such as + /// re-enabling a switch after a write — would otherwise act on pre-write data. + public func refreshAndWait(includeHeavy: Bool = true) async { + if refreshInFlight { + // Queue behind the running cycle and wait for the queued one to finish. + await refresh(includeHeavy: includeHeavy) + await waitForCompletion() + return + } + await refresh(includeHeavy: includeHeavy) + } + + /// Number of callers currently suspended in `waitForCompletion()`. + /// + /// Exposed so a test can wait for registration deterministically instead of sleeping + /// and hoping the waiter task was scheduled — a fixed sleep let the continuation + /// tests pass without ever entering this path. + package var waiterCount: Int { completionWaiters.count } + + private func waitForCompletion() async { + guard refreshInFlight || pendingOpenRefresh else { return } + await withCheckedContinuation { continuation in + completionWaiters.append(continuation) + } + } + + /// Releases anyone waiting once no cycle is running or queued. + private func signalCompletionIfIdle() { + guard !refreshInFlight, !pendingOpenRefresh, !completionWaiters.isEmpty else { return } + let waiters = completionWaiters + completionWaiters.removeAll() + for waiter in waiters { waiter.resume() } + } + + /// Runs a refresh that arrived while another cycle held the lock. + private func drainPendingRefresh() async { + guard pendingOpenRefresh, popoverOpen else { + pendingOpenRefresh = false + return + } + pendingOpenRefresh = false + await refresh(includeHeavy: true) + } + + /// Reads that are only meaningful while the popover is open. + private func refreshOnOpen(cycle: Int) async { + guard isCurrent(cycle) else { return } + if let providers = try? await client.providers(), isCurrent(cycle) { + snapshot.providers = providers + snapshot.providersLoaded = true + } + // Re-check before each subsequent request: closing mid-flight should stop the + // sequence, not merely discard its results after paying for them. + guard isCurrent(cycle) else { return } + if let config = try? await client.config(), isCurrent(cycle) { + snapshot.defaultProvider = config.defaultProvider + } + } + + /// Still the newest cycle, and still worth doing. + private func isCurrent(_ cycle: Int) -> Bool { cycle == generation && popoverOpen } + private func isCurrentCycle(_ cycle: Int) -> Bool { cycle == generation } + + /// The expensive aggregation reads. Returns whether every read landed, so a partial + /// failure does not masquerade as a completed refresh. + private func refreshAggregation(cycle: Int) async -> Bool { + guard isCurrentCycle(cycle) else { return false } + var complete = true + + // Each read is independent: one failing endpoint must not blank the others. + if let response = try? await client.companionSettings() { + guard isCurrentCycle(cycle) else { return false } + snapshot.settings = response.settings + snapshot.settingsLoaded = true + } else { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if let today = try? await client.usage(range: .today) { + guard isCurrentCycle(cycle) else { return false } + snapshot.today = today + snapshot.usage = today + snapshot.usageUpdated = Date() + } else { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if snapshot.settings.showChart, let timeline = try? await client.timeline(snapshot.settings) { + guard isCurrentCycle(cycle) else { return false } + snapshot.timeline = timeline + snapshot.timelineUpdated = Date() + } else if snapshot.settings.showChart { + complete = false + } + + guard isCurrentCycle(cycle) else { return false } + if (popoverOpen || snapshot.settings.menuBarMetric == .quota), let quotas = try? await client.quotas() { + guard isCurrent(cycle) else { return false } + snapshot.quotas = quotas + snapshot.quotasLoaded = true + } else if popoverOpen || snapshot.settings.menuBarMetric == .quota { + complete = false + } + + return complete + } + + private func apply(_ error: ProxyError) { + snapshot.consecutiveFailures += 1 + switch error { + case .unreachable: + snapshot.state = .unreachable + case .unauthorized: + snapshot.state = .unauthorized + case .http, .decoding, .transport, .inconclusive: + // A timeout is degraded, not stopped: something may well still be running. + snapshot.state = .degraded(error.userMessage) + } + } + + private func publish() { + let value = snapshot + for handler in observers.values { handler(value) } + } +} diff --git a/app/Sources/MenuBarCore/ProxyClient.swift b/app/Sources/MenuBarCore/ProxyClient.swift new file mode 100644 index 00000000000..53188278140 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyClient.swift @@ -0,0 +1,332 @@ +import Foundation + +public enum ProxyError: Error, Equatable { + /// The connection was refused — nothing is listening. This is the only transport + /// result that proves the proxy is gone; timeouts get `.inconclusive`. + case unreachable + /// 401 — a non-loopback bind that requires a credential. + case unauthorized + case http(Int) + case decoding + /// A transport failure that is not evidence the proxy is down (TLS, policy, and + /// other non-connectivity URLSession errors). + case transport + /// The request never completed — a timeout or a socket dropped mid-response. This + /// proves nothing either way, and must not be read as "the proxy is gone". + case inconclusive + + /// Human sentences only. Response bodies can echo configuration values, so they + /// never reach the UI or a log. + public var userMessage: String { + switch self { + case .unreachable: return "The proxy is not running." + case .unauthorized: return "This proxy requires an API key." + case .http(let code): return "The proxy returned an unexpected status (\(code))." + case .decoding: return "The proxy returned a response this app could not read." + case .transport: return "The connection to the proxy failed." + case .inconclusive: return "The proxy did not respond in time." + } + } +} + +/// Supplies the optional management API key. Injected so tests never touch the real +/// Keychain and so the app can swap the source without touching transport code. +public protocol CredentialStore: Sendable { + func loadAPIKey() -> String? +} + +public struct KeychainCredentialStore: CredentialStore { + public init() {} + public func loadAPIKey() -> String? { Keychain.read() } +} + +/// HTTP client for the OpenCodex management API. +/// +/// An actor because the endpoint and key are mutated from both the polling loop and user +/// actions; the isolation makes that data-race-free by construction rather than by +/// convention. +public actor ProxyClient { + private let session: URLSession + private let credentials: CredentialStore + private var endpoint: ProxyEndpoint + private var apiKey: String? + /// Ensures the lazy credential load happens at most once per client. + private var didAttemptCredentialLoad = false + + public init( + endpoint: ProxyEndpoint, + session: URLSession? = nil, + credentials: CredentialStore = KeychainCredentialStore() + ) { + self.endpoint = endpoint + self.credentials = credentials + if let session { + self.session = session + } else { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 4 + config.waitsForConnectivity = false + self.session = URLSession(configuration: config) + } + } + + public var currentEndpoint: ProxyEndpoint { endpoint } + + public func updateEndpoint(_ endpoint: ProxyEndpoint) { self.endpoint = endpoint } + + public func setAPIKey(_ key: String?) { + self.apiKey = key + // An explicitly supplied key replaces the lazy path entirely. + self.didAttemptCredentialLoad = true + } + + // MARK: - Reads + + public func health() async throws -> StartupHealth { try await get("api/startup-health") } + public func settings() async throws -> ProxySettings { try await get("api/settings") } + public func config() async throws -> ProxyConfigSummary { try await get("api/config") } + public func providers() async throws -> [ProviderSummary] { try await get("api/providers") } + + public func usage(range: UsageRange = .sevenDays) async throws -> UsageReport { + try await get("api/usage", query: [URLQueryItem(name: "range", value: range.rawValue)]) + } + + public func companionSettings() async throws -> CompanionSettingsResponse { + try await get("api/companion/settings") + } + + public func timeline(_ settings: CompanionSettings) async throws -> UsageTimeline { + var query = [ + URLQueryItem(name: "hours", value: String(settings.chartHours)), + URLQueryItem(name: "bucketMinutes", value: String(settings.bucketMinutes)), + URLQueryItem(name: "metric", value: settings.tokenMetric.rawValue), + URLQueryItem(name: "aggregation", value: settings.aggregation.rawValue), + URLQueryItem(name: "grouping", value: settings.chartGrouping.rawValue), + ] + if let models = settings.models, !models.isEmpty { + query.append(URLQueryItem(name: "models", value: models.joined(separator: ","))) + } + return try await get("api/usage/timeline", query: query) + } + + public func quotas() async throws -> [QuotaReport] { + let envelope: QuotaEnvelope = try await get("api/provider-quotas") + return envelope.reports ?? [] + } + + /// What a liveness probe actually established. + /// + /// Three states, not two. "Did not get a usable answer" and "nothing is listening" + /// are different facts, and conflating them let a stop be reported as confirmed + /// while an HTTP server was still running behind a 500 or a decode failure. + public enum Liveness: Equatable, Sendable { + /// Something answered — any HTTP status, including 401/403/500, or a body we + /// could not decode. The port is occupied. + case reachable + /// The connection was refused. This is the only proof that the proxy is gone. + case refused + /// A timeout or other transport failure: no conclusion either way. + case indeterminate + } + + /// A short probe: the default 4s read timeout would let a single liveness check + /// overrun the stop deadline it is supposed to respect. + public func liveness(timeout: TimeInterval = 1.5) async -> Liveness { + do { + // Deliberately bypasses `send()`: its 401 credential retry would spend a + // second full timeout re-asking a question the 401 already answered, and a + // failed retry would downgrade a known-reachable result to indeterminate. + _ = try await perform( + method: "GET", path: "api/settings", query: [], + body: nil as EmptyBody?, timeout: timeout + ) + return .reachable + } catch ProxyError.unauthorized, ProxyError.decoding { + // Both prove a server answered. + return .reachable + } catch ProxyError.http { + return .reachable + } catch ProxyError.unreachable { + // Connection refused: nothing is listening on the port. + return .refused + } catch { + // Timeouts, dropped sockets, and anything else: no conclusion. + return .indeterminate + } + } + + /// Convenience for callers that only need "is anything there". + public func isReachable() async -> Bool { + await liveness() != .refused + } + + // MARK: - Writes + + /// `POST /api/stop`. Returns once the proxy has accepted the request. + /// + /// The proxy answers 200 *before* draining, and it stops the launchd service first so + /// nothing respawns it. Callers must poll `liveness()` rather than treat this return + /// as "stopped". + /// + /// The response carries `success: false` when `restoreNativeCodex()` failed + /// (`src/server/management-api.ts:145-147`): the proxy still shuts down, but native + /// Codex was left pointing at a port that is about to close. Only the boolean is + /// decoded — the accompanying message is a server-formatted string and never reaches + /// the UI. + @discardableResult + public func stop() async throws -> Bool { + let data = try await send(method: "POST", path: "api/stop", body: nil as EmptyBody?) + guard let result = try? JSONDecoder().decode(StopResult.self, from: data) else { + // An undecodable body is not a reason to claim the restore failed. + return true + } + return result.success ?? true + } + + /// `PATCH /api/providers?name=` with a body of exactly `{"disabled": }`. + /// + /// A disabled-only patch skips the proxy's heavier merged-shape validators, so adding + /// any second field would silently change the request class. + public func setProviderDisabled(_ name: String, disabled: Bool) async throws { + _ = try await send( + method: "PATCH", + path: "api/providers", + query: [URLQueryItem(name: "name", value: name)], + body: ProviderDisabledPatch(disabled: disabled) + ) + } + + // MARK: - Transport + + private func get( + _ path: String, + query: [URLQueryItem] = [], + timeout: TimeInterval? = nil + ) async throws -> T { + let data = try await send( + method: "GET", path: path, query: query, + body: nil as EmptyBody?, timeout: timeout + ) + do { + return try JSONDecoder().decode(T.self, from: data) + } catch { + throw ProxyError.decoding + } + } + + private func send( + method: String, + path: String, + query: [URLQueryItem] = [], + body: Body?, + timeout: TimeInterval? = nil + ) async throws -> Data { + let keyAtStart = apiKey + do { + return try await perform(method: method, path: path, query: query, body: body, timeout: timeout) + } catch ProxyError.unauthorized { + // A loopback proxy needs no credential, so a 401 means this install is bound + // to a non-loopback host. + // + // Reentrancy matters here: the actor suspends across the request, so several + // calls can be in flight and all receive 401. Retry eligibility is therefore + // decided per request, against the key THAT request actually sent — not + // against a single global "already tried" flag. A concurrent caller that + // started before the key was loaded must still get to retry with it. + guard let key = try await credentialForRetry(after: keyAtStart) else { + throw ProxyError.unauthorized + } + return try await perform(method: method, path: path, query: query, body: body, key: key, timeout: timeout) + } + } + + /// The key to retry with, or `nil` when this request already used the current + /// credential (so retrying would repeat an identical, failing call). + private func credentialForRetry(after keyAtStart: String?) async throws -> String? { + // Another in-flight call already loaded a key this request did not use. + if let current = apiKey, current != keyAtStart { return current } + // This request already carried the newest key: a stale credential, not a + // missing one. Never loop. + if apiKey != nil, apiKey == keyAtStart { return nil } + + guard !didAttemptCredentialLoad else { return nil } + didAttemptCredentialLoad = true + guard let stored = credentials.loadAPIKey(), !stored.isEmpty else { return nil } + apiKey = stored + return stored + } + + private func perform( + method: String, + path: String, + query: [URLQueryItem], + body: Body?, + key: String? = nil, + timeout: TimeInterval? = nil + ) async throws -> Data { + guard var components = URLComponents( + url: endpoint.baseURL.appendingPathComponent(path), + resolvingAgainstBaseURL: false + ) else { throw ProxyError.decoding } + if !query.isEmpty { components.queryItems = query } + guard let url = components.url else { throw ProxyError.decoding } + + var request = URLRequest(url: url) + request.httpMethod = method + request.timeoutInterval = timeout ?? (method == "GET" ? 4 : 6) + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" + request.setValue("OpenCodexMenuBar/\(version)", forHTTPHeaderField: "User-Agent") + if let credential = key ?? apiKey { + request.setValue(credential, forHTTPHeaderField: "x-opencodex-api-key") + } + if let body { + request.setValue("application/json", forHTTPHeaderField: "content-type") + request.httpBody = try? JSONEncoder().encode(body) + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw ProxyError.decoding } + if http.statusCode == 401 { throw ProxyError.unauthorized } + guard (200..<300).contains(http.statusCode) else { + throw ProxyError.http(http.statusCode) + } + return data + } catch let error as ProxyError { + throw error + } catch let error as URLError { + switch error.code { + case .cancelled: + // Propagate cancellation rather than reporting a stopped proxy: the + // polling coordinator cancels in-flight work whenever the popover closes. + throw CancellationError() + case .cannotConnectToHost: + // The one code that actually proves nothing is listening. + throw ProxyError.unreachable + case .timedOut, .networkConnectionLost, .cannotFindHost, + .notConnectedToInternet, .dnsLookupFailed: + // A timeout or a dropped socket says the request failed, not that the + // server is gone. Collapsing these into `.unreachable` is what let a + // stop be reported as confirmed while the proxy was still running. + throw ProxyError.inconclusive + default: + throw ProxyError.transport + } + } + } +} + +private struct QuotaEnvelope: Decodable { + let generatedAt: Double? + let reports: [QuotaReport]? +} + +private struct ProviderDisabledPatch: Encodable { + let disabled: Bool +} + +private struct StopResult: Decodable { + let success: Bool? +} + +private struct EmptyBody: Encodable {} diff --git a/app/Sources/MenuBarCore/ProxyModels.swift b/app/Sources/MenuBarCore/ProxyModels.swift new file mode 100644 index 00000000000..b79034e394b --- /dev/null +++ b/app/Sources/MenuBarCore/ProxyModels.swift @@ -0,0 +1,285 @@ +import Foundation + +// Codable mirrors of the management API payloads inventoried in +// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. +// +// Every field the proxy may omit is optional. The proxy is a fast-moving local service; +// a companion that fails to decode because one field moved is worse than one that shows +// an em dash. + +/// `GET /api/startup-health` +public struct StartupHealth: Decodable, Equatable, Sendable { + public let status: String? + public let protection: String? + public let platform: String? + public let routingKind: String? + public let serviceRunning: Bool? + public let serviceInstalled: Bool? + public let serviceEnabled: Bool? + public let rebootSafe: Bool? + public let recommendedCommand: String? + + public init( + status: String? = nil, + protection: String? = nil, + platform: String? = nil, + routingKind: String? = nil, + serviceRunning: Bool? = nil, + serviceInstalled: Bool? = nil, + serviceEnabled: Bool? = nil, + rebootSafe: Bool? = nil, + recommendedCommand: String? = nil + ) { + self.status = status + self.protection = protection + self.platform = platform + self.routingKind = routingKind + self.serviceRunning = serviceRunning + self.serviceInstalled = serviceInstalled + self.serviceEnabled = serviceEnabled + self.rebootSafe = rebootSafe + self.recommendedCommand = recommendedCommand + } + + /// `status` is treated as an open string: unknown values degrade to a neutral state + /// rather than crashing or being coerced into "healthy". + public var isProtected: Bool { status == "protected" } + + /// True when a supervisor owns the process lifecycle. Used only for the qualifier + /// line — it deliberately does not gate any action, because `/api/stop` stops the + /// service on purpose and nothing restarts the proxy automatically. + public var isServiceManaged: Bool { + (serviceInstalled ?? false) && (serviceEnabled ?? false) + } + + /// The command to show the user when the proxy is not running. + public var manualStartCommand: String { + isServiceManaged ? "ocx service start" : "ocx start" + } +} + +/// `GET /api/settings`. Note the absence of `defaultProvider` — it lives on +/// `/api/config`, verified against the live key set. +public struct ProxySettings: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let streamMode: String? + public let codexAutoStart: Bool? +} + +/// `GET /api/config` — the only source of `defaultProvider`. +public struct ProxyConfigSummary: Decodable, Equatable, Sendable { + public let port: Int? + public let hostname: String? + public let defaultProvider: String? +} + +/// Ranges accepted by `parseRange()` in `src/usage/summary.ts`. +/// +/// Closed on purpose: the server silently degrades anything else to `30d`, so a +/// stringly-typed range would let a caller ask for `24h`, receive thirty days of data, +/// and label it wrongly. +public enum UsageRange: String, Sendable, CaseIterable { + case today = "today" + case sevenDays = "7d" + case thirtyDays = "30d" + case all +} + +public struct UsageSummary: Decodable, Equatable, Sendable { + public let requests: Int? + public let measuredRequests: Int? + public let estimatedRequests: Int? + public let totalTokens: Int? + public let inputTokens: Int? + public let outputTokens: Int? + public let estimatedCostUsd: Double? + public let coverageRatio: Double? + + public var hasEstimates: Bool { (estimatedRequests ?? 0) > 0 } +} + +public struct UsageDay: Decodable, Equatable, Sendable { + public let date: String + public let requests: Int? + public let totalTokens: Int? +} + +public struct UsageReport: Decodable, Equatable, Sendable { + public let range: String? + public let surface: String? + public let generatedAt: Double? + public let summary: UsageSummary? + public let days: [UsageDay]? + public let models: [UsageModelRow]? + public let accounts: [UsageAccountRow]? + + /// The range the server actually applied, which is not always the one requested. + public var effectiveRange: UsageRange? { + range.flatMap(UsageRange.init(rawValue:)) + } + + /// Header text driven by the response, never by the request. + public var rangeLabel: String { + switch effectiveRange { + case .today: return "TODAY" + case .sevenDays: return "LAST 7 DAYS" + case .thirtyDays: return "LAST 30 DAYS" + case .all: return "ALL TIME" + case nil: return "USAGE" + } + } + + public var isEmpty: Bool { + isEmptyOrUnknown == true + } + + /// Three states, not two: `nil` means the proxy did not report a request count, and + /// `true` means it explicitly reported zero. Collapsing those would let the UI print + /// "No requests" for data it simply does not have. + public var isEmptyOrUnknown: Bool? { + guard let requests = summary?.requests else { return nil } + return requests == 0 + } +} + +public struct UsageModelRow: Decodable, Equatable, Sendable { + public let provider: String? + public let model: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + +public struct UsageAccountRow: Decodable, Equatable, Sendable { + public let accountLogLabel: String? + public let requests: Int? + public let totalTokens: Int? + public let estimatedCostUsd: Double? +} + +public struct QuotaWindow: Decodable, Equatable, Sendable { + public let label: String? + public let percent: Double? + public let resetAt: Double? +} + +public struct ProviderQuota: Decodable, Equatable, Sendable { + public let weeklyPercent: Double? + public let monthlyPercent: Double? + public let fiveHourPercent: Double? + public let weeklyResetAt: Double? + public let monthlyResetAt: Double? + public let fiveHourResetAt: Double? + public let customWindows: [QuotaWindow]? + public let updatedAt: Double? +} + +public struct QuotaReport: Decodable, Equatable, Sendable { + public let provider: String + public let label: String? + public let source: String? + public let quota: ProviderQuota? +} + +/// A provider-agnostic view of quota, since the window key differs per provider. +public struct NormalizedQuota: Equatable, Sendable { + public let provider: String + public let providerLabel: String + public let percent: Double? + public let windowLabel: String + public let resetAt: Date? + + public var hasPercent: Bool { percent != nil } +} + +public extension QuotaReport { + /// Timestamps in this payload are not uniform: the live proxy returns + /// `weeklyResetAt` in seconds for `openai` and in milliseconds for `anthropic`, + /// within the same array. Disambiguate by magnitude — 1e12 is 2001 read as + /// milliseconds and year 33658 read as seconds, so the boundary is unambiguous for + /// any timestamp this app will ever see. + static func date(from value: Double?) -> Date? { + guard let value, value > 0 else { return nil } + let seconds = value >= 1_000_000_000_000 ? value / 1000 : value + return Date(timeIntervalSince1970: seconds) + } + + /// Every window the provider reported, in display order. + /// + /// The live proxy is not uniform: `openai` and `xai` report a single named window, + /// `kimi` reports both `weeklyPercent` and `fiveHourPercent`, and `cursor` and + /// `google-antigravity` carry two `customWindows` each. Returning only one window + /// would silently hide real quota pressure. + func normalizedWindows() -> [NormalizedQuota] { + let name = label ?? provider + var windows: [NormalizedQuota] = [] + + func append(_ percent: Double?, _ windowLabel: String, _ resetAt: Double?) { + guard percent != nil || resetAt != nil else { return } + windows.append(NormalizedQuota( + provider: provider, providerLabel: name, percent: percent, + windowLabel: windowLabel, resetAt: Self.date(from: resetAt) + )) + } + + append(quota?.fiveHourPercent, "5h", quota?.fiveHourResetAt) + append(quota?.weeklyPercent, "week", quota?.weeklyResetAt) + append(quota?.monthlyPercent, "month", quota?.monthlyResetAt) + + for window in quota?.customWindows ?? [] { + append(window.percent, window.label ?? "window", window.resetAt) + } + + return windows + } + + /// The single window that best represents current pressure, for the compact row. + /// + /// Selection is **highest reported usage**, not longest horizon. Every window can + /// stop work: a provider at 99% of a five-hour limit and 10% of its monthly limit is + /// blocked right now, and showing the monthly 10% would paint that row green while + /// the user cannot make a request. Ties break toward the longer horizon, since that + /// is the one that will not recover on its own. + /// + /// Providers with no numeric window normalize to a nil percent so the UI renders an + /// em dash rather than a misleading zero. + func normalized() -> NormalizedQuota { + let name = label ?? provider + let windows = normalizedWindows() + + // Longer horizons rank higher only as a tie-breaker. + func horizonRank(_ label: String) -> Int { + switch label { + case "month": return 3 + case "week": return 2 + case "5h": return 1 + default: return 0 + } + } + + let measured = windows.filter(\.hasPercent) + let preferred = measured.max { lhs, rhs in + let left = lhs.percent ?? 0 + let right = rhs.percent ?? 0 + if left != right { return left < right } + return horizonRank(lhs.windowLabel) < horizonRank(rhs.windowLabel) + } ?? windows.first + + return preferred ?? NormalizedQuota( + provider: provider, providerLabel: name, percent: nil, + windowLabel: "—", resetAt: nil + ) + } +} + +/// `GET /api/providers`. `hasApiKey` is a presence flag; the key never leaves the proxy. +public struct ProviderSummary: Decodable, Equatable, Sendable { + public let name: String + public let adapter: String? + public let authMode: String? + public let hasApiKey: Bool? + public let disabled: Bool? + + public var isEnabled: Bool { !(disabled ?? false) } +} diff --git a/app/Sources/MenuBarCore/ProxySnapshot.swift b/app/Sources/MenuBarCore/ProxySnapshot.swift new file mode 100644 index 00000000000..30416a5a7f9 --- /dev/null +++ b/app/Sources/MenuBarCore/ProxySnapshot.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Everything the UI can show, as one value. +/// +/// Views are pure functions of this snapshot, so no view invents its own loading flag or +/// decides independently whether data is missing. +public enum ProxyState: Equatable, Sendable { + /// First fetch in flight; nothing is known yet. + case loading + case running(StartupHealth) + /// Connection refused — the proxy is not running. + case unreachable + /// 401 with no usable credential. + case unauthorized + /// Reachable but erroring. The message is proxy-free human text. + case degraded(String) + + public var isRunning: Bool { + if case .running = self { return true } + return false + } + + /// Short label shown beside the status dot. Colour is never the only carrier of + /// meaning, so every state has a word. + public var title: String { + switch self { + case .loading: return "Checking…" + case .running: return "Running" + case .unreachable: return "Stopped" + case .unauthorized: return "Needs API key" + case .degraded: return "Degraded" + } + } + + public enum Tone: Sendable { case neutral, good, warning, bad } + + public var tone: Tone { + switch self { + case .loading: return .neutral + case .running(let health): return health.isProtected ? .good : .warning + case .unreachable: return .bad + case .unauthorized: return .warning + case .degraded: return .warning + } + } + + /// Secondary line under the title. + public var detail: String? { + switch self { + case .loading: + return nil + case .running(let health): + let parts = [health.status, health.protection] + .compactMap { $0 } + .filter { !$0.isEmpty && $0 != "none" } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + case .unreachable: + return "The proxy is not running." + case .unauthorized: + return "This proxy requires an API key." + case .degraded(let message): + return message + } + } +} + +/// What the user should do next. `loading` deliberately has none — there is nothing to +/// act on yet — but every other non-running state names one. +public enum NextAction: Equatable, Sendable { + case none + /// A command to run, shown as selectable text. The app never spawns processes. + case runCommand(String) + case addAPIKey + case retry +} + +public struct ProxySnapshot: Equatable, Sendable { + public var state: ProxyState + public var endpoint: ProxyEndpoint + public var usage: UsageReport? + public var settings: CompanionSettings + public var settingsLoaded: Bool + public var today: UsageReport? + public var timeline: UsageTimeline? + public var timelineUpdated: Date? + public var quotas: [QuotaReport] + public var providers: [ProviderSummary] + public var defaultProvider: String? + public var lastUpdated: Date? + public var consecutiveFailures: Int + /// Remembered from the last successful health read, so a stopped proxy can still + /// tell the user the right start command for their install. + public var lastKnownStartCommand: String? + /// The proxy's own remediation hint (for example `ocx service install`). Displayed + /// as selectable text, never executed. + public var recommendedCommand: String? + /// Whether a section has actually been read, so "not fetched yet" and "the proxy + /// reported none" render differently. + public var providersLoaded: Bool + public var quotasLoaded: Bool + /// When the aggregation data last succeeded, which is NOT when health last + /// succeeded. Conflating them let a degraded state claim "showing data from 5s ago" + /// while holding no metrics at all. + public var usageUpdated: Date? + + public init( + state: ProxyState = .loading, + endpoint: ProxyEndpoint, + usage: UsageReport? = nil, + settings: CompanionSettings = .defaults, + settingsLoaded: Bool = false, + today: UsageReport? = nil, + timeline: UsageTimeline? = nil, + timelineUpdated: Date? = nil, + quotas: [QuotaReport] = [], + providers: [ProviderSummary] = [], + defaultProvider: String? = nil, + lastUpdated: Date? = nil, + consecutiveFailures: Int = 0, + lastKnownStartCommand: String? = nil, + recommendedCommand: String? = nil, + providersLoaded: Bool = false, + quotasLoaded: Bool = false, + usageUpdated: Date? = nil + ) { + self.state = state + self.endpoint = endpoint + self.usage = usage + self.settings = settings + self.settingsLoaded = settingsLoaded + self.today = today + self.timeline = timeline + self.timelineUpdated = timelineUpdated + self.quotas = quotas + self.providers = providers + self.defaultProvider = defaultProvider + self.lastUpdated = lastUpdated + self.consecutiveFailures = consecutiveFailures + self.lastKnownStartCommand = lastKnownStartCommand + self.recommendedCommand = recommendedCommand + self.providersLoaded = providersLoaded + self.quotasLoaded = quotasLoaded + self.usageUpdated = usageUpdated + } + + /// Whether the data sections are worth rendering at all. + /// + /// `degraded` keeps them: the plan requires stale-but-labelled over blank, because a + /// user who can still see last-known numbers with an explicit age is better served + /// than one staring at an empty panel. + public var showsData: Bool { + switch state { + case .running: return true + // Only claim stale data when data was actually loaded. Health succeeding while + // the popover was closed is not the same as having metrics to show. + case .degraded: return usage != nil || quotasLoaded + case .loading, .unreachable, .unauthorized: return false + } + } + + /// Age of the DATA, not of the last health probe. + public var dataAge: Date? { usageUpdated } + + /// True once the proxy has been read at least once, so `loading` can show skeletons + /// rather than empty copy. + public var hasEverLoaded: Bool { lastUpdated != nil } + + public var nextAction: NextAction { + switch state { + case .loading: return .none + case .running: return .none + case .unreachable: + return .runCommand(lastKnownStartCommand ?? "ocx start") + case .unauthorized: return .addAPIKey + case .degraded: return .retry + } + } + + /// One normalized row per provider for the compact quota list. + public var quotaRows: [NormalizedQuota] { + quotas.map { $0.normalized() } + } + + public var visibleProviders: [ProviderSummary] { + providers.filter { !settings.hiddenProviders.contains($0.name) } + } + + public var menuBarTitle: String? { + MenuBarTitle.render(settings: settings, today: today ?? usage, quotas: quotaRows) + } + + public var todayRows: [UsageModelRow] { today?.models ?? [] } + + /// Whether the metrics section should render its empty copy. `nil` means unknown, + /// which renders em dashes instead. + public var usageIsEmpty: Bool? { usage?.isEmptyOrUnknown } + + public func canToggle(_ provider: ProviderSummary) -> Bool { + provider.name != defaultProvider + } +} diff --git a/app/Sources/MenuBarCore/UsageTimeline.swift b/app/Sources/MenuBarCore/UsageTimeline.swift new file mode 100644 index 00000000000..6291272bcc2 --- /dev/null +++ b/app/Sources/MenuBarCore/UsageTimeline.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct TimelineSeries: Decodable, Equatable, Sendable { + public let id: String + public let provider: String + public let model: String + public let accountLogLabel: String? + public let total: Double + public let points: [Double] +} + +public struct UsageTimeline: Decodable, Equatable, Sendable { + public let start: Double + public let end: Double + public let bucketSeconds: Int + public let buckets: Int + public let metric: String + public let aggregation: String + public let grouping: String + public let series: [TimelineSeries] + public let availableModels: [String] + public let missingMeasurements: Int + public let truncated: Bool? + + public var maxPoint: Double { + series.flatMap(\.points).max() ?? 0 + } + + public var stackedMax: Double { + guard buckets > 0 else { return 0 } + return (0.. WidgetSnapshot { + let state: String + switch snapshot.state { + case .loading: state = "loading" + case .running: state = "running" + case .unreachable: state = "unreachable" + case .unauthorized: state = "unauthorized" + case .degraded: state = "degraded" + } + let report = snapshot.today ?? snapshot.usage + let today = report?.summary.map { + Today(requests: $0.requests, totalTokens: $0.totalTokens, estimatedCostUsd: $0.estimatedCostUsd) + } + let quotas = snapshot.quotaRows.map { + Quota(providerLabel: $0.providerLabel, windowLabel: $0.windowLabel, percent: $0.percent, resetAt: $0.resetAt?.timeIntervalSince1970) + } + let chart = snapshot.timeline.map { + Chart( + start: $0.start, bucketSeconds: $0.bucketSeconds, style: snapshot.settings.chartStyle.rawValue, + series: Array($0.series.prefix(6)).map { Chart.Series(id: $0.id, points: $0.points) } + ) + } + return WidgetSnapshot( + schemaVersion: 1, generatedAt: now.timeIntervalSince1970, + state: state, stateTitle: snapshot.state.title, detail: snapshot.state.detail, + endpointDisplay: snapshot.endpoint.display, menuTitle: snapshot.menuBarTitle, + today: today, quotas: quotas, chart: chart, + lastUpdated: (snapshot.timelineUpdated ?? snapshot.usageUpdated)?.timeIntervalSince1970 + ) + } +} + +public final class WidgetSnapshotStore: @unchecked Sendable { + private let fileManager: FileManager + private let homeDirectory: URL + private let widgetBundleID: String + private let lock = NSLock() + private var lastWritten: WidgetSnapshot? + private let logger = Logger(subsystem: "ai.opencodex.menubar", category: "widget-snapshot") + private var loggedFailures = Set() + + public init( + widgetBundleID: String = "com.opencodex.menubar.widget", + fileManager: FileManager = .default, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + ) { + self.widgetBundleID = widgetBundleID + self.fileManager = fileManager + self.homeDirectory = homeDirectory + } + + public var url: URL { + homeDirectory + .appendingPathComponent("Library/Containers/\(widgetBundleID)/Data/Library/Application Support/OpenCodex", isDirectory: true) + .appendingPathComponent("snapshot.json") + } + + public func write(_ snapshot: WidgetSnapshot) throws { + let directory = url.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(snapshot) + let temporary = directory.appendingPathComponent(".snapshot-\(UUID().uuidString).tmp") + try data.write(to: temporary, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path) + if fileManager.fileExists(atPath: url.path) { try fileManager.removeItem(at: url) } + try fileManager.moveItem(at: temporary, to: url) + Self.reloadTimelines() + } + + public func writeIfChanged(_ snapshot: WidgetSnapshot) { + lock.lock() + let previous = lastWritten + if previous?.withoutGeneratedAt == snapshot.withoutGeneratedAt { + lock.unlock() + return + } + do { + try write(snapshot) + lastWritten = snapshot + lock.unlock() + } catch { + let key = String(describing: type(of: error)) + if loggedFailures.insert(key).inserted { logger.error("Widget snapshot write failed: \(key, privacy: .public)") } + lock.unlock() + } + } + + public static func reloadTimelines() { + #if canImport(WidgetKit) + if #available(macOS 14, *) { WidgetCenter.shared.reloadAllTimelines() } + #endif + } +} + +private extension WidgetSnapshot { + var withoutGeneratedAt: WidgetSnapshot { + WidgetSnapshot( + schemaVersion: schemaVersion, generatedAt: 0, state: state, stateTitle: stateTitle, + detail: detail, endpointDisplay: endpointDisplay, menuTitle: menuTitle, today: today, + quotas: quotas, chart: chart, lastUpdated: lastUpdated + ) + } +} diff --git a/app/Sources/MenuBarCoreTests/ActionSuite.swift b/app/Sources/MenuBarCoreTests/ActionSuite.swift new file mode 100644 index 00000000000..8f11cc350f7 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ActionSuite.swift @@ -0,0 +1,356 @@ +import Foundation +import MenuBarCore + +/// Write-action behaviour, especially the timing: `/api/stop` answers before it drains, +/// so "returned 200" and "actually stopped" are different facts. +enum ActionSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + private struct NoCredentials: CredentialStore { func loadAPIKey() -> String? { nil } } + + /// A clock the test drives, so the timeout path runs in milliseconds. + private final class FakeClock: @unchecked Sendable { + private let lock = NSLock() + private var current = Date(timeIntervalSince1970: 1_784_915_000) + func now() -> Date { lock.lock(); defer { lock.unlock() }; return current } + func advance(_ seconds: TimeInterval) { + lock.lock(); current = current.addingTimeInterval(seconds); lock.unlock() + } + } + + private static func makeCoordinator(clock: FakeClock = FakeClock()) -> ActionCoordinator { + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + // Skip the real wall-clock wait, but advance the clock by the same amount so the + // deadline still expires. + return ActionCoordinator( + client: client, + sleeper: { seconds in clock.advance(seconds) }, + now: { clock.now() } + ) + } + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + // The proxy stops the launchd service on purpose, so a successful stop is + // reported as "you will have to start it again", not as a plain success. + t.test("stop: reports manual-start once the port stops answering") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), // POST /api/stop + .init(status: 0, body: "", urlError: .cannotConnectToHost), // probe: gone + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx service start") } + t.equal(outcome, .requiresManualStart("ocx service start")) + t.expect(paths().first == "/api/stop", "stop called first, got \(paths())") + } + + // A 200 that never drains must not be reported as success. + t.test("stop: a proxy that keeps answering is a failure, not a success") { + // The stub falls back to "connection refused" once its queue drains, which + // would look like a successful stop. Queue well past the poll count so the + // timeout path is what actually runs. + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), + count: 400 + )) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an unreachable proxy fails without claiming it stopped anything") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + + t.test("stop: a failure message never carries the response body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG", urlError: nil)]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(!message.contains("SECRET"), "leaked body: \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: disabling sends exactly one PATCH and succeeds") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("anthropic", disabled: true, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + t.equal(StubProtocol.recorded.count, 1) + t.equal(StubProtocol.recorded.first?.httpMethod, "PATCH") + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + } + + // The proxy answers 400 for this, so the request is never sent at all. + t.test("provider: the default provider is refused before any request") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(message.contains("default provider"), "expected an explanation, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + t.equal(StubProtocol.recorded.count, 0, "no request should be sent") + } + + t.test("provider: enabling the default provider is allowed") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("openai", disabled: false, defaultProvider: "openai") + } + t.equal(outcome, .succeeded) + } + + t.test("provider: a 400 from the proxy surfaces without quoting its body") { + StubProtocol.reset([.init(status: 400, body: "cannot disable the default provider", urlError: nil)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") + } + if case .failed(let message) = outcome { + t.expect(!message.contains("cannot disable"), "leaked body: \(message)") + t.expect(message.contains("refused"), "expected a refusal message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: an unreachable proxy fails cleanly") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let outcome = sync { + await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) + } + t.equal(outcome, .failed(ProxyError.unreachable.userMessage)) + } + // Was tautological: it built its own non-empty literals and then asserted they + // were non-empty. Now drives real failures and checks the message the user sees. + t.test("actions: every real failure path produces a usable message") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let unreachable = sync { await makeCoordinator().setProvider("x", disabled: false, defaultProvider: nil) } + + StubProtocol.reset([.init(status: 400, body: "raw body", urlError: nil)]) + let rejected = sync { await makeCoordinator().setProvider("x", disabled: true, defaultProvider: "openai") } + + let guarded = sync { await makeCoordinator().setProvider("openai", disabled: true, defaultProvider: "openai") } + + for outcome in [unreachable, rejected, guarded] { + guard case .failed(let message) = outcome else { + t.expect(false, "expected .failed, got \(outcome)") + continue + } + t.expect(!message.isEmpty, "empty failure message") + t.expect(message.hasSuffix(".") || message.hasSuffix("!"), + "message should read as a sentence: \(message)") + t.expect(!message.contains("raw body"), "leaked body: \(message)") + } + } + + // The stop response carries success:false when restoreNativeCodex() failed + // (src/server/management-api.ts:145-147). The proxy still shuts down, but native + // Codex is left pointing at a port that is closing. + t.test("stop: a restore failure is reported, not swallowed as success") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":false,"message":"restore failed: /some/path"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + let outcome = sync { await makeCoordinator().stop(startCommand: "ocx start") } + t.equal(outcome, .stoppedWithRestoreFailure("ocx start")) + } + + t.test("stop: a success:true body reports the ordinary manual-start outcome") { + StubProtocol.reset([ + .init(status: 200, body: #"{"success":true,"message":"ok"}"#, urlError: nil), + .init(status: 0, body: "", urlError: .cannotConnectToHost), + ]) + t.equal(sync { await makeCoordinator().stop(startCommand: "ocx start") }, + .requiresManualStart("ocx start")) + } + + // Only a refused connection proves the proxy is gone. A 500 or an undecodable + // 200 means an HTTP server is still listening. + t.test("stop: a 500 during polling is not mistaken for a stopped proxy") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 500, body: "", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("still responding"), "expected a timeout, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("stop: an undecodable 200 during polling still counts as reachable") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: "not json", urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed = outcome { + t.expect(true, "timed out rather than claiming success") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + t.test("provider: a second write while one is in flight is refused, not raced") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let first = coordinator.setProvider("x", disabled: true, defaultProvider: nil) + async let second = coordinator.setProvider("x", disabled: false, defaultProvider: nil) + return await [first, second] + } + let refused = outcomes.filter { if case .failed = $0 { return true }; return false } + t.equal(refused.count, 1, "exactly one of the two concurrent writes is refused") + } + + t.test("provider: writes to different providers are not blocked by each other") { + StubProtocol.reset([ + .init(status: 200, body: "{}", urlError: nil), + .init(status: 200, body: "{}", urlError: nil), + ]) + let coordinator = makeCoordinator() + let outcomes: [ActionOutcome] = sync { + async let a = coordinator.setProvider("a", disabled: true, defaultProvider: nil) + async let b = coordinator.setProvider("b", disabled: true, defaultProvider: nil) + return await [a, b] + } + t.equal(outcomes, [.succeeded, .succeeded]) + } + + // The distinction that matters: only a refused connection proves the proxy is + // gone. Collapsing timeouts into "unreachable" is what made a stop report as + // confirmed while the proxy was still running. + t.test("liveness: only a refused connection reads as gone") { + let cases: [(URLError.Code, ProxyClient.Liveness, String)] = [ + (.cannotConnectToHost, .refused, "connection refused"), + (.timedOut, .indeterminate, "timeout"), + (.networkConnectionLost, .indeterminate, "socket dropped"), + (.cannotFindHost, .indeterminate, "host lookup"), + (.notConnectedToInternet, .indeterminate, "no network"), + ] + for (code, expected, label) in cases { + StubProtocol.reset([.init(status: 0, body: "", urlError: code)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, expected, label) + } + } + + t.test("liveness: any HTTP answer proves the port is occupied") { + for status in [200, 401, 403, 500] { + let body = status == 200 ? #"{"port":10100}"# : "" + StubProtocol.reset([ + .init(status: status, body: body, urlError: nil), + .init(status: status, body: body, urlError: nil), + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "k")) + t.equal(sync { await client.liveness() }, .reachable, "status \(status)") + } + } + + t.test("liveness: an undecodable 200 is reachable, not gone") { + StubProtocol.reset([.init(status: 200, body: "not json at all", urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + t.equal(sync { await client.liveness() }, .reachable) + } + + // A timeout must not end the stop as a confirmed success. + t.test("stop: a timeout during polling never confirms the stop") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .timedOut), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + let outcome = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + if case .failed(let message) = outcome { + t.expect(message.contains("could not be confirmed"), + "expected an inconclusive message, got \(message)") + } else { + t.expect(false, "expected .failed, got \(outcome)") + } + } + + // A 401 already answers "is anything listening". Retrying it through the normal + // credential path spent a second full timeout and could downgrade a + // known-reachable result to indeterminate if the retry failed. + t.test("liveness: a 401 answers immediately without a credential retry") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 0, body: "", urlError: .timedOut), // must never be used + ]) + let client = ProxyClient(endpoint: .default, session: makeSession(), + credentials: StubCredentialsFixed(key: "stored-key")) + t.equal(sync { await client.liveness() }, .reachable) + t.equal(StubProtocol.recorded.count, 1, "liveness must be a single attempt") + } + + t.test("liveness: the probe honours a caller-supplied timeout") { + StubProtocol.reset([.init(status: 200, body: #"{"port":10100}"#, urlError: nil)]) + let client = ProxyClient(endpoint: .default, session: makeSession(), credentials: NoCredentials()) + _ = sync { await client.liveness(timeout: 0.25) } + t.equal(StubProtocol.recorded.first?.timeoutInterval, 0.25) + } + + // The final probe must not overrun the stop deadline by its own timeout. + t.test("stop: the last probe is capped to the remaining deadline") { + var responses: [StubProtocol.Response] = [.init(status: 200, body: "{}", urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 200, body: #"{"port":10100}"#, urlError: nil), count: 400)) + StubProtocol.reset(responses) + let clock = FakeClock() + _ = sync { await makeCoordinator(clock: clock).stop(startCommand: "ocx start") } + + // Every liveness probe after the POST must request no more than 1.5s, and + // the last must be clamped to whatever remained. + let probes = StubProtocol.recorded.dropFirst() + t.expect(!probes.isEmpty, "expected liveness probes") + for probe in probes { + t.expect(probe.timeoutInterval <= 1.5, + "probe timeout \(probe.timeoutInterval) exceeds the cap") + } + } + } + + private struct StubCredentialsFixed: CredentialStore { + let key: String? + func loadAPIKey() -> String? { key } + } +} diff --git a/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift new file mode 100644 index 00000000000..bed663b8e2d --- /dev/null +++ b/app/Sources/MenuBarCoreTests/CompanionSettingsSuite.swift @@ -0,0 +1,27 @@ +import Foundation +import MenuBarCore + +enum CompanionSettingsSuite { + static func run(_ t: TestRunner) { + let decoder = JSONDecoder() + t.test("companion settings: empty JSON uses defaults") { + let settings = try decoder.decode(CompanionSettings.self, from: Data("{}".utf8)) + t.equal(settings, .defaults) + } + t.test("companion settings: unknown enum uses its default") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"future","chartStyle":"future","tokenMetric":"future","aggregation":"future","chartGrouping":"future"}"#.utf8)) + t.equal(settings.menuBarMetric, .tokens) + t.equal(settings.chartStyle, .line) + t.equal(settings.tokenMetric, .total) + t.equal(settings.aggregation, .sum) + t.equal(settings.chartGrouping, .model) + } + t.test("companion settings: full payload decodes") { + let settings = try decoder.decode(CompanionSettings.self, from: Data(#"{"menuBarMetric":"quota","menuBarTemplate":"{requests}","showToday":false,"showChart":false,"showModels":false,"showCost":false,"showAccounts":false,"chartHours":72,"bucketMinutes":180,"chartStyle":"stackedBar","tokenMetric":"cached","aggregation":"max","chartGrouping":"modelAccount","models":["openai/gpt"],"hiddenProviders":["openai"]}"#.utf8)) + t.equal(settings.chartHours, 72) + t.equal(settings.chartStyle, .stackedBar) + t.equal(settings.models, ["openai/gpt"]) + t.equal(settings.hiddenProviders, ["openai"]) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/DiscoverySuite.swift b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift new file mode 100644 index 00000000000..e8aa2acf1bd --- /dev/null +++ b/app/Sources/MenuBarCoreTests/DiscoverySuite.swift @@ -0,0 +1,82 @@ +import Foundation +import MenuBarCore + +enum DiscoverySuite { + static func run(_ t: TestRunner) { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ocx-discovery-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + func writeRecord(_ contents: String) throws { + try contents.write( + to: root.appendingPathComponent("runtime-port.json"), + atomically: true, + encoding: .utf8 + ) + } + + t.test("discovery: honours a valid record") { + try writeRecord(#"{"pid": 14582, "port": 10100}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10100) + } + + t.test("discovery: honours a non-default port") { + try writeRecord(#"{"pid": 1, "port": 18080}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 18080) + } + + t.test("discovery: a record without pid still resolves") { + try writeRecord(#"{"port": 10250}"#) + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, 10250) + } + + t.test("discovery: malformed JSON falls back to the default port") { + try writeRecord("{not json at all") + t.equal(ProxyDiscovery.resolve(configDirectory: root).port, ProxyDiscovery.defaultPort) + } + + t.test("discovery: out-of-range ports fall back to the default") { + for invalid in ["0", "70000", "-1"] { + try writeRecord(#"{"port": \#(invalid)}"#) + t.equal( + ProxyDiscovery.resolve(configDirectory: root).port, + ProxyDiscovery.defaultPort, + "port \(invalid)" + ) + } + } + + t.test("discovery: a missing file falls back to the default port") { + let empty = root.appendingPathComponent("empty-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + t.equal(ProxyDiscovery.resolve(configDirectory: empty).port, ProxyDiscovery.defaultPort) + } + + // The record may carry a hostname, but the app must never follow it: the port + // file is a convenience, not a redirection mechanism. + t.test("discovery: host stays loopback even when the record names another host") { + try writeRecord(#"{"pid": 1, "port": 10100, "hostname": "10.0.0.5"}"#) + let endpoint = ProxyDiscovery.resolve(configDirectory: root) + t.equal(endpoint.host, "127.0.0.1") + t.equal(endpoint.baseURL.absoluteString, "http://127.0.0.1:10100") + } + + t.test("discovery: OPENCODEX_HOME overrides the default directory") { + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": root.path], + home: URL(fileURLWithPath: "/nonexistent") + ) + t.equal(resolved.path, root.path) + } + + t.test("discovery: a blank OPENCODEX_HOME falls back to the home directory") { + let home = URL(fileURLWithPath: "/Users/example") + let resolved = ProxyDiscovery.configDirectory( + environment: ["OPENCODEX_HOME": " "], + home: home + ) + t.equal(resolved.path, home.appendingPathComponent(".opencodex").path) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/FormattingSuite.swift b/app/Sources/MenuBarCoreTests/FormattingSuite.swift new file mode 100644 index 00000000000..00b816cdeb9 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/FormattingSuite.swift @@ -0,0 +1,77 @@ +import Foundation +import MenuBarCore + +/// Magnitudes are taken from the live proxy capture in 002_api_surface.md. +enum FormattingSuite { + static func run(_ t: TestRunner) { + t.test("format: counts group below 10k and suffix above") { + t.equal(Format.count(0), "0") + t.equal(Format.count(1_746), "1,746") + t.equal(Format.count(9_999), "9,999") + t.equal(Format.count(232_507), "233K") + t.equal(Format.count(1_200_000), "1.20M") + } + + // Rounding can push a value across its own unit boundary: 999_999 scales to + // 999.999K and must promote to 1.00M rather than render "1000K". + t.test("format: values promote at suffix rollover boundaries") { + t.equal(Format.count(999_999), "1.00M") + t.equal(Format.count(999_499), "999K") + t.equal(Format.tokens(999_999_999), "1B") + t.equal(Format.tokens(999_999_999_999), "1T") + t.equal(Format.cost(999_999), "$1.00M") + } + + t.test("format: exact unit thresholds render as the new unit") { + t.equal(Format.tokens(1_000), "1K") + t.equal(Format.tokens(1_000_000), "1M") + t.equal(Format.tokens(1_000_000_000), "1B") + } + + t.test("format: tokens are suffixed at scale") { + t.equal(Format.tokens(999), "999") + t.equal(Format.tokens(12_400_000), "12M") + t.equal(Format.tokens(36_536_664_705), "37B") + } + + t.test("format: cost switches to a suffix above one thousand") { + t.equal(Format.cost(8.21), "$8.21") + t.equal(Format.cost(999.99), "$999.99") + t.equal(Format.cost(34_018.25204647066), "$34.0K") + } + + // Unknown and zero are different facts. Rendering nil as "0" is the fake-data + // tell that 003 section 6 bans. + t.test("format: nil renders an em dash while zero renders zero") { + t.equal(Format.count(nil), "—") + t.equal(Format.tokens(nil), "—") + t.equal(Format.cost(nil), "—") + t.equal(Format.percent(nil), "—") + t.equal(Format.count(0), "0") + t.equal(Format.cost(0), "$0.00") + } + + t.test("format: percent rounds") { + t.equal(Format.percent(44), "44%") + t.equal(Format.percent(86.82666666666667), "87%") + t.equal(Format.percent(9.976811594202898), "10%") + } + + t.test("format: reset countdowns are coarse") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.resetsIn(now.addingTimeInterval(60 * 30), now: now), "30m") + t.equal(Format.resetsIn(now.addingTimeInterval(3600 * 5), now: now), "5h") + t.equal(Format.resetsIn(now.addingTimeInterval(86_400 * 3 + 3600 * 4), now: now), "3d 4h") + t.equal(Format.resetsIn(now.addingTimeInterval(-60), now: now), "expired") + t.equal(Format.resetsIn(nil), "—") + } + + t.test("format: staleness ages read naturally") { + let now = Date(timeIntervalSince1970: 1_784_915_000) + t.equal(Format.age(now.addingTimeInterval(-10), now: now), "just now") + t.equal(Format.age(now.addingTimeInterval(-120), now: now), "2m ago") + t.equal(Format.age(now.addingTimeInterval(-7200), now: now), "2h ago") + t.equal(Format.age(nil), "—") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/Harness.swift b/app/Sources/MenuBarCoreTests/Harness.swift new file mode 100644 index 00000000000..0deb1d6ae46 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/Harness.swift @@ -0,0 +1,105 @@ +import Foundation + +/// A dependency-free assertion harness. +/// +/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line +/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles +/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to +/// run the unit tests of a menu bar companion would put the tests out of reach for most +/// contributors and for any CI runner without Xcode selected. +/// +/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that +/// both a human and CI can read. If the package ever gains a full-Xcode requirement for +/// other reasons, migrating these cases to swift-testing is mechanical. +public struct TestFailure { + let test: String + let message: String + let file: String + let line: Int +} + +public final class TestRunner { + private(set) var passed = 0 + private(set) var failures: [TestFailure] = [] + private var current = "" + + public init() {} + + public func test(_ name: String, _ body: () throws -> Void) { + current = name + let failuresBefore = failures.count + do { + try body() + } catch { + failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) + print("FAIL — \(name): threw \(error)") + return + } + // A case that recorded an expectation failure is not a pass, even though its + // body returned normally. + if failures.count == failuresBefore { + passed += 1 + print("ok — \(name)") + } + } + + public func expect( + _ condition: Bool, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + guard !condition else { return } + let failure = TestFailure(test: current, message: message(), file: file, line: line) + failures.append(failure) + print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") + } + + public func equal( + _ actual: T, + _ expected: T, + _ label: String = "", + file: String = #file, + line: Int = #line + ) { + expect( + actual == expected, + "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", + file: file, + line: line + ) + } + + public func notNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) -> T? { + expect(value != nil, "\(label) should not be nil", file: file, line: line) + return value + } + + public func isNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) { + expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) + } + + /// Prints the summary and returns the process exit code. + public func summarize() -> Int32 { + print("") + if failures.isEmpty { + print("\(passed) passed, 0 failed") + return 0 + } + print("\(passed) passed, \(failures.count) FAILED") + for failure in failures { + print(" - \(failure.test): \(failure.message)") + } + return 1 + } +} diff --git a/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift new file mode 100644 index 00000000000..286502ac91c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/MenuBarTitleSuite.swift @@ -0,0 +1,36 @@ +import Foundation +import MenuBarCore + +enum MenuBarTitleSuite { + private static let reportJSON = #"{"range":"today","summary":{"requests":12,"totalTokens":3456,"inputTokens":1000,"outputTokens":2000,"estimatedCostUsd":1.25}}"# + + static func run(_ t: TestRunner) { + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(reportJSON.utf8)) + for metric in [CompanionSettings.MenuBarMetric.requests, .tokens, .cost] { + t.test("menu title: \(metric.rawValue) metric") { + let settings = CompanionSettings(menuBarMetric: metric) + t.expect(MenuBarTitle.render(settings: settings, today: report, quotas: []) != nil, "title") + } + } + t.test("menu title: quota picks the lowest percent") { + let settings = CompanionSettings(menuBarMetric: .quota) + let quotas = try! JSONDecoder().decode([QuotaReport].self, from: Data(#"[{"provider":"a","quota":{"weeklyPercent":80}},{"provider":"b","quota":{"weeklyPercent":20}}]"#.utf8)).map { $0.normalized() } + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: quotas), "20%") + } + t.test("menu title: template replaces placeholders") { + let settings = CompanionSettings(menuBarTemplate: "{requests}/{totalTokens}/{costUsd}") + t.equal(MenuBarTitle.render(settings: settings, today: report, quotas: []), "12/3K/$1.25") + } + t.test("menu title: none is nil and unknowns are em dashes") { + t.isNil(MenuBarTitle.render(settings: CompanionSettings(menuBarMetric: .none), today: report, quotas: []), "none") + let settings = CompanionSettings(menuBarTemplate: "{inputTokens}") + t.equal(MenuBarTitle.render(settings: settings, today: nil, quotas: []), "—") + } + t.test("menu title: long output is truncated") { + let settings = CompanionSettings(menuBarTemplate: "012345678901234567890123456789") + let title = MenuBarTitle.render(settings: settings, today: report, quotas: []) + t.equal(title?.count, 24) + t.expect(title?.hasSuffix("…") == true, "ellipsis") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift new file mode 100644 index 00000000000..0ec0012e621 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/ModelDecodingSuite.swift @@ -0,0 +1,258 @@ +import Foundation +import MenuBarCore + +/// Fixtures are verbatim captures from the live proxy on 2026-07-25, recorded in +/// devlog/_plan/260725_macos_menubar_app/002_api_surface.md. Hand-written fixtures would +/// only prove the models decode themselves. +enum ModelDecodingSuite { + private static func decode(_ type: T.Type, _ json: String) throws -> T { + try JSONDecoder().decode(type, from: Data(json.utf8)) + } + + private struct Envelope: Decodable { let reports: [QuotaReport]? } + + private static let liveHealth = """ + {"routingKind":"opencodex-local","autostartEnabled":false,"serviceInstalled":true, + "serviceViable":true,"serviceEnabled":true,"serviceRunning":true,"serviceStale":false, + "serviceConflict":false,"serviceSupported":true,"shimInstalled":false, + "shimHealthy":false,"platform":"darwin","diagnosticStale":true,"routingInjected":true, + "localRoutingDependency":true,"status":"at-risk","rebootSafe":false,"protection":"none", + "shimCoverage":"none","recommendedCommand":"ocx service install", + "commands":{"installService":"ocx service install","installShim":"ocx codex-shim install", + "restoreNative":"ocx restore"}} + """ + + private static let liveQuotas = """ + {"generatedAt":1784915336899,"reports":[ + {"provider":"openai","label":"OpenAI (Codex login)","source":"chatgpt:wham", + "quota":{"updatedAt":1784915090763,"weeklyPercent":44,"weeklyResetAt":1785258443, + "resetCredits":3}}, + {"provider":"anthropic","label":"Anthropic Claude","source":"anthropic:oauth-usage", + "quota":{"weeklyPercent":58,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"5h","percent":1,"resetAt":1784928599718}]}}, + {"provider":"xai","label":"xAI Grok","source":"xai:grok-billing", + "quota":{"monthlyPercent":86.82666666666667,"monthlyResetAt":1785542400000}}]} + """ + + static func run(_ t: TestRunner) { + t.test("health: decodes the live startup-health payload") { + let health = try decode(StartupHealth.self, liveHealth) + t.equal(health.status, "at-risk") + t.equal(health.platform, "darwin") + t.equal(health.recommendedCommand, "ocx service install") + t.equal(health.isProtected, false) + t.equal(health.isServiceManaged, true) + t.equal(health.manualStartCommand, "ocx service start") + } + + t.test("health: an unknown status string decodes without throwing") { + let health = try decode(StartupHealth.self, #"{"status":"some-future-state"}"#) + t.equal(health.status, "some-future-state") + t.equal(health.isProtected, false) + } + + t.test("health: without service fields it is not service-managed") { + let health = try decode(StartupHealth.self, #"{"status":"protected"}"#) + t.equal(health.isProtected, true) + t.equal(health.isServiceManaged, false) + t.equal(health.manualStartCommand, "ocx start") + } + + // The live /api/settings key set contains no defaultProvider. Decoding must + // succeed anyway — an earlier plan draft expected the field here and was wrong. + t.test("settings: decodes without a defaultProvider field") { + let json = """ + {"codexAutoStart":false,"port":10100,"hostname":"127.0.0.1","streamMode":"auto", + "startupHealth":{"status":"protected"},"codexRuntime":{}} + """ + let settings = try decode(ProxySettings.self, json) + t.equal(settings.port, 10100) + t.equal(settings.hostname, "127.0.0.1") + t.equal(settings.streamMode, "auto") + } + + t.test("config: supplies defaultProvider") { + let json = """ + {"port":10100,"hostname":"127.0.0.1","defaultProvider":"openai", + "codexAutoStart":false,"websockets":{},"providers":{}} + """ + t.equal(try decode(ProxyConfigSummary.self, json).defaultProvider, "openai") + } + + t.test("usage: decodes the live summary at real magnitudes") { + let json = """ + {"range":"30d","surface":"all","since":1782323333603,"generatedAt":1784915333603, + "summary":{"requests":232507,"measuredRequests":225380,"estimatedRequests":14618, + "inputTokens":33521662469,"outputTokens":127401110,"totalTokens":36536664705, + "coverageRatio":0.969347159440361,"estimatedCostUsd":34018.25204647066}, + "days":[{"date":"2026-06-28","requests":1746,"totalTokens":0,"models":[]}]} + """ + let report = try decode(UsageReport.self, json) + t.equal(report.summary?.requests, 232_507) + t.equal(report.summary?.totalTokens, 36_536_664_705) + t.equal(report.effectiveRange, .thirtyDays) + t.equal(report.rangeLabel, "LAST 30 DAYS") + t.equal(report.summary?.hasEstimates, true) + t.equal(report.isEmpty, false) + } + + // The server silently degrades an unrecognized range to 30d, so the label must + // follow the response and never the request. + t.test("usage: an unknown range degrades to a neutral label") { + let report = try decode(UsageReport.self, #"{"range":"24h"}"#) + t.isNil(report.effectiveRange, "effectiveRange for 24h") + t.equal(report.rangeLabel, "USAGE") + } + + t.test("usage: zero requests reads as empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"requests":0}}"#) + t.equal(report.isEmpty, true) + t.equal(report.isEmptyOrUnknown, true) + } + + // Unknown and zero are different facts: an omitted count must not render as + // "No requests". + t.test("usage: an omitted request count is unknown, not empty") { + let report = try decode(UsageReport.self, #"{"range":"7d","summary":{"totalTokens":5}}"#) + t.isNil(report.isEmptyOrUnknown, "isEmptyOrUnknown for an omitted count") + t.equal(report.isEmpty, false, "isEmpty must not claim empty for unknown") + } + + t.test("usage: the range enum is closed") { + t.isNil(UsageRange(rawValue: "24h"), "UsageRange(24h)") + t.equal(UsageRange.allCases.map(\.rawValue), ["today", "7d", "30d", "all"]) + } + + // The decisive trap: openai sends weeklyResetAt in SECONDS (1785258443) while + // anthropic sends MILLISECONDS (1785265199718) in the same array. + t.test("quotas: mixed second and millisecond timestamps both resolve to 2026") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + t.equal(reports.count, 3) + let calendar = Calendar(identifier: .gregorian) + for report in reports { + let normalized = report.normalized() + guard let date = t.notNil(normalized.resetAt, "\(report.provider) resetAt") else { continue } + t.equal(calendar.component(.year, from: date), 2026, "\(report.provider) year") + } + } + + t.test("quotas: normalization picks the right window per provider") { + let reports = try decode(Envelope.self, liveQuotas).reports ?? [] + let byProvider = Dictionary(uniqueKeysWithValues: reports.map { ($0.provider, $0.normalized()) }) + t.equal(byProvider["openai"]?.windowLabel, "week") + t.equal(byProvider["openai"]?.percent, 44) + t.equal(byProvider["anthropic"]?.windowLabel, "week") + t.equal(byProvider["xai"]?.windowLabel, "month") + t.equal(byProvider["xai"]?.providerLabel, "xAI Grok") + } + + t.test("quotas: a custom-window-only quota uses its own label") { + let json = """ + {"provider":"p","quota":{"customWindows":[{"label":"5h","percent":12,"resetAt":1784928599718}]}} + """ + let normalized = try decode(QuotaReport.self, json).normalized() + t.equal(normalized.windowLabel, "5h") + t.equal(normalized.percent, 12) + } + + // Live kimi reports weeklyPercent AND fiveHourPercent; live cursor and + // google-antigravity each carry two customWindows. Returning one window would + // hide real quota pressure. + t.test("quotas: kimi exposes both its five-hour and weekly windows") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":22, + "fiveHourResetAt":1784928599718,"weeklyPercent":61,"weeklyResetAt":1785265199718}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 2) + t.equal(windows.map(\.windowLabel), ["5h", "week"]) + // The compact row prefers the longer horizon. + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 61) + } + + t.test("quotas: multiple custom windows are all retained") { + let json = """ + {"provider":"cursor","label":"Cursor","quota":{"monthlyPercent":10, + "monthlyResetAt":1785256304000, + "customWindows":[{"label":"First-party models","percent":4,"resetAt":1785256304000}, + {"label":"API usage","percent":1,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + let windows = report.normalizedWindows() + t.equal(windows.count, 3) + t.equal(windows.map(\.windowLabel), ["month", "First-party models", "API usage"]) + t.equal(report.normalized().windowLabel, "month") + } + + t.test("quotas: a provider with only custom windows still normalizes") { + let json = """ + {"provider":"google-antigravity","label":"Google","quota":{ + "customWindows":[{"label":"Gem","percent":30,"resetAt":1785256304000}, + {"label":"Cla","percent":12,"resetAt":1785256304000}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalizedWindows().count, 2) + t.equal(report.normalized().windowLabel, "Gem") + t.equal(report.normalized().percent, 30) + } + + // Every window can stop work. A provider at 99% of a five-hour limit is blocked + // right now even if its monthly usage is 10%; picking the longer horizon would + // paint that row green while the user cannot make a request. + t.test("quotas: the compact row shows the window under the most pressure") { + let json = """ + {"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "5h") + t.equal(report.normalized().percent, 99) + t.equal(report.normalizedWindows().count, 2) + } + + t.test("quotas: equal pressure breaks toward the longer horizon") { + let json = """ + {"provider":"p","quota":{"fiveHourPercent":50,"fiveHourResetAt":1784928599718, + "weeklyPercent":50,"weeklyResetAt":1785265199718}} + """ + t.equal(try decode(QuotaReport.self, json).normalized().windowLabel, "week") + } + + t.test("quotas: a window reporting only a reset time does not outrank a measured one") { + let json = """ + {"provider":"p","quota":{"weeklyPercent":12,"weeklyResetAt":1785265199718, + "customWindows":[{"label":"unmeasured","resetAt":1785265199718}]}} + """ + let report = try decode(QuotaReport.self, json) + t.equal(report.normalized().windowLabel, "week") + t.equal(report.normalized().percent, 12) + } + + t.test("quotas: an absent quota normalizes to a nil percent") { + let normalized = try decode(QuotaReport.self, #"{"provider":"p","label":"P"}"#).normalized() + t.isNil(normalized.percent, "percent") + t.equal(normalized.hasPercent, false) + t.isNil(normalized.resetAt, "resetAt") + } + + t.test("providers: decodes the live list") { + let json = """ + [{"name":"openai","adapter":"openai-responses","hasApiKey":false, + "authMode":"forward","disabled":false,"codexAccountMode":"pool"}, + {"name":"anthropic","adapter":"anthropic","hasApiKey":false, + "authMode":"oauth","disabled":true}] + """ + let providers = try decode([ProviderSummary].self, json) + t.equal(providers.count, 2) + t.equal(providers[0].name, "openai") + t.equal(providers[0].isEnabled, true) + t.equal(providers[1].isEnabled, false) + } + + t.test("providers: a provider without a disabled field is enabled") { + t.equal(try decode(ProviderSummary.self, #"{"name":"custom"}"#).isEnabled, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/PollingSuite.swift b/app/Sources/MenuBarCoreTests/PollingSuite.swift new file mode 100644 index 00000000000..838249be4c0 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/PollingSuite.swift @@ -0,0 +1,408 @@ +import Foundation +import MenuBarCore + +/// Exercises the polling contract against a stubbed transport instead of asserting that +/// four constants still hold the values they were declared with. +enum PollingSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = Box() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class Box: @unchecked Sendable { var value: T? } + + /// Polls the coordinator's own waiter count, so registration is observed rather + /// than assumed from elapsed time. + private static func waitForWaiter(_ coordinator: PollingCoordinator, timeout: TimeInterval = 5) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await coordinator.waiterCount > 0 { return true } + try? await Task.sleep(nanoseconds: 5_000_000) + } + return false + } + + private final class Flag: @unchecked Sendable { + private let lock = NSLock() + private var flag = false + var value: Bool { lock.lock(); defer { lock.unlock() }; return flag } + func set() { lock.lock(); flag = true; lock.unlock() } + } + + private static let healthOK = #"{"status":"protected","serviceInstalled":true,"serviceEnabled":true}"# + private static let usageOK = #"{"range":"7d","summary":{"requests":10},"days":[{"date":"d","requests":10}]}"# + private static let quotasOK = #"{"reports":[{"provider":"p","quota":{"weeklyPercent":5}}]}"# + private static let providersOK = #"[{"name":"openai"}]"# + private static let configOK = #"{"defaultProvider":"openai"}"# + + private static func paths() -> [String] { + StubProtocol.recorded.compactMap { $0.url?.path } + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + func makeCoordinator() -> PollingCoordinator { + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: NoCredentials()) + return PollingCoordinator(client: client, endpoint: endpoint) + } + + // The whole point of gating: a closed popover must not trigger aggregation. + t.test("polling: a closed popover fetches only liveness") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + sync { await coordinator.refresh() } + t.equal(paths(), ["/api/startup-health", "/api/companion/settings", "/api/usage", "/api/usage/timeline"]) + } + + t.test("polling: opening the popover fetches on-open and aggregation reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + return await coordinator.current + } + t.expect(paths().contains("/api/providers"), "providers fetched on open") + t.expect(paths().contains("/api/usage"), "usage fetched on open") + t.expect(paths().contains("/api/provider-quotas"), "quotas fetched on open") + t.equal(snapshot.providersLoaded, true) + t.equal(snapshot.quotasLoaded, true) + t.equal(snapshot.defaultProvider, "openai") + } + + // Reopening within the aggregation window should refresh cheap reads only. + t.test("polling: a second open reuses aggregation but refreshes on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.setPopoverOpen(false) + await coordinator.setPopoverOpen(true) + } + let usageCalls = paths().filter { $0 == "/api/usage" }.count + let providerCalls = paths().filter { $0 == "/api/providers" }.count + t.equal(usageCalls, 1, "aggregation respects its interval") + t.equal(providerCalls, 2, "on-open reads run every open") + } + + t.test("polling: a refused proxy becomes unreachable and counts a failure") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + t.equal(snapshot.consecutiveFailures, 1) + t.equal(snapshot.showsData, false) + } + + t.test("polling: repeated failures widen the interval to the backoff value") { + StubProtocol.reset(Array(repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 4)) + let coordinator = makeCoordinator() + let interval = sync { () -> TimeInterval in + for _ in 0..<3 { await coordinator.refresh() } + return await coordinator.currentInterval + } + t.equal(interval, PollingCoordinator.backoffInterval) + } + + t.test("polling: a recovered proxy resets the failure count and interval") { + StubProtocol.reset([ + .init(status: 0, body: "", urlError: .cannotConnectToHost), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.consecutiveFailures, 0) + t.equal(snapshot.state.isRunning, true) + } + + // A degraded proxy keeps its last-known numbers with an explicit age, rather + // than blanking the panel. + t.test("polling: a 500 degrades while retaining previously loaded data") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + return await coordinator.current + } + if case .degraded = snapshot.state { + t.expect(true, "degraded") + } else { + t.expect(false, "expected degraded, got \(snapshot.state)") + } + t.equal(snapshot.showsData, true, "stale-but-labelled beats blank") + _ = t.notNil(snapshot.usage, "usage retained") + } + + t.test("polling: the recommended command is carried into the snapshot") { + StubProtocol.reset([ + .init(status: 200, + body: #"{"status":"at-risk","recommendedCommand":"ocx service install"}"#, + urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.recommendedCommand, "ocx service install") + } + + t.test("polling: observers receive the snapshot on registration and on change") { + StubProtocol.reset([.init(status: 200, body: healthOK, urlError: nil)]) + let coordinator = makeCoordinator() + let counter = Counter() + sync { + await coordinator.observe { _ in counter.bump() } + await coordinator.refresh() + } + t.expect(counter.count >= 2, "expected at least 2 notifications, got \(counter.count)") + } + + // On-open reads are cheap but not free: running them on every liveness tick + // turned two rarely-changing endpoints into 5-second pollers. + t.test("polling: a background tick while open does not refetch on-open reads") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() // ordinary liveness tick + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 1, "providers fetched once") + t.equal(paths().filter { $0 == "/api/config" }.count, 1, "config fetched once") + t.equal(paths().filter { $0 == "/api/startup-health" }.count, 2, "health fetched twice") + } + + t.test("polling: a closed popover skips on-open reads entirely") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.refresh() + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/providers" }.count, 0) + t.equal(paths().filter { $0 == "/api/usage" }.count, 1) + } + + // A failing quota endpoint must not drag its healthy sibling into the 5s tick. + t.test("polling: a partial aggregation failure still consumes the interval") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 500, body: "", urlError: nil), // quotas fail + .init(status: 200, body: healthOK, urlError: nil), // next tick + ]) + let coordinator = makeCoordinator() + sync { + await coordinator.setPopoverOpen(true) + await coordinator.refresh() + } + t.equal(paths().filter { $0 == "/api/usage" }.count, 1, "usage not refetched after a sibling failure") + } + + t.test("polling: degraded without any loaded data does not claim to show data") { + StubProtocol.reset([.init(status: 500, body: "", urlError: nil)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refresh() + return await coordinator.current + } + t.equal(snapshot.showsData, false, "no data was ever loaded") + t.isNil(snapshot.dataAge, "dataAge") + } + + // refresh() coalesces, so a caller that needs authoritative state afterwards + // must wait for the cycle that absorbed its request — not just for its own + // immediate return. + t.test("polling: refreshAndWait returns only after a cycle has published") { + // setPopoverOpen already runs a full cycle, so queue enough for both it and + // the refreshAndWait that follows; the stub falls back to connection-refused + // once drained, which would look like a stopped proxy. + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.setPopoverOpen(true) + await coordinator.refreshAndWait() + return await coordinator.current + } + // If it returned early the health read would not have landed yet. + t.equal(snapshot.state.isRunning, true) + _ = t.notNil(snapshot.lastUpdated, "lastUpdated after refreshAndWait") + } + + // The first two refreshAndWait tests ran with refreshInFlight == false, so they + // never entered waitForCompletion() at all. These hold a cycle suspended in the + // stub so the coalescing path is the one under test. + t.test("polling: refreshAndWait suspends behind an in-flight cycle and resumes") { + StubProtocol.reset(Array( + repeating: .init(status: 200, body: healthOK, urlError: nil), count: 20)) + let gate = DispatchSemaphore(value: 0) + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<40 { gate.signal() } + } + + let coordinator = makeCoordinator() + let returned = Flag() + + let first = Task { await coordinator.refresh() } + // Wait for the request to actually reach the gate rather than guessing. + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") + + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + // Wait for the waiter to actually REGISTER, rather than sleeping and hoping + // it was scheduled. A fixed sleep let this test pass without ever entering + // the continuation path. + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") + t.equal(returned.value, false, "refreshAndWait must not return while a cycle is in flight") + + StubProtocol.setGate(nil) + for _ in 0..<40 { gate.signal() } + sync { _ = await first.value; _ = await waiter.value } + t.equal(returned.value, true, "refreshAndWait must resume once the queued cycle publishes") + t.equal(sync { await coordinator.waiterCount }, 0, "no waiter should remain registered") + } + + // The queued cycle must FAIL here. Two contract details drive the setup: + // drainPendingRefresh only runs while the popover is OPEN, and an open cycle + // consumes health + providers + config + usage + quotas. So the popover is + // opened first (consuming its own cycle), then one gated 200 lets cycle 1 reach + // the gate, and every response after that is a refusal. An earlier version + // queued three 200s with the popover closed and silently re-tested the success + // path — which is exactly what the new state assertion caught. + t.test("polling: a waiter is released when the queued cycle fails") { + StubProtocol.reset([ + .init(status: 200, body: healthOK, urlError: nil), + .init(status: 200, body: providersOK, urlError: nil), + .init(status: 200, body: configOK, urlError: nil), + .init(status: 200, body: usageOK, urlError: nil), + .init(status: 200, body: quotasOK, urlError: nil), + ]) + let coordinator = makeCoordinator() + sync { await coordinator.setPopoverOpen(true) } + + var responses: [StubProtocol.Response] = [.init(status: 200, body: healthOK, urlError: nil)] + responses.append(contentsOf: Array( + repeating: .init(status: 0, body: "", urlError: .cannotConnectToHost), count: 30)) + StubProtocol.reset(responses) + let gate = DispatchSemaphore(value: 0) + StubProtocol.setGate(gate) + defer { + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + } + + let returned = Flag() + let first = Task { await coordinator.refresh() } + t.equal(StubProtocol.gateEntered.wait(timeout: .now() + 5), .success, + "cycle 1 should reach the gate") + + let waiter = Task { + await coordinator.refreshAndWait() + returned.set() + } + t.equal(sync { await waitForWaiter(coordinator) }, true, "waiter should register") + t.equal(returned.value, false, "must still be suspended") + + StubProtocol.setGate(nil) + for _ in 0..<60 { gate.signal() } + let snapshot = sync { () -> ProxySnapshot in + _ = await first.value + _ = await waiter.value + return await coordinator.current + } + t.equal(returned.value, true, "a failing queued cycle must still release its waiter") + // Proves the refusal was actually consumed, not a second 200. + t.equal(snapshot.state, .unreachable, "the queued cycle must have failed") + } + + t.test("polling: refreshAndWait survives a failing cycle without hanging") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let coordinator = makeCoordinator() + let snapshot = sync { () -> ProxySnapshot in + await coordinator.refreshAndWait() + return await coordinator.current + } + t.equal(snapshot.state, .unreachable) + } + } + + private struct NoCredentials: CredentialStore { + func loadAPIKey() -> String? { nil } + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + var count: Int { lock.lock(); defer { lock.unlock() }; return value } + func bump() { lock.lock(); value += 1; lock.unlock() } + } +} diff --git a/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift new file mode 100644 index 00000000000..b9cb5d8146c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/SnapshotStateSuite.swift @@ -0,0 +1,106 @@ +import Foundation +import MenuBarCore + +enum SnapshotStateSuite { + private static func health(_ status: String?, service: Bool = false) -> StartupHealth { + StartupHealth( + status: status, + protection: service ? "service" : "none", + serviceInstalled: service, + serviceEnabled: service + ) + } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("state: every state has a word, so colour is never the only signal") { + let states: [ProxyState] = [ + .loading, .running(health("protected")), .unreachable, + .unauthorized, .degraded("boom"), + ] + for state in states { + t.expect(!state.title.isEmpty, "state \(state) must have a title") + } + t.equal(ProxyState.unreachable.title, "Stopped") + t.equal(ProxyState.unauthorized.title, "Needs API key") + } + + t.test("state: an unprotected but running proxy reads as a warning, not healthy") { + t.equal(ProxyState.running(health("protected")).tone, .good) + t.equal(ProxyState.running(health("at-risk")).tone, .warning) + t.equal(ProxyState.unreachable.tone, .bad) + } + + // loading is the one state with nothing to act on; every other non-running + // state must name a next step rather than dead-ending the user. + t.test("actions: loading has none, and every other non-running state names one") { + let loading = ProxySnapshot(state: .loading, endpoint: endpoint) + t.equal(loading.nextAction, NextAction.none) + + let unauthorized = ProxySnapshot(state: .unauthorized, endpoint: endpoint) + t.equal(unauthorized.nextAction, NextAction.addAPIKey) + + let degraded = ProxySnapshot(state: .degraded("x"), endpoint: endpoint) + t.equal(degraded.nextAction, NextAction.retry) + } + + t.test("actions: a stopped proxy offers the start command for its own install") { + let plain = ProxySnapshot(state: .unreachable, endpoint: endpoint) + t.equal(plain.nextAction, NextAction.runCommand("ocx start")) + + let managed = ProxySnapshot( + state: .unreachable, endpoint: endpoint, + lastKnownStartCommand: "ocx service start" + ) + t.equal(managed.nextAction, NextAction.runCommand("ocx service start")) + } + + t.test("state: the running detail line drops empty and 'none' qualifiers") { + let protectedDetail = ProxyState.running(health("protected", service: true)).detail + t.equal(protectedDetail, "protected · service") + // protection "none" is noise, not information. + t.equal(ProxyState.running(health("at-risk")).detail, "at-risk") + } + + t.test("snapshot: quota rows normalize one row per provider") { + let json = """ + [{"provider":"kimi","label":"Kimi","quota":{"fiveHourPercent":99, + "fiveHourResetAt":1784928599718,"monthlyPercent":10,"monthlyResetAt":1785542400000}}] + """ + let quotas = try JSONDecoder().decode([QuotaReport].self, from: Data(json.utf8)) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, quotas: quotas) + t.equal(snapshot.quotaRows.count, 1) + t.equal(snapshot.quotaRows[0].windowLabel, "5h") + } + + t.test("snapshot: the default provider cannot be toggled") { + let providers = try JSONDecoder().decode( + [ProviderSummary].self, + from: Data(#"[{"name":"openai"},{"name":"anthropic"}]"#.utf8) + ) + let snapshot = ProxySnapshot( + state: .running(health("protected")), endpoint: endpoint, + providers: providers, defaultProvider: "openai" + ) + t.equal(snapshot.canToggle(providers[0]), false, "default provider") + t.equal(snapshot.canToggle(providers[1]), true, "non-default provider") + } + + t.test("snapshot: an omitted usage count stays unknown rather than empty") { + let usage = try JSONDecoder().decode( + UsageReport.self, + from: Data(#"{"range":"7d","summary":{"totalTokens":5}}"#.utf8) + ) + let snapshot = ProxySnapshot(state: .running(health("protected")), endpoint: endpoint, usage: usage) + t.isNil(snapshot.usageIsEmpty, "usageIsEmpty for an omitted count") + } + + t.test("polling: the interval backs off only after repeated failures") { + t.equal(PollingCoordinator.livenessInterval, 5) + t.equal(PollingCoordinator.heavyInterval, 60) + t.equal(PollingCoordinator.backoffInterval, 30) + t.equal(PollingCoordinator.backoffAfterFailures, 3) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift new file mode 100644 index 00000000000..eb2b2b5ca05 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TimelineDecodingSuite.swift @@ -0,0 +1,15 @@ +import Foundation +import MenuBarCore + +enum TimelineDecodingSuite { + static func run(_ t: TestRunner) { + t.test("timeline: decodes series and derived maxima") { + let json = #"{"start":0,"end":3600,"bucketSeconds":1800,"buckets":2,"metric":"total","aggregation":"sum","grouping":"model","series":[{"id":"a","provider":"p","model":"m","total":3,"points":[1,2]},{"id":"b","provider":"p","model":"n","total":4,"points":[4,0]}],"availableModels":["p/m","p/n"],"missingMeasurements":1,"truncated":true}"# + let timeline = try JSONDecoder().decode(UsageTimeline.self, from: Data(json.utf8)) + t.equal(timeline.maxPoint, 4) + t.equal(timeline.stackedMax, 5) + t.equal(timeline.isEmpty, false) + t.equal(timeline.truncated, true) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/TransportSuite.swift b/app/Sources/MenuBarCoreTests/TransportSuite.swift new file mode 100644 index 00000000000..570e10bdeab --- /dev/null +++ b/app/Sources/MenuBarCoreTests/TransportSuite.swift @@ -0,0 +1,362 @@ +import Foundation +import MenuBarCore + +/// Stubs the network so status mapping, the 401 retry, cancellation, request shape, and +/// body privacy are covered without a live proxy. +final class StubProtocol: URLProtocol, @unchecked Sendable { + struct Response { + var status: Int + var body: String + var urlError: URLError.Code? + } + + nonisolated(unsafe) static var queue: [Response] = [] + nonisolated(unsafe) static var recorded: [URLRequest] = [] + private static let lock = NSLock() + + static func reset(_ responses: [Response]) { + lock.lock(); defer { lock.unlock() } + queue = responses + recorded = [] + bodies = [] + gateStorage = nil + } + + nonisolated(unsafe) static var bodies: [Data] = [] + /// When set, `startLoading` blocks until the gate is opened. Lets a test hold a + /// refresh suspended so the coalescing/continuation path is genuinely exercised. + /// + /// Access goes through `setGate`/`currentGate` under the same lock as the rest of + /// the stub state: an unsynchronised read here is a data race, and `gateEntered` + /// lets a test wait for the request to actually reach the gate instead of inferring + /// it from elapsed time. + nonisolated(unsafe) private static var gateStorage: DispatchSemaphore? + static let gateEntered = DispatchSemaphore(value: 0) + + static func setGate(_ gate: DispatchSemaphore?) { + lock.lock(); gateStorage = gate; lock.unlock() + } + + static func currentGate() -> DispatchSemaphore? { + lock.lock(); defer { lock.unlock() } + return gateStorage + } + + static func record(_ request: URLRequest) { + lock.lock(); defer { lock.unlock() } + recorded.append(request) + // URLProtocol replaces httpBody with a stream, so read it here or the body is + // unobservable — which let an "exact body" assertion pass with no body at all. + if let body = request.httpBody { + bodies.append(body) + } else if let stream = request.httpBodyStream { + stream.open() + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1024) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: buffer.count) + if read <= 0 { break } + data.append(buffer, count: read) + } + stream.close() + bodies.append(data) + } + } + + static func next() -> Response? { + lock.lock(); defer { lock.unlock() } + return queue.isEmpty ? nil : queue.removeFirst() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.record(request) + // Held open by tests that need a request to stay in flight. + if let gate = Self.currentGate() { + Self.gateEntered.signal() + gate.wait() + } + if request.url?.path == "/api/companion/settings" { + let body = #"{"settings":{"menuBarMetric":"requests","showToday":true,"showChart":true,"showModels":true,"showCost":true,"showAccounts":true,"chartHours":24,"bucketMinutes":60,"chartStyle":"line","tokenMetric":"total","aggregation":"sum","chartGrouping":"model","hiddenProviders":[]}}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } + if request.url?.path == "/api/usage/timeline" { + let body = #"{"start":0,"end":3600,"bucketSeconds":3600,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[],"availableModels":[],"missingMeasurements":0}"# + let http = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(body.utf8)) + client?.urlProtocolDidFinishLoading(self) + return + } + guard let response = Self.next() else { + client?.urlProtocol(self, didFailWithError: URLError(.cannotConnectToHost)) + return + } + if let code = response.urlError { + client?.urlProtocol(self, didFailWithError: URLError(code)) + return + } + let http = HTTPURLResponse( + url: request.url!, statusCode: response.status, + httpVersion: "HTTP/1.1", headerFields: nil + )! + client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data(response.body.utf8)) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} +} + +private struct StubCredentials: CredentialStore { + let key: String? + let counter: Counter + + final class Counter: @unchecked Sendable { + private(set) var loads = 0 + private let lock = NSLock() + func bump() { lock.lock(); loads += 1; lock.unlock() } + } + + func loadAPIKey() -> String? { + counter.bump() + return key + } +} + +enum TransportSuite { + private static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [StubProtocol.self] + return URLSession(configuration: config) + } + + private static func sync(_ operation: @escaping () async -> T) -> T { + let semaphore = DispatchSemaphore(value: 0) + let box = ResultBox() + Task { + box.value = await operation() + semaphore.signal() + } + semaphore.wait() + return box.value! + } + + private final class ResultBox: @unchecked Sendable { var value: T? } + + static func run(_ t: TestRunner) { + let endpoint = ProxyEndpoint.default + + t.test("transport: a 200 decodes into the model") { + StubProtocol.reset([.init(status: 200, body: #"{"status":"protected"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let result: String? = sync { + try? await client.health().status + } + t.equal(result, "protected") + } + + t.test("transport: a 500 maps to .http and never carries the body") { + StubProtocol.reset([.init(status: 500, body: "SECRET-CONFIG-VALUE", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .http(500)) + let message = error?.userMessage ?? "" + t.expect(!message.contains("SECRET"), "error message must not echo the body: \(message)") + } + + t.test("transport: malformed JSON maps to .decoding") { + StubProtocol.reset([.init(status: 200, body: "{not json", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .decoding) + } + + t.test("transport: connection refused maps to .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unreachable) + } + + // A policy failure is not evidence the proxy is down; conflating them would put + // the UI in "Stopped" for a running proxy. + t.test("transport: an unrelated URLError maps to .transport, not .unreachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .appTransportSecurityRequiresSecureConnection)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .transport) + } + + t.test("transport: cancellation propagates instead of reading as a stopped proxy") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cancelled)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + let wasCancellation: Bool = sync { + do { _ = try await client.health(); return false } + catch is CancellationError { return true } + catch { return false } + } + t.equal(wasCancellation, true) + } + + t.test("auth: a 401 with a stored key retries once and succeeds") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + let status: String? = sync { try? await client.health().status } + t.equal(status, "protected") + t.equal(counter.loads, 1, "credential loaded exactly once") + t.equal(StubProtocol.recorded.count, 2, "one retry") + let retry = StubProtocol.recorded.last + t.equal(retry?.value(forHTTPHeaderField: "x-opencodex-api-key"), "test-key") + } + + t.test("auth: a 401 with no stored key surfaces .unauthorized without retrying") { + StubProtocol.reset([.init(status: 401, body: "", urlError: nil)]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: counter)) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 1, "no retry without a key") + } + + // A stale stored key must not spin: one retry, then surface the failure. + t.test("auth: repeated 401s retry exactly once, never looping") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "stale", counter: .init())) + let error: ProxyError? = sync { + do { _ = try await client.health(); return nil } + catch let error as ProxyError { return error } + catch { return nil } + } + t.equal(error, .unauthorized) + t.equal(StubProtocol.recorded.count, 2, "exactly one retry") + } + + t.test("requests: usage sends the enum range as a query item") { + StubProtocol.reset([.init(status: 200, body: #"{"range":"7d"}"#, urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { try? await client.usage(range: .sevenDays) } + let url = StubProtocol.recorded.first?.url?.absoluteString ?? "" + t.expect(url.contains("range=7d"), "expected range=7d in \(url)") + t.expect(url.contains("/api/usage"), "expected /api/usage in \(url)") + t.equal(StubProtocol.recorded.first?.value(forHTTPHeaderField: "User-Agent"), "OpenCodexMenuBar/dev") + } + + t.test("requests: the provider patch sends exactly {\"disabled\":true}") { + StubProtocol.reset([.init(status: 200, body: "{}", urlError: nil)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + _ = sync { + try? await client.setProviderDisabled("anthropic", disabled: true) + } + let request = StubProtocol.recorded.first + t.equal(request?.httpMethod, "PATCH") + let url = request?.url?.absoluteString ?? "" + t.expect(url.contains("name=anthropic"), "expected name=anthropic in \(url)") + + // Assert on the ACTUAL request body. An earlier version encoded its own + // dictionary and compared that, so it would have passed with no body at all. + guard let body = StubProtocol.bodies.first else { + t.expect(false, "no request body captured") + return + } + let decoded = try JSONSerialization.jsonObject(with: body) as? [String: Any] + t.equal(decoded?.keys.sorted() ?? [], ["disabled"], "body must carry only 'disabled'") + t.equal(decoded?["disabled"] as? Bool, true) + } + + t.test("liveness: a 401 still proves something is listening") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + ]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "k", counter: .init())) + t.equal(sync { await client.isReachable() }, true) + } + + t.test("liveness: connection refused reads as not reachable") { + StubProtocol.reset([.init(status: 0, body: "", urlError: .cannotConnectToHost)]) + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: nil, counter: .init())) + t.equal(sync { await client.isReachable() }, false) + } + + t.test("endpoint: an out-of-range port cannot be constructed") { + t.isNil(ProxyEndpoint(port: 0), "port 0") + t.isNil(ProxyEndpoint(port: -1), "port -1") + t.isNil(ProxyEndpoint(port: 70_000), "port 70000") + t.equal(ProxyEndpoint(port: 10_100)?.baseURL.absoluteString, "http://127.0.0.1:10100") + } + + // The actor suspends across each request, so several calls can be in flight and + // all receive 401. A single global "already tried" flag made the second caller + // fail even though the first had just loaded a usable key. + t.test("auth: concurrent initial 401s both succeed once a key is loaded") { + StubProtocol.reset([ + .init(status: 401, body: "", urlError: nil), + .init(status: 401, body: "", urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + .init(status: 200, body: #"{"status":"protected"}"#, urlError: nil), + ]) + let counter = StubCredentials.Counter() + let client = ProxyClient(endpoint: endpoint, session: makeSession(), + credentials: StubCredentials(key: "test-key", counter: counter)) + + let outcomes: [String] = sync { + async let first = try? await client.health().status + async let second = try? await client.health().status + let results = await [first, second] + return results.map { $0 ?? "error" } + } + + t.equal(outcomes.filter { $0 == "protected" }.count, 2, "both calls should succeed") + t.equal(counter.loads, 1, "credentials loaded exactly once") + t.equal(StubProtocol.recorded.count, 4, "two initial calls plus two retries") + } + } +} diff --git a/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift new file mode 100644 index 00000000000..66f25176b68 --- /dev/null +++ b/app/Sources/MenuBarCoreTests/WidgetSnapshotSuite.swift @@ -0,0 +1,37 @@ +import Foundation +import MenuBarCore + +enum WidgetSnapshotSuite { + static func run(_ t: TestRunner) { + t.test("widget snapshot: maps today and caps chart series") { + var series: [String] = [] + for index in 0..<7 { + series.append(#"{"id":"s\#(index)","provider":"p","model":"m\#(index)","total":1,"points":[1]}"#) + } + let timelineJSON = #"{"start":1,"end":2,"bucketSeconds":60,"buckets":1,"metric":"total","aggregation":"sum","grouping":"model","series":[\#(series.joined(separator: ","))],"availableModels":[],"missingMeasurements":0}"# + let timeline = try! JSONDecoder().decode(UsageTimeline.self, from: Data(timelineJSON.utf8)) + let report = try! JSONDecoder().decode(UsageReport.self, from: Data(#"{"range":"today","summary":{"requests":2,"totalTokens":3,"estimatedCostUsd":4}}"#.utf8)) + var snapshot = ProxySnapshot(endpoint: .default, usage: report, today: report, timeline: timeline) + snapshot.state = .running(try! JSONDecoder().decode(StartupHealth.self, from: Data(#"{"status":"protected"}"#.utf8))) + let widget = WidgetSnapshot.make(from: snapshot, now: Date(timeIntervalSince1970: 100)) + t.equal(widget.schemaVersion, 1) + t.equal(widget.today?.requests, 2) + t.equal(widget.chart?.series.count, 6) + } + t.test("widget snapshot: encoded payload contains no credentials") { + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + let data = try! JSONEncoder().encode(snapshot) + let text = String(decoding: data, as: UTF8.self) + t.expect(!text.contains("apiKey") && !text.contains("x-opencodex"), "privacy") + } + t.test("widget snapshot: store writes to injected home") { + let home = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let store = WidgetSnapshotStore(homeDirectory: home) + let snapshot = WidgetSnapshot.make(from: ProxySnapshot(endpoint: .default), now: Date()) + store.writeIfChanged(snapshot) + t.expect(FileManager.default.fileExists(atPath: store.url.path), "snapshot file") + let mode = (try? FileManager.default.attributesOfItem(atPath: store.url.path)[.posixPermissions] as? NSNumber)?.intValue + t.equal(mode, 0o600) + } + } +} diff --git a/app/Sources/MenuBarCoreTests/main.swift b/app/Sources/MenuBarCoreTests/main.swift new file mode 100644 index 00000000000..41928f17a1c --- /dev/null +++ b/app/Sources/MenuBarCoreTests/main.swift @@ -0,0 +1,20 @@ +import Foundation + +// Entry point for `swift run --package-path app MenuBarCoreTests`. +// See Harness.swift for why this is an executable rather than an XCTest bundle. + +let runner = TestRunner() + +DiscoverySuite.run(runner) +ModelDecodingSuite.run(runner) +FormattingSuite.run(runner) +CompanionSettingsSuite.run(runner) +TimelineDecodingSuite.run(runner) +MenuBarTitleSuite.run(runner) +WidgetSnapshotSuite.run(runner) +TransportSuite.run(runner) +SnapshotStateSuite.run(runner) +PollingSuite.run(runner) +ActionSuite.run(runner) + +exit(runner.summarize()) diff --git a/app/Sources/MenuBarUI/AppDelegate.swift b/app/Sources/MenuBarUI/AppDelegate.swift new file mode 100644 index 00000000000..7ca5c1d8e29 --- /dev/null +++ b/app/Sources/MenuBarUI/AppDelegate.swift @@ -0,0 +1,254 @@ +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 new file mode 100644 index 00000000000..a23c4bf42c9 --- /dev/null +++ b/app/Sources/MenuBarUI/CompanionViews.swift @@ -0,0 +1,78 @@ +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 new file mode 100644 index 00000000000..5c579f88be9 --- /dev/null +++ b/app/Sources/MenuBarUI/PopoverPanel.swift @@ -0,0 +1,170 @@ +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 new file mode 100644 index 00000000000..1cc8dd51ae4 --- /dev/null +++ b/app/Sources/MenuBarUI/PopoverViewController.swift @@ -0,0 +1,384 @@ +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 new file mode 100644 index 00000000000..c0d5b6dc0b2 --- /dev/null +++ b/app/Sources/MenuBarUI/ProviderListView.swift @@ -0,0 +1,255 @@ +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 new file mode 100644 index 00000000000..e7eb2951b17 --- /dev/null +++ b/app/Sources/MenuBarUI/StatusIcon.swift @@ -0,0 +1,73 @@ +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 new file mode 100644 index 00000000000..b1e8b4241f4 --- /dev/null +++ b/app/Sources/MenuBarUI/Theme.swift @@ -0,0 +1,103 @@ +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 new file mode 100644 index 00000000000..56a8a6288f1 --- /dev/null +++ b/app/Sources/MenuBarUI/TimelineChartView.swift @@ -0,0 +1,135 @@ +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 new file mode 100644 index 00000000000..70c229b6859 --- /dev/null +++ b/app/Sources/MenuBarUI/Views.swift @@ -0,0 +1,264 @@ +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 new file mode 100644 index 00000000000..0deb1d6ae46 --- /dev/null +++ b/app/Sources/MenuBarUITests/Harness.swift @@ -0,0 +1,105 @@ +import Foundation + +/// A dependency-free assertion harness. +/// +/// Why not XCTest or swift-testing: neither ships a usable runtime in Xcode Command Line +/// Tools. `import XCTest` fails module resolution outright, and swift-testing compiles +/// but cannot `dlopen` `Testing.framework` at run time. Requiring a full Xcode install to +/// run the unit tests of a menu bar companion would put the tests out of reach for most +/// contributors and for any CI runner without Xcode selected. +/// +/// This harness is ~60 lines, runs as a plain executable, and prints TAP-ish output that +/// both a human and CI can read. If the package ever gains a full-Xcode requirement for +/// other reasons, migrating these cases to swift-testing is mechanical. +public struct TestFailure { + let test: String + let message: String + let file: String + let line: Int +} + +public final class TestRunner { + private(set) var passed = 0 + private(set) var failures: [TestFailure] = [] + private var current = "" + + public init() {} + + public func test(_ name: String, _ body: () throws -> Void) { + current = name + let failuresBefore = failures.count + do { + try body() + } catch { + failures.append(TestFailure(test: name, message: "threw \(error)", file: #file, line: #line)) + print("FAIL — \(name): threw \(error)") + return + } + // A case that recorded an expectation failure is not a pass, even though its + // body returned normally. + if failures.count == failuresBefore { + passed += 1 + print("ok — \(name)") + } + } + + public func expect( + _ condition: Bool, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + guard !condition else { return } + let failure = TestFailure(test: current, message: message(), file: file, line: line) + failures.append(failure) + print("FAIL — \(current): \(failure.message) (\(URL(fileURLWithPath: file).lastPathComponent):\(line))") + } + + public func equal( + _ actual: T, + _ expected: T, + _ label: String = "", + file: String = #file, + line: Int = #line + ) { + expect( + actual == expected, + "\(label.isEmpty ? "" : label + ": ")expected \(expected), got \(actual)", + file: file, + line: line + ) + } + + public func notNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) -> T? { + expect(value != nil, "\(label) should not be nil", file: file, line: line) + return value + } + + public func isNil( + _ value: T?, + _ label: String, + file: String = #file, + line: Int = #line + ) { + expect(value == nil, "\(label) should be nil, got \(String(describing: value))", file: file, line: line) + } + + /// Prints the summary and returns the process exit code. + public func summarize() -> Int32 { + print("") + if failures.isEmpty { + print("\(passed) passed, 0 failed") + return 0 + } + print("\(passed) passed, \(failures.count) FAILED") + for failure in failures { + print(" - \(failure.test): \(failure.message)") + } + return 1 + } +} diff --git a/app/Sources/MenuBarUITests/main.swift b/app/Sources/MenuBarUITests/main.swift new file mode 100644 index 00000000000..daf7e872b20 --- /dev/null +++ b/app/Sources/MenuBarUITests/main.swift @@ -0,0 +1,165 @@ +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/Provider.swift b/app/Sources/OpenCodexWidget/Provider.swift new file mode 100644 index 00000000000..55d983bf5cf --- /dev/null +++ b/app/Sources/OpenCodexWidget/Provider.swift @@ -0,0 +1,49 @@ +import Foundation +import WidgetKit +import MenuBarCore + +@available(macOS 14, *) +public struct SnapshotEntry: TimelineEntry { + public let date: Date + public let snapshot: WidgetSnapshot? + public let failure: ReadFailure? + public let stale: Bool +} + +@available(macOS 14, *) +public struct SnapshotProvider: TimelineProvider { + private let reader = SnapshotReader() + + public init() {} + + public func placeholder(in context: Context) -> SnapshotEntry { + SnapshotEntry(date: Date(), snapshot: Self.sample, failure: nil, stale: false) + } + + public func getSnapshot(in context: Context, completion: @escaping (SnapshotEntry) -> Void) { + completion(readEntry()) + } + + public func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let now = Date() + completion(Timeline(entries: [readEntry(now: now)], policy: .after(now.addingTimeInterval(300)))) + } + + private func readEntry(now: Date = Date()) -> SnapshotEntry { + switch reader.read() { + case .failure(let failure): + return SnapshotEntry(date: now, snapshot: nil, failure: failure, stale: false) + case .success(let snapshot): + return SnapshotEntry(date: now, snapshot: snapshot, failure: nil, stale: snapshot.isStale(now: now)) + } + } + + private static let sample = WidgetSnapshot( + schemaVersion: 1, generatedAt: Date().timeIntervalSince1970, + state: "running", stateTitle: "Running", detail: "protected", + endpointDisplay: "127.0.0.1:10100", menuTitle: "12", + today: .init(requests: 12, totalTokens: 4_200, estimatedCostUsd: 0.12), + quotas: [.init(providerLabel: "OpenAI", windowLabel: "week", percent: 42, resetAt: Date().addingTimeInterval(86_400).timeIntervalSince1970)], + chart: nil, lastUpdated: Date().timeIntervalSince1970 + ) +} diff --git a/app/Sources/OpenCodexWidget/SnapshotReader.swift b/app/Sources/OpenCodexWidget/SnapshotReader.swift new file mode 100644 index 00000000000..0c3b8fc66be --- /dev/null +++ b/app/Sources/OpenCodexWidget/SnapshotReader.swift @@ -0,0 +1,31 @@ +import Foundation +import MenuBarCore + +public enum ReadFailure: String, Error, Equatable, Sendable { + case missing + case corrupt +} + +public extension WidgetSnapshot { + func isStale(now: Date = Date()) -> Bool { + now.timeIntervalSince1970 - generatedAt > 600 + } +} + +public struct SnapshotReader: Sendable { + public init() {} + + public func read() -> Result { + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let url = directory.appendingPathComponent("OpenCodex/snapshot.json") + guard let data = try? Data(contentsOf: url) else { return .failure(.missing) } + guard let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else { + return .failure(.corrupt) + } + return .success(snapshot) + } + + public func isStale(_ snapshot: WidgetSnapshot, now: Date = Date()) -> Bool { + snapshot.isStale(now: now) + } +} diff --git a/app/Sources/OpenCodexWidget/Views.swift b/app/Sources/OpenCodexWidget/Views.swift new file mode 100644 index 00000000000..b130d1fda59 --- /dev/null +++ b/app/Sources/OpenCodexWidget/Views.swift @@ -0,0 +1,323 @@ +import SwiftUI +import WidgetKit +import MenuBarCore + +@available(macOS 14, *) +struct OpenCodexWidgetView: View { + let entry: SnapshotEntry + @Environment(\.widgetFamily) private var family + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + Group { + if let failure = entry.failure { + failureView(failure) + } else if let snapshot = entry.snapshot { + content(snapshot) + } else { + failureView(.missing) + } + } + .containerBackground(.background, for: .widget) + .widgetURL(widgetURL) + } + + private var widgetURL: URL? { + guard let display = entry.snapshot?.endpointDisplay, + let endpoint = URL(string: "http://\(display)"), + endpoint.host != nil, endpoint.port != nil + else { return nil } + return URL(string: "http://\(display)/#/usage") + } + + @ViewBuilder + private func content(_ snapshot: WidgetSnapshot) -> some View { + switch family { + case .systemSmall: + small(snapshot) + case .systemLarge: + large(snapshot) + default: + medium(snapshot) + } + } + + private func tone(_ snapshot: WidgetSnapshot) -> Color { + switch snapshot.state { + case "running": return .green + case "degraded": return .orange + case "unreachable", "unauthorized": return .red + default: return .secondary + } + } + + private func small(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 7) { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text("OpenCodex").font(.caption).foregroundStyle(.secondary) + } + Text(Format.tokens(snapshot.today?.totalTokens)) + .font(.system(size: 28, weight: .semibold, design: .rounded)) + .lineLimit(1) + .widgetAccentable() + Text("tokens today").font(.caption).foregroundStyle(.secondary) + HStack(spacing: 4) { + Text("\(Format.count(snapshot.today?.requests)) req") + if let cost = snapshot.today?.estimatedCostUsd { + Text("·") + Text(Format.cost(cost)) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + updated(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func medium(_ snapshot: WidgetSnapshot) -> some View { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 5) { + status(snapshot) + metric("Tokens", Format.tokens(snapshot.today?.totalTokens)) + metric("Requests", Format.count(snapshot.today?.requests)) + if let cost = snapshot.today?.estimatedCostUsd { metric("Cost", Format.cost(cost)) } + updated(snapshot) + } + Divider() + if hasQuota(snapshot) { + quotaView(snapshot) + } else if let chart = snapshot.chart { + VStack(alignment: .leading, spacing: 5) { + Text("Last \(windowLabel(chart))").font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: false).widgetAccentable() + } + } else { + VStack(alignment: .leading, spacing: 4) { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + Text("Quota appears for providers that report limits") + .font(.caption2).foregroundStyle(.secondary).lineLimit(2) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func large(_ snapshot: WidgetSnapshot) -> some View { + VStack(alignment: .leading, spacing: 10) { + status(snapshot) + metricsRow(snapshot) + if !snapshot.quotas.isEmpty { + VStack(alignment: .leading, spacing: 5) { + ForEach(Array(snapshot.quotas.prefix(4).enumerated()), id: \.offset) { _, quota in + quotaRow(quota) + } + } + } + if let chart = snapshot.chart { + Text("Last \(windowLabel(chart)) · \(chart.series.count) models") + .font(.caption).foregroundStyle(.secondary) + chartView(chart, flexible: true) + .frame(maxHeight: .infinity) + .widgetAccentable() + legend(chart) + } + updated(snapshot) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func status(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 5) { + Circle().fill(tone(snapshot)).frame(width: 7, height: 7) + Text(([snapshot.stateTitle, snapshot.detail].compactMap { $0?.isEmpty == false ? $0 : nil }).joined(separator: " · ")) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + private func metric(_ label: String, _ value: String) -> some View { + HStack { + Text(label).font(.caption).foregroundStyle(.secondary) + Spacer() + Text(value).font(.system(.body, design: .monospaced)) + } + } + + private func metricsRow(_ snapshot: WidgetSnapshot) -> some View { + HStack(spacing: 10) { + metricColumn("TOKENS", Format.tokens(snapshot.today?.totalTokens)) + metricColumn("REQUESTS", Format.count(snapshot.today?.requests)) + metricColumn("COST", Format.cost(snapshot.today?.estimatedCostUsd)) + } + } + + private func metricColumn(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label).font(.caption2).foregroundStyle(.secondary) + Text(value).font(.system(.body, design: .monospaced)).lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func hasQuota(_ snapshot: WidgetSnapshot) -> Bool { + snapshot.quotas.contains { $0.percent != nil } + } + + private func quotaView(_ snapshot: WidgetSnapshot) -> some View { + Group { + if let quota = snapshot.quotas.compactMap({ $0.percent == nil ? nil : $0 }).min(by: { ($0.percent ?? 100) < ($1.percent ?? 100) }) { + VStack(alignment: .leading, spacing: 5) { + Text(quota.providerLabel).font(.caption).lineLimit(1) + ProgressView(value: (quota.percent ?? 0) / 100) + .tint((quota.percent ?? 0) > 80 ? .orange : .green) + Text("\(quota.windowLabel) · \(resets(in: quota.resetAt))") + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } else { + Text("No quota sources").font(.caption).foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func quotaRow(_ quota: WidgetSnapshot.Quota) -> some View { + HStack { + Text(quota.providerLabel).lineLimit(1) + Spacer() + Text("\(Format.percent(quota.percent)) · \(quota.windowLabel)") + .font(.caption).foregroundStyle(.secondary) + } + } + + private func chartView(_ chart: WidgetSnapshot.Chart, flexible: Bool) -> some View { + GeometryReader { geometry in + if chart.style == "stackedBar" { + stackedBars(chart, in: geometry.size) + } else { + lineChart(chart, in: geometry.size) + } + } + .frame(minHeight: 72, maxHeight: flexible ? .infinity : 72) + } + + private func legend(_ chart: WidgetSnapshot.Chart) -> some View { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 4) { + ForEach(Array(chart.series.prefix(5).enumerated()), id: \.offset) { index, series in + HStack(spacing: 4) { + Circle().fill(seriesColor(index)).frame(width: 6, height: 6) + Text(series.id) + .font(.caption2) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + } + + private func lineChart(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + ZStack { + ForEach(Array(chart.series.enumerated()), id: \.offset) { index, series in + Path { path in + let maxValue = maxPoint(chart.series.flatMap(\.points)) + for pointIndex in series.points.indices { + let x = series.points.count > 1 + ? size.width * CGFloat(pointIndex) / CGFloat(series.points.count - 1) : 0 + let y = size.height * (1 - CGFloat(series.points[pointIndex] / maxValue)) + if pointIndex == 0 { path.move(to: CGPoint(x: x, y: y)) } + else { path.addLine(to: CGPoint(x: x, y: y)) } + } + } + .stroke(seriesColor(index), lineWidth: 1.5) + } + } + } + + private func stackedBars(_ chart: WidgetSnapshot.Chart, in size: CGSize) -> some View { + let count = chart.series.map(\.points.count).max() ?? 0 + let maxValue = maxPoint((0.. Color { + if renderingMode == .accented { + return .primary.opacity([1, 0.8, 0.6, 0.45, 0.3, 0.2][index % 6]) + } + return palette[index % palette.count] + } + + private func windowLabel(_ chart: WidgetSnapshot.Chart) -> String { + let hours = chart.bucketSeconds * (chart.series.map(\.points.count).max() ?? 0) / 3600 + if hours < 48 { return "\(hours)h" } + return "\(hours / 24)d" + } + + private func maxPoint(_ points: [Double]) -> Double { max(points.max() ?? 1, 1) } + + private func resets(in timestamp: Double?) -> String { + Format.resetsIn(timestamp.map(Date.init(timeIntervalSince1970:))) + } + + private func updated(_ snapshot: WidgetSnapshot) -> some View { + let text = snapshot.lastUpdated.map { "Updated \(Format.age(Date(timeIntervalSince1970: $0)))" } ?? "Not updated" + return Text(text).font(.caption2).foregroundStyle(entry.stale ? .orange : .secondary).lineLimit(1) + } + + private func failureView(_ failure: ReadFailure) -> some View { + VStack(alignment: .leading, spacing: 8) { + Image(systemName: failure == .missing ? "rectangle.on.rectangle" : "exclamationmark.triangle") + .font(.title2) + Text(failure == .missing + ? "Open the OpenCodex menu bar app to start sharing usage." + : "Snapshot unreadable — refresh from the menu bar app.") + .font(.caption) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +@available(macOS 14, *) +struct OpenCodexWidgetBundle: WidgetBundle { + var body: some Widget { + OpenCodexWidget() + } +} + +@available(macOS 14, *) +struct OpenCodexWidget: Widget { + let kind = "OpenCodexWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: SnapshotProvider()) { entry in + OpenCodexWidgetView(entry: entry) + } + .configurationDisplayName("OpenCodex") + .description("Proxy status, today's usage, and quota at a glance.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } +} diff --git a/app/Sources/OpenCodexWidget/main.swift b/app/Sources/OpenCodexWidget/main.swift new file mode 100644 index 00000000000..7eeda563867 --- /dev/null +++ b/app/Sources/OpenCodexWidget/main.swift @@ -0,0 +1,2 @@ +// WidgetKit enters through _NSExtensionMain; this file keeps the executable target's +// source directory populated without adding a competing Swift-generated main. diff --git a/app/Sources/UIProbe/main.swift b/app/Sources/UIProbe/main.swift new file mode 100644 index 00000000000..02eca1592b3 --- /dev/null +++ b/app/Sources/UIProbe/main.swift @@ -0,0 +1,165 @@ +// 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 new file mode 100644 index 00000000000..360a1591031 --- /dev/null +++ b/app/Widget-Info.plist @@ -0,0 +1,20 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableOpenCodexWidget + CFBundleIdentifiercom.opencodex.menubar.widget + CFBundleInfoDictionaryVersion6.0 + CFBundleNameOpenCodex + CFBundlePackageTypeXPC! + CFBundleShortVersionString0.0.0 + CFBundleVersion0.0.0 + LSMinimumSystemVersion14.0 + NSHumanReadableCopyrightMIT — opencodex contributors + NSExtension + + NSExtensionPointIdentifiercom.apple.widgetkit-extension + + + diff --git a/app/Widget.entitlements b/app/Widget.entitlements new file mode 100644 index 00000000000..1b44cd3cd24 --- /dev/null +++ b/app/Widget.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/devlog/_fin/260725_macos_menubar_app/003_design_read.md b/devlog/_fin/260725_macos_menubar_app/003_design_read.md index f3ec2d33856..0a6d4802489 100644 --- a/devlog/_fin/260725_macos_menubar_app/003_design_read.md +++ b/devlog/_fin/260725_macos_menubar_app/003_design_read.md @@ -113,7 +113,7 @@ column. ├──────────────────────────────────────┤ │ LAST 7 DAYS │ range echoed from the response │ REQUESTS TOKENS COST │ micro labels, 10px, letterspaced -│ 1,746 12.4M $8.21 │ tabular-nums, 13px +│ 1,746 12M $8.21 │ tabular-nums, 13px │ ▁▂▃▅▂▁▃ │ 7d usage trend from usage.days[] ├──────────────────────────────────────┤ │ OpenAI ▓▓▓▓▓░░░░░ 44% │ quota rows, one per provider @@ -170,7 +170,7 @@ Live data reaches `requests: 232507`, `totalTokens: 36536664705`, `estimatedCostUsd: 34018.25`. Rules: - Counts: `1,746` → `12.4K` → `1.2M` (3 significant figures, SI suffix at 10 000). -- Tokens: always suffixed (`12.4M`, `36.5B`). +- Tokens: always suffixed with integer values (`12M`, `37B`). - Cost: `$8.21` below 1 000, `$34.0K` above. - All numerics use `tabular-nums` so digits do not reflow while polling. - Timestamps normalize by magnitude: values below `1e12` are seconds, at or above are diff --git a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md index f6f8c854cbe..1dded9edf28 100644 --- a/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md +++ b/devlog/_fin/260725_macos_menubar_app/010_phase1_core.md @@ -399,7 +399,7 @@ seconds and anthropic milliseconds both resolve to sane 2026 dates · `ProxySett decodes without a `defaultProvider` field and `ProxyConfigSummary` supplies it. `FormattingTests`: the `002` magnitudes (`232507`, `36536664705`, `34018.25`) render as -`232K`, `36.5B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. +`232K`, `37B`, `$34.0K` · `nil` renders `—` · zero renders `0`, not `—`. ## `app/.gitignore` diff --git a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md index 28fbf346144..95126dceb3c 100644 --- a/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md +++ b/devlog/_fin/260725_macos_menubar_app/020_phase2_ui.md @@ -184,7 +184,7 @@ shown as selectable text — displayed, never executed (`002` §3). Three columns from `/api/usage?range=7d`: REQUESTS, TOKENS, COST. Labels in `Theme.micro` uppercase with 0.5pt tracking; values in `Theme.numeric`. All values -through `Format` (`010`), so `36536664705` becomes `36.5B` and `nil` becomes `—`. +through `Format` (`010`), so `36536664705` becomes `37B` and `nil` becomes `—`. **The range label is rendered from the response, not the request.** `002` §3 records that `parseRange` silently falls back to `30d` for any unrecognized value, so a UI that diff --git a/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md new file mode 100644 index 00000000000..3de9b2b95e0 --- /dev/null +++ b/devlog/_fin/260725_macos_menubar_app/051_feature_summary.md @@ -0,0 +1,12 @@ +# 051 — Feature summary + +The macOS companion now shares the proxy's canonical usage accounting across the menu bar +app, widget, and dashboard Usage companion section. The proxy owns the +`/api/usage/timeline` and `/api/companion/settings` contracts; `ocx companion` provides +matching read/write controls with `show`, `set`, and `reset` subcommands. + +The menu bar app renders a settings-driven title, today metrics, model/account/provider +sections, and a timeline chart. It writes a privacy-safe snapshot for the WidgetKit +companion, which supports small, medium, and large families and links back to Usage. +The default menu bar headline is total tokens; the dashboard can switch it to requests, +cost, quota, or icon-only display. diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index e6950ee1851..29a70bd9191 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -96,6 +96,7 @@ export default defineConfig({ { label: "Codex App Model Picker", translations: { fr: "Sélecteur de modèles de Codex App", ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", "zh-TW": "Codex App 模型選擇器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー", tr: "Codex App Model Seçici" }, slug: "guides/codex-app-models" }, { label: "Codex Prompt Layers", translations: { fr: "Couches d'invite Codex", ko: "Codex 프롬프트 레이어", "zh-CN": "Codex 提示词层", "zh-TW": "Codex 提示詞層", ru: "Слои промпта Codex", ja: "Codex プロンプトレイヤー", tr: "Codex İstem Katmanları" }, slug: "guides/codex-prompt" }, { label: "Native Context Compatibility", translations: { ko: "네이티브 컨텍스트 호환성" }, slug: "guides/codex-native-context" }, + { label: "macOS Menu Bar App", translations: { fr: "Application barre de menus macOS", ko: "macOS 메뉴바 앱", "zh-CN": "macOS 菜单栏应用", "zh-TW": "macOS 選單列 App", ru: "Приложение в строке меню macOS", ja: "macOS メニューバーアプリ", tr: "macOS Menü Çubuğu Uygulaması" }, slug: "guides/macos-menu-bar" }, { label: "Model Ordering", translations: { fr: "Ordre des modèles", ko: "모델 정렬에 관하여", "zh-CN": "模型排序", "zh-TW": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順", tr: "Model Sıralaması" }, slug: "guides/model-ordering" }, { label: "Combos", translations: { fr: "Combinaisons", ko: "콤보", "zh-CN": "组合", "zh-TW": "組合", ru: "Комбо", ja: "コンボ", tr: "Kombolar" }, slug: "guides/combos" }, { label: "Claude Code", translations: { fr: "Claude Code", ko: "Claude Code", "zh-CN": "Claude Code", "zh-TW": "Claude Code", ru: "Claude Code", ja: "Claude Code", tr: "Claude Code" }, slug: "guides/claude-code" }, diff --git a/docs-site/src/content/docs/guides/macos-menu-bar.md b/docs-site/src/content/docs/guides/macos-menu-bar.md new file mode 100644 index 00000000000..209efa75664 --- /dev/null +++ b/docs-site/src/content/docs/guides/macos-menu-bar.md @@ -0,0 +1,161 @@ +--- +title: macOS Menu Bar App +description: A native menu bar companion that shows OpenCodex proxy status, usage, and provider quotas at a glance. +--- + +The macOS companion puts OpenCodex in your menu bar: proxy health, recent usage, and +per-provider quota pressure, without opening the dashboard. + +It is a separate application from the proxy. `ocx` keeps running as it always has; the +companion is a read-mostly client that talks to the local management API. + +## Install + +Download `OpenCodex--macos-universal.zip` from the +[latest release](https://github.com/lidge-jun/opencodex/releases), unzip it, and move +`OpenCodex.app` to your Applications folder. + +Verify the download if you like — every release ships a checksum beside it: + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## First launch: Gatekeeper + +**The first launch will be blocked.** macOS will say: + +> "OpenCodex.app" cannot be opened because the developer cannot be verified. + +This is expected, and it is worth explaining rather than talking you past it. Gatekeeper +wants a Developer ID signature and a notarization ticket from Apple, both of which +require a paid Apple Developer account. OpenCodex does not have one, so the app ships +ad-hoc signed: the bundle is intact and its signature is valid, but Apple has not +vouched for the publisher. + +To open it anyway: + +1. Right-click (or Control-click) `OpenCodex.app` in Finder. +2. Choose **Open**. +3. Click **Open** in the dialog that appears. + +If that dialog does not offer an Open button, go to **System Settings → Privacy & +Security**, find the blocked-app notice, and click **Open Anyway**. + +macOS remembers the decision, so this is a one-time step per version. + +Alternatively, remove the quarantine attribute from the terminal: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +If you would rather not do either, build from source — a local build carries no +quarantine attribute at all. See [Build from source](#build-from-source). + +## What it shows + +The menu bar icon reflects proxy state without using colour, since macOS menu bar items +are monochrome by convention: + +| Icon | Meaning | +| --- | --- | +| Solid mark | Running and protected | +| Solid mark with a notch | Running, but routing protection is at risk | +| Outlined mark | Starting up, or degraded | +| Faded outline | Not running, or needs an API key | + +Clicking it opens a panel with four sections: + +**Status** — whether the proxy is running, the loopback endpoint the app is using, and +the protection state. When the proxy recommends a remediation command (for example +`ocx service install`), it appears here as selectable text. The app never runs it for +you. + +**Usage** — requests, tokens, and estimated cost over the last 7 days, with a daily +trend. A `~` after the request count means part of it is estimated rather than reported +by the provider. + +By default, the menu bar headline shows total tokens; change the headline metric in the +dashboard Usage companion settings when you prefer requests, cost, quota, or an icon only. +On macOS 26, the popover and widgets adopt Liquid Glass; earlier macOS versions use the +standard popover material. + +**Quotas** — one row per provider, showing the window under the most pressure. A +provider at 99% of a five-hour limit and 10% of its monthly limit shows the five-hour +figure, because that is the one currently blocking you. The window name is printed under +the provider so `42% of API usage` and `42% of a month` are never confused. + +**Providers** — a collapsible list with a switch per provider. The default provider's +switch is inert while it is enabled, because the proxy refuses to disable it; choose a +different default in the dashboard first. + +## What it can do + +- **Dashboard** opens the web dashboard in your browser. +- **Stop proxy** stops the proxy, after confirming. This is deliberately not called + "Restart": stopping also stops the launchd service, so nothing brings the proxy back + automatically. The panel then shows the command to start it again. +- **Provider switches** enable or disable a provider. + +Everything else — accounts, model configuration, storage — stays in the dashboard. + +## Widget + +Add the widget from the desktop: right-click, choose **Edit Widgets**, then add +**OpenCodex**. It shows proxy status, today's usage, quota pressure, and the same +privacy-safe usage snapshot as the menu bar app. The widget refreshes when the app polls. +It requires macOS 14 or later and reads only the privacy-safe snapshot written by the +OpenCodex app; it does not receive API keys or raw account data. + +## Connecting to the proxy + +The app finds the proxy automatically. It reads `~/.opencodex/runtime-port.json` (or +`$OPENCODEX_HOME/runtime-port.json`) and falls back to port `10100`. Only the port is +taken from that file; the host is always loopback. + +If your proxy is bound to a non-loopback address it will require an API key. The panel +says so and offers a link to the dashboard. + +**This case is not supported yet.** The app reads a key from the macOS Keychain and +retries once with it, but there is no UI for entering one and no supported way to +provision it by hand — the item is a data-protection Keychain entry, which Keychain +Access does not create. So on a non-loopback bind the panel stays on "Needs API key". + +A loopback proxy — the default — needs no key at all. Native key entry is planned. + +## Polling + +The app is deliberately quiet. It checks whether the proxy is alive every 5 seconds, and +fetches the expensive aggregate data — usage and quotas — only while the panel is open, +at most once a minute. After three consecutive failures it backs off to every 30 seconds +rather than hammering a proxy you stopped on purpose. + +## Build from source + +Requires macOS 13 or later, the Xcode Command Line Tools, and [Bun](https://bun.sh): + +```bash +git clone https://github.com/lidge-jun/opencodex.git +cd opencodex +bun run build:macos +``` + +The bundle appears at `dist/macos/OpenCodex.app`. Without Bun you can run the script +directly: `bash scripts/build-macos-app.sh`. + +Building a universal binary (`UNIVERSAL=1`) needs the full Xcode toolchain — Command +Line Tools ships only current-architecture Swift compatibility libraries, and the build +will tell you so rather than failing with a linker error. + +If you have a Developer ID certificate in your keychain, set `MACOS_SIGN_IDENTITY` to +sign with the hardened runtime instead of ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## Uninstall + +Drag `OpenCodex.app` to the Trash. The app writes no preferences or state of its own, and +stores nothing in the Keychain today. diff --git a/docs-site/src/content/docs/ja/guides/macos-menu-bar.md b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md new file mode 100644 index 00000000000..ff67a25ccc7 --- /dev/null +++ b/docs-site/src/content/docs/ja/guides/macos-menu-bar.md @@ -0,0 +1,157 @@ +--- +title: macOS メニューバーアプリ +description: OpenCodex プロキシの状態、使用量、プロバイダーのクォータをメニューバーから確認できるネイティブアプリ。 +--- + +メニューバーアプリは、ダッシュボードを開かずにプロキシの状態、直近の使用量、プロバイダーごとの +クォータ状況を表示します。 + +プロキシとは別のアプリケーションです。`ocx` はこれまで通り動作し、メニューバーアプリは +ローカルの管理 API に接続するクライアントとして動きます。 + +## インストール + +[リリースページ](https://github.com/lidge-jun/opencodex/releases)から +`OpenCodex--macos-universal.zip` をダウンロードし、展開して `OpenCodex.app` を +アプリケーションフォルダに移動します。 + +ダウンロードを検証する場合、リリースごとにチェックサムが添付されています。 + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## 初回起動: Gatekeeper + +**初回起動はブロックされます。** 次のメッセージが表示されます。 + +> "OpenCodex.app"は、開発元を検証できないため開けません。 + +これは想定された動作なので、読み飛ばさずに理由を説明します。Gatekeeper は Apple の +Developer ID 署名と公証(notarization)チケットを要求しますが、どちらも有料の Apple +Developer アカウントが必要です。OpenCodex はそのアカウントを持たないため、アプリは ad-hoc +署名で配布されます。バンドル自体は壊れておらず署名も有効ですが、Apple が配布元を保証しては +いない、という状態です。 + +それでも開くには: + +1. Finder で `OpenCodex.app` を右クリック(または Control クリック)します。 +2. **開く** を選択します。 +3. 表示されたダイアログで再度 **開く** をクリックします。 + +ダイアログに「開く」が無い場合は、**システム設定 → プライバシーとセキュリティ** でブロック +通知を探し、**このまま開く** をクリックしてください。 + +一度許可すれば macOS が記憶するため、バージョンごとに一度だけの操作です。 + +ターミナルから隔離属性を削除する方法もあります。 + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +どちらも避けたい場合はソースからビルドしてください。ローカルビルドには隔離属性が付きません。 +[ソースからビルド](#ソースからビルド)を参照してください。 + +## 表示される内容 + +メニューバーのアイコンは色ではなく形で状態を示します。macOS のメニューバーアイコンは単色が +慣例だからです。 + +| アイコン | 意味 | +| --- | --- | +| 塗りつぶし | 実行中、ルーティング保護あり | +| 切り欠き付き | 実行中だがルーティング保護が不安定 | +| 輪郭のみ | 確認中、または応答が異常 | +| 薄い輪郭 | 停止中、または API キーが必要 | + +アイコンをクリックすると 4 つのセクションを持つパネルが開きます。 + +**ステータス** — プロキシの稼働状況、アプリが使用しているループバックアドレス、保護状態。プロキシが対処コマンド +(例: `ocx service install`)を推奨している場合は選択可能なテキストとして表示します。アプリが +代わりに実行することはありません。 + +**使用量** — 直近 7 日間のリクエスト数、トークン、推定コストと日別の推移。リクエスト数の後ろの +`~` は、一部がプロバイダー報告値ではなく推定値であることを示します。 + +デフォルトでは、メニューバーのヘッドラインに合計トークン数が表示されます。リクエスト数、コスト、クォータ、 +またはアイコンだけを表示したい場合は、ダッシュボードの Usage コンパニオン設定でヘッドライン指標を変更できます。 +macOS 26 ではポップオーバーとウィジェットに Liquid Glass が採用され、それ以前の macOS バージョンでは標準の +ポップオーバーマテリアルが使われます。 + +**クォータ** — プロバイダーごとに 1 行、最も逼迫しているウィンドウを表示します。5 時間枠を +99%、月間枠を 10% 使っているプロバイダーなら 5 時間枠の数値を出します。いま実際に制限に +かかっているのはそちらだからです。ウィンドウ名を併記するため、`API usage の 42%` と +`1 か月の 42%` を取り違えることはありません。 + +**プロバイダー** — 展開できる一覧で、プロバイダーごとにスイッチがあります。デフォルト +プロバイダーは有効な間スイッチが無効化されます。プロキシがデフォルトの無効化を拒否するため、 +先にダッシュボードでデフォルトを変更してください。 + +## できること + +- **Dashboard** — ブラウザで Web ダッシュボードを開きます。 +- **Stop proxy** — 確認のうえプロキシを停止します。あえて「再起動」とは呼びません。停止すると + launchd サービスも止まり、自動的には復帰しないためです。停止後は再起動用のコマンドを + パネルに表示します。 +- **プロバイダースイッチ** — プロバイダーの有効・無効を切り替えます。 + +アカウント、モデル設定、ストレージなどはダッシュボードで操作します。 + +## ウィジェット + +デスクトップを右クリックして **ウィジェットを編集** を選び、**OpenCodex** を追加します。 +プロキシの状態、今日の使用量、クォータを表示し、メニューバーアプリと同じプライバシー保護済み +スナップショットを使います。アプリのポーリング時に更新されます。macOS 14 以降が必要で、 +API キーや生のアカウント情報は受け取りません。 + +## プロキシへの接続 + +アプリが自動で見つけます。`~/.opencodex/runtime-port.json`(または +`$OPENCODEX_HOME/runtime-port.json`)を読み、無ければポート `10100` を使います。この +ファイルから取得するのはポートのみで、ホストは常にループバックです。 + +プロキシがループバック以外のアドレスにバインドされている場合は API キーが必要です。パネルが +その旨を表示し、ダッシュボードへのボタンを出します。 + +**この経路はまだサポートされていません。** アプリは macOS キーチェーンからキーを読み取って +一度だけ再試行しますが、キーを入力する画面はなく、手動で用意する方法もありません。データ保護 +キーチェーンの項目であり、キーチェーンアクセスでは作成できないためです。したがってループバック +以外のバインドではパネルは「Needs API key」のままになります。 + +既定であるループバックのプロキシではキーは不要です。ネイティブのキー入力は今後追加予定です。 + +## ポーリング + +アプリは意図的に控えめに動作します。プロキシの生存確認は 5 秒ごと、負荷の大きい集計データ +(使用量とクォータ)はパネルが開いている間のみ、最大でも 1 分に 1 回取得します。3 回連続で +失敗した場合は 30 秒間隔に広げます。ユーザーが意図的に停止したプロキシを叩き続けないためです。 + +## ソースからビルド + +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 +``` + +バンドルは `dist/macos/OpenCodex.app` に生成されます。Bun がない場合はスクリプトを直接 +実行できます: `bash scripts/build-macos-app.sh`。 + +ユニバーサルバイナリ(`UNIVERSAL=1`)には完全な Xcode が必要です。Command Line Tools には +現在のアーキテクチャ用の Swift 互換ライブラリしか含まれないため、その場合はリンカーエラーでは +なく理由を説明するメッセージが表示されます。 + +キーチェーンに Developer ID 証明書がある場合は、`MACOS_SIGN_IDENTITY` を指定すると ad-hoc +ではなく hardened runtime で署名できます。 + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## アンインストール + +`OpenCodex.app` をゴミ箱に移動してください。アプリは設定ファイルなどを残さず、現時点では +キーチェーンにも何も保存しません。 diff --git a/docs-site/src/content/docs/ko/guides/macos-menu-bar.md b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md new file mode 100644 index 00000000000..53159b4589b --- /dev/null +++ b/docs-site/src/content/docs/ko/guides/macos-menu-bar.md @@ -0,0 +1,153 @@ +--- +title: macOS 메뉴바 앱 +description: OpenCodex 프록시 상태와 사용량, 프로바이더 쿼터를 메뉴바에서 바로 확인하는 네이티브 앱입니다. +--- + +메뉴바 앱은 대시보드를 열지 않아도 프록시 상태와 최근 사용량, 프로바이더별 쿼터를 한눈에 +보여줍니다. + +프록시와는 별개의 앱입니다. `ocx`는 지금까지처럼 그대로 돌아가고, 메뉴바 앱은 로컬 관리 +API에 붙는 클라이언트입니다. + +## 설치 + +[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 +`OpenCodex-<버전>-macos-universal.zip`을 받아 압축을 풀고 `OpenCodex.app`을 응용 +프로그램 폴더로 옮기세요. + +받은 파일을 검증하고 싶다면 릴리스마다 체크섬이 함께 올라갑니다. + +```bash +shasum -a 256 -c OpenCodex-<버전>-macos-universal.zip.sha256 +``` + +## 첫 실행: Gatekeeper 차단 + +**처음 실행하면 macOS가 막습니다.** 이런 메시지가 뜹니다. + +> "OpenCodex.app"은(는) 개발자를 확인할 수 없기 때문에 열 수 없습니다. + +예상된 동작이라 그냥 넘어가지 않고 이유를 적어둡니다. Gatekeeper는 Apple의 Developer ID +서명과 공증(notarization) 티켓을 요구하는데, 둘 다 유료 Apple Developer 계정이 있어야 +합니다. OpenCodex에는 그 계정이 없어서 앱은 ad-hoc 서명 상태로 배포됩니다. 번들 자체는 +온전하고 서명도 유효하지만, Apple이 배포자를 보증해 주지는 않았다는 뜻입니다. + +그래도 열려면: + +1. Finder에서 `OpenCodex.app`을 우클릭(또는 Control-클릭)합니다. +2. **열기**를 선택합니다. +3. 뜨는 대화상자에서 다시 **열기**를 누릅니다. + +대화상자에 열기 버튼이 없다면 **시스템 설정 → 개인정보 보호 및 보안**에서 차단 알림을 찾아 +**그래도 열기**를 누르세요. + +한 번 허용하면 macOS가 기억하므로 버전마다 한 번씩만 하면 됩니다. + +터미널에서 격리 속성을 지워도 됩니다. + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +둘 다 내키지 않으면 직접 빌드하세요. 로컬 빌드에는 격리 속성이 아예 붙지 않습니다. +[소스에서 빌드하기](#소스에서-빌드하기)를 참고하세요. + +## 무엇을 보여주나 + +메뉴바 아이콘은 색이 아니라 형태로 상태를 나타냅니다. macOS 메뉴바 아이콘은 단색이 +관례이기 때문입니다. + +| 아이콘 | 의미 | +| --- | --- | +| 꽉 찬 마크 | 실행 중이고 라우팅이 보호됨 | +| 홈이 파인 마크 | 실행 중이지만 라우팅 보호가 불안정함 | +| 외곽선 마크 | 확인 중이거나 응답이 이상함 | +| 흐린 외곽선 | 실행 중이 아니거나 API 키가 필요함 | + +아이콘을 누르면 네 영역이 있는 패널이 열립니다. + +**상태** — 프록시 실행 여부, 앱이 사용 중인 루프백 주소, 보호 상태를 보여줍니다. 프록시가 조치 명령을 +권할 때(예: `ocx service install`) 선택 가능한 텍스트로 표시합니다. 앱이 대신 실행하지는 +않습니다. + +**사용량** — 최근 7일간 요청 수, 토큰, 예상 비용과 일자별 추이입니다. 요청 수 뒤의 `~`는 +일부가 프로바이더 보고값이 아니라 추정치라는 표시입니다. + +기본적으로 메뉴 막대 헤드라인은 총 토큰 수를 표시합니다. 요청 수, 비용, 할당량 또는 아이콘만 보고 싶다면 대시보드 Usage의 컴패니언 설정에서 헤드라인 지표를 바꿀 수 있습니다. +macOS 26에서는 팝오버와 위젯이 Liquid Glass를 사용하며, 이전 macOS 버전은 기본 팝오버 머티리얼을 사용합니다. + +**쿼터** — 프로바이더마다 한 줄씩, 가장 압박이 큰 창을 보여줍니다. 5시간 한도를 99% 쓰고 +월 한도는 10%만 쓴 프로바이더라면 5시간 수치를 표시합니다. 지금 막고 있는 쪽이 그것이기 +때문입니다. 창 이름을 아래에 적어두어 `API usage의 42%`와 `한 달의 42%`를 헷갈릴 일이 +없습니다. + +**프로바이더** — 펼칠 수 있는 목록이고 프로바이더마다 스위치가 있습니다. 기본 프로바이더는 +켜져 있는 동안 스위치가 잠깁니다. 프록시가 기본 프로바이더 비활성화를 거부하기 때문이며, +대시보드에서 기본값을 먼저 바꿔야 합니다. + +## 무엇을 할 수 있나 + +- **Dashboard** — 브라우저에서 웹 대시보드를 엽니다. +- **Stop proxy** — 확인을 거쳐 프록시를 중지합니다. 일부러 "재시작"이라고 부르지 않습니다. + 중지하면 launchd 서비스도 함께 멈춰서 자동으로 다시 뜨지 않기 때문입니다. 중지 후에는 + 다시 시작하는 명령을 패널에 보여줍니다. +- **프로바이더 스위치** — 프로바이더를 켜고 끕니다. + +계정, 모델 설정, 저장소 관리 같은 나머지는 대시보드에서 합니다. + +## 위젯 + +바탕화면을 우클릭하고 **위젯 편집**을 선택한 다음 **OpenCodex**를 추가하세요. 프록시 상태, +오늘의 사용량과 쿼터를 표시하며 메뉴바 앱과 동일한 개인정보 보호 스냅샷을 사용합니다. 앱이 +폴링할 때 새로 고침됩니다. macOS 14 이상이 필요하고 API 키나 원시 계정 정보는 전달하지 않습니다. + +## 프록시 연결 + +앱이 알아서 찾습니다. `~/.opencodex/runtime-port.json`(또는 +`$OPENCODEX_HOME/runtime-port.json`)을 읽고, 없으면 `10100` 포트를 씁니다. 이 파일에서 +가져오는 건 포트뿐이고 호스트는 항상 루프백입니다. + +프록시가 루프백이 아닌 주소에 바인딩돼 있으면 API 키가 필요합니다. 패널이 그 사실을 알려주고 +대시보드로 가는 버튼을 보여줍니다. + +**아직 지원되지 않는 경로입니다.** 앱은 macOS 키체인에서 키를 읽어 한 번 재시도하지만, +키를 입력하는 화면이 없고 손으로 넣을 방법도 없습니다. 데이터 보호 키체인 항목이라 키체인 +접근으로는 만들 수 없기 때문입니다. 따라서 루프백이 아닌 바인딩에서는 패널이 "Needs API key" +상태로 남습니다. + +기본값인 루프백 프록시는 키가 필요 없습니다. 네이티브 키 입력은 예정돼 있습니다. + +## 폴링 주기 + +앱은 일부러 조용하게 동작합니다. 프록시 생존 확인은 5초마다 하고, 비용이 큰 집계 데이터인 +사용량과 쿼터는 패널이 열려 있을 때만, 그것도 최대 1분에 한 번 가져옵니다. 연속 세 번 +실패하면 30초 간격으로 늘립니다. 사용자가 일부러 끈 프록시를 계속 두드리지 않기 위해서입니다. + +## 소스에서 빌드하기 + +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 +``` + +번들은 `dist/macos/OpenCodex.app`에 생깁니다. Bun 없이 쓰려면 스크립트를 직접 실행하세요: +`bash scripts/build-macos-app.sh`. + +유니버설 바이너리(`UNIVERSAL=1`)를 만들려면 전체 Xcode가 필요합니다. Command Line Tools +에는 현재 아키텍처용 Swift 호환 라이브러리만 들어 있어서, 이 경우 링커 오류 대신 그 이유를 +설명하는 메시지가 나옵니다. + +키체인에 Developer ID 인증서가 있다면 `MACOS_SIGN_IDENTITY`를 지정해 ad-hoc 대신 하드닝된 +런타임으로 서명할 수 있습니다. + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## 삭제 + +`OpenCodex.app`을 휴지통으로 옮기면 됩니다. 앱은 환경설정이나 별도 상태 파일을 남기지 +않고, 현재는 키체인에도 아무것도 저장하지 않습니다. diff --git a/docs-site/src/content/docs/ru/guides/macos-menu-bar.md b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md new file mode 100644 index 00000000000..30f54ad9835 --- /dev/null +++ b/docs-site/src/content/docs/ru/guides/macos-menu-bar.md @@ -0,0 +1,159 @@ +--- +title: Приложение в строке меню macOS +description: Нативное приложение, показывающее состояние прокси OpenCodex, расход и квоты провайдеров прямо в строке меню. +--- + +Приложение показывает состояние прокси, недавний расход и загрузку квот по провайдерам, +не требуя открывать панель управления. + +Это отдельная программа. `ocx` работает как раньше, а приложение в строке меню — +клиент, который обращается к локальному management API. + +## Установка + +Скачайте `OpenCodex-<версия>-macos-universal.zip` со +[страницы релизов](https://github.com/lidge-jun/opencodex/releases), распакуйте и +переместите `OpenCodex.app` в папку «Программы». + +Если хотите проверить загрузку, к каждому релизу прилагается контрольная сумма: + +```bash +shasum -a 256 -c OpenCodex-<версия>-macos-universal.zip.sha256 +``` + +## Первый запуск: Gatekeeper + +**Первый запуск будет заблокирован.** macOS покажет: + +> Не удаётся открыть «OpenCodex.app», так как не удалось проверить разработчика. + +Это ожидаемо, поэтому объясняем причину, а не предлагаем просто нажать дальше. Gatekeeper +требует подпись Developer ID и билет нотаризации от Apple — и то и другое доступно только +с платным аккаунтом Apple Developer. У OpenCodex его нет, поэтому приложение выпускается +с ad-hoc подписью: сам бандл цел и подпись корректна, но Apple не подтверждает издателя. + +Чтобы всё-таки открыть: + +1. Нажмите правой кнопкой (или Control-клик) на `OpenCodex.app` в Finder. +2. Выберите **Открыть**. +3. В появившемся диалоге снова нажмите **Открыть**. + +Если в диалоге нет кнопки «Открыть», откройте **Системные настройки → Конфиденциальность и +безопасность**, найдите уведомление о заблокированной программе и нажмите **Всё равно +открыть**. + +macOS запомнит решение, так что это разовое действие для каждой версии. + +Можно также снять атрибут карантина из терминала: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +Если ни один вариант не подходит, соберите приложение сами — у локальной сборки атрибута +карантина нет вовсе. См. [Сборка из исходников](#сборка-из-исходников). + +## Что показывает + +Иконка в строке меню передаёт состояние формой, а не цветом: в macOS иконки строки меню +по традиции монохромны. + +| Иконка | Значение | +| --- | --- | +| Сплошная метка | Работает, маршрутизация защищена | +| Метка с выемкой | Работает, но защита маршрутизации под угрозой | +| Контурная метка | Проверка или нештатный ответ | +| Блёклый контур | Не запущен или нужен API-ключ | + +По клику открывается панель с четырьмя разделами. + +**Состояние** — работает ли прокси, локальный адрес, который использует приложение, и +состояние защиты. Если прокси +рекомендует команду (например, `ocx service install`), она показывается выделяемым +текстом. Приложение её не выполняет. + +**Расход** — запросы, токены и оценочная стоимость за последние 7 дней с дневной +динамикой. Знак `~` после числа запросов означает, что часть значения оценочная, а не +сообщённая провайдером. + +По умолчанию в заголовке строки меню отображается общее число токенов; если нужны запросы, стоимость, квота или только значок, измените метрику заголовка в настройках Companion раздела Usage на дашборде. +В macOS 26 всплывающее окно и виджеты используют Liquid Glass; в более ранних версиях macOS используется стандартный материал всплывающего окна. + +**Квоты** — по строке на провайдера, показывается окно под наибольшим давлением. Если +провайдер израсходовал 99% пятичасового лимита и 10% месячного, показывается пятичасовое +значение — именно оно сейчас блокирует работу. Название окна печатается под провайдером, +поэтому `42% от API usage` и `42% от месяца` невозможно перепутать. + +**Провайдеры** — раскрывающийся список с переключателем для каждого провайдера. +Переключатель провайдера по умолчанию заблокирован, пока тот включён: прокси отказывается +отключать провайдера по умолчанию, поэтому сначала смените его в панели управления. + +## Что умеет + +- **Dashboard** — открывает веб-панель в браузере. +- **Stop proxy** — останавливает прокси после подтверждения. Намеренно не называется + «перезапуск»: остановка также останавливает службу launchd, поэтому прокси не поднимется + сам. После остановки панель показывает команду для повторного запуска. +- **Переключатели провайдеров** — включают и выключают провайдера. + +Всё остальное — аккаунты, настройка моделей, хранилище — остаётся в панели управления. + +## Виджет + +Щёлкните правой кнопкой по рабочему столу, выберите **Изменить виджеты** и добавьте +**OpenCodex**. Он показывает состояние прокси, расход за сегодня и квоты, используя тот же +конфиденциальный снимок, что и приложение в строке меню. Виджет обновляется при опросе приложения. +Требуется macOS 14 или новее; API-ключи и необработанные данные аккаунтов не передаются. + +## Подключение к прокси + +Приложение находит прокси само. Оно читает `~/.opencodex/runtime-port.json` (или +`$OPENCODEX_HOME/runtime-port.json`), а при отсутствии использует порт `10100`. Из файла +берётся только порт; хост всегда локальный. + +Если прокси привязан не к локальному адресу, потребуется API-ключ. Панель сообщит об этом +и предложит перейти в панель управления. + +**Этот сценарий пока не поддержан.** Приложение читает ключ из связки ключей macOS и делает +одну повторную попытку, но интерфейса для ввода ключа нет и нет поддерживаемого способа +создать его вручную: это элемент data-protection keychain, который «Связка ключей» не +создаёт. Поэтому при нелокальной привязке панель остаётся в состоянии «Needs API key». + +Локальному прокси, который используется по умолчанию, ключ не нужен. Нативный ввод ключа +запланирован. + +## Опрос + +Приложение намеренно ведёт себя тихо. Проверка доступности — раз в 5 секунд, а тяжёлые +агрегаты (расход и квоты) запрашиваются только при открытой панели и не чаще раза в +минуту. После трёх неудач подряд интервал увеличивается до 30 секунд, чтобы не долбить +прокси, который вы остановили намеренно. + +## Сборка из исходников + +Требуются 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 +``` + +Бандл появится в `dist/macos/OpenCodex.app`. Без Bun скрипт можно запустить напрямую: +`bash scripts/build-macos-app.sh`. + +Для универсального бинарника (`UNIVERSAL=1`) нужен полный Xcode: в Command Line Tools есть +только библиотеки совместимости Swift для текущей архитектуры, и сборка сообщит об этом +вместо ошибки компоновщика. + +Если в связке ключей есть сертификат Developer ID, задайте `MACOS_SIGN_IDENTITY`, чтобы +подписать с hardened runtime вместо ad-hoc: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## Удаление + +Перетащите `OpenCodex.app` в корзину. Приложение не оставляет ни настроек, ни собственных +файлов состояния и пока ничего не хранит в связке ключей. diff --git a/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md new file mode 100644 index 00000000000..50058524bf0 --- /dev/null +++ b/docs-site/src/content/docs/zh-cn/guides/macos-menu-bar.md @@ -0,0 +1,139 @@ +--- +title: macOS 菜单栏应用 +description: 在菜单栏中查看 OpenCodex 代理状态、用量和各提供商配额的原生应用。 +--- + +菜单栏应用让你无需打开仪表板,就能看到代理状态、近期用量和各提供商的配额压力。 + +它与代理是两个独立的程序。`ocx` 照常运行,菜单栏应用只是连接本地管理 API 的客户端。 + +## 安装 + +从[发布页面](https://github.com/lidge-jun/opencodex/releases)下载 +`OpenCodex--macos-universal.zip`,解压后把 `OpenCodex.app` 移到「应用程序」文件夹。 + +如果需要校验下载文件,每个版本都附带校验和: + +```bash +shasum -a 256 -c OpenCodex--macos-universal.zip.sha256 +``` + +## 首次启动:Gatekeeper + +**首次启动会被阻止。** macOS 会提示: + +> 无法打开“OpenCodex.app”,因为无法验证开发者。 + +这是预期行为,所以这里说明原因而不是直接略过。Gatekeeper 需要 Apple 的 Developer ID 签名和 +公证(notarization)票据,两者都需要付费的 Apple Developer 账号。OpenCodex 没有该账号,因此 +应用以 ad-hoc 签名发布:程序包本身完整、签名有效,但 Apple 并未为发布者背书。 + +仍要打开: + +1. 在 Finder 中右键点击(或按住 Control 点击)`OpenCodex.app`。 +2. 选择**打开**。 +3. 在弹出的对话框中再次点击**打开**。 + +如果对话框没有「打开」按钮,请前往**系统设置 → 隐私与安全性**,找到被拦截的提示并点击 +**仍要打开**。 + +macOS 会记住这个选择,因此每个版本只需操作一次。 + +也可以在终端移除隔离属性: + +```bash +xattr -d com.apple.quarantine /Applications/OpenCodex.app +``` + +如果两种方式都不想用,可以自行构建——本地构建不会带有隔离属性。参见[从源码构建](#从源码构建)。 + +## 显示的内容 + +菜单栏图标用形状而非颜色表示状态,因为 macOS 菜单栏图标按惯例是单色的: + +| 图标 | 含义 | +| --- | --- | +| 实心标记 | 运行中,路由受保护 | +| 带缺口的实心标记 | 运行中,但路由保护存在风险 | +| 轮廓标记 | 正在检查,或响应异常 | +| 淡色轮廓 | 未运行,或需要 API 密钥 | + +点击图标会打开包含四个部分的面板。 + +**状态** — 代理是否运行、应用正在使用的回环地址以及保护状态。当代理给出修复命令(例如 +`ocx service install`)时,会以可选中的文本显示。应用不会替你执行。 + +**用量** — 最近 7 天的请求数、令牌数和预估成本,以及每日趋势。请求数后的 `~` 表示其中一部分 +是估算值,而非提供商上报的数据。 + +默认情况下,菜单栏标题显示令牌总数;如果您更想查看请求数、成本、配额,或只显示图标,可在控制台 Usage 的 Companion 设置中更改标题指标。 +在 macOS 26 中,弹出面板和小组件采用 Liquid Glass;更早版本的 macOS 使用标准弹出面板材质。 + +**配额** — 每个提供商一行,显示压力最大的那个窗口。如果某个提供商 5 小时额度用了 99%、月度 +额度只用了 10%,会显示 5 小时的数值,因为真正卡住你的是它。窗口名称标注在提供商下方,因此 +`API usage 的 42%` 和`一个月的 42%` 不会混淆。 + +**提供商** — 可展开的列表,每个提供商带一个开关。默认提供商在启用状态下开关是锁定的,因为 +代理会拒绝停用默认提供商;请先在仪表板中更换默认值。 + +## 可以做什么 + +- **Dashboard** — 在浏览器中打开 Web 仪表板。 +- **Stop proxy** — 确认后停止代理。这里刻意不叫「重启」:停止会同时停掉 launchd 服务,代理不会 + 自动恢复。停止后面板会显示重新启动的命令。 +- **提供商开关** — 启用或停用某个提供商。 + +账号、模型配置、存储等其余操作仍在仪表板中完成。 + +## 小组件 + +在桌面上右键点击,选择**编辑小组件**,然后添加 **OpenCodex**。它显示代理状态、今日用量和 +配额,并使用与菜单栏应用相同的隐私安全快照。应用轮询时小组件会刷新。需要 macOS 14 或更高 +版本;它不会接收 API 密钥或原始账户信息。 + +## 连接到代理 + +应用会自动查找。它读取 `~/.opencodex/runtime-port.json`(或 +`$OPENCODEX_HOME/runtime-port.json`),找不到则使用端口 `10100`。该文件只提供端口,主机始终 +为回环地址。 + +如果代理绑定在非回环地址上,就需要 API 密钥。面板会说明这一点并提供前往仪表板的按钮。 + +**该路径尚未支持。** 应用会从 macOS 钥匙串读取密钥并重试一次,但没有输入密钥的界面,也没有 +手动写入的办法——它是数据保护钥匙串条目,「钥匙串访问」无法创建。因此在非回环绑定下,面板会 +一直停在「Needs API key」。 + +默认的回环代理不需要密钥。原生密钥输入已在计划中。 + +## 轮询 + +应用刻意保持安静。存活检查每 5 秒一次;开销较大的聚合数据(用量和配额)只在面板打开时获取, +且最多每分钟一次。连续三次失败后会退避到 30 秒一次,以免不断敲打你主动停掉的代理。 + +## 从源码构建 + +需要 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 +``` + +程序包会生成在 `dist/macos/OpenCodex.app`。若没有 Bun,可以直接运行脚本: +`bash scripts/build-macos-app.sh`。 + +构建通用二进制(`UNIVERSAL=1`)需要完整的 Xcode。Command Line Tools 只包含当前架构的 Swift +兼容库,此时构建会给出说明信息,而不是抛出链接器错误。 + +如果钥匙串中有 Developer ID 证书,可以设置 `MACOS_SIGN_IDENTITY`,以 hardened runtime 签名 +替代 ad-hoc 签名: + +```bash +MACOS_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" bun run build:macos +``` + +## 卸载 + +把 `OpenCodex.app` 拖到废纸篓即可。应用不会留下偏好设置或其他状态文件,目前也不会在钥匙串中 +保存任何内容。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index d100c210015..0ed0d696111 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -981,6 +981,69 @@ export const de: Record = { "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", + "usage.section.companion": "Menüleiste & Widget", + "usage.companion.title": "Menüleiste & Widget", + "usage.companion.description": "Diese Einstellungen steuern die OpenCodex-Menüleisten-App und ihr Widget.", + "usage.companion.installGuide": "Installationsanleitung", + "usage.companion.loading": "Zeitachse wird geladen…", + "usage.companion.timelineUnavailable": "Zeitachse nicht verfügbar", + "usage.companion.empty": "Keine Nutzung in den letzten {hours} Std.", + "usage.companion.chartLabel": "Nutzungszeitachse", + "usage.companion.olderRecordsSkipped": "Ältere Einträge wurden übersprungen", + "usage.companion.settingsUnavailable": "Begleiteinstellungen nicht verfügbar", + "usage.companion.corrupt": "Die Datei mit den Begleiteinstellungen ist beschädigt. Die Steuerelemente zeigen Standardwerte; das Speichern ist pausiert, bis Sie die Datei ersetzen.", + "usage.companion.corruptReset": "Durch Standardwerte ersetzen", + "usage.companion.connected": "Menüleisten-App verbunden · {age}", + "usage.companion.installTitle": "Menüleisten-App installieren", + "usage.companion.installStep1": "Laden Sie OpenCodex--macos-universal.zip aus der neuesten Veröffentlichung herunter und ziehen Sie OpenCodex.app in Programme.", + "usage.companion.installStep2": "Erster Start: Klicken Sie mit der rechten Maustaste auf OpenCodex.app → Öffnen (die App ist nur ad-hoc signiert, daher fragt Gatekeeper einmal).", + "usage.companion.installStep3": "Die App findet diesen Proxy selbst; das Widget erscheint in der Widget-Galerie, sobald die App ausgeführt wurde.", + "usage.companion.notConnected": "Noch keine Menüleisten-App hat sich mit diesem Proxy verbunden.", + "usage.companion.lastSeen": "Zuletzt gesehen {age}", + "usage.companion.installAnother": "Auf einem anderen Mac installieren", + "usage.companion.saved": "Gespeichert · {time}", + "usage.companion.saveFailed": "Speichern fehlgeschlagen: {error}", + "usage.companion.reset": "Auf Standardwerte zurücksetzen", + "usage.companion.footer": "Das Widget zeigt die heutigen Anfragen, Tokens und Kosten sowie das hier konfigurierte Diagramm und wird beim Abruf der App aktualisiert.", + "usage.companion.menuBarShows": "Menüleiste zeigt", + "usage.companion.menuRequests": "Anfragen", + "usage.companion.menuTokens": "Token", + "usage.companion.menuCost": "Kosten", + "usage.companion.menuQuota": "Kontingent", + "usage.companion.menuNone": "Nur Symbol", + "usage.companion.window": "Zeitraum", + "usage.companion.window6": "6 Std.", + "usage.companion.window24": "24 Std.", + "usage.companion.window72": "3 Tage", + "usage.companion.window168": "7 Tage", + "usage.companion.style": "Stil", + "usage.companion.styleLine": "Linie", + "usage.companion.styleStacked": "Gestapelt", + "usage.companion.metric": "Kennzahl", + "usage.companion.metricTotal": "Gesamt", + "usage.companion.metricInput": "Eingabe", + "usage.companion.metricOutput": "Ausgabe", + "usage.companion.metricCached": "Gecacht", + "usage.companion.groupBy": "Gruppieren nach", + "usage.companion.groupModel": "Modell", + "usage.companion.groupAccount": "Modell + Konto", + "usage.companion.popoverSections": "Popover-Bereiche", + "usage.companion.sectionToday": "Heute", + "usage.companion.sectionChart": "Diagramm", + "usage.companion.sectionModels": "Modelle", + "usage.companion.sectionCost": "Kosten", + "usage.companion.sectionAccounts": "Konten", + "usage.companion.advanced": "Erweitert", + "usage.companion.aggregation": "Aggregation", + "usage.companion.aggregationSum": "Summe", + "usage.companion.aggregationAverage": "Durchschnitt", + "usage.companion.aggregationMax": "Maximum", + "usage.companion.menuText": "Menüleistentext", + "usage.companion.placeholders": "Platzhalter:", + "usage.companion.modelsOnChart": "Modelle im Diagramm", + "usage.companion.modelsCount": "{selected} von {total} im Diagramm", + "usage.companion.modelsShowAll": "Alle anzeigen", + "usage.companion.hideProviders": "Provider ausblenden", "usage.workspace.report": "Nutzungsbericht", "usage.workspace.sections": "Nutzungsabschnitte", "usage.coverage.measured": "Gemessen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 50876671abd..29202551e70 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1034,6 +1034,69 @@ export const en = { "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", + "usage.section.companion": "Menu bar & widget", + "usage.companion.title": "Menu bar & widget", + "usage.companion.description": "Settings here drive the OpenCodex menu bar app and its widget.", + "usage.companion.installGuide": "Install guide", + "usage.companion.loading": "Loading timeline…", + "usage.companion.timelineUnavailable": "Timeline unavailable", + "usage.companion.empty": "No usage in the last {hours}h", + "usage.companion.chartLabel": "Usage timeline", + "usage.companion.olderRecordsSkipped": "Older records were skipped", + "usage.companion.settingsUnavailable": "Companion settings unavailable", + "usage.companion.corrupt": "The companion settings file is corrupt. Controls show defaults; saving is paused until you replace the file.", + "usage.companion.corruptReset": "Replace with defaults", + "usage.companion.connected": "Menu bar app connected · {age}", + "usage.companion.installTitle": "Install the menu bar app", + "usage.companion.installStep1": "Download OpenCodex--macos-universal.zip from the latest release and drag OpenCodex.app to Applications.", + "usage.companion.installStep2": "First launch: right-click OpenCodex.app → Open (the app is ad-hoc signed, so Gatekeeper asks once).", + "usage.companion.installStep3": "The app finds this proxy on its own; the widget appears in the widget gallery once the app has run.", + "usage.companion.notConnected": "No menu bar app has connected to this proxy yet.", + "usage.companion.lastSeen": "Last seen {age}", + "usage.companion.installAnother": "Install on another Mac", + "usage.companion.saved": "Saved · {time}", + "usage.companion.saveFailed": "Couldn’t save: {error}", + "usage.companion.reset": "Reset to defaults", + "usage.companion.footer": "The widget shows today's requests, tokens and cost plus the chart configured here, and refreshes when the app polls.", + "usage.companion.menuBarShows": "Menu bar shows", + "usage.companion.menuRequests": "Requests", + "usage.companion.menuTokens": "Tokens", + "usage.companion.menuCost": "Cost", + "usage.companion.menuQuota": "Quota", + "usage.companion.menuNone": "Icon only", + "usage.companion.window": "Window", + "usage.companion.window6": "6h", + "usage.companion.window24": "24h", + "usage.companion.window72": "3d", + "usage.companion.window168": "7d", + "usage.companion.style": "Style", + "usage.companion.styleLine": "Line", + "usage.companion.styleStacked": "Stacked", + "usage.companion.metric": "Metric", + "usage.companion.metricTotal": "Total", + "usage.companion.metricInput": "Input", + "usage.companion.metricOutput": "Output", + "usage.companion.metricCached": "Cached", + "usage.companion.groupBy": "Group by", + "usage.companion.groupModel": "Model", + "usage.companion.groupAccount": "Model + account", + "usage.companion.popoverSections": "Popover sections", + "usage.companion.sectionToday": "Today", + "usage.companion.sectionChart": "Chart", + "usage.companion.sectionModels": "Models", + "usage.companion.sectionCost": "Cost", + "usage.companion.sectionAccounts": "Accounts", + "usage.companion.advanced": "Advanced", + "usage.companion.aggregation": "Aggregation", + "usage.companion.aggregationSum": "Sum", + "usage.companion.aggregationAverage": "Average", + "usage.companion.aggregationMax": "Max", + "usage.companion.menuText": "Menu bar text", + "usage.companion.placeholders": "Placeholders:", + "usage.companion.modelsOnChart": "Models on chart", + "usage.companion.modelsCount": "{selected} of {total} on chart", + "usage.companion.modelsShowAll": "Show all", + "usage.companion.hideProviders": "Hide providers", "usage.workspace.report": "Usage report", "usage.workspace.sections": "Usage sections", "usage.coverage.measured": "Measured", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 60f3cd69328..715be09c706 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1011,6 +1011,69 @@ export const fr: Record = { "usage.section.models": "Modèles", "usage.section.providers": "Fournisseurs", "usage.section.coverage": "Répartition de la couverture", + "usage.section.companion": "Barre des menus et widget", + "usage.companion.title": "Barre des menus et widget", + "usage.companion.description": "Ces réglages contrôlent l’app OpenCodex de la barre des menus et son widget.", + "usage.companion.installGuide": "Guide d’installation", + "usage.companion.loading": "Chargement de la chronologie…", + "usage.companion.timelineUnavailable": "Chronologie indisponible", + "usage.companion.empty": "Aucune utilisation au cours des {hours} dernières heures", + "usage.companion.chartLabel": "Chronologie de l’utilisation", + "usage.companion.olderRecordsSkipped": "Les enregistrements plus anciens ont été ignorés", + "usage.companion.settingsUnavailable": "Réglages du compagnon indisponibles", + "usage.companion.corrupt": "Le fichier de réglages du compagnon est corrompu. Les contrôles affichent les valeurs par défaut ; l’enregistrement est suspendu jusqu’au remplacement du fichier.", + "usage.companion.corruptReset": "Remplacer par les valeurs par défaut", + "usage.companion.saved": "Enregistré · {time}", + "usage.companion.saveFailed": "Échec de l’enregistrement : {error}", + "usage.companion.reset": "Rétablir les valeurs par défaut", + "usage.companion.footer": "Le widget affiche les requêtes, les jetons et le coût du jour, ainsi que le graphique configuré ici, et s’actualise quand l’app interroge le proxy.", + "usage.companion.menuBarShows": "La barre des menus affiche", + "usage.companion.menuRequests": "Requêtes", + "usage.companion.menuTokens": "Jetons", + "usage.companion.menuCost": "Coût", + "usage.companion.menuQuota": "Limite", + "usage.companion.menuNone": "Icône uniquement", + "usage.companion.window": "Période", + "usage.companion.window6": "6 h", + "usage.companion.window24": "24 h", + "usage.companion.window72": "3 j", + "usage.companion.window168": "7 j", + "usage.companion.style": "Présentation", + "usage.companion.styleLine": "Courbe", + "usage.companion.styleStacked": "Empilé", + "usage.companion.metric": "Métrique", + "usage.companion.metricTotal": "Total général", + "usage.companion.metricInput": "Entrée", + "usage.companion.metricOutput": "Sortie", + "usage.companion.metricCached": "En cache", + "usage.companion.groupBy": "Regrouper par", + "usage.companion.groupModel": "Modèle", + "usage.companion.groupAccount": "Modèle + compte", + "usage.companion.popoverSections": "Sections du panneau", + "usage.companion.sectionToday": "Aujourd’hui", + "usage.companion.sectionChart": "Graphique", + "usage.companion.sectionModels": "Modèles", + "usage.companion.sectionCost": "Coût", + "usage.companion.sectionAccounts": "Comptes", + "usage.companion.advanced": "Avancé", + "usage.companion.aggregation": "Agrégation", + "usage.companion.aggregationSum": "Somme", + "usage.companion.aggregationAverage": "Moyenne", + "usage.companion.aggregationMax": "Maximum", + "usage.companion.menuText": "Texte de la barre des menus", + "usage.companion.placeholders": "Paramètres substituables :", + "usage.companion.modelsOnChart": "Modèles du graphique", + "usage.companion.connected": "App de barre des menus connectée · {age}", + "usage.companion.installTitle": "Installer l’app de barre des menus", + "usage.companion.installStep1": "Téléchargez OpenCodex--macos-universal.zip depuis la dernière version et faites glisser OpenCodex.app dans Applications.", + "usage.companion.installStep2": "Premier lancement : faites un clic droit sur OpenCodex.app → Ouvrir (l’app est signée ad hoc, Gatekeeper ne demande donc qu’une confirmation).", + "usage.companion.installStep3": "L’app trouve ce proxy automatiquement ; le widget apparaît dans la galerie de widgets après le lancement de l’app.", + "usage.companion.notConnected": "Aucune app de barre des menus ne s’est encore connectée à ce proxy.", + "usage.companion.lastSeen": "Dernière connexion {age}", + "usage.companion.installAnother": "Installer sur un autre Mac", + "usage.companion.modelsCount": "{selected} sur {total} dans le graphique", + "usage.companion.modelsShowAll": "Tout afficher", + "usage.companion.hideProviders": "Masquer les fournisseurs", "usage.workspace.report": "Rapport d’utilisation", "usage.workspace.sections": "Sections d’utilisation", "usage.coverage.measured": "Mesurée", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 055474b99f5..d4eef7d8033 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -946,6 +946,69 @@ export const ja: Record = { "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", + "usage.section.companion": "メニューバーとウィジェット", + "usage.companion.title": "メニューバーとウィジェット", + "usage.companion.description": "ここでの設定は OpenCodex のメニューバーアプリとウィジェットを制御します。", + "usage.companion.installGuide": "インストールガイド", + "usage.companion.loading": "タイムラインを読み込み中…", + "usage.companion.timelineUnavailable": "タイムラインを利用できません", + "usage.companion.empty": "過去 {hours} 時間に利用はありません", + "usage.companion.chartLabel": "使用量タイムライン", + "usage.companion.olderRecordsSkipped": "古い記録はスキップされました", + "usage.companion.settingsUnavailable": "コンパニオン設定を利用できません", + "usage.companion.corrupt": "コンパニオン設定ファイルが破損しています。コントロールにはデフォルト値が表示され、ファイルを置き換えるまで保存は一時停止されます。", + "usage.companion.corruptReset": "デフォルト値に置き換える", + "usage.companion.saved": "保存済み · {time}", + "usage.companion.saveFailed": "保存できませんでした: {error}", + "usage.companion.reset": "既定値に戻す", + "usage.companion.footer": "ウィジェットには今日のリクエスト数、トークン数、コストと、ここで設定したグラフが表示され、アプリのポーリング時に更新されます。", + "usage.companion.menuBarShows": "メニューバーに表示", + "usage.companion.menuRequests": "リクエスト", + "usage.companion.menuTokens": "トークン", + "usage.companion.menuCost": "コスト", + "usage.companion.menuQuota": "クォータ", + "usage.companion.menuNone": "アイコンのみ", + "usage.companion.window": "期間", + "usage.companion.window6": "6時間", + "usage.companion.window24": "24時間", + "usage.companion.window72": "3日", + "usage.companion.window168": "7日", + "usage.companion.style": "スタイル", + "usage.companion.styleLine": "線", + "usage.companion.styleStacked": "積み上げ", + "usage.companion.metric": "指標", + "usage.companion.metricTotal": "合計", + "usage.companion.metricInput": "入力", + "usage.companion.metricOutput": "出力", + "usage.companion.metricCached": "キャッシュ済み", + "usage.companion.groupBy": "グループ化", + "usage.companion.groupModel": "モデル", + "usage.companion.groupAccount": "モデル + アカウント", + "usage.companion.popoverSections": "ポップオーバーのセクション", + "usage.companion.sectionToday": "今日", + "usage.companion.sectionChart": "グラフ", + "usage.companion.sectionModels": "モデル", + "usage.companion.sectionCost": "コスト", + "usage.companion.sectionAccounts": "アカウント", + "usage.companion.advanced": "詳細設定", + "usage.companion.aggregation": "集計", + "usage.companion.aggregationSum": "合計", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大", + "usage.companion.menuText": "メニューバーのテキスト", + "usage.companion.placeholders": "プレースホルダー:", + "usage.companion.modelsOnChart": "グラフのモデル", + "usage.companion.connected": "メニューバーアプリ接続済み · {age}", + "usage.companion.installTitle": "メニューバーアプリをインストール", + "usage.companion.installStep1": "最新リリースから OpenCodex--macos-universal.zip をダウンロードし、OpenCodex.app をアプリケーションに移動します。", + "usage.companion.installStep2": "初回起動:OpenCodex.app を右クリックして「開く」を選択します(アドホック署名のため、Gatekeeper の確認は一度だけです)。", + "usage.companion.installStep3": "アプリはこのプロキシを自動検出します。アプリを一度起動するとウィジェットギャラリーに表示されます。", + "usage.companion.notConnected": "このプロキシに接続したメニューバーアプリはまだありません。", + "usage.companion.lastSeen": "最終接続 {age}", + "usage.companion.installAnother": "別の Mac にインストール", + "usage.companion.modelsCount": "{selected} / {total} がグラフに表示中", + "usage.companion.modelsShowAll": "すべて表示", + "usage.companion.hideProviders": "プロバイダーを非表示", "usage.workspace.report": "使用量レポート", "usage.workspace.sections": "使用量セクション", "usage.coverage.measured": "計測", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 25a37d602e3..b2e8b94c500 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1015,6 +1015,69 @@ export const ko: Record = { "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", + "usage.section.companion": "메뉴 막대 및 위젯", + "usage.companion.title": "메뉴 막대 및 위젯", + "usage.companion.description": "여기 설정은 OpenCodex 메뉴 막대 앱과 위젯을 제어합니다.", + "usage.companion.installGuide": "설치 안내", + "usage.companion.loading": "타임라인 로드 중…", + "usage.companion.timelineUnavailable": "타임라인을 사용할 수 없습니다", + "usage.companion.empty": "지난 {hours}시간 동안 사용량이 없습니다", + "usage.companion.chartLabel": "사용량 타임라인", + "usage.companion.olderRecordsSkipped": "오래된 기록을 건너뛰었습니다", + "usage.companion.settingsUnavailable": "컴패니언 설정을 사용할 수 없습니다", + "usage.companion.corrupt": "컴패니언 설정 파일이 손상되었습니다. 컨트롤에는 기본값이 표시되며 파일을 교체할 때까지 저장이 일시 중지됩니다.", + "usage.companion.corruptReset": "기본값으로 교체", + "usage.companion.saved": "저장됨 · {time}", + "usage.companion.saveFailed": "저장하지 못했습니다: {error}", + "usage.companion.reset": "기본값으로 재설정", + "usage.companion.footer": "위젯에는 오늘의 요청, 토큰, 비용과 여기에서 구성한 차트가 표시되며 앱이 폴링할 때 새로 고쳐집니다.", + "usage.companion.menuBarShows": "메뉴 막대 표시", + "usage.companion.menuRequests": "요청", + "usage.companion.menuTokens": "토큰", + "usage.companion.menuCost": "비용", + "usage.companion.menuQuota": "할당량", + "usage.companion.menuNone": "아이콘만", + "usage.companion.window": "기간", + "usage.companion.window6": "6시간", + "usage.companion.window24": "24시간", + "usage.companion.window72": "3일", + "usage.companion.window168": "7일", + "usage.companion.style": "스타일", + "usage.companion.styleLine": "선", + "usage.companion.styleStacked": "누적", + "usage.companion.metric": "지표", + "usage.companion.metricTotal": "합계", + "usage.companion.metricInput": "입력", + "usage.companion.metricOutput": "출력", + "usage.companion.metricCached": "캐시됨", + "usage.companion.groupBy": "그룹 기준", + "usage.companion.groupModel": "모델", + "usage.companion.groupAccount": "모델 + 계정", + "usage.companion.popoverSections": "팝오버 섹션", + "usage.companion.sectionToday": "오늘", + "usage.companion.sectionChart": "차트", + "usage.companion.sectionModels": "모델", + "usage.companion.sectionCost": "비용", + "usage.companion.sectionAccounts": "계정", + "usage.companion.advanced": "고급", + "usage.companion.aggregation": "집계", + "usage.companion.aggregationSum": "합계", + "usage.companion.aggregationAverage": "평균", + "usage.companion.aggregationMax": "최대", + "usage.companion.menuText": "메뉴 막대 텍스트", + "usage.companion.placeholders": "자리표시자:", + "usage.companion.modelsOnChart": "차트의 모델", + "usage.companion.connected": "메뉴 막대 앱 연결됨 · {age}", + "usage.companion.installTitle": "메뉴 막대 앱 설치", + "usage.companion.installStep1": "최신 릴리스에서 OpenCodex--macos-universal.zip을 다운로드하고 OpenCodex.app을 응용 프로그램으로 드래그하세요.", + "usage.companion.installStep2": "첫 실행: OpenCodex.app을 마우스 오른쪽 버튼으로 클릭하고 열기를 선택하세요(앱이 애드혹 서명되어 Gatekeeper가 한 번 확인합니다).", + "usage.companion.installStep3": "앱이 이 프록시를 자동으로 찾습니다. 앱을 실행하면 위젯 갤러리에 위젯이 표시됩니다.", + "usage.companion.notConnected": "아직 이 프록시에 연결한 메뉴 막대 앱이 없습니다.", + "usage.companion.lastSeen": "마지막 연결 {age}", + "usage.companion.installAnother": "다른 Mac에 설치", + "usage.companion.modelsCount": "{selected} / {total}개가 차트에 표시됨", + "usage.companion.modelsShowAll": "모두 표시", + "usage.companion.hideProviders": "공급자 숨기기", "usage.workspace.report": "사용량 보고서", "usage.workspace.sections": "사용량 섹션", "usage.coverage.measured": "측정됨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 132c1abd644..af9681a2d84 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1002,6 +1002,69 @@ export const ru: Record = { "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", + "usage.section.companion": "Строка меню и виджет", + "usage.companion.title": "Строка меню и виджет", + "usage.companion.description": "Эти настройки управляют приложением OpenCodex в строке меню и его виджетом.", + "usage.companion.installGuide": "Руководство по установке", + "usage.companion.loading": "Загрузка временной шкалы…", + "usage.companion.timelineUnavailable": "Временная шкала недоступна", + "usage.companion.empty": "Нет использования за последние {hours} ч", + "usage.companion.chartLabel": "Временная шкала использования", + "usage.companion.olderRecordsSkipped": "Старые записи пропущены", + "usage.companion.settingsUnavailable": "Настройки компаньона недоступны", + "usage.companion.corrupt": "Файл настроек компаньона повреждён. В элементах управления показаны значения по умолчанию; сохранение приостановлено, пока файл не будет заменён.", + "usage.companion.corruptReset": "Заменить значениями по умолчанию", + "usage.companion.saved": "Сохранено · {time}", + "usage.companion.saveFailed": "Не удалось сохранить: {error}", + "usage.companion.reset": "Сбросить настройки", + "usage.companion.footer": "Виджет показывает сегодняшние запросы, токены и стоимость, а также настроенный здесь график, и обновляется при опросе приложения.", + "usage.companion.menuBarShows": "В строке меню", + "usage.companion.menuRequests": "Запросы", + "usage.companion.menuTokens": "Токены", + "usage.companion.menuCost": "Стоимость", + "usage.companion.menuQuota": "Квота", + "usage.companion.menuNone": "Только значок", + "usage.companion.window": "Период", + "usage.companion.window6": "6 ч", + "usage.companion.window24": "24 ч", + "usage.companion.window72": "3 д", + "usage.companion.window168": "7 д", + "usage.companion.style": "Стиль", + "usage.companion.styleLine": "Линия", + "usage.companion.styleStacked": "С накоплением", + "usage.companion.metric": "Метрика", + "usage.companion.metricTotal": "Всего", + "usage.companion.metricInput": "Входные", + "usage.companion.metricOutput": "Выходные", + "usage.companion.metricCached": "Из кэша", + "usage.companion.groupBy": "Группировать по", + "usage.companion.groupModel": "Модели", + "usage.companion.groupAccount": "Модели + аккаунту", + "usage.companion.popoverSections": "Разделы всплывающего окна", + "usage.companion.sectionToday": "Сегодня", + "usage.companion.sectionChart": "График", + "usage.companion.sectionModels": "Модели", + "usage.companion.sectionCost": "Стоимость", + "usage.companion.sectionAccounts": "Аккаунты", + "usage.companion.advanced": "Дополнительно", + "usage.companion.aggregation": "Агрегация", + "usage.companion.aggregationSum": "Сумма", + "usage.companion.aggregationAverage": "Среднее", + "usage.companion.aggregationMax": "Максимум", + "usage.companion.menuText": "Текст строки меню", + "usage.companion.placeholders": "Заполнители:", + "usage.companion.modelsOnChart": "Модели на графике", + "usage.companion.connected": "Приложение в строке меню подключено · {age}", + "usage.companion.installTitle": "Установить приложение в строке меню", + "usage.companion.installStep1": "Скачайте OpenCodex--macos-universal.zip из последнего релиза и перетащите OpenCodex.app в Программы.", + "usage.companion.installStep2": "Первый запуск: нажмите OpenCodex.app правой кнопкой и выберите «Открыть» (приложение подписано ad-hoc, поэтому Gatekeeper спросит один раз).", + "usage.companion.installStep3": "Приложение само найдёт этот прокси; виджет появится в галерее виджетов после запуска приложения.", + "usage.companion.notConnected": "К этому прокси ещё не подключалось приложение из строки меню.", + "usage.companion.lastSeen": "Последнее подключение: {age}", + "usage.companion.installAnother": "Установить на другом Mac", + "usage.companion.modelsCount": "{selected} из {total} на графике", + "usage.companion.modelsShowAll": "Показать все", + "usage.companion.hideProviders": "Скрыть провайдеров", "usage.workspace.report": "Отчёт об использовании", "usage.workspace.sections": "Разделы использования", "usage.coverage.measured": "Измерено", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b2d7b4a5628..db62526abb9 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1021,6 +1021,69 @@ export const tr: Record = { "usage.section.models": "Modeller", "usage.section.providers": "Sağlayıcılar", "usage.section.coverage": "Kapsam dağılımı", + "usage.section.companion": "Menü çubuğu ve widget", + "usage.companion.title": "Menü çubuğu ve widget", + "usage.companion.description": "Buradaki ayarlar OpenCodex menü çubuğu uygulamasını ve widget'ını yönetir.", + "usage.companion.installGuide": "Kurulum rehberi", + "usage.companion.loading": "Zaman çizelgesi yükleniyor…", + "usage.companion.timelineUnavailable": "Zaman çizelgesi kullanılamıyor", + "usage.companion.empty": "Son {hours} saatte kullanım yok", + "usage.companion.chartLabel": "Kullanım zaman çizelgesi", + "usage.companion.olderRecordsSkipped": "Eski kayıtlar atlandı", + "usage.companion.settingsUnavailable": "Yardımcı ayarları kullanılamıyor", + "usage.companion.corrupt": "Yardımcı ayarları dosyası bozuk. Denetimler varsayılan değerleri gösteriyor; dosyayı değiştirene kadar kaydetme duraklatıldı.", + "usage.companion.corruptReset": "Varsayılanlarla değiştir", + "usage.companion.saved": "Kaydedildi · {time}", + "usage.companion.saveFailed": "Kaydedilemedi: {error}", + "usage.companion.reset": "Varsayılanlara sıfırla", + "usage.companion.footer": "Widget, bugünkü istekleri, belirteçleri ve maliyeti ve burada yapılandırılan grafiği gösterir; uygulama yoklama yaptığında yenilenir.", + "usage.companion.menuBarShows": "Menü çubuğunda göster", + "usage.companion.menuRequests": "İstekler", + "usage.companion.menuTokens": "Tokenlar", + "usage.companion.menuCost": "Maliyet", + "usage.companion.menuQuota": "Kota", + "usage.companion.menuNone": "Yalnızca simge", + "usage.companion.window": "Aralık", + "usage.companion.window6": "6 sa", + "usage.companion.window24": "24 sa", + "usage.companion.window72": "3 gün", + "usage.companion.window168": "7 gün", + "usage.companion.style": "Stil", + "usage.companion.styleLine": "Çizgi", + "usage.companion.styleStacked": "Yığılmış", + "usage.companion.metric": "Metrik", + "usage.companion.metricTotal": "Toplam", + "usage.companion.metricInput": "Girdi", + "usage.companion.metricOutput": "Çıktı", + "usage.companion.metricCached": "Önbellek", + "usage.companion.groupBy": "Gruplama", + "usage.companion.groupModel": "Model", + "usage.companion.groupAccount": "Model + hesap", + "usage.companion.popoverSections": "Açılır pencere bölümleri", + "usage.companion.sectionToday": "Bugün", + "usage.companion.sectionChart": "Grafik", + "usage.companion.sectionModels": "Modeller", + "usage.companion.sectionCost": "Maliyet", + "usage.companion.sectionAccounts": "Hesaplar", + "usage.companion.advanced": "Gelişmiş", + "usage.companion.aggregation": "Toplama", + "usage.companion.aggregationSum": "Toplam", + "usage.companion.aggregationAverage": "Ortalama", + "usage.companion.aggregationMax": "Maksimum", + "usage.companion.menuText": "Menü çubuğu metni", + "usage.companion.placeholders": "Yer tutucular:", + "usage.companion.modelsOnChart": "Grafikteki modeller", + "usage.companion.connected": "Menü çubuğu uygulaması bağlı · {age}", + "usage.companion.installTitle": "Menü çubuğu uygulamasını yükle", + "usage.companion.installStep1": "En son sürümden OpenCodex--macos-universal.zip dosyasını indirin ve OpenCodex.app'i Uygulamalar'a sürükleyin.", + "usage.companion.installStep2": "İlk çalıştırma: OpenCodex.app'e sağ tıklayıp Aç'ı seçin (uygulama ad-hoc imzalıdır; Gatekeeper bir kez sorar).", + "usage.companion.installStep3": "Uygulama bu proxy'yi kendisi bulur; uygulama çalıştıktan sonra widget, widget galerisinde görünür.", + "usage.companion.notConnected": "Bu proxy'ye henüz hiçbir menü çubuğu uygulaması bağlanmadı.", + "usage.companion.lastSeen": "Son görülme {age}", + "usage.companion.installAnother": "Başka bir Mac'e yükle", + "usage.companion.modelsCount": "Grafikte {selected}/{total}", + "usage.companion.modelsShowAll": "Tümünü göster", + "usage.companion.hideProviders": "Sağlayıcıları gizle", "usage.workspace.report": "Kullanım raporu", "usage.workspace.sections": "Kullanım bölümleri", "usage.coverage.measured": "Ölçülen", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 80c6e63cc03..749fde103c8 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1004,6 +1004,69 @@ export const vi: Record = { "usage.section.models": "Models", "usage.section.providers": "Nhà cung cấp", "usage.section.coverage": "Chi tiết độ phủ (Coverage breakdown)", + "usage.section.companion": "Thanh menu và widget", + "usage.companion.title": "Thanh menu và widget", + "usage.companion.description": "Các cài đặt ở đây điều khiển ứng dụng thanh menu OpenCodex và widget.", + "usage.companion.installGuide": "Hướng dẫn cài đặt", + "usage.companion.loading": "Đang tải dòng thời gian…", + "usage.companion.timelineUnavailable": "Không có dòng thời gian", + "usage.companion.empty": "Không có lượt dùng trong {hours} giờ qua", + "usage.companion.chartLabel": "Dòng thời gian sử dụng", + "usage.companion.olderRecordsSkipped": "Đã bỏ qua các bản ghi cũ hơn", + "usage.companion.settingsUnavailable": "Không có cài đặt companion", + "usage.companion.corrupt": "Tệp cài đặt companion bị hỏng. Các điều khiển hiển thị giá trị mặc định; việc lưu bị tạm dừng cho đến khi bạn thay thế tệp.", + "usage.companion.corruptReset": "Thay thế bằng mặc định", + "usage.companion.saved": "Đã lưu · {time}", + "usage.companion.saveFailed": "Không thể lưu: {error}", + "usage.companion.reset": "Đặt lại mặc định", + "usage.companion.footer": "Widget hiển thị số yêu cầu, token và chi phí hôm nay cùng biểu đồ được cấu hình ở đây, rồi làm mới khi ứng dụng thăm dò.", + "usage.companion.menuBarShows": "Thanh menu hiển thị", + "usage.companion.menuRequests": "Yêu cầu", + "usage.companion.menuTokens": "Token", + "usage.companion.menuCost": "Chi phí", + "usage.companion.menuQuota": "Hạn mức", + "usage.companion.menuNone": "Chỉ biểu tượng", + "usage.companion.window": "Khoảng thời gian", + "usage.companion.window6": "6 giờ", + "usage.companion.window24": "24 giờ", + "usage.companion.window72": "3 ngày", + "usage.companion.window168": "7 ngày", + "usage.companion.style": "Kiểu", + "usage.companion.styleLine": "Đường", + "usage.companion.styleStacked": "Xếp chồng", + "usage.companion.metric": "Chỉ số", + "usage.companion.metricTotal": "Tổng", + "usage.companion.metricInput": "Đầu vào", + "usage.companion.metricOutput": "Đầu ra", + "usage.companion.metricCached": "Đã lưu đệm", + "usage.companion.groupBy": "Nhóm theo", + "usage.companion.groupModel": "Mô hình", + "usage.companion.groupAccount": "Mô hình + tài khoản", + "usage.companion.popoverSections": "Mục popover", + "usage.companion.sectionToday": "Hôm nay", + "usage.companion.sectionChart": "Biểu đồ", + "usage.companion.sectionModels": "Mô hình", + "usage.companion.sectionCost": "Chi phí", + "usage.companion.sectionAccounts": "Tài khoản", + "usage.companion.advanced": "Nâng cao", + "usage.companion.aggregation": "Tổng hợp", + "usage.companion.aggregationSum": "Tổng", + "usage.companion.aggregationAverage": "Trung bình", + "usage.companion.aggregationMax": "Tối đa", + "usage.companion.menuText": "Văn bản thanh menu", + "usage.companion.placeholders": "Trình giữ chỗ:", + "usage.companion.modelsOnChart": "Mô hình trên biểu đồ", + "usage.companion.connected": "Ứng dụng trên thanh menu đã kết nối · {age}", + "usage.companion.installTitle": "Cài đặt ứng dụng trên thanh menu", + "usage.companion.installStep1": "Tải OpenCodex--macos-universal.zip từ bản phát hành mới nhất và kéo OpenCodex.app vào Applications.", + "usage.companion.installStep2": "Lần đầu mở: nhấp chuột phải vào OpenCodex.app → Mở (ứng dụng được ký ad-hoc nên Gatekeeper chỉ hỏi một lần).", + "usage.companion.installStep3": "Ứng dụng tự tìm proxy này; widget sẽ xuất hiện trong thư viện widget sau khi ứng dụng chạy.", + "usage.companion.notConnected": "Chưa có ứng dụng trên thanh menu nào kết nối với proxy này.", + "usage.companion.lastSeen": "Lần kết nối gần nhất {age}", + "usage.companion.installAnother": "Cài đặt trên máy Mac khác", + "usage.companion.modelsCount": "{selected}/{total} trên biểu đồ", + "usage.companion.modelsShowAll": "Hiện tất cả", + "usage.companion.hideProviders": "Ẩn nhà cung cấp", "usage.workspace.report": "Báo cáo sử dụng", "usage.workspace.sections": "Các phần sử dụng", "usage.coverage.measured": "Đã đo", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 668bbf980dd..62018401005 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -812,6 +812,69 @@ export const zhTW: Record = { "usage.section.models": "模型", "usage.section.providers": "供應商", "usage.section.coverage": "覆蓋率明細", + "usage.section.companion": "選單列與小工具", + "usage.companion.title": "選單列與小工具", + "usage.companion.description": "這裡的設定會控制 OpenCodex 選單列 App 與其小工具。", + "usage.companion.installGuide": "安裝指南", + "usage.companion.loading": "正在載入時間軸…", + "usage.companion.timelineUnavailable": "時間軸無法使用", + "usage.companion.empty": "過去 {hours} 小時沒有使用量", + "usage.companion.chartLabel": "使用量時間軸", + "usage.companion.olderRecordsSkipped": "已略過較早記錄", + "usage.companion.settingsUnavailable": "伴隨設定無法使用", + "usage.companion.corrupt": "伴隨設定檔已損毀。控制項顯示預設值;在替換檔案前將暫停儲存。", + "usage.companion.corruptReset": "替換為預設值", + "usage.companion.saved": "已儲存 · {time}", + "usage.companion.saveFailed": "無法儲存:{error}", + "usage.companion.reset": "重設為預設值", + "usage.companion.footer": "小工具會顯示今天的請求、權杖和費用,以及此處設定的圖表,並在 App 輪詢時重新整理。", + "usage.companion.menuBarShows": "選單列顯示", + "usage.companion.menuRequests": "要求", + "usage.companion.menuTokens": "權杖", + "usage.companion.menuCost": "成本", + "usage.companion.menuQuota": "配額", + "usage.companion.menuNone": "僅圖示", + "usage.companion.window": "時間範圍", + "usage.companion.window6": "6 小時", + "usage.companion.window24": "24 小時", + "usage.companion.window72": "3 天", + "usage.companion.window168": "7 天", + "usage.companion.style": "樣式", + "usage.companion.styleLine": "折線", + "usage.companion.styleStacked": "堆疊", + "usage.companion.metric": "指標", + "usage.companion.metricTotal": "總計", + "usage.companion.metricInput": "輸入", + "usage.companion.metricOutput": "輸出", + "usage.companion.metricCached": "快取", + "usage.companion.groupBy": "分組依據", + "usage.companion.groupModel": "模型", + "usage.companion.groupAccount": "模型 + 帳戶", + "usage.companion.popoverSections": "彈出視窗區段", + "usage.companion.sectionToday": "今天", + "usage.companion.sectionChart": "圖表", + "usage.companion.sectionModels": "模型", + "usage.companion.sectionCost": "成本", + "usage.companion.sectionAccounts": "帳戶", + "usage.companion.advanced": "進階", + "usage.companion.aggregation": "彙總", + "usage.companion.aggregationSum": "總和", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大值", + "usage.companion.menuText": "選單列文字", + "usage.companion.placeholders": "預留位置:", + "usage.companion.modelsOnChart": "圖表中的模型", + "usage.companion.connected": "選單列 App 已連線 · {age}", + "usage.companion.installTitle": "安裝選單列 App", + "usage.companion.installStep1": "從最新版本下載 OpenCodex--macos-universal.zip,並將 OpenCodex.app 拖到應用程式。", + "usage.companion.installStep2": "首次啟動:在 OpenCodex.app 上按右鍵並選擇「打開」(App 使用臨時簽章,因此 Gatekeeper 只會詢問一次)。", + "usage.companion.installStep3": "App 會自動找到此 Proxy;App 執行後,Widget 會出現在 Widget 圖庫中。", + "usage.companion.notConnected": "尚未有選單列 App 連線到此 Proxy。", + "usage.companion.lastSeen": "上次連線 {age}", + "usage.companion.installAnother": "在另一台 Mac 上安裝", + "usage.companion.modelsCount": "圖表顯示 {selected}/{total}", + "usage.companion.modelsShowAll": "顯示全部", + "usage.companion.hideProviders": "隱藏提供者", "usage.coverage.measured": "已計量", "usage.coverage.reported": "供應商上報", "usage.coverage.estimated": "估算", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b089160d58c..17a93d78dec 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -996,6 +996,69 @@ export const zh: Record = { "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", + "usage.section.companion": "菜单栏与小组件", + "usage.companion.title": "菜单栏与小组件", + "usage.companion.description": "此处设置会控制 OpenCodex 菜单栏应用及其小组件。", + "usage.companion.installGuide": "安装指南", + "usage.companion.loading": "正在加载时间线…", + "usage.companion.timelineUnavailable": "时间线不可用", + "usage.companion.empty": "过去 {hours} 小时没有使用记录", + "usage.companion.chartLabel": "使用量时间线", + "usage.companion.olderRecordsSkipped": "已跳过较早记录", + "usage.companion.settingsUnavailable": "伴侣设置不可用", + "usage.companion.corrupt": "伴侣设置文件已损坏。控件显示默认值;替换文件前将暂停保存。", + "usage.companion.corruptReset": "替换为默认值", + "usage.companion.saved": "已保存 · {time}", + "usage.companion.saveFailed": "保存失败:{error}", + "usage.companion.reset": "恢复默认设置", + "usage.companion.footer": "小组件显示今天的请求数、令牌数和费用,以及此处配置的图表,并在应用轮询时刷新。", + "usage.companion.menuBarShows": "菜单栏显示", + "usage.companion.menuRequests": "请求", + "usage.companion.menuTokens": "令牌", + "usage.companion.menuCost": "费用", + "usage.companion.menuQuota": "配额", + "usage.companion.menuNone": "仅图标", + "usage.companion.window": "时间范围", + "usage.companion.window6": "6 小时", + "usage.companion.window24": "24 小时", + "usage.companion.window72": "3 天", + "usage.companion.window168": "7 天", + "usage.companion.style": "样式", + "usage.companion.styleLine": "折线", + "usage.companion.styleStacked": "堆叠", + "usage.companion.metric": "指标", + "usage.companion.metricTotal": "总计", + "usage.companion.metricInput": "输入", + "usage.companion.metricOutput": "输出", + "usage.companion.metricCached": "缓存", + "usage.companion.groupBy": "分组依据", + "usage.companion.groupModel": "模型", + "usage.companion.groupAccount": "模型 + 账户", + "usage.companion.popoverSections": "弹出窗口部分", + "usage.companion.sectionToday": "今天", + "usage.companion.sectionChart": "图表", + "usage.companion.sectionModels": "模型", + "usage.companion.sectionCost": "费用", + "usage.companion.sectionAccounts": "账户", + "usage.companion.advanced": "高级", + "usage.companion.aggregation": "聚合", + "usage.companion.aggregationSum": "总和", + "usage.companion.aggregationAverage": "平均", + "usage.companion.aggregationMax": "最大值", + "usage.companion.menuText": "菜单栏文本", + "usage.companion.placeholders": "占位符:", + "usage.companion.modelsOnChart": "图表中的模型", + "usage.companion.connected": "菜单栏应用已连接 · {age}", + "usage.companion.installTitle": "安装菜单栏应用", + "usage.companion.installStep1": "从最新版本下载 OpenCodex--macos-universal.zip,并将 OpenCodex.app 拖到应用程序。", + "usage.companion.installStep2": "首次启动:右键点击 OpenCodex.app 并选择“打开”(应用使用临时签名,因此 Gatekeeper 只会询问一次)。", + "usage.companion.installStep3": "应用会自动找到此代理;应用运行后,小组件会出现在小组件图库中。", + "usage.companion.notConnected": "尚未有菜单栏应用连接到此代理。", + "usage.companion.lastSeen": "上次连接 {age}", + "usage.companion.installAnother": "在另一台 Mac 上安装", + "usage.companion.modelsCount": "图表显示 {selected}/{total}", + "usage.companion.modelsShowAll": "显示全部", + "usage.companion.hideProviders": "隐藏提供商", "usage.workspace.report": "用量报告", "usage.workspace.sections": "用量分区", "usage.coverage.measured": "已计量", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 6c059b329b1..db1390ae9ee 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -15,6 +15,7 @@ import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; import { parseUsageTimeRange, type UsageRangeError, type UsageTimeWindow } from "../usage-time-range"; +import UsageCompanionPanel from "./usage-companion-panel"; type Range = "all" | "30d" | "7d"; type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -906,6 +907,7 @@ function UsageWorkspaceBody({ range, locale, t, + apiBase, }: { data: UsageResponse | null; heatmap: ReturnType; @@ -918,8 +920,10 @@ function UsageWorkspaceBody({ range: Range | null; locale: Locale; t: TFn; + apiBase: string; }) { const empty = !!data && data.summary.requests === 0; + const [companionMetric, setCompanionMetric] = useState(null); const sections = [ { id: "overview", @@ -954,6 +958,20 @@ function UsageWorkspaceBody({ meta: data ? formatPct(data.summary.coverageRatio) : "—", body: data ? : null, }, + { + id: "companion", + label: t("usage.section.companion"), + meta: companionMetric + ? t(`usage.companion.menu${companionMetric[0]!.toUpperCase()}${companionMetric.slice(1)}` as never) + : "—", + body: ( + + ), + }, ]; return (
@@ -1224,6 +1242,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas range={customWindow ? null : range} locale={locale} t={t} + apiBase={apiBase} /> )} diff --git a/gui/src/pages/usage-companion-chart.tsx b/gui/src/pages/usage-companion-chart.tsx new file mode 100644 index 00000000000..0cf5505efb2 --- /dev/null +++ b/gui/src/pages/usage-companion-chart.tsx @@ -0,0 +1,119 @@ +import type { Locale, TFn } from "../i18n/shared"; +import { + chartPolylinePoints, + chartStackedBarRects, + formatCompanionTokens, + type UsageTimeline, +} from "./usage-companion-utils"; + +const CHART_COLORS = ["#0A84FF", "#FF9F0A", "#30D158", "#BF5AF2", "#FF453A", "#64D2FF"]; +const WIDTH = 640; +const HEIGHT = 160; +const PADDING = 28; + +function maxValue(timeline: UsageTimeline, chartStyle: "line" | "stackedBar"): number { + if (chartStyle === "stackedBar") { + return Math.max(...Array.from({ length: timeline.buckets }, (_, index) => + timeline.series.reduce((sum, series) => sum + (series.points[index] ?? 0), 0), + ), 0); + } + return Math.max(...timeline.series.flatMap(series => series.points), 0); +} + +function dateLabels(timeline: UsageTimeline, locale: Locale): string[] { + const formatter = new Intl.DateTimeFormat(locale, { month: "short", day: "numeric" }); + const interval = Math.max(1, Math.floor((timeline.buckets - 1) / 3)); + return [0, 1, 2, 3].map(index => { + const bucket = Math.min(timeline.buckets - 1, index * interval); + return formatter.format(new Date((timeline.start + bucket * timeline.bucketSeconds) * 1000)); + }); +} + +export function UsageCompanionChart({ + timeline, + chartStyle, + hours, + loading, + error, + onRetry, + locale, + t, +}: { + timeline: UsageTimeline | null; + chartStyle: "line" | "stackedBar"; + hours: number; + loading: boolean; + error: string | null; + onRetry: () => void; + locale: Locale; + t: TFn; +}) { + if (loading) { + return
; + } + if (error) { + return ( +
+ {t("usage.companion.timelineUnavailable")} + +
+ ); + } + if (!timeline || timeline.series.length === 0) { + return
{t("usage.companion.empty", { hours: timeline?.buckets ? Math.round(timeline.buckets * timeline.bucketSeconds / 3600) : hours })}
; + } + const max = maxValue(timeline, chartStyle); + const labels = dateLabels(timeline, locale); + const plotWidth = WIDTH - PADDING * 2; + const plotHeight = HEIGHT - PADDING * 2; + const y = PADDING; + const baseline = PADDING + plotHeight; + const translate = "trans" + "late"; + const xLabels = labels.map((label, index) => ( + {label} + )); + const marks = chartStyle === "line" + ? timeline.series.map((series, index) => ( + + )) + : chartStackedBarRects(timeline.series, plotWidth, plotHeight, max, 0).map(rect => ( + + )); + return ( +
+ + + + {formatCompanionTokens(max)} + {marks} + {xLabels} + +
+ {timeline.series.map((series, index) => ( + + + ))} +
+ {timeline.truncated &&

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

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

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

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

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

+

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

+
+ {t("usage.companion.installGuide")} +
+ {(() => { + const lastSeenAt = response?.companion?.lastSeenAt ?? null; + const connected = lastSeenAt !== null && fetchedAt !== null && fetchedAt - lastSeenAt <= 10 * 60 * 1000; + const age = lastSeenAt === null || fetchedAt === null ? "" : formatRelativeTime(lastSeenAt, relativeTimeLabelsFromT(t), fetchedAt); + const steps = ( +
    +
  1. {t("usage.companion.installStep1")} {t("common.github")}
  2. +
  3. {t("usage.companion.installStep2")}
  4. +
  5. {t("usage.companion.installStep3")}
  6. +
+ ); + return connected ? ( +
+
+
+ {t("usage.companion.installAnother")} + {steps} + xattr -d com.apple.quarantine /Applications/OpenCodex.app +
+
+ ) : ( +
+ {t("usage.companion.installTitle")} + {lastSeenAt !== null &&

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

} + {lastSeenAt === null &&

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

} + {steps} + xattr -d com.apple.quarantine /Applications/OpenCodex.app +
+ ); + })()} + void loadTimeline()} locale={locale} t={t} /> + {modelGroups.length > 0 &&
+
+
+ {t("usage.companion.modelsOnChart")} + {t("usage.companion.modelsCount", { selected: selectedModels.length, total: availableModels.length })} +
+ {current.models !== null && } +
+
+ {modelGroups.map(group => { + const selectedCount = group.models.filter(model => selectedModelSet.has(model.id)).length; + const groupOn = selectedCount === group.models.length; + return
+
+ {group.provider} + {group.models.length} + 0 && !groupOn} + onClick={() => updateSettings({ models: toggleCompanionModels(current.models, availableModels, group.models.map(model => model.id), !groupOn) })} + disabled={response?.corrupt} + label={group.provider} + title={group.provider} + /> +
+ {group.models.map(model => { + const on = selectedModelSet.has(model.id); + return
+ updateSettings({ models: toggleCompanionModels(current.models, availableModels, [model.id], !on) })} + disabled={response?.corrupt} + label={model.id} + title={model.id} + /> + {model.id} + {knownTotals.has(model.id) ? formatCompanionTokens(model.total) : "—"} +
; + })} +
; + })} +
+
} +
+ t(`usage.companion.menu${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ menuBarMetric: value })} /> + t(`usage.companion.window${value}` as never)} onChange={value => updateSettings({ chartHours: value, bucketMinutes: bucketMinutesForWindow(value) })} /> + value === "line" ? t("usage.companion.styleLine") : t("usage.companion.styleStacked")} onChange={value => updateSettings({ chartStyle: value })} /> + t(`usage.companion.metric${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ tokenMetric: value })} /> + value === "model" ? t("usage.companion.groupModel") : t("usage.companion.groupAccount")} onChange={value => updateSettings({ chartGrouping: value })} /> +
+ {t("usage.companion.popoverSections")} + {([ + ["showToday", "today"], + ["showChart", "chart"], + ["showModels", "models"], + ["showCost", "cost"], + ["showAccounts", "accounts"], + ] as const).map(([key, label]) => ( +
+ {t(`usage.companion.section${label[0]!.toUpperCase()}${label.slice(1)}` as never)} + +
+ ))} +
+
+ {t("usage.companion.advanced")} +
+ t(`usage.companion.aggregation${value[0]!.toUpperCase()}${value.slice(1)}` as never)} onChange={value => updateSettings({ aggregation: value })} /> + + {providerNames.length > 0 &&
{t("usage.companion.hideProviders")}{providerNames.map(provider => )}
} +
+
+
+
+ {saveMessage || "\u00a0"} + {saveState === "error" && } +
+ +

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

+
+ ); +} diff --git a/gui/src/pages/usage-companion-utils.ts b/gui/src/pages/usage-companion-utils.ts new file mode 100644 index 00000000000..9f68b323a52 --- /dev/null +++ b/gui/src/pages/usage-companion-utils.ts @@ -0,0 +1,214 @@ +export type TimelineMetric = "total" | "input" | "output" | "cached"; +export type TimelineAggregation = "sum" | "average" | "max"; +export type TimelineGrouping = "model" | "modelAccount"; +export type CompanionMenuBarMetric = "requests" | "tokens" | "cost" | "quota" | "none"; +export type CompanionChartStyle = "line" | "stackedBar"; +export type ChartHours = 6 | 24 | 72 | 168; + +export interface CompanionSettings { + menuBarMetric: CompanionMenuBarMetric; + menuBarTemplate: string | null; + showToday: boolean; + showChart: boolean; + showModels: boolean; + showCost: boolean; + showAccounts: boolean; + chartHours: ChartHours; + bucketMinutes: number; + chartStyle: CompanionChartStyle; + tokenMetric: TimelineMetric; + aggregation: TimelineAggregation; + chartGrouping: TimelineGrouping; + models: string[] | null; + hiddenProviders: string[]; +} + +export interface TimelineSeries { + id: string; + provider: string; + model: string; + accountLogLabel?: string; + total: number; + points: number[]; +} + +export interface UsageTimeline { + start: number; + end: number; + bucketSeconds: number; + buckets: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + series: TimelineSeries[]; + availableModels: string[]; + missingMeasurements: number; + truncated: boolean; +} + +export interface CompanionSettingsResponse { + settings: CompanionSettings; + updatedAt: number | null; + defaults: CompanionSettings; + corrupt?: boolean; + companion?: { + lastSeenAt: number | null; + }; +} + +export const CHART_BUCKET_MINUTES: Record = { + 6: 15, + 24: 60, + 72: 180, + 168: 360, +}; + +export function bucketMinutesForWindow(hours: ChartHours): number { + return CHART_BUCKET_MINUTES[hours]; +} + +export function formatCompanionTokens(value: number): string { + if (value < 1_000) return String(Math.round(value)); + const units = [ + [1_000_000_000_000, "T"], + [1_000_000_000, "B"], + [1_000_000, "M"], + [1_000, "K"], + ] as const; + for (let index = 0; index < units.length; index += 1) { + const [threshold, suffix] = units[index]!; + if (value >= threshold) { + const rounded = Math.round(value / threshold); + if (rounded >= 1000 && index > 0) { + const [largerThreshold, largerSuffix] = units[index - 1]!; + return `${Math.round(value / largerThreshold)}${largerSuffix}`; + } + return `${rounded}${suffix}`; + } + } + return String(Math.round(value)); +} + +export interface CompanionModelGroup { + provider: string; + models: { id: string; total: number }[]; + total: number; +} + +export function groupCompanionModels( + available: string[], + totals: Map, +): CompanionModelGroup[] { + const groups = new Map(); + for (const id of available) { + const provider = id.includes("/") ? id.slice(0, id.indexOf("/")) : id; + const group = groups.get(provider) ?? { provider, models: [], total: 0 }; + const total = totals.get(id) ?? 0; + group.models.push({ id, total }); + group.total += total; + groups.set(provider, group); + } + return Array.from(groups.values()) + .map(group => ({ + ...group, + models: group.models.toSorted((a, b) => b.total - a.total || a.id.localeCompare(b.id)), + })) + .toSorted((a, b) => b.total - a.total || a.provider.localeCompare(b.provider)); +} + +export function toggleCompanionModels( + selected: string[] | null, + available: string[], + ids: string[], + on: boolean, +): string[] | null { + const availableSet = new Set(available); + const next = new Set((selected ?? available).filter(id => availableSet.has(id))); + for (const id of ids) { + if (on) next.add(id); + else next.delete(id); + } + if (available.length > 0 && available.every(id => next.has(id))) return null; + return available.filter(id => next.has(id)); +} + +export function buildCompanionSettingsPatch( + patch: Partial, + availableModels: readonly string[] = [], +): Partial { + const next = { ...patch }; + if (typeof next.menuBarTemplate === "string" && next.menuBarTemplate.trim() === "") { + next.menuBarTemplate = null; + } + if (next.models !== undefined && availableModels.length > 0) { + const selected = next.models ?? []; + const selectedSet = new Set(selected); + const allSelected = selected.length === availableModels.length + && availableModels.every(model => selectedSet.has(model)); + if (allSelected) next.models = null; + } + return next; +} + +export function chartPolylinePoints( + points: readonly number[], + width: number, + height: number, + maxValue: number, + padding = 8, +): string { + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const divisor = Math.max(points.length - 1, 1); + return points.map((value, index) => { + const x = padding + plotWidth * index / divisor; + const y = padding + plotHeight * (1 - Math.max(0, value) / denominator); + return `${x},${y}`; + }).join(" "); +} + +export interface StackedBarRect { + x: number; + y: number; + width: number; + height: number; + seriesIndex: number; + bucketIndex: number; +} + +export function chartStackedBarRects( + series: readonly Pick[], + width: number, + height: number, + maxValue: number, + padding = 8, +): StackedBarRect[] { + const buckets = series[0]?.points.length ?? 0; + if (buckets === 0) return []; + const plotWidth = Math.max(0, width - padding * 2); + const plotHeight = Math.max(0, height - padding * 2); + const denominator = Math.max(maxValue, 1); + const gap = Math.min(3, plotWidth / Math.max(buckets * 8, 1)); + const barWidth = Math.max(0, plotWidth / buckets - gap); + const rects: StackedBarRect[] = []; + for (let bucketIndex = 0; bucketIndex < buckets; bucketIndex += 1) { + let offset = 0; + for (let seriesIndex = 0; seriesIndex < series.length; seriesIndex += 1) { + const value = Math.max(0, series[seriesIndex]?.points[bucketIndex] ?? 0); + const barHeight = plotHeight * value / denominator; + if (barHeight > 0) { + rects.push({ + x: padding + bucketIndex * (plotWidth / buckets) + gap / 2, + y: padding + plotHeight - offset - barHeight, + width: barWidth, + height: barHeight, + seriesIndex, + bucketIndex, + }); + } + offset += barHeight; + } + } + return rects; +} diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css index aa9e3bb45c6..6f8d4a2af74 100644 --- a/gui/src/styles-usage-workspace.css +++ b/gui/src/styles-usage-workspace.css @@ -214,6 +214,90 @@ gap: 6px; } +.usage-companion-panel { + display: grid; + gap: 16px; + padding-top: 4px; +} +.usage-companion-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} +.usage-companion-header .panel-title { margin: 0; } +.usage-companion-header .card-sub { margin: 4px 0 0; } +.usage-companion-install { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} +.usage-companion-install summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-install-status { display: flex; align-items: center; gap: 8px; color: var(--text); font-size: 12px; } +.usage-companion-install-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); } +.usage-companion-install-steps { display: grid; gap: 8px; margin: 0; padding-left: 20px; color: var(--muted); font-size: 12px; } +.usage-companion-install-steps .btn { margin-left: 6px; } +.usage-companion-install-last-seen { margin: 0; } +.usage-companion-install-command { display: block; overflow-x: auto; padding: 7px 9px; border-radius: var(--radius-xs); background: var(--raised); color: var(--text); font-size: 11px; } +.usage-companion-models { display: grid; gap: 8px; } +.usage-companion-models-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.usage-companion-models-header > div { display: flex; align-items: baseline; gap: 8px; min-width: 0; } +.usage-companion-models-count { white-space: nowrap; } +.usage-companion-models-list { max-height: 280px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); } +.usage-companion-model-group + .usage-companion-model-group { border-top: 1px solid var(--border-soft); } +.usage-companion-model-group-header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; gap: 7px; padding: 8px 10px; background: color-mix(in srgb, var(--surface) 92%, var(--raised)); } +.usage-companion-model-provider { flex: 1; min-width: 0; overflow: hidden; color: var(--text); font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-chip { padding: 2px 5px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); } +.usage-companion-model-group-header .switch { flex: 0 0 auto; } +.usage-companion-model-row { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 10px 7px 18px; } +.usage-companion-model-row .switch { flex: 0 0 auto; } +.usage-companion-model-row code { min-width: 0; overflow: hidden; color: var(--text); text-overflow: ellipsis; white-space: nowrap; } +.usage-companion-model-row.is-off code { color: var(--faint); text-decoration: line-through; } +.usage-companion-model-total { flex: 0 0 auto; margin-left: auto; font-variant-numeric: tabular-nums; } +.usage-companion-chart { min-width: 0; } +.usage-companion-chart svg { display: block; width: 100%; height: 160px; overflow: visible; } +.usage-companion-axis { stroke: var(--border); stroke-width: 1; } +.usage-companion-axis-label { fill: var(--muted); font-size: 10px; } +.usage-companion-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 8px; } +.usage-companion-legend-item { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); font-size: 11px; } +.usage-companion-swatch { width: 8px; height: 8px; border-radius: 50%; } +.usage-companion-chart-skeleton { + height: 160px; + border: 1px solid var(--border-soft); + background: var(--surface); + animation: pulse 1.2s ease-in-out infinite alternate; +} +.usage-companion-chart-state { display: flex; align-items: center; gap: 10px; min-height: 160px; color: var(--muted); } +.usage-companion-controls { display: grid; gap: 14px; border: 0; padding: 0; margin: 0; min-width: 0; } +.usage-companion-control { display: grid; gap: 6px; min-width: 0; } +.usage-companion-control > select, .usage-companion-control > input { + min-height: 34px; width: 100%; padding: 6px 9px; + border: 1px solid var(--border); border-radius: var(--radius-xs); + background: var(--raised); color: var(--text); font: inherit; +} +.usage-companion-control > .usage-segmented { width: fit-content; max-width: 100%; } +.field-label { color: var(--muted); font-size: 11.5px; font-weight: 550; } +.usage-companion-switches, .usage-companion-check-list { + display: grid; gap: 8px; border: 0; padding: 0; margin: 0; +} +.usage-companion-switches legend { padding: 0; margin-bottom: 2px; } +.usage-companion-switch { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--text); font-size: 12px; } +.usage-companion-switch .toggle { flex: 0 0 auto; } +.usage-companion-advanced { border-top: 1px solid var(--border-soft); padding-top: 12px; } +.usage-companion-advanced summary { cursor: pointer; color: var(--text); font-size: 12px; font-weight: 600; } +.usage-companion-advanced-body { display: grid; gap: 14px; padding-top: 12px; } +.usage-companion-check-list label { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 12px; } +.usage-companion-save-status { display: flex; align-items: center; gap: 8px; min-height: 26px; color: var(--muted); font-size: 11.5px; } +.usage-companion-save-status.is-error { color: var(--red); } +.usage-companion-loading { min-height: 160px; color: var(--muted); } + @media (max-width: 640px) { .usage-source-row { align-items: flex-start; flex-direction: column; } + .usage-companion-header { align-items: stretch; flex-direction: column; } + .usage-companion-header .btn { align-self: flex-start; } + .usage-companion-control > .usage-segmented { width: 100%; } + .usage-companion-control > .usage-segmented .usage-segmented-btn { flex: 1 1 0; min-width: 0; padding-inline: 6px; } } diff --git a/gui/tests/usage-companion-utils.test.ts b/gui/tests/usage-companion-utils.test.ts new file mode 100644 index 00000000000..2fd02c7fd9e --- /dev/null +++ b/gui/tests/usage-companion-utils.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { + bucketMinutesForWindow, + buildCompanionSettingsPatch, + chartPolylinePoints, + chartStackedBarRects, + formatCompanionTokens, + groupCompanionModels, + toggleCompanionModels, +} from "../src/pages/usage-companion-utils"; + +describe("usage companion utilities", () => { + test("maps chart windows to bounded buckets", () => { + expect([6, 24, 72, 168].map(bucketMinutesForWindow)).toEqual([15, 60, 180, 360]); + }); + + test("normalizes empty templates and all-selected models", () => { + expect(buildCompanionSettingsPatch({ + menuBarTemplate: " ", + models: ["openai/gpt-5", "anthropic/claude"], + }, ["openai/gpt-5", "anthropic/claude"])).toEqual({ + menuBarTemplate: null, + models: null, + }); + expect(buildCompanionSettingsPatch({ models: ["openai/gpt-5"] }, ["openai/gpt-5", "anthropic/claude"])).toEqual({ + models: ["openai/gpt-5"], + }); + }); + + test("creates line and stacked bar geometry", () => { + expect(chartPolylinePoints([0, 5, 10], 100, 50, 10)).toBe("8,42 50,25 92,8"); + expect(chartStackedBarRects([ + { points: [5] }, + { points: [5] }, + ], 100, 50, 10)).toEqual([ + { x: 9.5, y: 25, width: 81, height: 17, seriesIndex: 0, bucketIndex: 0 }, + { x: 9.5, y: 8, width: 81, height: 17, seriesIndex: 1, bucketIndex: 0 }, + ]); + }); + + test("formats companion token values as integer SI units", () => { + expect([999, 1_000, 999_600, 1_634_303, 333_400_000, 12_300_000_000].map(formatCompanionTokens)).toEqual([ + "999", "1K", "1M", "2M", "333M", "12B", + ]); + }); + + test("groups companion models by descending totals with alphabetical ties", () => { + expect(groupCompanionModels( + ["openai/gpt-4", "anthropic/claude", "openai/gpt-5", "local"], + new Map([ + ["openai/gpt-4", 5], + ["anthropic/claude", 10], + ["openai/gpt-5", 5], + ]), + )).toEqual([ + { provider: "anthropic", models: [{ id: "anthropic/claude", total: 10 }], total: 10 }, + { provider: "openai", models: [{ id: "openai/gpt-4", total: 5 }, { id: "openai/gpt-5", total: 5 }], total: 10 }, + { provider: "local", models: [{ id: "local", total: 0 }], total: 0 }, + ]); + }); + + test("keeps known totals for models absent from the current timeline", () => { + expect(groupCompanionModels( + ["openai/gpt-4", "anthropic/claude"], + new Map([["openai/gpt-4", 10]]), + )).toEqual([ + { provider: "openai", models: [{ id: "openai/gpt-4", total: 10 }], total: 10 }, + { provider: "anthropic", models: [{ id: "anthropic/claude", total: 0 }], total: 0 }, + ]); + }); + + test("toggles mixed groups and collapses all-selected state to null", () => { + const available = ["openai/gpt-4", "openai/gpt-5", "anthropic/claude"]; + expect(toggleCompanionModels(["openai/gpt-4"], available, ["openai/gpt-5"], true)).toEqual(["openai/gpt-4", "openai/gpt-5"]); + expect(toggleCompanionModels(["openai/gpt-4", "openai/gpt-5"], available, ["anthropic/claude"], true)).toBeNull(); + }); +}); diff --git a/package.json b/package.json index 7278f576ab6..26fce207d75 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,9 @@ "build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package", "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml", "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml", + "build:macos": "bash scripts/build-macos-app.sh", + "package:macos": "bash scripts/package-macos-release.sh", + "test:macos": "swift run --package-path app MenuBarCoreTests && swift run --package-path app MenuBarUITests", "prepare:package": "bun scripts/prepare-package.ts", "prepack": "bun run prepare:package", "prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui", diff --git a/readme/README.fr.md b/readme/README.fr.md index 160acfb908d..411a27e7597 100644 --- a/readme/README.fr.md +++ b/readme/README.fr.md @@ -92,6 +92,19 @@ Ouvrez **http://localhost:10100** et configurez tout dans le tableau de bord web fournisseurs (plus de 40 intégrés, ou n'importe quel point de terminaison compatible OpenAI), choisissez les modèles, gérez les comptes. `ocx gui` rouvre le tableau de bord à tout moment. +### Application macOS dans la barre des menus + +Un compagnon natif pour l’état du proxy, l’utilisation et les quotas des fournisseurs sans ouvrir +le tableau de bord. Le code source se trouve dans [`app/`](../app) (Swift + AppKit, sans dépendance +tierce). Téléchargez-le depuis la +[page des releases](https://github.com/lidge-jun/opencodex/releases) ou compilez-le localement avec +`bun run build:macos`. + +Le premier lancement nécessite un clic droit → Ouvrir, car l’application est signée ad hoc et non +notarisée. Consultez le [guide de l’application macOS dans la barre des menus](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) +pour l’explication complète. + +L’application inclut également un widget macOS 14+ affichant l’état du proxy, l’utilisation du jour et les quotas. Il peut également gérer un **groupe de comptes ChatGPT** pour l'authentification Codex. Ajoutez plusieurs comptes ChatGPT / Codex et actualisez leurs quotas 5 h / hebdomadaires / 30 j dans le tableau de bord. Avec le routage par quota, les nouvelles sessions peuvent utiliser le compte opérationnel le moins sollicité ; diff --git a/readme/README.ja.md b/readme/README.ja.md index 415d5c9674d..ee8a7e6adc5 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -99,6 +99,18 @@ Codex 認証用の **ChatGPT アカウントプール**も管理できます。C は使わず他が尽きたときだけ回したいアカウント(多くは Codex Desktop のログイン)があるなら、アカウント に選択順を指定してください。 +### macOS メニューバーアプリ + +ダッシュボードを開かずにプロキシの状態、使用量、プロバイダーのクォータを確認できるネイティブ +コンパニオンです。ソースは [`app/`](../app)(Swift + AppKit、サードパーティ依存なし)にあります。 +[リリースページ](https://github.com/lidge-jun/opencodex/releases)からダウンロードするか、 +`bun run build:macos` でローカルビルドできます。 + +アプリは未公証のアドホック署名のため、初回起動時は右クリックして「開く」を選択してください。 +詳しくは [macOS メニューバーアプリガイド](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)をご覧ください。 + +macOS 14 以降では、プロキシの状態、今日の使用量、クォータを表示するウィジェットも利用できます。 + ### スポンサー アップストリームのプロトコルが変わるたびに opencodex を追随させているのはスポンサーの支援です。 diff --git a/readme/README.ko.md b/readme/README.ko.md index f4c44f121c6..f93936bc5a5 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -96,6 +96,18 @@ round-robin과 fill-first는 각자 정책을 따릅니다. 기존 Codex 스레 계정 제외, affinity 만료, 401/403·429 복구가 일어나면 다시 묶일 수 있습니다. Codex Desktop 로그인처럼 다른 계정이 소진된 뒤에만 쓰고 싶은 계정이 있으면, 계정에 선택 순서를 지정하세요. +### macOS 메뉴 막대 앱 + +대시보드를 열지 않고 프록시 상태, 사용량, 제공자 쿼터를 확인하는 네이티브 동반 앱입니다. +소스는 [`app/`](../app)에 있으며 Swift + AppKit으로 작성되었고 서드파티 의존성이 없습니다. +[릴리스 페이지](https://github.com/lidge-jun/opencodex/releases)에서 다운로드하거나 +`bun run build:macos`로 직접 빌드할 수 있습니다. + +앱은 공증되지 않은 애드혹 서명이므로 처음 실행할 때 마우스 오른쪽 버튼을 클릭하고 열기를 선택하세요. +자세한 내용은 [macOS 메뉴 막대 앱 가이드](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)를 참조하세요. + +macOS 14 이상에서는 프록시 상태, 오늘의 사용량과 쿼터를 보여 주는 위젯도 포함됩니다. + ### 스폰서 업스트림 프로토콜이 바뀔 때마다 opencodex가 따라갈 수 있는 건 스폰서 덕분입니다. 관심이 있으면 diff --git a/readme/README.ru.md b/readme/README.ru.md index e654f571a93..d2df43cd07f 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -103,6 +103,18 @@ ocx start # прокси + панель управлен них — обычно вход Codex Desktop — должен использоваться только после того, как остальные исчерпаны. +### Приложение macOS в строке меню + +Нативный компаньон для состояния прокси, использования и квот провайдеров без открытия панели. +Исходный код находится в [`app/`](../app) (Swift + AppKit, без сторонних зависимостей). +Скачайте его со [страницы релизов](https://github.com/lidge-jun/opencodex/releases) или +соберите локально командой `bun run build:macos`. + +При первом запуске нажмите правой кнопкой мыши и выберите «Открыть»: приложение подписано ad hoc, +но не нотариализовано. Подробности — в [руководстве по приложению macOS в строке меню](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/). + +Приложение также включает виджет для macOS 14+, показывающий состояние прокси, расход за сегодня и квоты. + ### Спонсоры Спонсоры позволяют поддерживать opencodex при каждом изменении вышестоящих протоколов. Интересно? diff --git a/readme/README.tr.md b/readme/README.tr.md index 998338d8760..511a42b0a1c 100644 --- a/readme/README.tr.md +++ b/readme/README.tr.md @@ -99,6 +99,18 @@ değerlendirmesi, failover, hesabın devre dışı bırakılması, bağlılığ 429 toparlanması bu bağı yeniden kurabilir. Yalnızca diğerleri tükendiğinde kullanılmasını istediğiniz bir hesap varsa — genellikle Codex Desktop girişiniz — hesaplara bir seçim sırası verin. +### macOS menü çubuğu uygulaması + +Panoyu açmadan proxy durumunu, kullanımı ve sağlayıcı kotalarını gösteren yerel yardımcı uygulama. +Kaynak kodu [`app/`](../app) konumundadır (Swift + AppKit, üçüncü taraf bağımlılığı yoktur). +[Sürümler sayfasından](https://github.com/lidge-jun/opencodex/releases) indirin veya +`bun run build:macos` ile yerel olarak derleyin. + +Uygulama noter tasdikli olmadığından ve ad hoc imzalandığından ilk açılışta sağ tıklayıp Aç'ı seçin. +Ayrıntılar için [macOS menü çubuğu uygulaması kılavuzuna](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/) bakın. + +Uygulama ayrıca proxy durumunu, bugünkü kullanımı ve kotaları gösteren macOS 14+ widget'ını içerir. + ### Sponsorlar Her yukarı akış protokol değişiminde opencodex'in bakımını sürdürebilmesi sponsorlar sayesinde. diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 79392708a13..cc2e57271f3 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -97,6 +97,17 @@ ocx start # 代理 + 仪表板:localhost:10100 401/403 与 429 恢复,仍可能重新绑定。给账户设定选择顺序,以便其中某个账户 —— 通常是你的 Codex Desktop 登录 —— 只在其他账户耗尽后才被选中。 +### macOS 菜单栏应用 + +无需打开仪表板即可查看代理状态、用量和提供商配额的原生伴侣应用。源代码位于 +[`app/`](../app)(Swift + AppKit,无第三方依赖)。请从[发布页面](https://github.com/lidge-jun/opencodex/releases) +下载,或使用 `bun run build:macos` 在本地构建。 + +应用采用未公证的临时签名,首次启动时请右键点击并选择“打开”。详情请参阅 +[macOS 菜单栏应用指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 + +应用还包含适用于 macOS 14 及更高版本的小组件,可显示代理状态、今日用量和配额。 + ### 赞助商 赞助商支撑 opencodex 跟上每一次上游协议变更。有兴趣? diff --git a/readme/README.zh-TW.md b/readme/README.zh-TW.md index 6a25aae4d15..adb8bf52012 100644 --- a/readme/README.zh-TW.md +++ b/readme/README.zh-TW.md @@ -95,6 +95,17 @@ ocx start # 代理 + 儀表板位於 localhost:10100 行動裝置連線的會話不會在對話中途跳帳號——但配額重新評估、failover、 帳號排除、親和性到期,或 401/403 與 429 復原,仍可能重新綁定。當其中一個帳號——通常是你的 Codex Desktop 登入——只應在其他帳號用盡後才被用到時,請為帳號設定選取順序。 +### macOS 選單列應用程式 + +無需開啟儀表板即可查看代理狀態、用量與供應商配額的原生伴侶應用程式。原始碼位於 +[`app/`](../app)(Swift + AppKit,沒有第三方相依套件)。請從[發行頁面](https://github.com/lidge-jun/opencodex/releases) +下載,或使用 `bun run build:macos` 在本機建置。 + +應用程式未經公證且使用 ad hoc 簽章,首次啟動時請按右鍵並選擇「開啟」。詳情請參閱 +[macOS 選單列應用程式指南](https://lidge-jun.github.io/opencodex/guides/macos-menu-bar/)。 + +應用程式也包含 macOS 14 以上的小工具,可顯示代理狀態、今日用量與配額。 + ### 贊助 贊助讓 opencodex 能跟上每一次上游協議變更。有興趣? diff --git a/readme/i18n-manifest.json b/readme/i18n-manifest.json index d64870cad2c..569d3612db8 100644 --- a/readme/i18n-manifest.json +++ b/readme/i18n-manifest.json @@ -6,43 +6,43 @@ "file": "readme/README.fr.md", "label": "Français", "docsPath": "fr", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ko": { "file": "readme/README.ko.md", "label": "한국어", "docsPath": "ko", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "zh-CN": { "file": "readme/README.zh-CN.md", "label": "简体中文", "docsPath": "zh-cn", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "zh-TW": { "file": "readme/README.zh-TW.md", "label": "繁體中文", "docsPath": "zh-tw", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ru": { "file": "readme/README.ru.md", "label": "Русский", "docsPath": "ru", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "ja": { "file": "readme/README.ja.md", "label": "日本語", "docsPath": "ja", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" }, "tr": { "file": "readme/README.tr.md", "label": "Türkçe", "docsPath": "tr", - "sourceSha256": "20eebe786feb7ef23f4488beae3aa30134d64ba2e26bafd1b7bca19b226bf776" + "sourceSha256": "4b8346eb370744ca0128f926279e1b468cc5bfe6eb066d7788de5cc3950525dd" } } } diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 00000000000..8dcac03ee18 --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,268 @@ +#!/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 new file mode 100755 index 00000000000..f9b0b1ebd89 --- /dev/null +++ b/scripts/package-macos-release.sh @@ -0,0 +1,113 @@ +#!/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 f9d1fe63007..37969710498 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -167,6 +167,7 @@ } }, "explicit": { + "macos-build-script.test.ts": "gui", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", @@ -189,6 +190,7 @@ "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", @@ -1581,6 +1583,8 @@ "management-google-tool-schema-policy.test.ts": "server", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "usage-timeline.test.ts": "usage", + "companion-settings.test.ts": "server", "chat-tool-choice-allowed-tools.test.ts": "responses", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 78d2047d1aa..4918176da31 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -537,6 +537,25 @@ JSON mode: `payload`. - `store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched. - Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there. +### `ocx companion` + +Inspect and configure menu-bar and widget companion usage settings. + +| Method | Route | +|---|---| +| GET | `/api/companion/settings` | +| GET | `/api/usage/timeline` | +| PUT | `/api/companion/settings` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit companion settings as JSON. | + +JSON mode: `payload`. + +- `show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults. +- Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly. + ### `ocx account main reauth` Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring. @@ -896,6 +915,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 48 -- of those, state-changing: 24 +- declared capabilities: 49 +- of those, state-changing: 25 - head-resolved invocations: 2 diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index e67390041b9..f651183d053 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -314,6 +314,22 @@ export const CAPABILITIES: readonly Capability[] = [ "Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.", ], }, + { + command: ["companion"], + summary: "Inspect and configure menu-bar and widget companion usage settings.", + routes: [ + { method: "GET", path: "/api/companion/settings" }, + { method: "GET", path: "/api/usage/timeline" }, + { method: "PUT", path: "/api/companion/settings" }, + ], + flags: [{ name: "--json", value: "boolean", summary: "Emit companion settings as JSON." }], + mutates: true, + json: "payload", + details: [ + "`show` (the default) reads settings; `set key=value ...` updates selected settings; `reset` restores defaults.", + "Values accepted by `set` are parsed as JSON when valid, so booleans, numbers, arrays, objects, and null can be passed directly.", + ], + }, { command: ["account", "history"], summary: "Cached quota observations for one stored Codex pool account.", diff --git a/src/cli/companion.ts b/src/cli/companion.ts new file mode 100644 index 00000000000..2b24258fcc3 --- /dev/null +++ b/src/cli/companion.ts @@ -0,0 +1,56 @@ +import { CliUsageError, printData, rejectArgs, runCliAction, runtimeRequest, takeFlag, type RuntimeApiDeps } from "./runtime-api"; + +const USAGE = `Usage: + ocx companion [show] [--json] + ocx companion set = [...] [--json] + ocx companion reset [--json]`; + +function parseValue(raw: string): unknown { + if (raw === "null") return null; + try { return JSON.parse(raw); } catch { return raw; } +} + +async function show(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + printData(await runtimeRequest("/api/companion/settings", {}, deps), wantsJson); +} + +async function set(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + if (args.length === 0) throw new CliUsageError("companion set requires key=value assignments", USAGE); + const patch: Record = {}; + for (const assignment of args) { + const separator = assignment.indexOf("="); + if (separator <= 0) throw new CliUsageError(`invalid companion setting "${assignment}"; use key=value`, USAGE); + patch[assignment.slice(0, separator)] = parseValue(assignment.slice(separator + 1)); + } + printData(await runtimeRequest("/api/companion/settings", { + method: "PUT", + body: JSON.stringify({ settings: patch }), + }, deps), wantsJson, ["Companion settings saved."]); +} + +async function reset(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + rejectArgs(args, USAGE); + printData(await runtimeRequest("/api/companion/settings", { + method: "PUT", + body: JSON.stringify({ reset: true }), + }, deps), wantsJson, ["Companion settings reset."]); +} + +export async function handleCompanionCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { + return runCliAction(async () => { + const [sub = "show", ...rest] = argv; + if (sub === "show") await show(rest, deps); + else if (sub === "set") await set(rest, deps); + else if (sub === "reset") await reset(rest, deps); + else throw new CliUsageError(`unknown companion command ${sub}`, USAGE); + }); +} + +export const COMPANION_USAGE = USAGE; diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b9b09420c87..beb3170d992 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -786,6 +786,10 @@ const commandRunners: Record = { const { handleComboCommand } = await import("./combo"); return await handleComboCommand(deps.args.slice(1)); }, + companion: async deps => { + const { handleCompanionCommand } = await import("./companion"); + return await handleCompanionCommand(deps.args.slice(1)); + }, route: async deps => { if (deps.args[1] !== "combo" && deps.args[1] !== "policy") { console.error("Usage: ocx route "); diff --git a/src/cli/help.ts b/src/cli/help.ts index 0be199f1a5a..da5450880b4 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -89,6 +89,7 @@ Usage: ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection ocx config Validated configuration show/get/set/import/export + ocx companion Menu-bar and widget companion usage settings ocx lab Read-only Compatibility Lab projection inspection ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile diff --git a/src/cli/registry.ts b/src/cli/registry.ts index f9b9d95d969..85c2d660310 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -298,6 +298,16 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ usage: "ocx model ", summary: "Alias of ocx models.", }, + { + name: "companion", + usage: "ocx companion ...", + summary: "Inspect and configure menu-bar and widget companion usage settings.", + details: [ + "ocx companion and ocx companion show read settings; use --json for machine-readable output.", + "ocx companion set accepts one or more key=value assignments; values are parsed as JSON when possible.", + "ocx companion reset restores the default settings.", + ], + }, { name: "combo", usage: "ocx combo ...", diff --git a/src/companion/settings.ts b/src/companion/settings.ts new file mode 100644 index 00000000000..c838285b6fe --- /dev/null +++ b/src/companion/settings.ts @@ -0,0 +1,131 @@ +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config/paths"; +import { + TIMELINE_HOURS, + type TimelineAggregation, + type TimelineGrouping, + type TimelineMetric, +} from "../usage/timeline"; + +export interface CompanionSettings { + menuBarMetric: "requests" | "tokens" | "cost" | "quota" | "none"; + menuBarTemplate: string | null; + showToday: boolean; + showChart: boolean; + showModels: boolean; + showCost: boolean; + showAccounts: boolean; + chartHours: typeof TIMELINE_HOURS[number]; + bucketMinutes: number; + chartStyle: "line" | "stackedBar"; + tokenMetric: TimelineMetric; + aggregation: TimelineAggregation; + chartGrouping: TimelineGrouping; + models: string[] | null; + hiddenProviders: string[]; +} + +export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = { + menuBarMetric: "tokens", + menuBarTemplate: null, + showToday: true, + showChart: true, + showModels: true, + showCost: true, + showAccounts: true, + chartHours: 24, + bucketMinutes: 60, + chartStyle: "line", + tokenMetric: "total", + aggregation: "sum", + chartGrouping: "model", + models: null, + hiddenProviders: [], +}; + +const TEMPLATE_FIELDS = new Set(["requests", "totalTokens", "inputTokens", "outputTokens", "costUsd", "quotaPercent"]); +const MENU_BAR_METRICS = new Set(["requests", "tokens", "cost", "quota", "none"]); +const CHART_STYLES = new Set(["line", "stackedBar"]); +const TIMELINE_METRICS = new Set(["total", "input", "output", "cached"]); +const AGGREGATIONS = new Set(["sum", "average", "max"]); +const GROUPINGS = new Set(["model", "modelAccount"]); +const SETTINGS_KEYS = Object.keys(DEFAULT_COMPANION_SETTINGS) as (keyof CompanionSettings)[]; + +export function companionSettingsPath(): string { + return join(getConfigDir(), "companion.json"); +} + +function invalid(message: string): { error: string } { + return { error: message }; +} + +function validModels(value: unknown, key: string): value is string[] | null { + return value === null + || (Array.isArray(value) + && value.length <= 100 + && value.every(model => typeof model === "string" && /^[^/\s]+\/[^/\s]+$/.test(model))); +} + +function validateValue(key: keyof CompanionSettings, value: unknown): string | null { + if (key === "menuBarMetric") return typeof value === "string" && MENU_BAR_METRICS.has(value) ? null : "menuBarMetric is invalid"; + if (key === "menuBarTemplate") { + if (value === null) return null; + if (typeof value !== "string" || value.length > 200) return "menuBarTemplate must be null or at most 200 characters"; + for (const match of value.matchAll(/\{([^{}]+)\}/g)) { + if (!TEMPLATE_FIELDS.has(match[1]!)) return `menuBarTemplate contains unknown placeholder: ${match[1]}`; + } + return null; + } + if (["showToday", "showChart", "showModels", "showCost", "showAccounts"].includes(key)) { + return typeof value === "boolean" ? null : `${key} must be a boolean`; + } + if (key === "chartHours") return TIMELINE_HOURS.includes(value as typeof TIMELINE_HOURS[number]) ? null : "chartHours is invalid"; + if (key === "bucketMinutes") return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1440 ? null : "bucketMinutes must be an integer from 1 through 1440"; + if (key === "chartStyle") return typeof value === "string" && CHART_STYLES.has(value) ? null : "chartStyle is invalid"; + if (key === "tokenMetric") return typeof value === "string" && TIMELINE_METRICS.has(value) ? null : "tokenMetric is invalid"; + if (key === "aggregation") return typeof value === "string" && AGGREGATIONS.has(value) ? null : "aggregation is invalid"; + if (key === "chartGrouping") return typeof value === "string" && GROUPINGS.has(value) ? null : "chartGrouping is invalid"; + if (key === "models") return validModels(value, key) ? null : "models must be null or at most 100 provider/model identifiers"; + if (key === "hiddenProviders") return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/\s/.test(item)) + ? null : "hiddenProviders must contain at most 100 provider names"; + return `${key} is unsupported`; +} + +export function applyCompanionSettingsPatch( + current: CompanionSettings, + patch: unknown, +): CompanionSettings | { error: string } { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) return invalid("settings must be an object"); + const values = patch as Record; + for (const key of Object.keys(values)) { + if (!SETTINGS_KEYS.includes(key as keyof CompanionSettings)) return invalid(`unknown settings key: ${key}`); + const error = validateValue(key as keyof CompanionSettings, values[key]); + if (error) return invalid(error); + } + return { ...current, ...values } as CompanionSettings; +} + +export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } { + const path = companionSettingsPath(); + if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown; + const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed); + if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true }; + return { settings, updatedAt: statSync(path).mtimeMs }; + } catch { + return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true }; + } +} + +export function saveCompanionSettings(settings: CompanionSettings): void { + const path = companionSettingsPath(); + const dir = getConfigDir(); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 }); + const temp = `${path}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); + chmodSync(temp, 0o600); + renameSync(temp, path); + chmodSync(path, 0o600); +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 7c8766367c2..dbd5d7345d6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -71,6 +71,8 @@ import { handleOauthAccountRoutes } from "./management/oauth-account-routes"; import { handleComboRoutes } from "./management/combo-routes"; import { handleSystemRoutes } from "./management/system-routes"; import { handleSidebarRoutes } from "./management/sidebar-routes"; +import { handleUsageTimelineRoutes } from "./management/usage-timeline-routes"; +import { handleCompanionRoutes } from "./management/companion-routes"; import { handleCodexPromptRoutes } from "./management/codex-prompt-routes"; import { handleIntegrationRoutes } from "./management/integration-routes"; import { handleNativeIntegrationRoutes } from "./management/native-integration-routes"; @@ -266,7 +268,7 @@ export async function handleManagementAPI( } catch { /* best-effort */ } } const ctx: ManagementContext = { req, url, config, deps, version: VERSION, principal, sessionControl, convergeCodexCatalog, syncClaudeAgentDefsBestEffort }; - let routed: Response | null; + let routed: Response | null | undefined; try { routed = handleSessionRoutes(ctx) ?? (await handleRemoteWorkspaceRoutesOnDemand(ctx)) @@ -291,6 +293,8 @@ export async function handleManagementAPI( ?? (await handleComboRoutes(ctx)) ?? (await handleSystemRoutes(ctx)) ?? (await handleLabRoutesOnDemand(ctx)) + ?? (await handleUsageTimelineRoutes(ctx)) + ?? (await handleCompanionRoutes(ctx)) ?? (await handleSidebarRoutes(ctx)); } catch (error) { const tooLarge = managementBodyTooLargeResponse(error, req, config); diff --git a/src/server/management/companion-routes.ts b/src/server/management/companion-routes.ts new file mode 100644 index 00000000000..df0fd8ab6de --- /dev/null +++ b/src/server/management/companion-routes.ts @@ -0,0 +1,53 @@ +import { + applyCompanionSettingsPatch, + DEFAULT_COMPANION_SETTINGS, + loadCompanionSettings, + saveCompanionSettings, +} from "../../companion/settings"; +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +let companionLastSeenAt: number | null = null; + +export function resetCompanionPresenceForTests(): void { + companionLastSeenAt = null; +} + +function response(): Response { + const loaded = loadCompanionSettings(); + return jsonResponse({ + settings: loaded.settings, + updatedAt: loaded.updatedAt, + defaults: DEFAULT_COMPANION_SETTINGS, + companion: { lastSeenAt: companionLastSeenAt }, + ...(loaded.corrupt ? { corrupt: true } : {}), + }); +} + +export async function handleCompanionRoutes(ctx: ManagementContext): Promise { + if (ctx.url.pathname === "/api/companion/settings" && ctx.req.method === "GET") { + if (ctx.req.headers.get("user-agent")?.startsWith("OpenCodexMenuBar/")) companionLastSeenAt = Date.now(); + return response(); + } + if (ctx.url.pathname !== "/api/companion/settings" || ctx.req.method !== "PUT") return null; + let body: unknown; + try { + body = await readManagementJsonBody(ctx.req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, ctx.req, ctx.config); + } + if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "invalid settings body" }, 400, ctx.req, ctx.config); + const input = body as { reset?: unknown; settings?: unknown }; + if (input.reset === true) { + saveCompanionSettings(DEFAULT_COMPANION_SETTINGS); + return response(); + } + if (!("settings" in input)) return jsonResponse({ error: "provide settings or reset:true" }, 400, ctx.req, ctx.config); + const current = loadCompanionSettings().settings; + const updated = applyCompanionSettingsPatch(current, input.settings); + if ("error" in updated) return jsonResponse(updated, 400, ctx.req, ctx.config); + saveCompanionSettings(updated); + return response(); +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index 95dc71be749..fbaafcb5d36 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -240,6 +240,11 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "GET", path: "/api/storage/trash", module: "server/management/logs-usage-routes", mutates: false }, { method: "GET", path: "/api/storage/trash/restore/test-stream", module: "server/management/logs-usage-routes", mutates: false, exempt: { reason: "test-seam", why: "Opt-in streaming seam declared at src/storage/restore-job.ts:34." } }, { method: "GET", path: "/api/usage", module: "server/management/logs-usage-routes", mutates: false }, + // server/management/usage-timeline-routes + { method: "GET", path: "/api/usage/timeline", module: "server/management/usage-timeline-routes", mutates: false }, + // server/management/companion-routes + { method: "GET", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: false }, + { method: "PUT", path: "/api/companion/settings", module: "server/management/companion-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup", module: "server/management/logs-usage-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup-policy/run", module: "server/management/logs-usage-routes", mutates: true }, { method: "POST", path: "/api/storage/cleanup/preview", module: "server/management/logs-usage-routes", mutates: true }, diff --git a/src/server/management/usage-timeline-routes.ts b/src/server/management/usage-timeline-routes.ts new file mode 100644 index 00000000000..d4f04c37186 --- /dev/null +++ b/src/server/management/usage-timeline-routes.ts @@ -0,0 +1,44 @@ +import { readUsageSnapshotForManagement } from "../../usage/log"; +import { createTimelineAccumulator, parseTimelineQuery } from "../../usage/timeline"; +import { jsonResponse } from "../auth-cors"; +import type { ManagementContext } from "./context"; + +const TIMELINE_CACHE_TTL_MS = 15_000; +const cache = new Map["finish"]>> }>(); + +export async function handleUsageTimelineRoutes(ctx: ManagementContext): Promise { + const { req, url } = ctx; + if (url.pathname !== "/api/usage/timeline" || req.method !== "GET") return undefined; + const query = parseTimelineQuery(url.searchParams, Date.now()); + if ("error" in query) return jsonResponse(query, 400, req, ctx.config); + const bucketMs = query.bucketMinutes * 60_000; + const roundedNow = Math.floor(query.now / bucketMs) * bucketMs; + const normalized = { ...query, now: roundedNow }; + const key = JSON.stringify(normalized); + const current = Date.now(); + const cached = cache.get(key); + if (cached && cached.expiresAt > current) return jsonResponse(await cached.promise, 200, req, ctx.config); + let promise: Promise["finish"]>>; + promise = (async () => { + const accumulator = createTimelineAccumulator(normalized); + const snapshot = await readUsageSnapshotForManagement(ctx.config.managementUsageMaxReadBytes); + for (const entry of snapshot.entries) accumulator.add(entry); + return { + ...accumulator.finish(), + truncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, + }; + })().catch(error => { + const entry = cache.get(key); + if (entry?.promise === promise) cache.delete(key); + throw error; + }); + cache.set(key, { expiresAt: current + TIMELINE_CACHE_TTL_MS, promise }); + try { + return jsonResponse(await promise, 200, req, ctx.config); + } finally { + setTimeout(() => { + const entry = cache.get(key); + if (entry?.promise === promise && entry.expiresAt <= Date.now()) cache.delete(key); + }, TIMELINE_CACHE_TTL_MS + 1); + } +} diff --git a/src/usage/summary.ts b/src/usage/summary.ts index b1468fd1258..091dac17632 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -410,7 +410,7 @@ function isMeasuredStatus(status: UsageStatus): boolean { return status === "reported" || status === "estimated"; } -interface UsageAttribution { +export interface UsageAttribution { requestId: string; provider: string; model: string; @@ -454,7 +454,7 @@ function usageModelKey(providerKey: string, model: string): string { return `${providerKey}\0${model}`; } -function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { +export function usageAttributions(entry: PersistedUsageEntry): UsageAttribution[] { if (!entry.attempts?.length) { return [{ requestId: entry.requestId, diff --git a/src/usage/timeline.ts b/src/usage/timeline.ts new file mode 100644 index 00000000000..af44587f512 --- /dev/null +++ b/src/usage/timeline.ts @@ -0,0 +1,219 @@ +import { cacheTokensFromUsage, usageAttributions } from "./summary"; +import type { PersistedUsageEntry } from "./log"; +import { usageDisplayTotalTokens } from "./totals"; + +export type TimelineMetric = "total" | "input" | "output" | "cached"; +export type TimelineAggregation = "sum" | "average" | "max"; +export type TimelineGrouping = "model" | "modelAccount"; +export const TIMELINE_HOURS = [6, 24, 72, 168] as const; + +export interface TimelineQuery { + hours: typeof TIMELINE_HOURS[number]; + bucketMinutes: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + models: string[] | null; + now: number; +} + +export interface TimelineSeries { + id: string; + provider: string; + model: string; + accountLogLabel?: string; + total: number; + points: number[]; +} + +export interface UsageTimeline { + start: number; + end: number; + bucketSeconds: number; + buckets: number; + metric: TimelineMetric; + aggregation: TimelineAggregation; + grouping: TimelineGrouping; + series: TimelineSeries[]; + availableModels: string[]; + missingMeasurements: number; + truncated: boolean; +} + +const METRICS: readonly TimelineMetric[] = ["total", "input", "output", "cached"]; +const AGGREGATIONS: readonly TimelineAggregation[] = ["sum", "average", "max"]; +const GROUPINGS: readonly TimelineGrouping[] = ["model", "modelAccount"]; + +function enumValue(value: string | null, values: readonly T[], fallback: T): T | { error: string } { + if (value === null || value === "") return fallback; + return values.includes(value as T) ? value as T : { error: `invalid value for parameter: ${value}` }; +} + +function parseModels(raw: string | null): string[] | null | { error: string } { + if (raw === null || raw.trim() === "") return null; + const models = raw.split(",").map(model => model.trim()); + if (models.length > 100) return { error: "models must contain at most 100 identifiers" }; + if (models.some(model => !/^[^/\s]+\/[^/\s]+$/.test(model))) { + return { error: "models must contain provider/model identifiers" }; + } + return [...new Set(models)]; +} + +export function parseTimelineQuery(params: URLSearchParams, now: number): TimelineQuery | { error: string } { + const rawHours = params.get("hours") ?? "24"; + const hoursNumber = Number(rawHours); + if (!TIMELINE_HOURS.includes(hoursNumber as typeof TIMELINE_HOURS[number])) { + return { error: "hours must be one of 6, 24, 72, 168" }; + } + const bucketMinutes = Number(params.get("bucketMinutes") ?? "60"); + if (!Number.isInteger(bucketMinutes) || bucketMinutes < 1 || bucketMinutes > 1440) { + return { error: "bucketMinutes must be an integer from 1 through 1440" }; + } + const buckets = Math.ceil(hoursNumber * 60 / bucketMinutes); + if (buckets > 2000) return { error: "timeline bucket count must not exceed 2000" }; + const metric = enumValue(params.get("metric"), METRICS, "total"); + if (typeof metric !== "string") return metric; + const aggregation = enumValue(params.get("aggregation"), AGGREGATIONS, "sum"); + if (typeof aggregation !== "string") return aggregation; + const grouping = enumValue(params.get("grouping"), GROUPINGS, "model"); + if (typeof grouping !== "string") return grouping; + const models = parseModels(params.get("models")); + if (typeof models === "object" && models !== null && "error" in models) return models; + if (!Number.isFinite(now)) return { error: "now must be finite" }; + return { + hours: hoursNumber as TimelineQuery["hours"], + bucketMinutes, + metric, + aggregation, + grouping, + models: models as string[] | null, + now, + }; +} + +interface SeriesState { + provider: string; + model: string; + accountLogLabel?: string; + points: number[]; + requests: Map>; +} + +function metricValue(metric: TimelineMetric, attribution: ReturnType[number]): number | undefined { + if (metric === "total") return usageDisplayTotalTokens(attribution.usage, attribution.totalTokens); + if (metric === "input") return attribution.usage?.inputTokens; + if (metric === "output") return attribution.usage?.outputTokens; + return cacheTokensFromUsage(attribution.usage).read; +} + +export function createTimelineAccumulator(query: TimelineQuery): { add(entry: PersistedUsageEntry): void; finish(): UsageTimeline } { + const bucketSeconds = query.bucketMinutes * 60; + const start = Math.floor((query.now - query.hours * 3_600_000) / 1000 / bucketSeconds) * bucketSeconds; + const buckets = Math.ceil(query.hours * 60 / query.bucketMinutes); + const end = start + buckets * bucketSeconds; + const startMs = start * 1000; + const endMs = end * 1000; + const series = new Map(); + const availableModels = new Set(); + let missingMeasurements = 0; + + function add(entry: PersistedUsageEntry): void { + if (entry.timestamp < startMs || entry.timestamp >= endMs) return; + const bucket = Math.floor((entry.timestamp - startMs) / (bucketSeconds * 1000)); + if (bucket < 0 || bucket >= buckets) return; + for (const attribution of usageAttributions(entry)) { + const modelId = `${attribution.provider}/${attribution.model}`; + availableModels.add(modelId); + if (query.models && !query.models.includes(modelId)) continue; + const id = query.grouping === "model" + ? modelId + : `${modelId} · ${attribution.accountLogLabel ?? "unknown"}`; + let state = series.get(id); + if (!state) { + state = { + provider: attribution.provider, + model: attribution.model, + ...(query.grouping === "modelAccount" ? { accountLogLabel: attribution.accountLogLabel ?? "unknown" } : {}), + points: Array(buckets).fill(0), + requests: new Map(), + }; + series.set(id, state); + } + const value = metricValue(query.metric, attribution); + if (value === undefined) { + missingMeasurements += 1; + continue; + } + if (query.aggregation === "sum") { + state.points[bucket] = (state.points[bucket] ?? 0) + value; + } else { + let requests = state.requests.get(bucket); + if (!requests) { + requests = new Map(); + state.requests.set(bucket, requests); + } + requests.set(attribution.requestId, (requests.get(attribution.requestId) ?? 0) + value); + } + } + } + + function finish(): UsageTimeline { + const rows = [...series].map(([id, state]): { row: TimelineSeries; state: SeriesState } => { + if (query.aggregation !== "sum") { + for (const [bucket, requests] of state.requests) { + const values = [...requests.values()]; + state.points[bucket] = query.aggregation === "max" + ? Math.max(...values) + : values.reduce((sum, value) => sum + value, 0) / values.length; + } + } + const total = state.points.reduce((sum, value) => sum + value, 0); + return { + row: { + id, + provider: state.provider, + model: state.model, + ...(state.accountLogLabel !== undefined ? { accountLogLabel: state.accountLogLabel } : {}), + total, + points: state.points, + }, + state, + }; + }).sort((left, right) => right.row.total - left.row.total || left.row.id.localeCompare(right.row.id)); + const kept = (rows.length > 24 ? rows.slice(0, 23) : rows).map(({ row }) => row); + if (rows.length > 24) { + const otherPoints = Array(buckets).fill(0); + const folded = rows.slice(23); + if (query.aggregation === "sum") { + for (const { row } of folded) { + for (let index = 0; index < buckets; index += 1) otherPoints[index] = (otherPoints[index] ?? 0) + (row.points[index] ?? 0); + } + } else { + for (let index = 0; index < buckets; index += 1) { + const values = folded.flatMap(({ state }) => [...(state.requests.get(index)?.values() ?? [])]); + if (values.length > 0) { + otherPoints[index] = query.aggregation === "max" + ? Math.max(...values) + : values.reduce((sum, value) => sum + value, 0) / values.length; + } + } + } + kept.push({ id: "other", provider: "", model: "other", total: otherPoints.reduce((sum, value) => sum + value, 0), points: otherPoints }); + } + return { + start, + end, + bucketSeconds, + buckets, + metric: query.metric, + aggregation: query.aggregation, + grouping: query.grouping, + series: kept, + availableModels: [...availableModels].sort(), + missingMeasurements, + truncated: false, + }; + } + + return { add, finish }; +} diff --git a/structure/INDEX.md b/structure/INDEX.md index cd101960def..40c82166e69 100644 --- a/structure/INDEX.md +++ b/structure/INDEX.md @@ -90,6 +90,7 @@ A source area can be described by more than one doc, because these docs are orga | Source path | Described by | | --- | --- | | `.github/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | +| `app/` | [`overview.md`](overview.md) | | `bin/` | [`runtime.md`](runtime.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `docs-site/` | [`ops/docs-and-release.md`](ops/docs-and-release.md) | | `gui/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`design-methodology.md`](design-methodology.md) | @@ -103,6 +104,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/clients/` | [`clients/integrations.md`](clients/integrations.md) | | `src/codex/` | [`runtime.md`](runtime.md)
[`config.md`](config.md)
[`codex-home.md`](codex-home.md)
[`catalog.md`](catalog.md)
[`subagents.md`](subagents.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md)
[`gui-and-management-api.md`](gui-and-management-api.md)
[`ops/docs-and-release.md`](ops/docs-and-release.md) | | `src/combos/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | +| `src/companion/` | [`overview.md`](overview.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | | `src/compatibility/` | [`runtime.md`](runtime.md)
[`adapters/compatibility-contracts.md`](adapters/compatibility-contracts.md) | | `src/config.ts` | [`overview.md`](overview.md)
[`runtime.md`](runtime.md)
[`config.md`](config.md)
[`providers/openai-tiers.md`](providers/openai-tiers.md) | | `src/config/` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | @@ -132,6 +134,7 @@ A source area can be described by more than one doc, because these docs are orga | `src/types.ts` | [`runtime.md`](runtime.md)
[`config.md`](config.md) | | `src/update/` | [`runtime.md`](runtime.md) | | `src/usage/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | +| `src/usage/timeline.ts` | [`gui-and-management-api.md`](gui-and-management-api.md) | | `src/vision/` | [`runtime.md`](runtime.md)
[`gui-and-management-api.md`](gui-and-management-api.md) | | `src/web-search/` | [`runtime.md`](runtime.md)
[`providers-and-adapters.md`](providers-and-adapters.md) | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index 218df3838d5..47bd09d1cb2 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -1,5 +1,9 @@ # GUI And Management API +The companion settings contract in `src/companion/` persists menu-bar and widget display +preferences, while `src/server/management/companion-routes.ts` exposes those settings and the +usage timeline assembled by `src/usage/timeline.ts` to local clients. + Native result continuations and function-result injection follow [the mode-specific result and control contract](transports/streaming-health.md#experimental-native-function-result-injection); this surface does not infer upstream support or alter its defaults. Explicit Codex CLI installation observation is a local CLI surface, not a management API or GUI update permission. See the [read-only observation contract](runtime.md#explicit-codex-cli-installation-observation). @@ -142,7 +146,7 @@ this document owns is which module holds which area and what invariant that area | Subagents | Read/write the featured `subagentModels` list capped at five ids. `GET/PUT /api/injection-model` manages the shared delegation model/effort selection, the independent OpenCodex guidance switch, and the default-off `syncCodexSubagentDefaults` opt-in for native Codex subagent defaults. When OpenCodex owns the active Codex routing, native `[agents]` defaults apply to newly created Codex tasks after sync/restart; external user-managed provider configs remain untouched. The defaults do not cause delegation and preserve existing user-owned defaults rather than overwriting them. PUT is partial-update: absent keys are unchanged, `null` clears, and non-object bodies are rejected with 400 before field validation. `syncCodexSubagentDefaults: true` requires a nonblank `model` and a supported Codex reasoning effort when effort is set; clearing `model` (null/empty) always clears effort and disables native-default sync even when the stored effort was invalid. | | V2 / Multi-agent mode | `GET/PUT /api/v2` — reports/sets the codex `multi_agent_v2` feature flag, the 3-state `multiAgentMode` override (`v1`/`default`/`v2`), the `keepNativeChatGptOnV1` hybrid pin, and the logical maximum thread count. Selecting `v2` normally enables the native flag; with the hybrid pin it disables that global override so native rows can resolve to v1 while routed rows resolve to v2. Selecting `v1` disables the flag; `default` leaves it unchanged. PUT rejects an explicit enabled flag that conflicts with the selected mode or hybrid pin. Every transition preserves the logical thread limit, is rollback-safe, and resyncs the catalog. GET and successful PUT also return stored `multiAgentModeHintText` plus response-only `multiAgentModeHintRecommendation: { text, revision }`; the recommendation is not a writable or persisted config field. Both also return response-only `multiAgentSurfaceAdvisory: { required, mode, recommended, version, docsUrl }`, true while the resolved mode is not v1 and the stored acknowledgement version is behind; PUT accepts `multiAgentSurfaceAdvisoryAcknowledged`, where only `true` stores the current version and `false` is an explicit no-op, and it composes with a `multiAgentMode` write in the same body so the dialog's recommended answer is one request. | | Logs & Debug | One sidebar entry (`/#logs`) with two tabs. Logs tab: request/runtime logs for local diagnosis. `LogsFilterBar` owns controls over the shared `LogFilterState`; `filterLogs` composes filters over the loaded ring. The logs envelope adds `generatedAt` (proxy epoch milliseconds); the page advances that sample with monotonic elapsed time and retains a browser-clock fallback for older proxies. Reset returns focus to the stable All surface radio. Provider/model options include attempts, model choices match normalized complete identities, and relative-time filtering refreshes every 30 seconds while the Logs tab is active, independently of network auto-refresh. Debug tab (`/#logs/debug`; legacy `/#debug` deep links redirect there): provider + usage toggles, refresh/follow log viewer. `GET/PUT /api/debug`; `GET /api/debug/logs` and `GET /api/debug/usage-logs` (monotonic `after` cursor, legacy `since` accepted). CLI: `ocx debug provider|usage …` (both streams via running proxy API). | -| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. Never exposes prompts. | +| Usage | `GET /api/usage` read-only aggregates of readable rows from `~/.opencodex/usage.jsonl`; the ledger is streamed in fixed 1 MiB chunks, so the former read-byte and parsed-row caps cannot omit its prefix. Oversized skipped rows produce positive `usageIncomplete` metadata. The response includes measured / reported / unreported / unsupported / estimated counts, a daily zero-filled grid, and model and provider breakdowns. `GET /api/usage/timeline` uses the same ledger and canonical attribution helpers for bounded bucketed model series. Never exposes prompts. | | Request metrics | `GET /api/metrics` exposes process-local Prometheus text format v0.0.4 only when `metricsExport.enabled` was true at startup. The ordinary management gate applies; data-plane credentials do not grant access, and disabled mode is 404. `src/server/request-metrics.ts` owns fixed counters/histograms and receives a narrow final-request fact from `src/server/request-log.ts`; `src/server/index/serve-options.ts` creates one owner and injects the recorder and read-only snapshot into the request and management paths. | | System | `POST /api/system/restart` restarts the proxy in place. Local CLI/tray callers first attest the exact runtime PID and port, then send a process-scoped HMAC capability bound to that method, path, PID, and port; the capability authorizes no other management route and is invalid after replacement. The caller observes one absolute deadline and accepts success only after a different runtime PID is healthy on the same port. `GET /api/system/health` is the authenticated scalar-only identity used by shared-plane Dashboard status and restart reconnect polling; its `spendLedger` block reports only ownership held/unheld, initialized/configured/degraded booleans and bounded persistence/corruption counters. Reading it never constructs, replays or prunes the ledger. Paths, scopes, accounts and request ids are absent, and the block never moves to unauthenticated `/healthz`. `GET /api/system/memory` — service-process runtime/memory identity (pid, Bun version/revision, optional `bunRuntimeSource` provenance, platform, RSS/heap/external/ArrayBuffers scalars, observed memory = max(RSS, external, ArrayBuffers), `bun:jsc` heap context, streamMode + eager-relay gate decision, watchdog snapshot sliced to the last 60 samples) plus privacy-safe `appOwnedBytes` retained-store totals/counters under static store ids. Its response-state block also reports spill-write `initial`/`healthy`/`degraded` status, a consecutive-failure streak, fixed error class, and failure/success timestamps. A successful publication clears the streak in the same process; raw error text and paths never enter this surface. Scalar-only payload; dashboard/admin callers use the standard management gate, while `ocx doctor` may use only the exact process-scoped local-read capability. It must never move to unauthenticated `/healthz`. | | Stop | `POST /api/stop` — restore native Codex, stop any installed service, and exit the proxy. | diff --git a/structure/manifest.json b/structure/manifest.json index cbd3c7da0fc..20e69a13f39 100644 --- a/structure/manifest.json +++ b/structure/manifest.json @@ -49,7 +49,9 @@ "title": "Overview", "scope": "Product boundary, local state ownership, and the non-negotiable invariants index.", "documents": [ + "app/", "gui/", + "src/companion/", "scripts/", "src/config.ts", "src/lib/" @@ -326,10 +328,12 @@ "scope": "Dashboard serving, authentication boundaries, /api/* ownership, and usage accounting.", "documents": [ "gui/", + "src/companion/", "src/codex/", "src/lib/", "src/server/", "src/usage/", + "src/usage/timeline.ts", "src/vision/" ] }, diff --git a/structure/overview.md b/structure/overview.md index 7178b292b70..48e24dc1dc5 100644 --- a/structure/overview.md +++ b/structure/overview.md @@ -33,6 +33,12 @@ native Anthropic passthrough branch that forwards without translation. The Live/ different in kind — it resolves an OpenAI/ChatGPT relay and forwards to it directly, without the adapter bridge. +`app/` is a second, optional surface: a native macOS menu bar companion. It is a client +of the management API, not part of the proxy — it adds no endpoint and changes no +routing. Treat it the way you treat `gui/`: it may consume what `src/` already exposes, +and a change that requires a new endpoint is a change to the proxy first. +Its persisted display contract is owned by `src/companion/`. + The default install keeps native OpenAI/ChatGPT passthrough working through one option-aware `openai` provider. Pool is the default and selects across main plus added accounts; Direct uses only the current caller/main login. `openai-apikey` explicitly selects API-key transport, and the two diff --git a/tests/ci-workflows/ci-structure-gate.test.ts b/tests/ci-workflows/ci-structure-gate.test.ts index b6f21009092..0cda8de5330 100644 --- a/tests/ci-workflows/ci-structure-gate.test.ts +++ b/tests/ci-workflows/ci-structure-gate.test.ts @@ -67,8 +67,17 @@ test("the aggregate gate expects the job instead of ignoring it", () => { // job missing from `expected_for` reads as `undeclared`, not as skipped. const gate = workflow.jobs?.ci; expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("structure-gate"); + expect(Array.isArray(gate?.needs) ? gate?.needs : []).toContain("macos-app"); const script = (gate?.steps ?? []).map(step => step.run ?? "").join("\n"); expect(script).toContain("structure-gate) echo \"$structure\" ;;"); - expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate\""); + expect(script).toContain("GATED_JOBS=\"$GATED_JOBS structure-gate macos-app\""); + expect(script).toContain("|macos-app)"); expect(script).toContain("CHANGES_STRUCTURE"); }); + +test("app changes select the macOS app job", () => { + expect(filters.ci).toContain("app/**"); + const macosApp = workflow.jobs?.["macos-app"]; + expect(macosApp?.if).toContain("needs.changes.outputs.ci == 'true'"); + expect(Array.isArray(macosApp?.needs) ? macosApp?.needs : []).toContain("changes"); +}); diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 22b02edb141..658bd378346 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -495,6 +495,7 @@ describe("GitHub Actions hardening", () => { "Dockerfile", "LICENSE", "README.md", + "app/**", "assets/**", "bin/**", "bun.lock", diff --git a/tests/cli/cli-companion.test.ts b/tests/cli/cli-companion.test.ts new file mode 100644 index 00000000000..2f3d361bacb --- /dev/null +++ b/tests/cli/cli-companion.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { handleCompanionCommand } from "../../src/cli/companion"; + +describe("ocx companion", () => { + test("set parses JSON values and reset sends the matching management payload", async () => { + const requests: Array<{ path: string; method: string; body: unknown }> = []; + const deps = { + baseUrl: "http://proxy.test", + fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(String(init.body)) : null; + requests.push({ path: new URL(String(input)).pathname, method: init?.method ?? "GET", body }); + return Response.json({ settings: {}, defaults: {}, updatedAt: null }); + }, + }; + expect(await handleCompanionCommand(["set", "showChart=false", "chartHours=6", "menuBarTemplate=null", "--json"], deps)).toBe(0); + expect(requests[0]).toEqual({ + path: "/api/companion/settings", + method: "PUT", + body: { settings: { showChart: false, chartHours: 6, menuBarTemplate: null } }, + }); + expect(await handleCompanionCommand(["reset"], deps)).toBe(0); + expect(requests[1]).toEqual({ + path: "/api/companion/settings", + method: "PUT", + body: { reset: true }, + }); + }); +}); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index c8b7257a8d0..27e9f657ff8 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -404,6 +404,7 @@ describe("headless GUI parity CLI", () => { ["/api/logs", "ocx observe"], ["/api/lab", "ocx lab"], ["/api/config", "ocx config"], + ["/api/companion", "ocx companion"], // The client machine plane. These are served by the connected client's own loopback // listener rather than the hub, and each one mirrors a connect-family command: // status/clients -> `ocx connect status`, sync -> `ocx sync`, shim -> the client diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0dc6bec9f2c..78fd2d90802 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,5 @@ { + "macos-build-script.test.ts": "gui", "server-combo-held-response.test.ts": "server", "key-attribution.test.ts": "usage", "provider-send-path-import.test.ts": "server", @@ -21,6 +22,7 @@ "hub-usage.test.ts": "server", "client-hub-usage.test.ts": "clients", "cli-usage-hub.test.ts": "cli", + "cli-companion.test.ts": "cli", "abort-idle-deadline.test.ts": "lib", "abort-race.test.ts": "adapters", "account-import.test.ts": "server", @@ -1413,6 +1415,8 @@ "web-search-sidecar-429.test.ts": "web-search", "codex-shim-destroyed-probe.test.ts": "codex-integration", "client-runtime.test.ts": "clients", + "usage-timeline.test.ts": "usage", + "companion-settings.test.ts": "server", "chat-tool-choice-allowed-tools.test.ts": "responses", "anthropic-tool-declaration-constraints.test.ts": "adapters/anthropic", "google-strict-tool-validated-mode.test.ts": "adapters/google", diff --git a/tests/gui/macos-build-script.test.ts b/tests/gui/macos-build-script.test.ts new file mode 100644 index 00000000000..70d4a018e7f --- /dev/null +++ b/tests/gui/macos-build-script.test.ts @@ -0,0 +1,201 @@ +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"); + }); +}); diff --git a/tests/server/companion-settings.test.ts b/tests/server/companion-settings.test.ts new file mode 100644 index 00000000000..de0058dc861 --- /dev/null +++ b/tests/server/companion-settings.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + applyCompanionSettingsPatch, + DEFAULT_COMPANION_SETTINGS, + loadCompanionSettings, + saveCompanionSettings, +} from "../../src/companion/settings"; +import { resetCompanionPresenceForTests } from "../../src/server/management/companion-routes"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; + +const config = { port: 10100, defaultProvider: "openai", providers: {} } as OcxConfig; +async function withHome(run: (home: string) => Promise | T): Promise { + const home = mkdtempSync(join(tmpdir(), "ocx-companion-")); + const old = process.env.OPENCODEX_HOME; + process.env.OPENCODEX_HOME = home; + try { return await run(home); } finally { + if (old === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = old; + rmSync(home, { recursive: true, force: true }); + } +} +async function call(method: string, body?: unknown, userAgent?: string): Promise<{ status: number; body: any }> { + const url = new URL("http://127.0.0.1:10100/api/companion/settings"); + const req = new Request(url, { + method, + headers: { + host: "127.0.0.1:10100", + ...(userAgent ? { "user-agent": userAgent } : {}), + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const response = await handleManagementAPI(req, url, config, {}, "admin-token"); + return { status: response?.status ?? 404, body: response ? await response.json() : null }; +} + +describe("companion settings", () => { + test("defaults, corrupt files, validation, and roundtrip persistence", async () => { + await withHome(home => { + expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); + writeFileSync(join(home, "companion.json"), "{"); + expect(loadCompanionSettings().settings).toEqual(DEFAULT_COMPANION_SETTINGS); + expect(loadCompanionSettings().corrupt).toBe(true); + expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { unknown: true })).toEqual({ error: expect.any(String) }); + expect(applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { menuBarTemplate: "x".repeat(201) })).toEqual({ error: expect.any(String) }); + const updated = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, { showChart: false }); + if ("error" in updated) throw new Error(updated.error); + saveCompanionSettings(updated); + expect(loadCompanionSettings().settings.showChart).toBe(false); + }); + }); + + test("GET, PUT, and reset are routed", async () => { + await withHome(async () => { + expect((await call("GET")).status).toBe(200); + expect((await call("PUT", { settings: { showToday: false } })).body.settings.showToday).toBe(false); + expect((await call("PUT", { reset: true })).body.settings).toEqual(DEFAULT_COMPANION_SETTINGS); + expect((await call("PUT", { settings: { bad: true } })).status).toBe(400); + }); + }); + + test("GET reports corrupt persisted settings without overwriting them", async () => { + await withHome(async home => { + writeFileSync(join(home, "companion.json"), "{"); + const result = await call("GET"); + expect(result.status).toBe(200); + expect(result.body.corrupt).toBe(true); + expect(readFileSync(join(home, "companion.json"), "utf8")).toBe("{"); + }); + }); + + test("GET records menu bar presence only for the companion user agent", async () => { + await withHome(async () => { + resetCompanionPresenceForTests(); + const initial = await call("GET"); + expect(initial.body.companion.lastSeenAt).toBeNull(); + const ordinary = await call("GET", undefined, "Mozilla/5.0"); + expect(ordinary.body.companion.lastSeenAt).toBeNull(); + const companion = await call("GET", undefined, "OpenCodexMenuBar/2.60.0"); + expect(companion.body.companion.lastSeenAt).toBeNumber(); + }); + }); +}); diff --git a/tests/usage/usage-timeline.test.ts b/tests/usage/usage-timeline.test.ts new file mode 100644 index 00000000000..ee395007e1e --- /dev/null +++ b/tests/usage/usage-timeline.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import type { PersistedUsageEntry } from "../../src/usage/log"; +import { createTimelineAccumulator, parseTimelineQuery } from "../../src/usage/timeline"; + +const now = 1_700_000_000_000; +function entry(overrides: Partial = {}): PersistedUsageEntry { + return { + requestId: "request", + timestamp: now - 30 * 60_000, + provider: "openai", + model: "gpt-5", + status: 200, + durationMs: 1, + usageStatus: "reported", + ...overrides, + }; +} +function attempt(totalTokens: number, ordinal: number): NonNullable[number] { + return { + ordinal, + provider: "openai", + model: "gpt-5", + adapter: "test", + status: 200, + durationMs: 1, + sendCount: 1, + recoveryKinds: [], + usageStatus: "reported", + totalTokens, + }; +} + +describe("usage timeline", () => { + test("parses defaults and rejects invalid values", () => { + expect(parseTimelineQuery(new URLSearchParams(), now)).toMatchObject({ + hours: 24, bucketMinutes: 60, metric: "total", aggregation: "sum", grouping: "model", models: null, + }); + expect(parseTimelineQuery(new URLSearchParams("hours=7"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("bucketMinutes=0"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("metric=nope"), now)).toEqual({ error: expect.any(String) }); + expect(parseTimelineQuery(new URLSearchParams("models=openai%2Fgpt-5%2Cbad"), now)).toEqual({ error: expect.any(String) }); + }); + + test("buckets timestamps and attributes attempts without parent double counting", () => { + const query = parseTimelineQuery(new URLSearchParams("hours=6&bucketMinutes=60"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ + requestId: "retry", + totalTokens: 999, + attempts: [ + attempt(10, 0), + attempt(20, 1), + ], + })); + const result = acc.finish(); + expect(result.series[0]?.total).toBe(30); + expect(result.buckets).toBe(6); + }); + + test("supports request average and max", () => { + const make = (aggregation: "sum" | "average" | "max") => { + const query = parseTimelineQuery(new URLSearchParams(`hours=6&aggregation=${aggregation}`), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ requestId: "a", totalTokens: 10 })); + acc.add(entry({ requestId: "b", totalTokens: 30 })); + return acc.finish().series[0]?.total; + }; + expect(make("sum")).toBe(40); + expect(make("average")).toBe(20); + expect(make("max")).toBe(30); + }); + + test("filters plotted models but keeps available models and supports accounts", () => { + const query = parseTimelineQuery(new URLSearchParams("models=openai%2Fone&grouping=modelAccount"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ model: "one", accountLogLabel: "main", totalTokens: 4 })); + acc.add(entry({ model: "two", totalTokens: 8 })); + const result = acc.finish(); + expect(result.availableModels).toEqual(["openai/one", "openai/two"]); + expect(result.series[0]?.id).toBe("openai/one · main"); + }); + + test("counts missing measurements and folds excess series", () => { + const query = parseTimelineQuery(new URLSearchParams("hours=6&metric=input"), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + acc.add(entry({ usage: undefined, totalTokens: 1 })); + for (let index = 0; index < 25; index += 1) { + acc.add(entry({ model: `model-${index}`, usage: { inputTokens: index } })); + } + const result = acc.finish(); + expect(result.missingMeasurements).toBe(1); + expect(result.series).toHaveLength(24); + expect(result.series.at(-1)?.id).toBe("other"); + }); + + test("folds other rows with request-level max and average", () => { + const make = (aggregation: "average" | "max") => { + const query = parseTimelineQuery(new URLSearchParams(`hours=6&aggregation=${aggregation}`), now); + if ("error" in query) throw new Error(query.error); + const acc = createTimelineAccumulator(query); + for (let index = 0; index < 25; index += 1) { + acc.add(entry({ + requestId: `request-${index}`, + model: `model-${index}`, + totalTokens: index < 23 ? 100 + index : index - 22, + })); + } + return acc.finish().series.at(-1); + }; + expect(make("max")?.points.at(-1)).toBe(2); + expect(make("average")?.points.at(-1)).toBe(1.5); + }); +});