From 5153a6d1f319e0fdfe599c66792fa08d14ec49e2 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Wed, 7 Oct 2026 11:25:39 -0400 Subject: [PATCH 1/8] feat: add opt-in macOS desktop account connections --- CHANGELOG.md | 23 ++ CONTRIBUTING.md | 8 + README.md | 5 + Scripts/electron/desktop-keychain.swift | 21 ++ docs/DESKTOP-CONNECTIONS.md | 86 +++++ docs/KB.md | 6 +- docs/PRIVACY.md | 71 +++- docs/adr/0011-desktop-account-connections.md | 45 +++ docs/adr/README.md | 1 + electron-builder.yml | 3 + package.json | 3 +- src/domain/alerts.ts | 3 + src/domain/models.ts | 3 +- src/main/cli.ts | 7 +- src/main/composition.ts | 12 +- src/main/connections.ts | 209 +++++++++++ src/main/coordinator.ts | 14 +- src/main/electron.ts | 68 +++- src/main/ipc.ts | 1 + src/main/preferences.ts | 4 + src/main/providers/claude-desktop.ts | 81 ++++ src/main/providers/transport.ts | 21 +- src/renderer/app.tsx | 14 +- src/shared/ipc.ts | 4 + tests/connections.test.ts | 369 +++++++++++++++++++ tests/coordinator.test.ts | 153 +++++++- tests/ipc.test.ts | 6 + tests/providers.test.ts | 4 +- tests/renderer.test.ts | 52 ++- 29 files changed, 1256 insertions(+), 41 deletions(-) create mode 100644 Scripts/electron/desktop-keychain.swift create mode 100644 docs/DESKTOP-CONNECTIONS.md create mode 100644 docs/adr/0011-desktop-account-connections.md create mode 100644 src/main/connections.ts create mode 100644 src/main/providers/claude-desktop.ts create mode 100644 tests/connections.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f2b0f6..1e201f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ follows [Semantic Versioning](https://semver.org/). ### Added +- Add candidate account setup for Codex browser sign-in and opt-in Claude + Desktop allowance, with automatic refresh, account isolation and disconnect. + Grok consumer connection remains unavailable. Native and provider acceptance + are pending; the published Swift app is unchanged. + - Add a macOS TypeScript/Electron/React migration candidate and headless schema-1 JSON CLI, with synthetic provider, account, history, pacing and IPC tests. Use pnpm, Vite+, Tailwind, Vitest and electron-builder. Retain Swift as @@ -16,6 +21,10 @@ follows [Semantic Versioning](https://semver.org/). ### Changed +- Label local activity as activity on this Mac, keep folder-based accounts + under advanced settings, and omit local CLI activity from separately + connected account cards. Record the new opt-in authentication boundary. + - Record synthetic Claude and Codex source-scanner timings alongside the Swift baseline. Counts and cache transitions pass; packaged-app performance, idle RSS and native GUI acceptance remain open. @@ -32,6 +41,20 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Reject malformed connection provider values before consent and target the + bundled Desktop Keychain helper explicitly at arm64/macOS 13. +- Disable cancelled or disconnected account collection before cleanup, recover + unfinished sign-in profiles, and verify Claude identity after HTTP failures. + Preserve connection readers across cache clearing and headless reports. +- Stop Claude credential rereads after disconnect, discard unverified account + allowance on collection timeout, and retry retained Codex cleanup from the + sign-in button before opening a new login. +- Show general weekly Claude allowance, hide unsupported reset actions, clear + replaced-account alert suppression, and exclude remote usage from local totals. + +- Check repaired cache token fields directly in the synthetic regression test; + a raw substring check could also match an unrelated timestamp or opaque ID. + - Rescan corrupt Claude cache entries with non-finite costs or unsupported dates. Ignore unsupported optional provider timestamps before JSON output. - Use canonical temporary paths in candidate fixtures and demo commands. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 44b172e..e59f25b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,6 +124,14 @@ are optional; synthetic tests and demo mode require no login. Shared checks can run on Linux. Packaging, system symbol export and native acceptance require macOS with the existing Swift and signing tools. +`pnpm package:mac` also compiles the fixed Claude Desktop Keychain helper and +bundles it as an app resource. `pnpm build:desktop-helper` targets arm64 and +macOS 13 explicitly, matching the candidate bundle. Verify the packaged helper's +architecture and deployment target with `file` and `vtool -show-build` on macOS. +The same build command prepares an unpackaged native candidate. Do not invoke the helper during synthetic +QA; it reads an actual Keychain item. Desktop connections are disabled in demo +mode. Live connection acceptance remains separate from source checks. + ```sh pnpm install --frozen-lockfile pnpm typecheck diff --git a/README.md b/README.md index 5cf8614..6d7fcdf 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ and React. The candidate uses pnpm, Vite+, Tailwind, Vitest and electron-builder The downloadable 0.2.41 app above remains the Swift release. The Electron candidate has not passed native macOS acceptance and is not a published release. +The candidate also has [desktop account connection controls](docs/DESKTOP-CONNECTIONS.md) +for Codex browser sign-in and opt-in Claude Desktop allowance. Setup uses buttons +and approval dialogs. Grok consumer connection remains unavailable. Native +sign-in, provider responses and Keychain acceptance still require verification. + Provider readers and credentials stay in the main process. The sandboxed renderer receives validated snapshots through a small IPC bridge. Existing history/archive JSON, preference keys, managed account IDs and provider marks diff --git a/Scripts/electron/desktop-keychain.swift b/Scripts/electron/desktop-keychain.swift new file mode 100644 index 0000000..fc2d1c3 --- /dev/null +++ b/Scripts/electron/desktop-keychain.swift @@ -0,0 +1,21 @@ +import Foundation +import LocalAuthentication +import Security + +// The helper has one fixed read. Background polling cannot open an auth dialog. +let context = LAContext() +context.interactionNotAllowed = !CommandLine.arguments.contains("--allow-ui") +SecKeychainSetUserInteractionAllowed(!context.interactionNotAllowed) +let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "Claude Safe Storage", + kSecAttrAccount as String: "Claude Key", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context +] +var result: CFTypeRef? +guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, + let password = result as? Data, !password.isEmpty else { exit(1) } +// Only the owning main process receives stdout. Never run this helper for QA. +FileHandle.standardOutput.write(password) diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md new file mode 100644 index 0000000..8ad0fe4 --- /dev/null +++ b/docs/DESKTOP-CONNECTIONS.md @@ -0,0 +1,86 @@ +# Desktop account connections + +The macOS Electron candidate adds account connection controls to first-run +setup and Settings. This is candidate source behavior, not support in the +published Swift app. Native sign-in, Keychain permission, packaging and live +provider acceptance remain unverified. + +## Setup + +- Codex: choose **Sign in to Codex** and complete browser sign-in. The candidate + finds the helper in an existing Codex app in Applications, with existing + command installations as a compatibility path. Users do not open a terminal + or choose a configuration folder. A missing helper produces an unavailable + message. The Codex app's authentication is not changed. +- Claude: sign in to Claude Desktop on the Mac, choose **Connect Claude + Desktop**, and approve MeterUsage's access explanation and any macOS + Keychain prompt. The candidate requests account allowance using Desktop's + current access token. This interface can change and is not a documented + third-party consumer API contract. +- Grok: automatic consumer connection is unavailable. Existing command-based + collection remains available for existing users. It does not establish + browser/mobile consumer support. The setup does not ask for manual numbers. + +The normal refresh interval applies after connection. An expired login, +unsupported cache format, missing permission or provider failure keeps usage +unavailable. Open the relevant desktop app, disconnect the MeterUsage +connection, then connect again. Disconnect removes MeterUsage's own connection; +it does not sign out or erase data in another app. + +## Data coverage + +Account allowance comes from the provider account service. It can reflect use +outside the Mac where the provider shares that allowance. Provider account, +plan and product rules determine coverage. A quota percentage is not a token +ledger, message total or subscription invoice. + +Local activity charts are labelled as activity on this Mac. OpenRouter's +remote usage is labelled as account activity and excluded from local totals. Connected +Codex/Claude account cards omit the separate local CLI activity source because +MeterUsage cannot establish that it belongs to the connected account. No local +and account total is added together. Detailed account token history, ordinary +Claude chat tokens and Grok consumer history are outside this implementation. + +## Authentication and isolation + +Codex uses a generated MeterUsage-owned helper profile. Before login or polling, +the candidate checks the effective helper configuration for `keyring` storage. +It refuses another mode, including plaintext-file fallback. The browser URL +must use HTTPS on an approved OpenAI host. Disconnect invokes logout only for +that generated profile. Cancellation attempts the same cleanup; a cleanup +failure is reported. + +An unfinished sign-in profile enables cleanup only. Cancellation and disconnect +disable collection before logout, even if logout fails. The candidate retains +the owned profile ID for cleanup on the next GUI launch or explicit sign-in +retry. Retry completes cleanup before starting another login. Normal quit waits +up to five seconds for authentication cleanup. Headless reports only read existing +connections; they never start sign-in, show a permission prompt or perform +cleanup logout. Clear local scan cache keeps the established connection reader. +Connected account cards show reset-credit details without redemption controls; +redemption stays limited to existing sources that support it. + +Claude access requires explicit consent. A bundled native helper performs one +fixed Keychain read. Background calls forbid interactive authentication. The +main process decrypts the Desktop cache, reads the active organization cookie +from a read-only database, and selects one valid account-scoped access token. +Ambiguous, legacy, expired or unsupported caches fail closed. Refresh tokens +are skipped, and MeterUsage never writes to Claude Desktop's stores. It uses +its own User-Agent and requests no reset grants or model inference. + +Only an opaque account/organization fingerprint is retained for Claude. It is +not published to the renderer. The candidate checks it before and after the +allowance request so a Desktop account switch cannot update the old account +card. Switching the connection clears prior quota. Connected-account quota +archives are discarded at launch, and authentication/data errors remove them. +This avoids restoring a reading without proving its account identity. +If an allowance request fails, the current account is checked again before a +dated last-known reading can remain. Missing or revoked Desktop access clears +that reading. A collection timeout clears account-bound quota because the +identity check may not have completed. Disconnect prevents the post-request +credential reread. Changing a connection also clears that slot's alert suppression. + +Demo mode blocks live account connections, Keychain access and helper login. +Synthetic tests cover protocol, consent, token selection, account switching, +disconnect, numeric validation, stale source results and IPC boundaries. +See [privacy](PRIVACY.md) and [ADR 0011](adr/0011-desktop-account-connections.md). diff --git a/docs/KB.md b/docs/KB.md index 5fb1db5..d60e82a 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -1,6 +1,6 @@ # Project knowledge -Last source review: 2026-10-06 +Last source review: 2026-10-07 ## Repository state @@ -10,6 +10,8 @@ Last source review: 2026-10-06 ## Product and source facts +- The Electron candidate implements opt-in desktop account setup in `src/main/connections.ts`, `src/main/providers/claude-desktop.ts` and the renderer. Codex discovers an existing app helper and uses a separate generated keyring-only profile with browser login. Claude uses a fixed native Keychain helper, read-only Desktop cache/cookie selection and the OAuth allowance endpoint after consent. Grok consumer connection remains unavailable. These are source and synthetic-fixture facts, not native or live-provider proof. [Desktop connections](DESKTOP-CONNECTIONS.md) and ADR 0011 define setup, data coverage, privacy and acceptance gaps. Connected-account cards omit local CLI activity, clear old quota on account changes and do not restore quota archives across launches. + - meterusage is a macOS menu-bar app that displays AI coding-assistant quota and usage signals. `README.md` describes provider clusters, quota cards, heatmaps, sparklines, alerts, diagnostics, and a scriptable JSON CLI. - The published app is Swift Package Manager based, targets macOS 13 or later, and includes an executable target and a test target. `Package.swift` is the source for this package structure. Its AppKit and SwiftUI imports require macOS for package build and test checks; Swift itself supports other platforms. `AGENTS.md` describes a generic macOS SSH route for Linux development without private host aliases. - `README.md` links directly to the prebuilt v0.2.41 app ZIP as the default installation path. Installing and running it does not require Swift, Xcode, Command Line Tools, or Git; its Swift runtime ships with macOS. `Scripts/make-app.sh` also downloads that pinned release by default, checks its SHA-256 and code signature, and prepares `dist/MeterUsage.app` without compiling or installing developer tools. It copies and verifies the replacement in staging on the destination filesystem before replacing existing output, so copy or verification failures preserve the previous generated app. It rejects unsupported platforms and never falls back to a source build. Source builds require `--build-from-source` and are documented in `CONTRIBUTING.md`. The v0.2.41 asset targets Apple silicon and macOS 13 or later, is ad-hoc signed, and is not notarized. GitHub's source archives contain source and scripts, not an app bundle. ADR 0009 records the script's default. @@ -35,7 +37,7 @@ Last source review: 2026-10-06 - Windows implementation, installers, CI, testing and releases are paused until a test machine is available. The former RN/Tauri Windows-first plan is superseded; shared portable source does not establish Windows support. - Candidate checks use synthetic fixtures. Demo/candidate selection isolates preferences, history, caches and Electron state before composition. Live provider/key discovery, updater installation and login-item changes are disabled in demo. `pnpm package:mac` creates a separate candidate with publication disabled, using existing macOS tools. - Candidate account visibility rechecks directory presence. Sharing freezes the displayed account key. Pacing-off hides forecasts; notch details reuse window-scoped burn metrics and have a bounded scroller. These contracts have synthetic/static-markup proof; native UI acceptance remains open. -- Current source checks have matching passing execution proof for 128 TypeScript fixture tests, TypeScript checking and production bundling. The rebuilt bundled headless demo preserves stable schema-1 fields against the Swift demo oracle. This is source/fixture evidence only, not native parity or a published Electron release. +- The desktop-connection candidate has matching passing source proof for 165 synthetic TypeScript tests, TypeScript checking and production bundling on Linux. Its connection cleanup, account verification, cache/JSON readers, reset capability, alerts and locality labels have regression coverage. Disconnect blocks Claude's post-request credential reread; account-bound collection timeout removes unverified allowance; explicit Codex sign-in retry recovers retained cleanup before a new login. The unchanged migration baseline's headless demo comparison preserves stable schema-1 fields against the Swift demo oracle; that older native receipt does not verify the new connections. Source checks do not establish native parity or a published Electron release. - The first macOS fixture attempt passed 90 cases and failed 29 due to temporary-path aliases and a case-sensitive filename assertion. Canonical roots and exact directory-entry checks now pass all 128 current fixtures on macOS, along with TypeScript checking and production bundling. Candidate symlink rejection is unchanged. - A temporary candidate-local pnpm PATH alias resolved the dependency collector's broken launcher without changing host configuration. At source `193c2af42bd2a8641accbb3f7b5b333ba6cd9ac3`, the Apple silicon ZIP is 116,696,318 bytes with SHA-256 `4ea7a6f297402958a52173696880f8183a05454c2e08966d71ebeaa69f52e634`. Its extracted bundle passes `codesign --verify --deep --strict` with an ad-hoc signature and hardened runtime. It is not notarized, installed or published. - The extracted native Electron executable exits successfully with schema-1 demo JSON and no stderr. Its eight provider/account entries match the Swift oracle's stable fields at numerical tolerance `1e-12`; comparison excludes `generated_at`, `resets_at`, `burn_rate`, `eta_seconds` and `eta_text`. The extracted bundle uses 290,148 KiB on disk. GUI behavior, idle RSS and packaged-app performance remain unverified. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 27d5fc8..ef3c66d 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,12 +1,13 @@ # Privacy and security design MeterUsage reads quota and usage from existing local provider stores, provider -CLIs and aggregate endpoints. It has no sign-in form, telemetry service, -analytics or crash-upload endpoint. It does not ask for credentials or send +CLIs and aggregate endpoints. It has no telemetry service, analytics or +crash-upload endpoint. The Electron candidate adds opt-in account connections; +Codex sign-in occurs in the provider browser page, not a MeterUsage password form. It does not ask for credentials or send prompts, code or tool payloads to a provider. -The published Swift app and TypeScript/Electron candidate share these data -boundaries. The candidate has source/fixture checks, but native behavior and +The published Swift app retains the existing local/CLI boundaries. The +TypeScript/Electron candidate adds the opt-in boundaries described below. The candidate has source/fixture checks, but native behavior and cutover acceptance remain pending. See [ADR 0010](adr/0010-macos-typescript-electron.md). ## Provider inputs and requests @@ -32,7 +33,10 @@ before its two `.claude` companion files. The selected file's modification time supplies capture age when the payload omits `updated_at`; polling does not make an old reading fresh. -MeterUsage does not open Claude `.credentials.json` or macOS Keychain items. +The published Swift app does not open Claude `.credentials.json` or macOS +Keychain items. The Electron candidate still skips `.credentials.json`, but +explicit Claude Desktop connection permits one fixed Keychain read and +read-only cache/cookie access. See the opt-in connection section below. Grok, OpenCode Go and OpenRouter are explicit existing-key readers. Keys stay in main-process memory and request headers; they are not displayed, logged, written to cache or sent to the renderer. Antigravity and Codex CLIs manage @@ -44,6 +48,63 @@ an image. A healthy runtime is not restarted to repair internet or auth errors. SQLite usage reads are read-only. Antigravity copies the database and WAL to an owned temporary directory for a coherent read, then removes that copy. +## Opt-in candidate account connections + +The candidate offers connection buttons in setup and Settings. No terminal, +folder picker or credential copying is part of the primary connection flow. +Existing local account directories remain an advanced option. + +Codex browser sign-in uses the official app-server helper discovered in an +existing Codex app, with existing command installations as a compatibility path. +MeterUsage creates a separate generated profile under its own support directory +and checks effective `cli_auth_credentials_store=keyring` before login or +polling. Any other mode is refused. It never opens the user's Codex `auth.json`. +Login opens only an approved HTTPS OpenAI host and uses the helper's local +callback. Requests are initialization, effective config read, account login, +rate-limit read and own-profile logout. No model session or prompt is started. +The provider helper handles tokens in OS storage; no plaintext fallback is +permitted. Cancelling login attempts own-profile logout and reports cleanup +failure. Disconnect does not sign out the user's Codex desktop session. +Unfinished sign-in profiles are retained only for cleanup and cannot enable +collection. Cancellation and disconnect disable collection before logout. +GUI startup and explicit sign-in retry recover owned-profile cleanup before +starting another login. Normal quit waits up to five seconds. Headless reports +do not sign in or perform cleanup logout. + +Claude requires an explicit explanation and consent before access. The bundled +native helper reads only the `Claude Safe Storage` / `Claude Key` item. Background +reads disable interaction. Its stdout goes only to the main-process bounded +transport; do not run the helper interactively or record its output. The main +process decrypts `oauth:tokenCacheV2` from Claude Desktop's `config.json` and +reads only the active organization cookie from a read-only cookie database. +It selects account-scoped current access-token/expiry fields, skips refresh +tokens, and rejects ambiguous, legacy or expired entries. It never writes to +Claude stores or rotates their tokens. + +The access token authenticates a GET to +`https://api.anthropic.com/api/oauth/usage` with the OAuth beta header and +MeterUsage's own User-Agent. The candidate requests no reset grants, uses no +CLI impersonation and starts no inference. This compatibility endpoint is not +an established third-party consumer API contract and may become unavailable. +Only numeric quota percentages, reset times and known window labels reach the +renderer or quota archive. The account/organization fingerprint is stored only +as an opaque hash in preferences, excluded from settings publication. It is +checked before and after HTTP. Identity changes invalidate the observation. +HTTP failures also require a fresh identity check before dated allowance can +remain. Local credential and permission failures clear that allowance. +Collection timeout also clears account-bound allowance when the identity check +may not have completed. Disconnect removes consent metadata and prevents the +post-request credential reread without changing Claude Desktop. + +Connected Codex/Claude sources do not attach local CLI activity to the connected +account. Quota archives are cleared on connection changes and candidate launch; +authentication or absent-data failures clear the connected archive. Tokens, +passwords, raw caches, account identifiers and responses are not logged or +persisted by MeterUsage. In-memory strings remain subject to garbage collection. +Demo mode blocks live connection code before helper or key discovery. Grok has +no new consumer connection in this candidate. [ADR 0011](adr/0011-desktop-account-connections.md) +amends the candidate boundary; it does not change the published Swift release. + ## Selective parsing and display boundary The Swift readers use narrow decoding structs. The TypeScript candidate uses diff --git a/docs/adr/0011-desktop-account-connections.md b/docs/adr/0011-desktop-account-connections.md new file mode 100644 index 0000000..6c68336 --- /dev/null +++ b/docs/adr/0011-desktop-account-connections.md @@ -0,0 +1,45 @@ +# ADR 0011: Add opt-in desktop account connections + +- Status: Accepted implementation direction; native and provider acceptance pending +- Date: 2026-10-07 + +The macOS Electron candidate serves users who run desktop and cloud assistants +without using a terminal. Account allowance must come from the account service; +local activity alone cannot represent usage on another device. Windows remains +paused under ADR 0010. + +Use the existing main-process source/coordinator boundary and validated IPC. +First-run setup and Settings offer browser sign-in for Codex and explicit +consent for Claude Desktop access. Keep additional configuration-directory +accounts under an advanced disclosure. + +Codex owns a separate generated helper profile with OS keyring storage only. +The candidate checks effective storage before authentication. It discovers an +existing desktop helper rather than downloading or bundling another executable. +Missing or incompatible helpers fail closed. Native helper discovery and +protocol compatibility remain acceptance requirements. + +Claude is an explicit change to the candidate's previous credential boundary. +After consent, a fixed native Keychain helper and read-only Desktop cache/cookie +reader obtain the current account-scoped access token. Background reads cannot +prompt. Do not use refresh tokens, impersonate another client, modify Desktop +stores or issue model requests. Bind allowance to the account and organization; +reject a changed identity, ambiguous cache or unsupported format. This is an +opt-in compatibility integration whose provider interface can change. + +This amends ADR 0005's prohibition on reading account identity only for this +opt-in main-process connection. Account identity is not displayed or retained +in plaintext. Existing additional-account slot rules remain unchanged. + +Grok consumer collection has no verified automatic contract in this change. +Show it as unavailable. Manual entry is not fulfillment of automatic setup. +Existing integrations keep their documented boundaries. + +Do not attribute local CLI tokens to a separately connected account or add +account allowance to local totals. Disconnect clears only MeterUsage-owned +connection state and authentication. Clear account quota on connection changes +and reject old source results. Native GUI, Keychain, live provider data and +packaged acceptance are required before claiming this works for desktop users. + +The published Swift app, release installer and installed-app cutover remain +under ADR 0010 and ADR 0009. This decision does not authorize a release. diff --git a/docs/adr/README.md b/docs/adr/README.md index a51429e..25a8713 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -15,3 +15,4 @@ This directory records decisions that affect repository work. Read an ADR before | [0008](0008-reset-aware-quota-pacing.md) | ADR 0008: Collect fresh quota observations after a manual reset | Accepted | | [0009](0009-prebuilt-bundle-default.md) | Download and verify the prebuilt app by default; compile only with `--build-from-source`. | Accepted | | [0010](0010-macos-typescript-electron.md) | Port macOS and the schema-1 JSON CLI to TypeScript/Electron/React; preserve data contracts and pause Windows until a test machine is available. | Accepted architecture; native acceptance pending | +| [0011](0011-desktop-account-connections.md) | Add opt-in Codex browser sign-in and Claude Desktop account allowance; separate account allowance from local activity. | Accepted implementation direction; native and provider acceptance pending | diff --git a/electron-builder.yml b/electron-builder.yml index 245e521..872641a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -10,6 +10,9 @@ files: - dist/preload/** - dist/renderer/** - package.json +extraResources: + - from: build/desktop-keychain + to: desktop-keychain mac: target: - target: zip diff --git a/package.json b/package.json index d9f0724..b1dc41c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "dev": "vp dev --host 127.0.0.1", "start": "electron .", "json": "node dist/main/cli.cjs json", - "package:mac": "pnpm export:marks && pnpm build && electron-builder --mac --publish never", + "package:mac": "pnpm export:marks && pnpm build:desktop-helper && pnpm build && electron-builder --mac --publish never", + "build:desktop-helper": "mkdir -p build && swiftc -target arm64-apple-macosx13.0 Scripts/electron/desktop-keychain.swift -o build/desktop-keychain", "export:marks": "swift Scripts/electron/export-symbols.swift Resources" }, "dependencies": { diff --git a/src/domain/alerts.ts b/src/domain/alerts.ts index 4e1f3df..26e25eb 100644 --- a/src/domain/alerts.ts +++ b/src/domain/alerts.ts @@ -6,6 +6,9 @@ export class AlertEvaluator { private credits = new Set(); private cliffs = new Set(); private soft = new Set(); + forgetSlot(slot: string) { + for (const state of [this.highWater, this.credits, this.cliffs, this.soft]) for (const key of state.keys()) if (key.startsWith(`${slot}/`)) state.delete(key); + } events(slots: Slot[], quotas: Record>, burns: Record, now: number): Alert[] { const events: Alert[] = []; for (const slot of slots) { diff --git a/src/domain/models.ts b/src/domain/models.ts index 589a143..4b573d0 100644 --- a/src/domain/models.ts +++ b/src/domain/models.ts @@ -19,6 +19,7 @@ export interface Credits { usedDollars?: number; limitDollars?: number; dollarBalance?: number; } export interface Quota { + canConsumeReset?: boolean; provider: Provider; windows: QuotaWindow[]; groups: QuotaGroup[]; credits?: Credits; resetCreditCount?: number; resetCredits: ResetCredit[]; planType?: string; resetPacingSince?: number; capturedAt: number; } @@ -31,7 +32,7 @@ export const quota = (provider: Provider, windows: QuotaWindow[], capturedAt = D export const isSessionWindow = (w: QuotaWindow) => w.label.toLowerCase() === "5-hour" || w.label.toLowerCase().includes("session"); export function headlineWindow(provider: Provider, windows: QuotaWindow[]): QuotaWindow | undefined { if (provider === "codex") return windows.find(isSessionWindow) ?? (windows.length === 1 ? windows[0] : undefined); - if (provider === "claude") return windows.find(isSessionWindow) ?? windows.find(w => w.label === "7-day"); + if (provider === "claude") return windows.find(isSessionWindow) ?? windows.find(w => w.label === "7-day" || w.label === "Weekly · All models"); if (provider === "openCodeGo") return windows.find(w => w.label.toLowerCase() === "rolling"); if (["cursor", "copilot", "gemini"].includes(provider)) { const session = windows.find(isSessionWindow); if (session) return session; } return windows.reduce((max, w) => !max || w.usedPercent > max.usedPercent ? w : max, undefined); diff --git a/src/main/cli.ts b/src/main/cli.ts index 2f88cbb..ab66e12 100644 --- a/src/main/cli.ts +++ b/src/main/cli.ts @@ -3,6 +3,8 @@ import { Preferences } from "./preferences"; import { compose, type Source } from "./composition"; import { limitsReport, canonicalJSON } from "../domain/report"; import { type Loaded, type Quota, slotKey, value, missing, lastBurn } from "../domain/models"; +import { join } from "node:path"; +import { DesktopConnections } from "./connections"; export async function jsonReport(sources: Source[], now = Date.now()): Promise> { const quotas: Record> = Object.create(null), burns: Record = Object.create(null); await Promise.all(sources.map(async source => { @@ -17,9 +19,10 @@ export async function jsonReport(sources: Source[], now = Date.now()): Promise s.quota).map(s => s.slot), quotas, burns, now); } -export async function runJSON(args: string[], env: NodeJS.ProcessEnv = process.env): Promise { +export async function runJSON(args: string[], env: NodeJS.ProcessEnv = process.env, helper?: string): Promise { const launch = launchConfiguration(args, env), preferences = await Preferences.load(launch); - return canonicalJSON(await jsonReport(compose(launch, preferences))); + const connections = launch.demo ? undefined : new DesktopConnections(launch, preferences, helper ?? join(__dirname, "../../build/desktop-keychain"), () => {}); + return canonicalJSON(await jsonReport(compose(launch, preferences, { connections }))); } // Bundled CLI entry. Importing this module for fixtures never launches it. if (process.argv[1]?.endsWith("cli.cjs")) { diff --git a/src/main/composition.ts b/src/main/composition.ts index e3551e5..c7e623c 100644 --- a/src/main/composition.ts +++ b/src/main/composition.ts @@ -10,7 +10,9 @@ import { cliPath, cliEnvironment, codexRPC, runCommand, httpTransport, endpoints import { AntigravityRuntime, runtimeCandidates } from "./providers/antigravity"; import { scanGrok, openRouterUsage, openCodeUsage, parseOpenCodeRows, openCodeQuery } from "./providers/usage"; import { demoSources } from "./demo"; +import type { DesktopConnections } from "./connections"; export interface Source { + accountBound?: boolean; slot: Slot; quota?: (signal?: AbortSignal) => Promise; activity?: (signal?: AbortSignal) => Promise; @@ -40,7 +42,7 @@ function openCodeKey(launch: Launch): string | undefined { const raw = read(join(launch.home, ".local/share/opencode/auth.json")); if (!raw) return; try { return text(object(object(selectJSON(raw, { "opencode-go": { key: true } }))["opencode-go"]).key); } catch { return; } } -export function compose(launch: Launch, prefs: Preferences, options: { command?: Command; http?: HTTP; now?: () => number } = {}): Source[] { +export function compose(launch: Launch, prefs: Preferences, options: { command?: Command; http?: HTTP; now?: () => number; connections?: DesktopConnections } = {}): Source[] { const now = options.now ?? Date.now; if (launch.demo) return demoSources(now, prefs.accounts); const command = options.command ?? runCommand, http = options.http ?? httpTransport(), env = cliEnvironment(launch.home, launch.env); @@ -64,6 +66,14 @@ export function compose(launch: Launch, prefs: Preferences, options: { command?: }, activity: async signal => activity.scan(now(), signal), plan: async () => { const raw = read(alternate ? join(home, ".claude.json") : join(launch.home, ".claude.json")); if (!raw) throw new Unavailable("noData"); try { return parseClaudePlan(raw); } catch { throw new Unavailable("noData"); } } }; }; const codex = makeCodex(primary("codex")), claude = makeClaude(primary("claude"), join(launch.home, ".claude")); + for (const [provider, source, preference] of [["codex", codex, "desktopCodexConnection"], ["claude", claude, "desktopClaudeIdentity"]] as const) { + if (prefs.values[preference] !== "") { + source.accountBound = true; + source.quota = signal => options.connections ? options.connections.quota(provider, signal) : Promise.reject(new Unavailable("notSignedIn", provider)); + // Local CLI activity cannot be attributed to a separately connected account. + delete source.activity; delete source.plan; delete source.consumeReset; + } + } codex.status = async signal => parseStatus("codex", await http(endpoints.codexStatus, "codex", undefined, signal), now()); claude.status = async signal => parseStatus("claude", await http(endpoints.claudeStatus, "claude", undefined, signal), now()); const sources: Source[] = [codex, { diff --git a/src/main/connections.ts b/src/main/connections.ts new file mode 100644 index 0000000..50547d1 --- /dev/null +++ b/src/main/connections.ts @@ -0,0 +1,209 @@ +import { accessSync, constants, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import type { Launch } from "./launch"; +import { Preferences } from "./preferences"; +import { boundedChild, cliPath, codexRPC, endpoints, httpTransport, runCommand, type Command, type HTTP } from "./providers/transport"; +import { object, parseCodex } from "./providers/parsers"; +import { desktopQuota, readDesktopCredential } from "./providers/claude-desktop"; +import { selectJSON } from "./select-json"; +import { Unavailable } from "../domain/models"; +import type { ConnectionState, ConnectionProvider } from "../shared/ipc"; + +const configuration = ["-c", 'cli_auth_credentials_store="keyring"', "-c", 'forced_login_method="chatgpt"', "-c", 'chatgpt_base_url="https://chatgpt.com"']; +export function codexLoginURL(raw: unknown): string { + if (typeof raw !== "string" || raw.length > 8192) throw new Error("Invalid sign-in address"); + const url = new URL(raw); + if (url.protocol !== "https:" || !["chatgpt.com", "auth.openai.com"].includes(url.hostname) || url.username || url.password || url.port) throw new Error("Invalid sign-in address"); + return url.href; +} +export function connectCodex(binary: string, env: NodeJS.ProcessEnv, open: (url: string) => Promise, signal?: AbortSignal) { + return boundedChild(binary, [...configuration, "app-server", "--stdio"], { env, signal, timeoutMs: 180000, maxBytes: 1048576 }, (child, finish) => { + const write = (message: unknown) => child.stdin.write(JSON.stringify(message) + "\n"); + let pending = "", loginID: string | undefined, completion: Record | undefined; + const completed = () => { + if (!loginID || completion?.loginId !== loginID) return; + if (completion.success !== true) { finish(new Error("Codex sign-in failed")); return; } + completion = undefined; + write({ id: 3, method: "account/rateLimits/read", params: {} }); + }; + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + pending += chunk; + let newline: number; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); pending = pending.slice(newline + 1); + try { + const message = object(selectJSON(line, { id: true, error: { code: true }, result: { config: { cli_auth_credentials_store: true }, type: true, loginId: true, authUrl: true }, method: true, params: { loginId: true, success: true } })); + if (message.error) { finish(new Error("Codex sign-in unavailable")); return; } + if (message.id === 1) { + write({ method: "initialized", params: {} }); + write({ id: 4, method: "config/read", params: { includeLayers: false } }); + } else if (message.id === 4) { + if (object(object(message.result).config).cli_auth_credentials_store !== "keyring") throw new Error(); + write({ id: 2, method: "account/login/start", params: { type: "chatgpt" } }); + } else if (message.id === 2) { + const result = object(message.result); + if (result.type !== "chatgpt" || typeof result.loginId !== "string") throw new Error(); + loginID = result.loginId; + void open(codexLoginURL(result.authUrl)).catch(() => finish(new Error("Could not open sign-in"))); + completed(); + } else if (message.method === "account/login/completed") { completion = object(message.params); completed(); } + else if (message.id === 3) finish(line); + } catch { finish(new Error("Codex sign-in unavailable")); return; } + } + }); + write({ id: 1, method: "initialize", params: { clientInfo: { name: "meterusage", version: "0.2.41" }, capabilities: { experimentalApi: true } } }); + }); +} + +export class DesktopConnections { + private pending = new Map }>(); + private errors = new Set(); + private stopped = false; + private stopping?: Promise; + private cleanupController = new AbortController(); + constructor(readonly launch: Launch, readonly prefs: Preferences, readonly helper: string, readonly changed: () => void, readonly command: Command = runCommand, readonly http: HTTP = httpTransport(), readonly now = Date.now) {} + get state(): ConnectionState[] { + return (["codex", "claude", "grok"] as const).map(provider => ({ provider, status: this.pending.has(provider) ? "connecting" : provider === "grok" ? "unsupported" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected" })); + } + enabled(provider: "codex" | "claude") { const value = this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; return !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; } + configured(provider: "codex" | "claude") { return this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"] !== ""; } + private binary() { + for (const root of ["/Applications", join(this.launch.home, "Applications")]) { + const path = join(root, "Codex.app/Contents/Resources/codex"); + try { accessSync(path, constants.X_OK); return path; } catch { /* Existing command installations remain supported. */ } + } + return cliPath("codex", this.launch.home, this.launch.env); + } + private env(id: string) { + const home = join(this.launch.data, "connections", "codex", id); + mkdirSync(home, { recursive: true, mode: 0o700 }); + return { HOME: this.launch.home, PATH: "/usr/bin:/bin", CODEX_HOME: home, TMPDIR: this.launch.env.TMPDIR }; + } + private async claude(allowUI: boolean, signal?: AbortSignal) { + try { + const password = await this.command(this.helper, allowUI ? ["--allow-ui"] : [], { signal, timeoutMs: allowUI ? 60000 : 5000, maxBytes: 4096 }); + return await readDesktopCredential(this.launch.home, password, this.now()); + } catch { throw new Unavailable("notSignedIn", "Claude Desktop"); } + } + async quota(provider: "codex" | "claude", signal?: AbortSignal) { + if (this.launch.demo || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider); + if (provider === "codex") return parseCodex(await codexRPC(this.binary(), { env: this.env(String(this.prefs.values.desktopCodexConnection)), signal }, undefined, configuration), this.now()); + const credential = await this.claude(false, signal); + if (credential.identity !== this.prefs.values.desktopClaudeIdentity) throw new Unavailable("notSignedIn", "Claude Desktop"); + let raw: string; + try { raw = await this.http(endpoints.claudeAllowance, "claude", credential.token, signal); } + finally { + // A failed request permits last-known allowance only for a verified account. + if (this.prefs.values.desktopClaudeIdentity !== credential.identity || !this.enabled("claude")) throw new Unavailable("notSignedIn", "Claude Desktop"); + if ((await this.claude(false, signal)).identity !== credential.identity || this.prefs.values.desktopClaudeIdentity !== credential.identity || !this.enabled("claude")) throw new Unavailable("notSignedIn", "Claude Desktop"); + } + return desktopQuota(raw, this.now()); + } + async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => true) { + if (this.stopped) throw new Error("Connection unavailable"); + if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); + if (provider === "grok") throw new Error("Automatic Grok connection is unavailable"); + if (this.pending.has(provider) || this.enabled(provider)) throw new Error("Connection already active"); + if (provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") { + await this.recover(); + if (this.stopped) throw new Error("Connection unavailable"); + if (this.pending.has(provider) || this.enabled(provider)) throw new Error("Connection already active"); + } + const { controller, finish } = this.begin(provider); + let candidateID: string | undefined; + let savedIdentity: string | undefined; + try { + if (!await consent()) return false; + controller.signal.throwIfAborted(); + if (provider === "codex") { + const binary = this.binary(), id = randomUUID(); + // An unfinished profile permits cleanup, never collection. + await this.disable("codex"); + await this.prefs.set("desktopCodexCleanup", id); candidateID = id; + controller.signal.throwIfAborted(); + const q = parseCodex(await connectCodex(binary, this.env(id), open, controller.signal), this.now()); + if (!q.windows.length) throw new Unavailable("noData"); + controller.signal.throwIfAborted(); + } else { + const credential = await this.claude(true, controller.signal); + const q = desktopQuota(await this.http(endpoints.claudeAllowance, "claude", credential.token, controller.signal), this.now()); + if (!q.windows.length || (await this.claude(false, controller.signal)).identity !== credential.identity) throw new Unavailable("noData"); + controller.signal.throwIfAborted(); savedIdentity = credential.identity; await this.prefs.set("desktopClaudeIdentity", credential.identity); + } + await this.prefs.set(provider === "codex" ? "showProviderCodex" : "showProviderClaude", true); + controller.signal.throwIfAborted(); + if (candidateID) { + await this.prefs.set("desktopCodexConnection", candidateID); + controller.signal.throwIfAborted(); + await this.prefs.set("desktopCodexCleanup", ""); + controller.signal.throwIfAborted(); + } + return true; + } catch { + if (candidateID) { + await this.disable("codex", candidateID); + try { await this.cleanup(candidateID); } + catch { this.errors.add(provider); throw new Error("Could not clear the cancelled Codex connection"); } + } + if (savedIdentity && this.prefs.values.desktopClaudeIdentity === savedIdentity) await this.disable("claude"); + this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : "Open Claude Desktop and sign in, then try connecting again"); + } + finally { finish(); } + } + private begin(provider: ConnectionProvider) { + const controller = new AbortController(); + let settle!: () => void; + const done = new Promise(resolve => { settle = resolve; }); + this.pending.set(provider, { controller, done }); this.errors.delete(provider); this.changed(); + return { controller, finish: () => { this.pending.delete(provider); if (!this.stopped) this.changed(); settle(); } }; + } + cancel(provider: ConnectionProvider) { this.pending.get(provider)?.controller.abort(); } + private async disable(provider: "codex" | "claude", cleanupID?: string) { + const key = provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"; + this.prefs.values[key] = "off"; if (!this.stopped) this.changed(); + // Save cleanup first so a crash during the disabling write stays fail-closed. + if (cleanupID) await this.prefs.set("desktopCodexCleanup", cleanupID); + await this.prefs.set(key, "off"); + } + private async cleanup(id: string) { + await this.logout(id); + if (this.prefs.values.desktopCodexCleanup === id) await this.prefs.set("desktopCodexCleanup", ""); + } + private async logout(id: string) { + const raw = await codexRPC(this.binary(), { env: this.env(id), signal: this.cleanupController.signal }, undefined, configuration, "logout"); + if (object(selectJSON(raw, { error: { code: true } })).error) throw new Error("Could not disconnect Codex"); + } + async disconnect(provider: ConnectionProvider) { + if (this.stopped) throw new Error("Connection unavailable"); + if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); + if (provider === "grok" || this.pending.has(provider)) throw new Error("Connection unavailable"); + const id = String(this.prefs.values.desktopCodexCleanup || this.prefs.values.desktopCodexConnection); + const { finish } = this.begin(provider); + try { + await this.disable(provider, provider === "codex" && id !== "" && id !== "off" ? id : undefined); + if (provider === "codex" && id !== "" && id !== "off") await this.cleanup(id); + this.errors.delete(provider); + } catch { this.errors.add(provider); throw new Error(provider === "codex" ? "Could not disconnect Codex" : "Could not disconnect Claude Desktop"); + } finally { finish(); } + } + async recover() { + const id = String(this.prefs.values.desktopCodexCleanup); + if (this.launch.demo || !id) return; + if (this.stopped || this.pending.has("codex")) throw new Error("Connection unavailable"); + const { finish } = this.begin("codex"); + try { await this.disable("codex", id); await this.cleanup(id); } + catch { this.errors.add("codex"); throw new Error("Could not clear the cancelled Codex connection"); } + finally { finish(); } + } + stop(timeoutMs = 5000): Promise { + if (this.stopping) return this.stopping; + this.stopped = true; + for (const pending of this.pending.values()) pending.controller.abort(); + let timer: ReturnType; + const expired = new Promise(resolve => { timer = setTimeout(() => { this.cleanupController.abort(); resolve(); }, timeoutMs); }); + this.stopping = Promise.race([Promise.all([...this.pending.values()].map(p => p.done)).then(() => {}), expired]).finally(() => clearTimeout(timer)); + return this.stopping; + } +} diff --git a/src/main/coordinator.ts b/src/main/coordinator.ts index 217dddb..6da7748 100644 --- a/src/main/coordinator.ts +++ b/src/main/coordinator.ts @@ -35,7 +35,7 @@ export class Coordinator { private confirmations = new Map(); private resets = new Set(); private currentSources: readonly Source[]; - constructor(readonly launch: Launch, readonly preferences: Preferences, sources: Source[], readonly now: () => number = Date.now, readonly notify: (alert: Alert) => void = () => {}) { + constructor(readonly launch: Launch, readonly preferences: Preferences, sources: Source[], readonly now: () => number = Date.now, readonly notify: (alert: Alert) => void = () => {}, readonly sourceFactory: () => Source[] = () => compose(launch, preferences)) { this.currentSources = sources; this.history = new HistoryStore(join(launch.data, "durable-daily-history.json")); this.archived = readArchive(join(launch.data, "quota-archive.json")); } @@ -65,7 +65,9 @@ export class Coordinator { get burns() { return Object.fromEntries(Object.entries(this.activities).flatMap(([key, state]) => { const at = state.status === "value" ? lastBurn(state.value.sessions) : undefined; return at === undefined ? [] : [[key, at]]; })); } snapshot(): Snapshot { const p = this.preferences.values, slots = this.slots, tray = slots.filter(s => p[trayPreference(s.provider)] === true); - return structuredClone({ demo: this.launch.demo, refreshing: !!this.inflight, clock: this.now(), lastRefreshedAt: this.lastRefreshedAt, slots, traySlots: tray.filter(s => s.provider !== "openRouter"), notchSlots: tray, quotas: this.quotas, archived: this.archived, activities: this.activities, usages: this.usages, plans: this.plans, statuses: this.statuses, appearance: { theme: p.appearanceTheme as string, accent: p.accentTheme as string, heatmap: p.showHeatmap === true, claudeHeatmap: p.showClaudeHeatmap === true, codexHeatmap: p.showCodexHeatmap === true, pacing: p.showPacingBurnRate === true, telemetry: p.showActivityTelemetry === true, chart: p.showDailyActivityChart === true, resetButton: p.showSideNotchResetButton === true, compactTray: p.menuBarCompactEnabled === true, notch: p.sideNotchPanelEnabled === true, pinned: p.sideNotchPanelPinned === true, onboarding: p.onboardingCompleted === true }, historyError: this.history.error, archiveWriteFailed: this.archiveWriteFailed, clearingCache: this.clearingCache }); + const resettable = new Set(this.sources.filter(s => s.consumeReset).map(s => slotKey(s.slot))); + const quotas = Object.fromEntries(Object.entries(this.quotas).map(([key, state]) => [key, state.status === "value" ? value({ ...state.value, canConsumeReset: resettable.has(key) }) : state])); + return structuredClone({ demo: this.launch.demo, refreshing: !!this.inflight, clock: this.now(), lastRefreshedAt: this.lastRefreshedAt, slots, traySlots: tray.filter(s => s.provider !== "openRouter"), notchSlots: tray, quotas, archived: this.archived, activities: this.activities, usages: this.usages, plans: this.plans, statuses: this.statuses, appearance: { theme: p.appearanceTheme as string, accent: p.accentTheme as string, heatmap: p.showHeatmap === true, claudeHeatmap: p.showClaudeHeatmap === true, codexHeatmap: p.showCodexHeatmap === true, pacing: p.showPacingBurnRate === true, telemetry: p.showActivityTelemetry === true, chart: p.showDailyActivityChart === true, resetButton: p.showSideNotchResetButton === true, compactTray: p.menuBarCompactEnabled === true, notch: p.sideNotchPanelEnabled === true, pinned: p.sideNotchPanelPinned === true, onboarding: p.onboardingCompleted === true }, historyError: this.history.error, archiveWriteFailed: this.archiveWriteFailed, clearingCache: this.clearingCache }); } subscribe(observer: (state: Snapshot) => void) { this.observers.add(observer); return () => this.observers.delete(observer); } publish() { const state = this.snapshot(); for (const observe of this.observers) observe(state); } @@ -126,6 +128,11 @@ export class Coordinator { this.archiveWriteFailed = false; } catch { this.archiveWriteFailed = true; } } + forgetQuota(key: string) { + delete this.quotas[key]; delete this.archived[key]; delete this.plans[key]; + this.evaluator.forgetSlot(key); + this.backoffs.delete(`quota-${key}`); this.persistArchive(); + } private async load(kind: Kind, source: Source, force: boolean, read: (signal?: AbortSignal) => Promise, target: Record>) { const key = kind === "status" ? source.slot.provider : slotKey(source.slot), backoffKey = `${kind}-${key}`, prior = this.backoffs.get(backoffKey); if (!force && prior && this.now() < prior.next) return; @@ -148,6 +155,7 @@ export class Coordinator { } } target[key] = result; + if (kind === "quota" && source.accountBound && result.status === "missing" && (controller.signal.aborted || ["notSignedIn", "noData"].includes(result.code))) delete this.archived[key]; if (result.status === "missing" && ["failed", "offline"].includes(result.code)) { const failures = (prior?.failures ?? 0) + 1; this.backoffs.set(backoffKey, { failures, next: this.now() + backoffDelay(failures) }); } else this.backoffs.delete(backoffKey); } @@ -184,7 +192,7 @@ export class Coordinator { if (this.inflight) await this.inflight; const entries = await readdir(this.launch.data, { withFileTypes: true }).catch(() => []); for (const entry of entries) if (entry.isFile() && /^claude-local-scan-cache(?:-[A-Za-z0-9-]{1,80})?\.json$/.test(entry.name)) await unlink(join(this.launch.data, entry.name)); - this.sources = compose(this.launch, this.preferences); this.backoffs.clear(); await this.refresh(); + this.sources = this.sourceFactory(); this.backoffs.clear(); await this.refresh(); } finally { this.clearingCache = false; this.publish(); } } diagnostics(): string { diff --git a/src/main/electron.ts b/src/main/electron.ts index 20d6e18..21fe214 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -6,6 +6,7 @@ import { randomUUID } from "node:crypto"; import { launchConfiguration } from "./launch"; import { Preferences } from "./preferences"; import { compose } from "./composition"; +import { DesktopConnections } from "./connections"; import { Coordinator } from "./coordinator"; import { NotchFold } from "./notch-fold"; import { Updater } from "./updater"; @@ -17,11 +18,12 @@ import { notchFrame, cardOnRight, stripWidth, cardWidth, minimumShareSize } from import { trayTooltip } from "../domain/overview"; const args = process.argv.slice(1), launch = launchConfiguration(args); +const desktopHelper = app.isPackaged ? join(process.resourcesPath, "desktop-keychain") : join(__dirname, "../../build/desktop-keychain"); // Isolation is selected before Electron creates a session or any source reads. if (launch.demo) { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); } app.setName("MeterUsage"); if (args.includes("json") || args.includes("--json")) { - runJSON(args).then(json => { process.stdout.write(json + "\n"); app.exit(0); }, () => { process.stderr.write("meterusage: could not read report\n"); app.exit(1); }); + runJSON(args, process.env, desktopHelper).then(json => { process.stdout.write(json + "\n"); app.exit(0); }, () => { process.stderr.write("meterusage: could not read report\n"); app.exit(1); }); } else if (process.platform !== "darwin") { process.stderr.write("MeterUsage desktop candidates currently support macOS only.\n"); app.exit(1); } else { @@ -33,9 +35,21 @@ async function start() { if (!app.requestSingleInstanceLock({ candidate: launch.demo })) { app.quit(); return; } const prefs = await Preferences.load(launch); const documents = new Map(), windows = new Map(); - const coordinator = new Coordinator(launch, prefs, compose(launch, prefs), Date.now, alert => { - if (!launch.demo && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); + const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity }; + const connections = new DesktopConnections(launch, prefs, desktopHelper, () => { + const changed = (["codex", "claude"] as const).filter(p => markers[p] !== prefs.values[p === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]); + if (changed.length) { + for (const p of changed) { markers[p] = prefs.values[p === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; coordinator.forgetQuota(p); } + coordinator.sources = sources(); + } + publish(); }); + const sources = () => compose(launch, prefs, { connections }); + const coordinator = new Coordinator(launch, prefs, sources(), Date.now, alert => { + if (!launch.demo && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); + }, sources); + // Do not restore a legacy archive under a newly connected account. + for (const p of ["codex", "claude"] as const) if (connections.configured(p)) coordinator.forgetQuota(p); const notch = { expanded: prefs.values.sideNotchPanelPinned === true, cardOnRight: false, selected: "codex", dragging: false }; let notchHeight = 200, anchor: { x: number; y: number } | undefined; let drag: { cursor: Electron.Point; anchor: Electron.Point; timer: ReturnType; timeout: ReturnType } | undefined; @@ -47,7 +61,7 @@ async function start() { tray.on("right-click", () => tray.popUpContextMenu(Menu.buildFromTemplate([{ label: "Open MeterUsage", click: () => showFlyout() }, { label: "Settings…", click: () => showSettings() }, { type: "separator" }, { label: "Quit MeterUsage", role: "quit" }]))); tray.on("click", () => { const w = windows.get("flyout"); w?.isVisible() ? w.hide() : showFlyout(); }); let shareState: ViewState | undefined, shareHeight = 0, shareReady: ((image: Electron.NativeImage) => void) | undefined, shareFailed: ((error: Error) => void) | undefined; - const state = (surface?: Surface): ViewState => surface === "share" && shareState ? shareState : ({ snapshot: coordinator.snapshot(), settings: projectSettings(prefs.values, prefs.accounts, launch.home, surface), systemDark: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI, notch: { ...notch, maxHeight: Math.floor((anchor ? screen.getDisplayNearestPoint(anchor) : screen.getPrimaryDisplay()).workArea.height) }, update: updater.visible ? { version: updater.visible.version, state: updater.installState } : undefined }); + const state = (surface?: Surface): ViewState => surface === "share" && shareState ? shareState : ({ connections: connections.state, snapshot: coordinator.snapshot(), settings: projectSettings(prefs.values, prefs.accounts, launch.home, surface), systemDark: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI, notch: { ...notch, maxHeight: Math.floor((anchor ? screen.getDisplayNearestPoint(anchor) : screen.getPrimaryDisplay()).workArea.height) }, update: updater.visible ? { version: updater.visible.version, state: updater.installState } : undefined }); const updater = new Updater(prefs, app.getVersion(), publish, undefined, release => { if (!launch.demo && Notification.isSupported()) new Notification({ title: "MeterUsage update available", body: `MeterUsage ${release.version} is ready to install.`, silent: true }).show(); }); @@ -147,6 +161,23 @@ async function start() { case "close": source.hide(); break; case "quit": app.quit(); break; case "clearCache": await coordinator.clearCache(); break; + case "connect": { + if (surface !== "settings" && surface !== "flyout") throw new Error("Invalid request"); + const connected = await connections.connect(r.provider, url => shell.openExternal(url), async () => { + if (r.provider !== "claude") return true; + const consent = await dialog.showMessageBox(source, { type: "question", title: "Connect Claude Desktop?", message: "Allow MeterUsage to read your Claude allowance?", detail: "MeterUsage will read Claude Desktop's current access token through macOS Keychain and request account allowance from Anthropic. It will not read chats, use refresh tokens or change Claude Desktop. This connection depends on an interface that can change.", buttons: ["Cancel", "Allow access"], defaultId: 0, cancelId: 0, noLink: true }); + return consent.response === 1; + }); + if (!connected) break; + await coordinator.refresh(); break; + } + case "connectionCancel": + if (surface !== "settings" && surface !== "flyout") throw new Error("Invalid request"); + connections.cancel(r.provider); break; + case "disconnect": + if (surface !== "settings" && surface !== "flyout") throw new Error("Invalid request"); + await connections.disconnect(r.provider); + await coordinator.refresh(); break; case "copyDiagnostics": clipboard.writeText(coordinator.diagnostics()); break; case "copyJSON": clipboard.writeText(coordinator.json()); break; case "setPreference": @@ -166,7 +197,7 @@ async function start() { const chosen = await dialog.showOpenDialog(source, { title: `Choose ${r.provider === "codex" ? "Codex" : "Claude"} config directory`, properties: ["openDirectory"] }); if (chosen.canceled || !chosen.filePaths[0]) break; await prefs.updateAccounts(accounts => [...accounts, { id: randomUUID(), provider: r.provider, label: "", path: chosen.filePaths[0], enabled: true }]); - coordinator.sources = compose(launch, prefs); await coordinator.refresh(); break; + coordinator.sources = sources(); await coordinator.refresh(); break; } case "accountPath": { const account = prefs.accounts.find(a => a.id === r.id); @@ -174,12 +205,12 @@ async function start() { const chosen = await dialog.showOpenDialog(source, { title: `Choose ${account.provider === "codex" ? "Codex" : "Claude"} config directory`, properties: ["openDirectory"] }); if (chosen.canceled || !chosen.filePaths[0]) break; await prefs.updateAccounts(accounts => accounts.map(a => a.id === r.id ? { ...a, path: chosen.filePaths[0] } : a)); - coordinator.sources = compose(launch, prefs); await coordinator.refresh(); break; + coordinator.sources = sources(); await coordinator.refresh(); break; } case "accountUpdate": case "accountRemove": { if (surface !== "settings" || !prefs.accounts.some(a => a.id === r.id)) throw new Error("Unknown account"); await prefs.updateAccounts(accounts => r.action === "accountRemove" ? accounts.filter(a => a.id !== r.id) : accounts.map(a => a.id === r.id ? { ...a, ...(r.label === undefined ? {} : { label: r.label }), ...(r.enabled === undefined ? {} : { enabled: r.enabled }) } : a)); - coordinator.sources = compose(launch, prefs); await coordinator.refresh(); break; + coordinator.sources = sources(); await coordinator.refresh(); break; } case "reset": { if (confirmingReset) throw new Error("Reset confirmation already open"); @@ -255,7 +286,7 @@ async function start() { const source = BrowserWindow.fromWebContents(event.sender), surface = [...windows].find(([, w]) => w === source)?.[0]; if (!source || !surface) return { ok: false, error: "Unknown window" }; try { return await handle(parseRequest(raw), source, surface); } - catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share"].includes(e.message) ? e.message : "Could not complete action" }; } + catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Connections are disabled in demo mode", "Automatic Grok connection is unavailable"].includes(e.message) ? e.message : "Could not complete action" }; } }); coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); }); screen.on("display-metrics-changed", () => { placeNotch(); publish(); }); screen.on("display-removed", () => { placeNotch(); publish(); }); @@ -264,8 +295,23 @@ async function start() { app.on("window-all-closed", () => {}); const clockTimer = setInterval(publish, 60000); nativeTheme.on("updated", publish); - app.on("before-quit", () => { coordinator.stop(); updater.reset(); clearInterval(clockTimer); if (drag) { clearInterval(drag.timer); clearTimeout(drag.timeout); } folding.cancel(); if (shareDirectory) rmSync(shareDirectory, { recursive: true, force: true }); for (const directory of sharedDirectories) rmSync(directory, { recursive: true, force: true }); tray.destroy(); }); + let quitting = false, quitReady = false; + app.on("before-quit", event => { + if (quitReady) return; + event.preventDefault(); + if (quitting) return; + quitting = true; + coordinator.stop(); updater.reset(); clearInterval(clockTimer); if (drag) { clearInterval(drag.timer); clearTimeout(drag.timeout); } folding.cancel(); + void connections.stop().finally(() => { + if (shareDirectory) rmSync(shareDirectory, { recursive: true, force: true }); for (const directory of sharedDirectories) rmSync(directory, { recursive: true, force: true }); tray.destroy(); + quitReady = true; app.quit(); + }); + }); nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light"; - create("tray"); restoreAnchor(); syncNotch(); await coordinator.start(); - if (launch.candidate || prefs.values.onboardingCompleted !== true) showFlyout(); + create("tray"); restoreAnchor(); + // Recovery failures remain visible in connection state, with collection disabled. + await connections.recover().catch(() => {}); + if (quitting) return; + syncNotch(); await coordinator.start(); + if (!quitting && (launch.candidate || prefs.values.onboardingCompleted !== true)) showFlyout(); } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 0d6072f..3a369ac 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -21,6 +21,7 @@ export function parseRequest(raw: unknown): Request { valid = typeof r.key === "string" && (boolKeys.has(r.key) ? typeof r.value === "boolean" : r.key === "refreshIntervalSeconds" ? typeof r.value === "number" && [30, 60, 120, 300, 900].includes(r.value) : r.key === "appearanceTheme" ? typeof r.value === "string" && ["system", "light", "dark"].includes(r.value) : r.key === "accentTheme" && typeof r.value === "string" && ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(r.value)); break; case "accountAdd": fields = ["provider"]; valid = r.provider === "codex" || r.provider === "claude"; break; + case "connect": case "disconnect": case "connectionCancel": fields = ["provider"]; valid = typeof r.provider === "string" && ["codex", "claude", "grok"].includes(r.provider); break; case "statusPage": fields = ["provider"]; valid = typeof r.provider === "string" && Object.hasOwn(statusPages, r.provider); break; case "accountUpdate": fields = ["id", ...("label" in r ? ["label"] : []), ...("enabled" in r ? ["enabled"] : [])]; valid = id(r.id) && fields.length > 1 && (!("label" in r) || string(r.label, 100)) && (!("enabled" in r) || typeof r.enabled === "boolean"); break; case "accountRemove": case "accountPath": fields = ["id"]; valid = id(r.id); break; diff --git a/src/main/preferences.ts b/src/main/preferences.ts index b5b89e6..1fe2538 100644 --- a/src/main/preferences.ts +++ b/src/main/preferences.ts @@ -13,6 +13,7 @@ type Kind = "boolean" | "number" | "string" | "data" | "date"; const kinds: Record = { refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", managedAccounts: "data", sideNotchPanelCorner: "string", updateLastCheckDate: "date", updateDismissedVersion: "string", updateAnnouncedVersion: "string", + desktopCodexConnection: "string", desktopCodexCleanup: "string", desktopClaudeIdentity: "string", ...Object.fromEntries(["launchAtLogin", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "quotaAlertsEnabled", "sideNotchPanelEnabled", "sideNotchPanelPinned", "menuBarCompactEnabled", "onboardingCompleted", "updateCheckEnabled", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart", "showSideNotchResetButton", ...providers.flatMap(p => [providerPreference(p), trayPreference(p)])].map(k => [k, "boolean" as const])), }; export const initialPreferences: Record = { @@ -21,6 +22,7 @@ export const initialPreferences: Record = { sideNotchPanelEnabled: false, sideNotchPanelPinned: false, menuBarCompactEnabled: true, onboardingCompleted: false, updateCheckEnabled: true, showPacingBurnRate: true, showActivityTelemetry: true, showDailyActivityChart: true, showSideNotchResetButton: true, updateDismissedVersion: "", updateAnnouncedVersion: "", + desktopCodexConnection: "", desktopCodexCleanup: "", desktopClaudeIdentity: "", ...Object.fromEntries(providers.flatMap(p => [[providerPreference(p), ["codex", "openCodeGo", "openRouter"].includes(p)], [trayPreference(p), true]])), }; export function validAccounts(raw: unknown): raw is ManagedAccount[] { @@ -35,6 +37,8 @@ function validate(key: string, v: unknown): v is PreferenceValue { if (key === "appearanceTheme") return ["system", "light", "dark"].includes(v); if (key === "accentTheme") return ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(v); if (key === "sideNotchPanelCorner") return v === "" || /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(v); + if (key === "desktopCodexConnection" || key === "desktopCodexCleanup") return v === "" || key === "desktopCodexConnection" && v === "off" || /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(v); + if (key === "desktopClaudeIdentity") return v === "" || v === "off" || /^[a-f\d]{64}$/.test(v); return true; } export class Preferences { diff --git a/src/main/providers/claude-desktop.ts b/src/main/providers/claude-desktop.ts new file mode 100644 index 0000000..b3ff828 --- /dev/null +++ b/src/main/providers/claude-desktop.ts @@ -0,0 +1,81 @@ +import { readFileSync, statSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import { createDecipheriv, createHash, pbkdf2Sync } from "node:crypto"; +import { selectJSON } from "../select-json"; +import { date, object, text } from "./parsers"; +import { Unavailable, quota, window } from "../../domain/models"; + +const uuid = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i; +const unavailable = () => new Unavailable("notSignedIn", "Claude Desktop"); +export function decryptDesktop(bytes: Uint8Array, password: string): Buffer { + const input = Buffer.from(bytes); + if (input.length > 1048576 || input.subarray(0, 3).toString() !== "v10") throw unavailable(); + const key = pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1"); + try { + const cipher = createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, 32)); + return Buffer.concat([cipher.update(input.subarray(3)), cipher.final()]); + } catch { throw unavailable(); } + finally { key.fill(0); } +} +export function desktopCredential(config: string, organization: string, password: string, now: number) { + try { + const root = object(selectJSON(config, { lastKnownAccountUuid: true, "oauth:tokenCacheV2": true })); + const account = text(root.lastKnownAccountUuid)?.toLowerCase(); + if (!account || !uuid.test(account) || !uuid.test(organization)) throw unavailable(); + const cache = text(root["oauth:tokenCacheV2"]); if (!cache || cache.length > 1400000) throw unavailable(); + const plaintext = decryptDesktop(Buffer.from(cache, "base64"), password); + try { + // Select only scoped access tokens and expiry. Refresh tokens stay skipped. + const entries = object(selectJSON(plaintext.toString("utf8"), { "*": { token: true, expiresAt: true } })); + const candidates = Object.entries(entries).filter(([key, value]) => { + const match = /^acct:([^|]+)\|([^:]+):([^:]+):https:\/\/api\.anthropic\.com:(.*)$/.exec(key); + const entry = object(value), scopes = match?.[4].split(/\s+/); + return match && match[1].toLowerCase() === account && uuid.test(match[2]) && match[3].toLowerCase() === organization.toLowerCase() && scopes?.includes("user:profile") && scopes.includes("user:inference") && typeof entry.expiresAt === "number" && Number.isFinite(entry.expiresAt) && entry.expiresAt > now + 120000 && !!text(entry.token); + }); + // Fail closed for ambiguous caches instead of guessing which login to use. + if (candidates.length !== 1) throw unavailable(); + return { token: text(object(candidates[0][1]).token)!, identity: createHash("sha256").update(account + ":" + organization.toLowerCase()).digest("hex") }; + } finally { plaintext.fill(0); } + } catch { throw unavailable(); } +} +function configFile(home: string) { + const path = join(home, "Library/Application Support/Claude/config.json"); + if (statSync(path).size > 2 * 1048576) throw unavailable(); + return readFileSync(path, "utf8"); +} +export async function readDesktopCredential(home: string, password: string, now: number) { + try { + const config = configFile(home); + const { DatabaseSync } = await import("node:sqlite"); + for (const relative of ["Cookies", "Network/Cookies"]) { + const path = join(home, "Library/Application Support/Claude", relative); if (!existsSync(path)) continue; + const db = new DatabaseSync(path, { readOnly: true }); + try { + const rows = db.prepare("SELECT host_key, value, encrypted_value FROM cookies WHERE name = 'lastActiveOrg' AND host_key IN ('.claude.ai', 'claude.ai') ORDER BY last_update_utc DESC LIMIT 1").all(); + for (const row of rows) { + let organization = typeof row.value === "string" ? row.value : ""; + if (!organization && row.encrypted_value instanceof Uint8Array && typeof row.host_key === "string") { + const decrypted = decryptDesktop(row.encrypted_value, password), hash = createHash("sha256").update(row.host_key).digest(); + try { if (decrypted.subarray(0, 32).equals(hash)) organization = decrypted.subarray(32).toString("utf8"); } + finally { decrypted.fill(0); } + } + return desktopCredential(config, organization, password, now); + } + } finally { db.close(); } + } + throw unavailable(); + } catch { throw unavailable(); } +} +export function desktopQuota(raw: string, now: number) { + const keys = ["five_hour", "seven_day", "seven_day_sonnet", "seven_day_opus", "seven_day_oauth_apps", "seven_day_cowork"]; + const selected = object(selectJSON(raw, Object.fromEntries(keys.map(key => [key, { utilization: true, resets_at: true }])))); + const windows = keys.flatMap(key => { + if (selected[key] == null) return []; + const value = object(selected[key]), percent = value.utilization, reset = date(value.resets_at); + if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 || value.resets_at != null && reset === undefined) throw new Unavailable("noData"); + const label = key === "five_hour" ? "5-hour" : key === "seven_day" ? "Weekly · All models" : `Weekly · ${key.slice(10).replaceAll("_", " ")}`; + return [window(label, percent, reset, key === "five_hour" ? 300 : 10080)]; + }); + if (!windows.length) throw new Unavailable("noData"); + return quota("claude", windows, now); +} diff --git a/src/main/providers/transport.ts b/src/main/providers/transport.ts index a35c53a..c2a1cfb 100644 --- a/src/main/providers/transport.ts +++ b/src/main/providers/transport.ts @@ -18,7 +18,7 @@ export function cliEnvironment(home: string, env: NodeJS.ProcessEnv): NodeJS.Pro } // Provider stderr may contain credentials or paths. Drain it, but never retain // or propagate it. All failures crossing this boundary use fixed messages. -function boundedChild(binary: string, args: string[], options: CommandOptions, exchange: (child: ChildProcessWithoutNullStreams, finish: (result: string | Error) => void) => void): Promise { +export function boundedChild(binary: string, args: string[], options: CommandOptions, exchange: (child: ChildProcessWithoutNullStreams, finish: (result: string | Error) => void) => void): Promise { return new Promise((resolve, reject) => { if (options.signal?.aborted) { reject(new Error("Provider request cancelled")); return; } const child = spawn(binary, args, { env: options.env, stdio: "pipe", shell: false }); @@ -49,9 +49,10 @@ export const runCommand: Command = (binary, args, options = {}) => boundedChild( child.on("exit", code => { exitCode = code; complete(); }); child.stdin.end(); }); -export async function codexRPC(binary: string, options: CommandOptions, creditID?: string): Promise { - return boundedChild(binary, ["app-server", "--stdio"], options, (child, finish) => { +export async function codexRPC(binary: string, options: CommandOptions, creditID?: string, configuration: string[] = [], operation: "read" | "logout" = "read"): Promise { + return boundedChild(binary, [...configuration, "app-server", "--stdio"], options, (child, finish) => { const write = (v: unknown) => child.stdin.write(JSON.stringify(v) + "\n"); + const request = () => write({ jsonrpc: "2.0", id: 2, method: operation === "logout" ? "account/logout" : creditID === undefined ? "account/rateLimits/read" : "account/rateLimitResetCredit/consume", params: creditID === undefined ? {} : { creditId: creditID, idempotencyKey: randomUUID() } }); let pending = "", initialized = false; child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => { @@ -62,8 +63,15 @@ export async function codexRPC(binary: string, options: CommandOptions, creditID let id: unknown; try { id = object(selectJSON(line, { id: true })).id; } catch { continue; } if (id === 1 && !initialized) { initialized = true; + if (object(selectJSON(line, { error: { code: true } })).error) { finish(new Error("Provider initialization failed")); return; } write({ jsonrpc: "2.0", method: "initialized", params: {} }); - write({ jsonrpc: "2.0", id: 2, method: creditID === undefined ? "account/rateLimits/read" : "account/rateLimitResetCredit/consume", params: creditID === undefined ? {} : { creditId: creditID, idempotencyKey: randomUUID() } }); + if (configuration.length) write({ id: 4, method: "config/read", params: { includeLayers: false } }); else request(); + } else if (id === 4 && initialized && configuration.length) { + try { + const result = object(selectJSON(line, { result: { config: { cli_auth_credentials_store: true } } })); + if (object(object(result.result).config).cli_auth_credentials_store !== "keyring") throw new Error(); + request(); + } catch { finish(new Error("Secure credential storage unavailable")); } } else if (id === 2 && initialized) finish(line); } }); @@ -76,6 +84,7 @@ export const endpoints = { openRouterActivity: "https://openrouter.ai/api/v1/activity", openCodeGo: "https://opencode.ai/zen/go/v1/usage", grok: "https://cli-chat-proxy.grok.com/v1/billing?format=credits", + claudeAllowance: "https://api.anthropic.com/api/oauth/usage", codexStatus: "https://status.openai.com/api/v2/components.json", claudeStatus: "https://status.claude.com/api/v2/components.json", } as const; @@ -86,8 +95,8 @@ export function httpTransport(fetcher: typeof fetch = fetch): HTTP { if (!(Object.values(endpoints) as string[]).includes(url)) throw failed(provider); const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000); try { - const response = await fetcher(url, { signal: boundedSignal, redirect: "error", cache: "no-store", headers: { Accept: "application/json", "User-Agent": "meterusage/0.2.41", ...(key ? { Authorization: `Bearer ${key}` } : {}) } }); - if (response.status === 401) throw new Unavailable("notSignedIn", provider === "openCodeGo" ? "OpenCode Go" : provider === "openRouter" ? "OpenRouter" : "Grok"); + const response = await fetcher(url, { signal: boundedSignal, redirect: "error", cache: "no-store", headers: { Accept: "application/json", "User-Agent": "meterusage/0.2.41", ...(url === endpoints.claudeAllowance ? { "anthropic-beta": "oauth-2025-04-20" } : {}), ...(key ? { Authorization: `Bearer ${key}` } : {}) } }); + if (response.status === 401 || response.status === 403 && url === endpoints.claudeAllowance) throw new Unavailable("notSignedIn", provider === "claude" ? "Claude Desktop" : provider === "openCodeGo" ? "OpenCode Go" : provider === "openRouter" ? "OpenRouter" : "Grok"); if (response.status === 403 && url === endpoints.openRouterActivity) throw new Unavailable("dataNotFound", "OpenRouter Management Key"); if (!response.ok || !response.body) throw failed(provider); const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 4182962..f393ec4 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -48,7 +48,7 @@ export function ProviderCard({ slot, state, action, detail = false }: { slot: Sl const t = activity?.telemetry ?? usage?.telemetry, at = activity && lastBurn(activity.sessions), status = extract(s.statuses[slot.provider]); const activeWindow = detail && q && activity ? headlineWindow(slot.provider, effectiveWindows(q)) : undefined; const heatmap = s.appearance.heatmap && (slot.provider !== "claude" || s.appearance.claudeHeatmap) && (slot.provider !== "codex" || s.appearance.codexHeatmap); - return

{slotName(slot)}

{plan && {plan}}
{status &&

{status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

Last known · {new Date(q.capturedAt).toLocaleString()}
{reason(s.quotas[key])}

}{q.groups.length ? q.groups.map(g =>

{g.title}

{g.windows.map(w => )}
) : (detail ? effectiveWindows(q) : q.windows).map(w => )}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

≈ {money(q.credits.dollarBalance)} equivalent

}{q.credits.usedDollars !== undefined &&

{money(q.credits.usedDollars)} used{q.credits.limitDollars === undefined ? "" : ` of ${money(q.credits.limitDollars)}`}

}
}{q.resetCreditCount !== undefined &&

{q.resetCreditCount} earned reset credits

{q.resetCredits.map(c =>
{c.title}{(!detail || s.appearance.resetButton) && resetCreditAvailable(c, s.clock) && }

{c.status ?? "Status not reported"}{c.expiresAt !== undefined && c.expiresAt <= s.clock ? " · expired" : ""} · {c.expiresAt === undefined ? "Expiry not reported" : `Expires ${new Date(c.expiresAt).toLocaleDateString()}`}

)}
} :

{reason(s.quotas[key])}

}{usage &&

Local activity

Sessions
{n(usage.sessionCount)}
Messages
{n(usage.messageCount)}
{usage.tokens !== undefined &&
Measured tokens
{n(totalTokens(usage.tokens))}
}{usage.estimatedCostUSD !== undefined &&
Estimated cost
{money(usage.estimatedCostUSD)}
}

{usage.todayMessageCount > 0 || usage.todaySessionCount > 0 ? `Today: ${n(usage.todaySessionCount)} session${usage.todaySessionCount === 1 ? "" : "s"} · ${n(usage.todayMessageCount)} messages · updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago` : `Updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago · ${usage.tokens !== undefined ? "measured token totals" : "token totals unavailable"}`}

{usage.usageWindows && }
}{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && }{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

{session.model} · {session.messageCount} turns · estimated {money(session.estimatedCostUSD)}

)}
}{!activity && !usage && (s.activities[key] || s.usages[key]) &&

{reason(s.activities[key] ?? s.usages[key])}

}{detail &&
}
; + return

{slotName(slot)}

{plan && {plan}}
{status &&

{status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

Last known · {new Date(q.capturedAt).toLocaleString()}
{reason(s.quotas[key])}

}{q.groups.length ? q.groups.map(g =>

{g.title}

{g.windows.map(w => )}
) : (detail ? effectiveWindows(q) : q.windows).map(w => )}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

≈ {money(q.credits.dollarBalance)} equivalent

}{q.credits.usedDollars !== undefined &&

{money(q.credits.usedDollars)} used{q.credits.limitDollars === undefined ? "" : ` of ${money(q.credits.limitDollars)}`}

}
}{q.resetCreditCount !== undefined &&

{q.resetCreditCount} earned reset credits

{q.resetCredits.map(c =>
{c.title}{fresh?.canConsumeReset === true && (!detail || s.appearance.resetButton) && resetCreditAvailable(c, s.clock) && }

{c.status ?? "Status not reported"}{c.expiresAt !== undefined && c.expiresAt <= s.clock ? " · expired" : ""} · {c.expiresAt === undefined ? "Expiry not reported" : `Expires ${new Date(c.expiresAt).toLocaleDateString()}`}

)}
} :

{reason(s.quotas[key])}

}{usage &&

{slot.provider === "openRouter" ? "Account activity" : "Activity on this Mac"}

Sessions
{n(usage.sessionCount)}
Messages
{n(usage.messageCount)}
{usage.tokens !== undefined &&
Measured tokens
{n(totalTokens(usage.tokens))}
}{usage.estimatedCostUSD !== undefined &&
Estimated cost
{money(usage.estimatedCostUSD)}
}

{usage.todayMessageCount > 0 || usage.todaySessionCount > 0 ? `Today: ${n(usage.todaySessionCount)} session${usage.todaySessionCount === 1 ? "" : "s"} · ${n(usage.todayMessageCount)} messages · updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago` : `Updated ${formatETA((s.clock - usage.capturedAt) / 1000, true)} ago · ${usage.tokens !== undefined ? "measured token totals" : "token totals unavailable"}`}

{usage.usageWindows && }
}{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && }{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

{session.model} · {session.messageCount} turns · estimated {money(session.estimatedCostUSD)}

)}
}{!activity && !usage && (s.activities[key] || s.usages[key]) &&

{reason(s.activities[key] ?? s.usages[key])}

}{detail &&
}
; } function RollingUsage({ windows, provider }: { windows: UsageWindow[]; provider: Provider }) { const reference = windows.find(w => w.label === "last 30d"), cost = (reference?.estimatedCostUSD ?? 0) > 0; @@ -71,17 +71,21 @@ function BurnCard({ state }: { state: ViewState }) { return

Measured burn · last 7 days

{n(b.totalTokens)} tokens{b.cacheHitRate === undefined ? "" : ` · ${Math.round(b.cacheHitRate)}% cache hit`}

{b.contributors.map(c =>
{c.projectName}{Math.round(c.shareOfWindow)}%

{c.model} · {n(c.totalTokens)} tokens{c.isLongChat ? " · Long chat" : ""}

)}
; } function UnifiedStrip({ state }: { state: ViewState }) { - const s = state.snapshot, activities = s.slots.flatMap(slot => { const a = extract(s.activities[slotKey(slot)]); return a ? [a] : []; }), usages = s.slots.flatMap(slot => { const u = extract(s.usages[slotKey(slot)]); return u ? [u] : []; }); + const s = state.snapshot, localSlots = s.slots.filter(slot => slot.provider !== "openRouter"), activities = localSlots.flatMap(slot => { const a = extract(s.activities[slotKey(slot)]); return a ? [a] : []; }), usages = localSlots.flatMap(slot => { const u = extract(s.usages[slotKey(slot)]); return u ? [u] : []; }); const totals = stripTotals(activities, usages, s.clock), nudge = failoverNudge(s.slots, s.quotas, s.activities, s.clock); if (totals.todayTokens === undefined && !nudge) return null; - return

All AI coding today

{totals.todayTokens !== undefined &&
Tokens today
{n(totals.todayTokens)}
Last 7 days
{n(totals.weekTokens!)}
Estimated cost today
{money(totals.todayCost!)}
}{nudge &&

{nudge}

}
; + return

AI activity on this Mac

{totals.todayTokens !== undefined &&
Tokens today
{n(totals.todayTokens)}
Last 7 days
{n(totals.weekTokens!)}
Estimated cost today
{money(totals.todayCost!)}
}{nudge &&

{nudge}

}
; } const labels: Record = { launchAtLogin: "Launch at login", quotaAlertsEnabled: "Quota alerts", updateCheckEnabled: "Check for updates", sideNotchPanelEnabled: "Show side notch", sideNotchPanelPinned: "Pin detail card", menuBarCompactEnabled: "Compact menu bar", showHeatmap: "Daily heatmaps", showClaudeHeatmap: "Claude heatmap", showCodexHeatmap: "Codex heatmap", showPacingBurnRate: "Pacing and burn rate", showActivityTelemetry: "Activity statistics", showDailyActivityChart: "Daily activity chart", showSideNotchResetButton: "Codex limit resets in the side notch" }; +export function Connections({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { + const connections = state.connections ?? ["codex", "claude", "grok"].map(provider => ({ provider: provider as "codex" | "claude" | "grok", status: provider === "grok" ? "unsupported" as const : "disconnected" as const })); + return

Connect your accounts

Choose an account once. Allowance comes from the provider account and refreshes automatically.

{connections.map(c =>
{providerNames[c.provider]}{c.status === "connecting" ? : c.status === "connected" ? : c.status !== "unsupported" && }

{c.status === "connecting" ? "Waiting for your approval…" : c.status === "connected" ? state.snapshot.quotas[c.provider]?.status === "missing" ? "Usage unavailable. Disconnect, then connect again." : "Connected · Account allowance" : c.status === "failed" ? "Usage unavailable. Open the desktop app and try again." : c.status === "unsupported" ? "Automatic connection is not available yet." : c.provider === "claude" ? "Uses the account signed in to Claude Desktop on this Mac." : "Sign in in your browser. Your Codex app stays signed in."}

)}{state.snapshot.demo &&

Demo uses sample data. Account access is disabled.

}
; +} function Settings({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { const values = state.settings.values; const set = (key: string, value: string | boolean | number) => void action({ action: "setPreference", key, value }); const toggle = (key: string, text: string) => ; - return

Settings

General

{["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))}

Providers

{providers.map(p =>
{toggle(providerKey(p), providerNames[p])}{toggle(trayKey(p), "Menu bar and notch")}
)}

Additional accounts

{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

Appearance

{Object.entries(labels).filter(([k]) => !["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].includes(k)).map(([k, text]) => toggle(k, text))}

Diagnostics

Provider files and durable history stay intact.

MeterUsage 0.2.41 · Cost figures are local estimates.

; + return

Settings

General

{["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))}

Providers

{providers.map(p =>
{toggle(providerKey(p), providerNames[p])}{toggle(trayKey(p), "Menu bar and notch")}
)}
Advanced: local accounts{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

Appearance

{Object.entries(labels).filter(([k]) => !["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].includes(k)).map(([k, text]) => toggle(k, text))}

Diagnostics

Provider files and durable history stay intact.

MeterUsage 0.2.41 · Cost figures are local estimates.

; } function AccountRow({ account: a, action }: { account: ViewState["settings"]["accounts"][number]; action: (r: Request) => Promise }) { const [label, setLabel] = useState(a.label); @@ -151,5 +155,5 @@ export function App({ bridge }: { bridge: Bridge }) { if (!state) return

{error || "Loading usage…"}

; const s = state.snapshot; const style = { "--accent": accents[s.appearance.accent] ?? accents.blue, "--notch-max-height": `${state.notch.maxHeight ?? 2000}px` } as CSSProperties; - return
{error &&
{error}
}{surface === "share" ? (() => { const slot = notchEntries(s).find(e => e.key === state.notch.selected)?.slot; return slot ? :

Card unavailable

; })() : surface === "tray" ? : surface === "settings" ? : surface === "notch" ? :

MeterUsage {s.demo && Demo}

{!s.appearance.onboarding &&

Your usage, at a glance

Quota cards show what providers report. Local activity supplies token estimates and pacing. Enable your providers in Settings.

}{state.update &&
MeterUsage {state.update.version} available
}{s.historyError &&

History could not be {s.historyError === "loadFailed" ? "read. Repair the file and relaunch to resume saving" : "saved. Live readings remain available"}.

}{s.archiveWriteFailed &&

Last known quota could not be saved.

}{s.slots.length ? s.slots.map(slot => ) :

Enable a provider in Settings.

}
{s.lastRefreshedAt === undefined ? "Not refreshed yet" : `Updated ${new Date(s.lastRefreshedAt).toLocaleTimeString()}`}
}
; + return
{error &&
{error}
}{surface === "share" ? (() => { const slot = notchEntries(s).find(e => e.key === state.notch.selected)?.slot; return slot ? :

Card unavailable

; })() : surface === "tray" ? : surface === "settings" ? : surface === "notch" ? :

MeterUsage {s.demo && Demo}

{!s.appearance.onboarding &&

Your usage, at a glance

Connect Codex or Claude Desktop below. Account allowance comes from the provider. Local activity covers this Mac; OpenRouter activity covers its account.

}{state.update &&
MeterUsage {state.update.version} available
}{s.historyError &&

History could not be {s.historyError === "loadFailed" ? "read. Repair the file and relaunch to resume saving" : "saved. Live readings remain available"}.

}{s.archiveWriteFailed &&

Last known quota could not be saved.

}{s.slots.length ? s.slots.map(slot => ) :

Enable a provider in Settings.

}
{s.lastRefreshedAt === undefined ? "Not refreshed yet" : `Updated ${new Date(s.lastRefreshedAt).toLocaleTimeString()}`}
}
; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 3c16a75..9cf91d0 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -3,11 +3,14 @@ import type { Provider } from "../domain/models"; export const channel = "meterusage:request", stateChannel = "meterusage:state"; export const statusPages = { codex: "https://status.openai.com/", claude: "https://status.claude.com/", cursor: "https://status.cursor.com/", copilot: "https://www.githubstatus.com/" }; export type Surface = "flyout" | "settings" | "notch" | "tray" | "share"; +export type ConnectionProvider = "codex" | "claude" | "grok"; +export interface ConnectionState { provider: ConnectionProvider; status: "disconnected" | "connecting" | "connected" | "failed" | "unsupported" } export interface SettingsState { values: Record; accounts: { id: string; provider: "codex" | "claude"; label: string; pathLabel?: string; enabled: boolean }[]; } export interface ViewState { + connections?: ConnectionState[]; snapshot: Snapshot; settings: SettingsState; systemDark: boolean; notch: { expanded: boolean; cardOnRight: boolean; selected: string; dragging: boolean; maxHeight?: number }; update?: { version: string; state: "idle" | "downloading" | "installing" | "failed" }; @@ -18,6 +21,7 @@ export type Request = | { action: "statusPage"; provider: keyof typeof statusPages } | { action: "setPreference"; key: string; value: boolean | number | string } | { action: "accountAdd"; provider: "codex" | "claude" } + | { action: "connect" | "disconnect" | "connectionCancel"; provider: ConnectionProvider } | { action: "accountUpdate"; id: string; label?: string; enabled?: boolean } | { action: "accountRemove"; id: string } | { action: "accountPath"; id: string } diff --git a/tests/connections.test.ts b/tests/connections.test.ts new file mode 100644 index 0000000..5d731c8 --- /dev/null +++ b/tests/connections.test.ts @@ -0,0 +1,369 @@ +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createCipheriv, createHash, pbkdf2Sync } from "node:crypto"; +import { DatabaseSync } from "node:sqlite"; +import { DesktopConnections, connectCodex, codexLoginURL } from "../src/main/connections"; +import { desktopCredential, desktopQuota, readDesktopCredential } from "../src/main/providers/claude-desktop"; +import { Unavailable } from "../src/domain/models"; +import { Preferences } from "../src/main/preferences"; +import { compose } from "../src/main/composition"; +import { launchConfiguration } from "../src/main/launch"; +import { endpoints, httpTransport, type Command } from "../src/main/providers/transport"; +import * as transport from "../src/main/providers/transport"; +import { parseRequest, projectSettings } from "../src/main/ipc"; + +const roots: string[] = []; +const temp = () => { const root = mkdtempSync(join(tmpdir(), "meterusage-connection-fixture-")); roots.push(root); return root; }; +const realHTTPTransport = httpTransport; +beforeEach(() => { + vi.spyOn(transport, "runCommand").mockRejectedValue(new Error("Unexpected native command")); + vi.spyOn(transport, "cliPath").mockImplementation((name, _home, env) => { + const root = roots.find(root => env.PATH === root); + if (!root || name !== "codex") throw new Error("Unexpected provider process"); + return join(root, name); + }); + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Unexpected network request"); })); + vi.spyOn(transport, "httpTransport").mockImplementation(fetcher => realHTTPTransport(fetcher ?? fetch)); +}); +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +const now = Date.parse("2026-10-07T12:00:00Z"), account = "11111111-1111-1111-1111-111111111111", org = "22222222-2222-2222-2222-222222222222", client = "33333333-3333-3333-3333-333333333333"; +const password = "synthetic-safe-storage-password"; +function encrypt(raw: string | Buffer) { + const cipher = createCipheriv("aes-128-cbc", pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1"), Buffer.alloc(16, 32)); + return Buffer.concat([Buffer.from("v10"), cipher.update(raw), cipher.final()]); +} +const cacheKey = (owner = account) => `acct:${owner}|${client}:${org}:https://api.anthropic.com:user:profile user:inference`; +function config(entries: Record, owner = account) { + return JSON.stringify({ lastKnownAccountUuid: owner, "oauth:tokenCacheV2": encrypt(JSON.stringify(entries)).toString("base64"), privateMetadata: "OMIT_PRIVATE_METADATA" }); +} +const entry = (token = "synthetic-access-one", expiresAt = now + 3600000) => ({ token, expiresAt, refreshToken: "OMIT_REFRESH_TOKEN" }); +function desktop(home: string, owner = account, token = "synthetic-access-one") { + const root = join(home, "Library/Application Support/Claude"); mkdirSync(root, { recursive: true }); + writeFileSync(join(root, "config.json"), config({ [cacheKey(owner)]: entry(token) }, owner)); + const db = new DatabaseSync(join(root, "Cookies")); + try { + db.exec("CREATE TABLE IF NOT EXISTS cookies (name TEXT, host_key TEXT, value TEXT, encrypted_value BLOB, last_update_utc INTEGER); DELETE FROM cookies;"); + const host = ".claude.ai", plain = Buffer.concat([createHash("sha256").update(host).digest(), Buffer.from(org)]); + db.prepare("INSERT INTO cookies VALUES (?, ?, ?, ?, ?)").run("lastActiveOrg", host, "", encrypt(plain), 1); + } finally { db.close(); } +} +async function prefs(home = temp(), demo = false) { + const launch = launchConfiguration(demo ? ["--demo"] : [], {}, home); + const preferences = await Preferences.load(launch, async () => ""); + return { launch, preferences }; +} +function fakeCodex(root: string, failure: boolean | "wait" = false, logoutFailure: boolean | "wait" = false) { + const binary = join(root, "codex"); + writeFileSync(binary, `#!${process.execPath}\nconst fs = require('node:fs'), path = require('node:path'), rl = require('node:readline').createInterface({input:process.stdin}); +const send = m => process.stdout.write(JSON.stringify(m)+'\\n'); +rl.on('line', line => { const r = JSON.parse(line); +fs.appendFileSync(path.join(process.env.CODEX_HOME,'requests'), r.method+'\\n'); +if (r.method==='initialize') send({id:r.id, result:{}}); +if (r.method==='config/read') send({id:r.id,result:{config:{cli_auth_credentials_store:'keyring'}}}); +if (r.method==='account/login/start') { +${failure === true ? "send({id:r.id,error:{code:-1,message:'OMIT_RAW_AUTH_ERROR'}});" : (failure === "wait" ? "" : "send({method:'account/login/completed',params:{loginId:'fixture-login',success:true}});") + "send({id:r.id,result:{type:'chatgpt',loginId:'fixture-login',authUrl:'https://auth.openai.com/authorize?state=fixture'}});"} +} +if (r.method==='account/rateLimits/read') send({id:r.id,result:{rateLimits:{primary:{usedPercent:25,windowDurationMins:300}}}}); +if (r.method==='account/logout') { ${logoutFailure === "wait" ? "" : `send(${logoutFailure ? "{id:r.id,error:{code:-1}}" : "{id:r.id,result:{}}"});`} } +});\n`); + chmodSync(binary, 0o755); return binary; +} +function savedPreferences(): Command { + const stored = new Map(); + return async (_binary, args) => { + const [operation, , key, type, value] = args; + if (operation === "write") { stored.set(key, { type: type === "-bool" ? "boolean" : "string", value }); return ""; } + const saved = stored.get(key); if (!saved) throw new Error("Absent fixture preference"); + return operation === "read-type" ? saved.type : saved.value; + }; +} + +it("allows only fixed provider connection requests and approved HTTPS login hosts", () => { + for (const provider of ["codex", "claude", "grok"]) expect(parseRequest({ action: "connect", provider })).toEqual({ action: "connect", provider }); + for (const raw of [{ action: "connect", provider: "claude", path: "/tmp" }, { action: "connect", provider: "other" }, { action: "setPreference", key: "desktopClaudeIdentity", value: "off" }]) expect(() => parseRequest(raw)).toThrow(); + expect(codexLoginURL("https://auth.openai.com/authorize?state=fixture")).toContain("auth.openai.com"); + for (const raw of ["http://auth.openai.com", "https://auth.openai.com.evil.invalid", "https://user@chatgpt.com", "https://chatgpt.com:444", "file:///tmp"]) expect(() => codexLoginURL(raw)).toThrow(); +}); + +it("disables a cancelled Codex login even when logout fails and retains only its cleanup profile", async () => { + const root = temp(); fakeCodex(root, "wait", true); + const launch = launchConfiguration([], { PATH: root }, root), preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + let opened!: () => void; const ready = new Promise(resolve => { opened = resolve; }); + const pending = connections.connect("codex", async () => { opened(); }); await ready; + const id = preferences.values.desktopCodexCleanup || preferences.values.desktopCodexConnection; + connections.cancel("codex"); await expect(pending).rejects.toThrow("Could not clear"); + expect(connections.enabled("codex")).toBe(false); + expect(preferences.values.desktopCodexConnection).toBe("off"); + expect(preferences.values.desktopCodexCleanup).toBe(id); + expect(connections.state.find(s => s.provider === "codex")?.status).toBe("failed"); + await expect(connections.quota("codex")).rejects.toMatchObject({ code: "notSignedIn" }); + await expect(connections.connect("codex", async () => {})).rejects.toThrow("Could not clear"); +}); + +it("disables a connected Codex account before failed disconnect cleanup", async () => { + const root = temp(); fakeCodex(root, false, true); + const launch = launchConfiguration([], { PATH: root }, root), preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + await connections.connect("codex", async () => {}); + const id = preferences.values.desktopCodexConnection; + await expect(connections.disconnect("codex")).rejects.toThrow("Could not disconnect"); + expect(connections.enabled("codex")).toBe(false); + expect(preferences.values.desktopCodexConnection).toBe("off"); + expect(preferences.values.desktopCodexCleanup).toBe(id); + expect(connections.state.find(s => s.provider === "codex")?.status).toBe("failed"); + await expect(connections.quota("codex")).rejects.toMatchObject({ code: "notSignedIn" }); +}); + +it("recovers retained Codex cleanup on explicit sign-in retry before enabling collection", async () => { + const root = temp(); fakeCodex(root, "wait", true); + const launch = launchConfiguration([], { PATH: root }, root), preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + let opened!: () => void; const ready = new Promise(resolve => { opened = resolve; }); + const cancelled = connections.connect("codex", async () => { opened(); }); await ready; + const oldID = String(preferences.values.desktopCodexCleanup); + connections.cancel("codex"); await expect(cancelled).rejects.toThrow("Could not clear"); + fakeCodex(root); + const rpc = transport.codexRPC, cleanupProfiles: string[] = []; + vi.spyOn(transport, "codexRPC").mockImplementation(async (binary, options, creditID, configuration, operation) => { + expect(operation).toBe("logout"); cleanupProfiles.push(options.env!.CODEX_HOME!); + expect(connections.enabled("codex")).toBe(false); + expect(preferences.values.desktopCodexConnection).toBe("off"); + expect(preferences.values.desktopCodexCleanup).toBe(oldID); + await expect(connections.quota("codex")).rejects.toMatchObject({ code: "notSignedIn" }); + const raw = await rpc(binary, options, creditID, configuration, operation); + expect(connections.enabled("codex")).toBe(false); + return raw; + }); + let newLoginOpened = false; + await expect(connections.connect("codex", async () => { newLoginOpened = true; })).resolves.toBe(true); + expect(cleanupProfiles).toEqual([join(launch.data, "connections/codex", oldID)]); + expect(readFileSync(join(cleanupProfiles[0], "requests"), "utf8").match(/account\/logout/g)).toHaveLength(2); + expect(newLoginOpened).toBe(true); + expect(preferences.values.desktopCodexCleanup).toBe(""); + expect(preferences.values.desktopCodexConnection).not.toBe(oldID); + expect(connections.enabled("codex")).toBe(true); +}); + +it("persists an unfinished login only for cleanup and never enables polling after a crash", async () => { + const root = temp(); fakeCodex(root, "wait"); + const launch = launchConfiguration([], { PATH: root }, root), command = savedPreferences(), preferences = await Preferences.load(launch, command); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + let opened!: () => void; const ready = new Promise(resolve => { opened = resolve; }); + const pending = connections.connect("codex", async () => { opened(); }); await ready; + try { + const restored = await Preferences.load(launch, command); + const restart = new DesktopConnections(launch, restored, "/not-a-helper", () => {}); + expect(restored.values.desktopCodexCleanup).toMatch(/^[a-f\d-]{36}$/); + expect(restored.values.desktopCodexConnection).toBe("off"); + expect(restart.enabled("codex")).toBe(false); + await expect(restart.quota("codex")).rejects.toMatchObject({ code: "notSignedIn" }); + } finally { connections.cancel("codex"); await expect(pending).rejects.toThrow(); } +}); + +it("recovers only the persisted owned profile on startup without enabling collection", async () => { + const root = temp(); fakeCodex(root); + const launch = launchConfiguration([], { PATH: root }, root), command = savedPreferences(), preferences = await Preferences.load(launch, command); + // Simulate a crash between committing the active profile and clearing cleanup. + await preferences.set("desktopCodexCleanup", client); await preferences.set("desktopCodexConnection", client); + const restored = await Preferences.load(launch, command); + const connections = new DesktopConnections(launch, restored, "/not-a-helper", () => {}); + expect(connections.enabled("codex")).toBe(false); + await connections.recover(); + const saved = await Preferences.load(launch, command); + expect(saved.values.desktopCodexCleanup).toBe(""); expect(saved.values.desktopCodexConnection).toBe("off"); + expect(connections.enabled("codex")).toBe(false); + expect(readFileSync(join(launch.data, "connections/codex", client, "requests"), "utf8")).toBe("initialize\ninitialized\nconfig/read\naccount/logout\n"); +}); + +it("retains failed startup cleanup across restarts while collection stays disabled", async () => { + const root = temp(); fakeCodex(root, false, true); + const launch = launchConfiguration([], { PATH: root }, root), command = savedPreferences(), preferences = await Preferences.load(launch, command); + await preferences.set("desktopCodexCleanup", client); await preferences.set("desktopCodexConnection", client); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + await expect(connections.recover()).rejects.toThrow("Could not clear"); + const saved = await Preferences.load(launch, command); + expect(saved.values.desktopCodexCleanup).toBe(client); expect(saved.values.desktopCodexConnection).toBe("off"); + expect(connections.enabled("codex")).toBe(false); + expect(connections.state.find(s => s.provider === "codex")?.status).toBe("failed"); +}); + +it("waits for cancelled login cleanup on stop without logging out established connections", async () => { + const root = temp(); fakeCodex(root, "wait"); + const launch = launchConfiguration([], { PATH: root }, root), preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + let opened!: () => void; const ready = new Promise(resolve => { opened = resolve; }); + const pending = connections.connect("codex", async () => { opened(); }).catch(() => false); await ready; + const id = String(preferences.values.desktopCodexCleanup); + await connections.stop(); + expect(preferences.values.desktopCodexCleanup).toBe(""); expect(preferences.values.desktopCodexConnection).toBe("off"); + expect(readFileSync(join(launch.data, "connections/codex", id, "requests"), "utf8")).toContain("account/logout"); + expect(await pending).toBe(false); + const connectedRoot = temp(); fakeCodex(connectedRoot); + const connectedLaunch = launchConfiguration([], { PATH: connectedRoot }, connectedRoot), connectedPrefs = await Preferences.load(connectedLaunch, async () => ""); + const established = new DesktopConnections(connectedLaunch, connectedPrefs, "/not-a-helper", () => {}); + await established.connect("codex", async () => {}); + const connectedID = String(connectedPrefs.values.desktopCodexConnection); + await established.stop(); + expect(connectedPrefs.values.desktopCodexConnection).toBe(connectedID); + expect(readFileSync(join(connectedLaunch.data, "connections/codex", connectedID, "requests"), "utf8")).not.toContain("account/logout"); +}); + +it("bounds stop and retains cleanup when a logout helper does not answer", async () => { + const root = temp(); fakeCodex(root, "wait", "wait"); + const launch = launchConfiguration([], { PATH: root }, root), preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + let opened!: () => void; const ready = new Promise(resolve => { opened = resolve; }); + const pending = connections.connect("codex", async () => { opened(); }).catch(() => false); await ready; + const id = preferences.values.desktopCodexCleanup, started = performance.now(); + await connections.stop(100); + expect(performance.now() - started).toBeLessThan(1500); + expect(preferences.values.desktopCodexConnection).toBe("off"); expect(preferences.values.desktopCodexCleanup).toBe(id); + expect(await pending).toBe(false); +}); +it("completes out-of-order official login notifications and polls only account allowance", async () => { + const root = temp(), opened: string[] = [], binary = fakeCodex(root); + const raw = await connectCodex(binary, { CODEX_HOME: root }, async url => { opened.push(url); }); + expect(JSON.parse(raw).result.rateLimits.primary.usedPercent).toBe(25); + expect(opened).toEqual(["https://auth.openai.com/authorize?state=fixture"]); + expect(readFileSync(join(root, "requests"), "utf8")).toBe("initialize\ninitialized\nconfig/read\naccount/login/start\naccount/rateLimits/read\n"); +}); +it("sanitizes helper failures and cancels without starting a process", async () => { + const root = temp(), binary = fakeCodex(root, true); + await expect(connectCodex(binary, { CODEX_HOME: root }, async () => {})).rejects.toThrow("Codex sign-in unavailable"); + const aborted = new AbortController(); aborted.abort(); + await expect(connectCodex("/not-a-helper", {}, async () => {}, aborted.signal)).rejects.toThrow("cancelled"); +}); +it("selects only current account/org access tokens and rejects stale, ambiguous and deleted entries", () => { + const credential = desktopCredential(config({ [cacheKey()]: entry() }), org, password, now); + expect(credential.token).toBe("synthetic-access-one"); expect(credential.identity).toMatch(/^[a-f\d]{64}$/); + expect(JSON.stringify(credential)).not.toContain("OMIT"); + for (const entries of [{ [cacheKey()]: entry("expired", now) }, { [cacheKey()]: null }, { [cacheKey("44444444-4444-4444-4444-444444444444")]: entry() }, { [cacheKey()]: entry(), [cacheKey().replace(client, "55555555-5555-5555-5555-555555555555")]: entry("second") }]) { + expect(() => desktopCredential(config(entries), org, password, now)).toThrow(); + } + expect(() => desktopCredential(config({ [cacheKey()]: entry() }), "other-org", password, now)).toThrow(); + expect(() => desktopCredential(config({ [cacheKey()]: entry() }), org, "wrong-password", now)).toThrow(); +}); +it("normalizes local Claude file, SQLite, decryption, JSON and helper failures to notSignedIn", async () => { + const home = temp(); + await expect(readDesktopCredential(home, password, now)).rejects.toMatchObject({ code: "notSignedIn" }); + for (const raw of ["{", config({ [cacheKey()]: entry() }).replace(/oauth:tokenCacheV2/, "unused")]) { + expect(() => desktopCredential(raw, org, password, now)).toThrow("Not signed in"); + } + expect(() => desktopCredential(config({ [cacheKey()]: entry() }), org, "wrong-password", now)).toThrow("Not signed in"); + desktop(home); + writeFileSync(join(home, "Library/Application Support/Claude/Cookies"), "invalid synthetic sqlite"); + await expect(readDesktopCredential(home, password, now)).rejects.toMatchObject({ code: "notSignedIn" }); + const { launch, preferences } = await prefs(); desktop(launch.home); + await preferences.set("desktopClaudeIdentity", desktopCredential(config({ [cacheKey()]: entry() }), org, password, now).identity); + const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, async () => { throw new Error("OMIT_PRIVATE_HELPER_ERROR"); }, async () => { throw new Error("must not request"); }, () => now); + await expect(connections.quota("claude")).rejects.toMatchObject({ code: "notSignedIn" }); +}); + +it.each(["disconnect", "replace"])("does not reread Claude credentials after %s during an allowance request", async action => { + const { launch, preferences } = await prefs(); desktop(launch.home); + await preferences.set("desktopClaudeIdentity", desktopCredential(config({ [cacheKey()]: entry() }), org, password, now).identity); + let release!: (raw: string) => void, started!: () => void, reads = 0; + const response = new Promise(resolve => { release = resolve; }); + const ready = new Promise(resolve => { started = resolve; }); + const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, async () => { reads++; return password; }, async () => { started(); return response; }, () => now); + const pending = connections.quota("claude"); await ready; + expect(reads).toBe(1); + if (action === "disconnect") await connections.disconnect("claude"); + else await preferences.set("desktopClaudeIdentity", "a".repeat(64)); + release('{"five_hour":{"utilization":25}}'); + await expect(pending).rejects.toMatchObject({ code: "notSignedIn" }); + expect(reads).toBe(1); +}); + +it("verifies the current Claude identity after HTTP failure and permits fallback only for that verified account", async () => { + const { launch, preferences } = await prefs(); desktop(launch.home); + await preferences.set("desktopClaudeIdentity", desktopCredential(config({ [cacheKey()]: entry() }), org, password, now).identity); + let switchAccount = false, denyHelper = false, reads = 0; + const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, async () => { + reads++; if (denyHelper) throw new Error("OMIT_PRIVATE_HELPER_ERROR"); return password; + }, async () => { + if (switchAccount) desktop(launch.home, "66666666-6666-6666-6666-666666666666"); + throw new Unavailable("offline"); + }, () => now); + await expect(connections.quota("claude")).rejects.toMatchObject({ code: "offline" }); + expect(reads).toBe(2); + switchAccount = true; + await expect(connections.quota("claude")).rejects.toMatchObject({ code: "notSignedIn" }); + desktop(launch.home); switchAccount = false; + const revoked = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, async () => { + if (denyHelper) throw new Error("OMIT_PRIVATE_HELPER_ERROR"); return password; + }, async () => { denyHelper = true; throw new Unavailable("failed", "Claude"); }, () => now); + await expect(revoked.quota("claude")).rejects.toMatchObject({ code: "notSignedIn" }); +}); +it("connects Claude with consent, refreshes rotated access tokens, rejects account switches and disconnects without desktop writes", async () => { + const { launch, preferences } = await prefs(); desktop(launch.home); + const calls: string[][] = [], tokens: string[] = [], urls: string[] = []; + const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, async (_binary, args) => { calls.push(args); return password; }, async (url, _provider, token) => { urls.push(url); tokens.push(token!); return '{"five_hour":{"utilization":25}}'; }, () => now); + expect(await connections.connect("claude", async () => {}, async () => false)).toBe(false); expect(calls).toEqual([]); + expect(await connections.connect("claude", async () => {}, async () => true)).toBe(true); + expect(calls).toEqual([["--allow-ui"], []]); + const source = compose(launch, preferences, { connections }).find(s => s.slot.provider === "claude")!; + expect(source.activity).toBeUndefined(); expect(source.plan).toBeUndefined(); + expect((await source.quota!()).windows[0].usedPercent).toBe(25); + desktop(launch.home, account, "synthetic-access-two"); await source.quota!(); expect(tokens.at(-1)).toBe("synthetic-access-two"); + desktop(launch.home, "66666666-6666-6666-6666-666666666666"); + await expect(source.quota!()).rejects.toThrow("Not signed in"); + const before = readFileSync(join(launch.home, "Library/Application Support/Claude/config.json"), "utf8"); + await connections.disconnect("claude"); expect(preferences.values.desktopClaudeIdentity).toBe("off"); + expect(readFileSync(join(launch.home, "Library/Application Support/Claude/config.json"), "utf8")).toBe(before); + expect(urls.every(url => url === endpoints.claudeAllowance)).toBe(true); + expect(JSON.stringify(connections.state)).not.toMatch(/synthetic-access|11111111|OMIT/); + expect(JSON.stringify(projectSettings(preferences.values, [], launch.home))).not.toContain(String(preferences.values.desktopClaudeIdentity)); +}); +it("blocks demo and unavailable Grok access and never falls back after a configured disconnect", async () => { + const { launch, preferences } = await prefs(undefined, true); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}, async () => { throw new Error("must not read"); }); + await expect(connections.connect("claude", async () => {})).rejects.toThrow("demo mode"); + await expect(connections.disconnect("codex")).rejects.toThrow("demo mode"); + const live = await prefs(), real = new DesktopConnections(live.launch, live.preferences, "/not-a-helper", () => {}); + await expect(real.connect("grok", async () => {})).rejects.toThrow("unavailable"); + await real.disconnect("codex"); + const source = compose(live.launch, live.preferences, { connections: real }).find(s => s.slot.provider === "codex")!; + expect(source.activity).toBeUndefined(); expect(source.consumeReset).toBeUndefined(); + await expect(source.quota!()).rejects.toThrow("Not signed in"); +}); +it("keeps Codex helper authentication isolated and removes only its own login on disconnect", async () => { + const root = temp(), binary = fakeCodex(root), launch = launchConfiguration([], { PATH: root, OPENAI_API_KEY: "OMIT_INHERITED_API_KEY" }, root); + const preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + expect(await connections.connect("codex", async () => {})).toBe(true); + const id = String(preferences.values.desktopCodexConnection), profile = join(launch.data, "connections/codex", id); + expect((await connections.quota("codex")).windows[0].usedPercent).toBe(25); + await connections.disconnect("codex"); expect(preferences.values.desktopCodexConnection).toBe("off"); + expect(readFileSync(join(profile, "requests"), "utf8")).toContain("account/logout"); + expect(binary).toBe(join(root, "codex")); +}); +it("uses the MeterUsage identity and bounded HTTP without provider CLI impersonation", async () => { + const calls: RequestInit[] = []; + const http = httpTransport(async (_url, init) => { calls.push(init!); return new Response('{"five_hour":{"used_percentage":0}}'); }); + await http(endpoints.claudeAllowance, "claude", "synthetic-access"); + expect(calls[0].redirect).toBe("error"); expect(calls[0].headers).toMatchObject({ "User-Agent": "meterusage/0.2.41", "anthropic-beta": "oauth-2025-04-20" }); +}); +it("retains only numeric Claude allowance windows and preserves missing versus measured zero", () => { + const q = desktopQuota('{"five_hour":{"utilization":0,"resets_at":"2026-10-07T13:00:00Z"},"seven_day":null,"metadata":"OMIT_METADATA"}', now); + expect(q.windows).toHaveLength(1); expect(q.windows[0].usedPercent).toBe(0); expect(JSON.stringify(q)).not.toContain("OMIT"); + for (const raw of ['{}', '{"five_hour":{"utilization":-1}}', '{"five_hour":{"utilization":101}}', '{"five_hour":{"utilization":0,"resets_at":"wrong"}}']) expect(() => desktopQuota(raw, now)).toThrow(); +}); +it("rejects effective plaintext storage before login and cleans up an in-progress cancelled login", async () => { + const deniedRoot = temp(), denied = fakeCodex(deniedRoot); + writeFileSync(denied, readFileSync(denied, "utf8").replace("cli_auth_credentials_store:'keyring'", "cli_auth_credentials_store:'file'")); + await expect(connectCodex(denied, { CODEX_HOME: deniedRoot }, async () => {})).rejects.toThrow("unavailable"); + expect(readFileSync(join(deniedRoot, "requests"), "utf8")).not.toContain("account/login/start"); + const root = temp(); fakeCodex(root, "wait"); + const launch = launchConfiguration([], { PATH: root }, root), preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + let opened!: () => void; const ready = new Promise(r => { opened = r; }); + const pending = connections.connect("codex", async () => { opened(); }); await ready; + const id = String(preferences.values.desktopCodexCleanup); + connections.cancel("codex"); await expect(pending).rejects.toThrow("try connecting again"); + expect(preferences.values.desktopCodexConnection).toBe("off"); + expect(readFileSync(join(launch.data, "connections/codex", id, "requests"), "utf8")).toContain("account/logout"); +}); diff --git a/tests/coordinator.test.ts b/tests/coordinator.test.ts index 9c49d65..302713a 100644 --- a/tests/coordinator.test.ts +++ b/tests/coordinator.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vite-plus/test"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, realpathSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -8,11 +8,149 @@ import { launchConfiguration } from "../src/main/launch"; import { compose, type Source } from "../src/main/composition"; import { primary, slotKey, quota, window, value, missing, tokens, Unavailable } from "../src/domain/models"; import { AlertEvaluator } from "../src/domain/alerts"; +import { DesktopConnections } from "../src/main/connections"; +import { runJSON } from "../src/main/cli"; +import * as launchModule from "../src/main/launch"; +import * as transport from "../src/main/providers/transport"; +import * as claudeDesktop from "../src/main/providers/claude-desktop"; const roots: string[] = [], controllers: Coordinator[] = []; const temp = () => { const root = mkdtempSync(join(realpathSync(tmpdir()), "meterusage-fixture-")); roots.push(root); return root; }; -afterEach(() => { for (const c of controllers.splice(0)) c.stop(); for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); }); +beforeEach(() => { + vi.spyOn(transport, "runCommand").mockRejectedValue(new Error("Unexpected native command")); + vi.spyOn(transport, "httpTransport").mockReturnValue(async () => { throw new Error("Unexpected HTTP"); }); + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("Unexpected network request"); })); +}); +afterEach(() => { for (const c of controllers.splice(0)) c.stop(); vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); }); const now = Date.parse("2026-10-06T12:00:00Z"); async function setup(sources?: Source[], time = () => now) { const root = temp(), launch = launchConfiguration(["--demo", "--candidate-profile", root], {}), prefs = await Preferences.load(launch); const c = new Coordinator(launch, prefs, sources ?? compose(launch, prefs, { now: time }), time); controllers.push(c); return { c, root, prefs }; } describe("coordinator refresh journey", () => { + it.each(["switch", "revoke"])("discards unverified account quota on deadline after a Claude %s", async action => { + const { c: initial, root, prefs } = await setup([]), launch = { ...initial.launch, demo: false }; + let identity = "a".repeat(64), denied = false, hold = false, release!: () => void, started!: () => void; + const response = new Promise(resolve => { release = resolve; }); + const ready = new Promise(resolve => { started = resolve; }); + prefs.values.desktopClaudeIdentity = identity; prefs.values.showProviderClaude = true; + const credentials = vi.spyOn(claudeDesktop, "readDesktopCredential").mockImplementation(async () => { + if (denied) throw new Unavailable("notSignedIn"); + return { identity, token: "synthetic-access" }; + }); + const http: transport.HTTP = async url => { + if (url !== transport.endpoints.claudeAllowance) return '{"components":[]}'; + if (hold) { started(); await response; throw new Unavailable("offline"); } + return '{"five_hour":{"utilization":42}}'; + }; + const connections = new DesktopConnections(launch, prefs, "/synthetic/helper", () => {}, async () => "synthetic-password", http, () => now); + const source = compose(launch, prefs, { connections, http, now: () => now }).find(s => s.slot.provider === "claude")!; + const c = new Coordinator(launch, prefs, [source], () => now); controllers.push(c); + await c.refresh(); expect(c.snapshot().archived.claude.windows[0].usedPercent).toBe(42); + hold = true; vi.useFakeTimers(); + const pending = c.refresh(); await ready; + if (action === "switch") identity = "b".repeat(64); else denied = true; + await vi.advanceTimersByTimeAsync(30000); await pending; + // The deadline wins before the source can verify the post-request identity. + expect(credentials).toHaveBeenCalledTimes(3); + const snapshot = c.snapshot(), archive = readFileSync(join(root, "quota-archive.json"), "utf8"); + release(); await vi.advanceTimersByTimeAsync(0); + expect(credentials).toHaveBeenCalledTimes(4); + expect(c.quotas.claude).toMatchObject({ status: "missing", code: "failed" }); + expect(snapshot.archived.claude).toBeUndefined(); + expect(c.snapshot().archived.claude).toBeUndefined(); + expect(archive).not.toContain('"claude"'); + }); + + it("retains account quota only after an offline request completes Claude identity verification", async () => { + const { c: initial, root, prefs } = await setup([]), launch = { ...initial.launch, demo: false }; + const identity = "a".repeat(64); let offline = false; + prefs.values.desktopClaudeIdentity = identity; prefs.values.showProviderClaude = true; + const credentials = vi.spyOn(claudeDesktop, "readDesktopCredential").mockResolvedValue({ identity, token: "synthetic-access" }); + const http: transport.HTTP = async url => { + if (url !== transport.endpoints.claudeAllowance) return '{"components":[]}'; + if (offline) throw new Unavailable("offline"); + return '{"five_hour":{"utilization":42}}'; + }; + const connections = new DesktopConnections(launch, prefs, "/synthetic/helper", () => {}, async () => "synthetic-password", http, () => now); + const source = compose(launch, prefs, { connections, http, now: () => now }).find(s => s.slot.provider === "claude")!; + const c = new Coordinator(launch, prefs, [source], () => now); controllers.push(c); + await c.refresh(); offline = true; await c.refresh(); + expect(credentials).toHaveBeenCalledTimes(4); + expect(c.quotas.claude).toMatchObject({ status: "missing", code: "offline" }); + expect(c.snapshot().archived.claude.windows[0].usedPercent).toBe(42); + expect(readFileSync(join(root, "quota-archive.json"), "utf8")).toContain('"claude"'); + }); + + it("retains ordinary quota archives after a source deadline", async () => { + let hold = false, release!: (q: ReturnType) => void; + const source: Source = { slot: primary("codex"), quota: () => hold ? new Promise(resolve => { release = resolve; }) : Promise.resolve(quota("codex", [window("5-hour", 42)], now)) }; + const { c } = await setup([source]); await c.refresh(); + hold = true; vi.useFakeTimers(); const pending = c.refresh(); + await vi.advanceTimersByTimeAsync(30000); await pending; + expect(c.quotas.codex).toMatchObject({ status: "missing", code: "failed" }); + expect(c.snapshot().archived.codex.windows[0].usedPercent).toBe(42); + release(quota("codex", [window("5-hour", 99)], now)); await vi.advanceTimersByTimeAsync(0); + expect(c.snapshot().archived.codex.windows[0].usedPercent).toBe(42); + }); + + it("keeps established connection collection after clearing the scan cache", async () => { + vi.spyOn(transport, "runCommand").mockRejectedValue(new Error("Synthetic command unavailable")); + vi.spyOn(transport, "httpTransport").mockReturnValue(async () => { throw new Unavailable("offline"); }); + const { c: initial, prefs } = await setup([]), launch = { ...initial.launch, demo: false }; + prefs.values.desktopClaudeIdentity = "a".repeat(64); prefs.values.showProviderClaude = true; + const connections = new DesktopConnections(launch, prefs, "synthetic-helper", () => {}); + vi.spyOn(connections, "quota").mockResolvedValue(quota("claude", [window("Weekly · All models", 42)], now)); + const factory = () => compose(launch, prefs, { connections, http: async () => { throw new Unavailable("offline"); } }).filter(s => s.slot.provider === "claude"); + const c = new Coordinator(launch, prefs, factory(), () => now, () => {}, factory); controllers.push(c); + await c.refresh(); expect(c.quotas.claude).toMatchObject({ status: "value", value: { windows: [{ usedPercent: 42 }] } }); + await c.clearCache(); expect(c.quotas.claude).toMatchObject({ status: "value", value: { windows: [{ usedPercent: 42 }] } }); + expect(c.sources[0].activity).toBeUndefined(); + }); + it("uses established connections for JSON without starting sign-in or consent", async () => { + const { c, prefs } = await setup([]), launch = { ...c.launch, demo: false }; + prefs.values.desktopCodexConnection = "00000000-0000-0000-0000-000000000001"; prefs.values.desktopClaudeIdentity = "a".repeat(64); + vi.spyOn(launchModule, "launchConfiguration").mockReturnValue(launch); + vi.spyOn(Preferences, "load").mockResolvedValue(prefs); + vi.spyOn(transport, "runCommand").mockRejectedValue(new Error("Synthetic command unavailable")); + vi.spyOn(transport, "httpTransport").mockReturnValue(async () => { throw new Error("Unexpected HTTP"); }); + const reads = vi.spyOn(DesktopConnections.prototype, "quota").mockImplementation(async provider => quota(provider, [window("Weekly · All models", 42)], now)); + const connect = vi.spyOn(DesktopConnections.prototype, "connect").mockRejectedValue(new Error("Unexpected sign-in")); + const report = JSON.parse(await runJSON(["--json"], {}, "synthetic-helper")); + for (const provider of ["codex", "claude"]) expect(report.providers.find((p: { provider: string }) => p.provider === provider)).toMatchObject({ status: "ok", windows: [{ used_percent: 42 }] }); + expect(reads.mock.calls.map(([provider]) => provider).sort()).toEqual(["claude", "codex"]); expect(connect).not.toHaveBeenCalled(); + expect(reads.mock.instances.every(connection => (connection as DesktopConnections).helper === "synthetic-helper")).toBe(true); + reads.mockRestore(); prefs.values.desktopCodexConnection = prefs.values.desktopClaudeIdentity = "off"; + const disconnected = JSON.parse(await runJSON(["--json"], {}, "synthetic-helper")); + for (const provider of ["codex", "claude"]) expect(disconnected.providers.find((p: { provider: string }) => p.provider === provider)).toMatchObject({ status: "unavailable", windows: [] }); + expect(connect).not.toHaveBeenCalled(); + }); + it("publishes reset capability from the current source rather than earned credits", async () => { + const reading = () => ({ ...quota("codex", [], now), canConsumeReset: true, resetCreditCount: 1, resetCredits: [{ id: "fixture", title: "Reset", status: "available" }] }); + const { c } = await setup([{ slot: primary("codex"), quota: async () => reading() }]); + await c.refresh(); expect(c.snapshot().quotas.codex).toMatchObject({ status: "value", value: { canConsumeReset: false } }); + c.sources = [{ slot: primary("codex"), quota: async () => reading(), consumeReset: async () => true }]; + await c.refresh(); expect(c.snapshot().quotas.codex).toMatchObject({ status: "value", value: { canConsumeReset: true } }); + }); + it("re-arms only the replaced connection's alert suppression", async () => { + const { c, prefs } = await setup(["codex", "claude"].map(provider => ({ slot: primary(provider as "codex" | "claude"), quota: async () => { + const q = quota(provider as "codex" | "claude", [window("5-hour", 96)], now); + q.resetCredits = [{ id: "same-credit", title: "Reset", status: "available", expiresAt: now + 3600000 }]; return q; + } }))); + prefs.values.showProviderClaude = true; prefs.values.quotaAlertsEnabled = true; + const notifications: string[] = []; + const next = new Coordinator(c.launch, prefs, [...c.sources], () => now, alert => notifications.push(`${alert.slot.provider}/${alert.kind}`)); controllers.push(next); + await next.refresh(); expect(notifications).toEqual(["codex/threshold", "codex/expiringCredit", "claude/threshold", "claude/expiringCredit"]); + notifications.length = 0; next.forgetQuota("codex"); await next.refresh(); + expect(notifications).toEqual(["codex/threshold", "codex/expiringCredit"]); + }); + it("drops prior-account quota when a desktop connection expires and discards replaced-source results", async () => { + let resolve!: (q: ReturnType) => void; + const old: Source = { slot: primary("claude"), quota: () => new Promise(r => { resolve = r; }) }; + const { c, root } = await setup([old]); + c.archived.claude = quota("claude", [window("Weekly", 90)], now); + const pending = c.refresh(); await Promise.resolve(); + c.forgetQuota("claude"); + c.sources = [{ slot: primary("claude"), accountBound: true, quota: async () => { throw new Unavailable("notSignedIn", "Claude Desktop"); } }]; + resolve(quota("claude", [window("Weekly", 99)], now)); await pending; + expect(c.quotas.claude).toMatchObject({ status: "missing", code: "notSignedIn" }); expect(c.archived.claude).toBeUndefined(); + expect(readFileSync(join(root, "quota-archive.json"), "utf8")).not.toContain("99"); + }); it("drops unknown history fields before durable merge, persistence and renderer publication", async () => { const { c, root, prefs } = await setup([]), path = join(root, "durable-daily-history.json"); c.stop(); writeFileSync(path, JSON.stringify({ claude: [{ dayISO: "2026-10-06", tokens: { ...tokens({ input: 100 }), privatePayload: "OMIT_HISTORY_TOKEN_FIELD" }, estimatedCostUSD: 1, sessionCount: 2, peakUsedPercent: 70, unknown: "OMIT_HISTORY_RECORD_FIELD" }] })); @@ -120,6 +258,17 @@ describe("explicit per-account reset journey", () => { }); }); describe("alert edge and recency parity", () => { + it("clears every suppression kind for one slot without clearing a same-provider account", () => { + const a = primary("codex"), b = { ...a, slotID: "other", label: "Other" }, evaluator = new AlertEvaluator(); + const q = quota("codex", [window("5-hour", 96, now + 4 * 3600000, 300)], now); + q.resetCredits = [{ id: "fixture", title: "Reset", status: "available", expiresAt: now + 3600000 }]; + const readings = { codex: value(q), "codex#other": value(q) }, burns = { codex: now, "codex#other": now }; + expect(evaluator.events([a, b], readings, burns, now)).toHaveLength(8); + expect(evaluator.events([a, b], readings, burns, now)).toEqual([]); evaluator.forgetSlot("codex"); + const events = evaluator.events([a, b], readings, burns, now); + expect(events.map(e => e.kind)).toEqual(["threshold", "paceSoftWarning", "paceCliff", "expiringCredit"]); + expect(events.every(e => slotKey(e.slot) === "codex")).toBe(true); + }); it("alerts only highest threshold, re-arms after reset and isolates accounts", () => { const a = primary("codex"), b = { provider: "codex" as const, slotID: "other", label: "Other" }, evaluator = new AlertEvaluator(); const state = (used: number) => value(quota("codex", [window("5-hour", used)], now)); diff --git a/tests/ipc.test.ts b/tests/ipc.test.ts index ccc4358..0271866 100644 --- a/tests/ipc.test.ts +++ b/tests/ipc.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect } from "vite-plus/test"; import { parseRequest, trustedSender, projectSettings } from "../src/main/ipc"; describe("renderer boundary", () => { + it("requires primitive provider names before connection consent", () => { + for (const action of ["connect", "disconnect", "connectionCancel"]) { + for (const provider of [["claude"], ["codex"], { toString: () => "claude" }, null, 1]) expect(() => parseRequest({ action, provider })).toThrow("Invalid request"); + for (const provider of ["codex", "claude", "grok"]) expect(parseRequest({ action, provider })).toEqual({ action, provider }); + } + }); it("rejects arbitrary paths, settings, malformed accounts and unknown fields", () => { for (const r of [null, [], { action: "open", path: "/tmp" }, { action: "accountAdd", provider: "codex", path: "/tmp" }, { action: "setPreference", key: "managedAccounts", value: [] }, { action: "setPreference", key: "updateLastCheckDate", value: 1 }, { action: "accountUpdate", id: "../a", label: "a", enabled: true }, { action: "reset", key: "codex#../a", creditID: "a" }, { action: "refresh", args: [] }, { action: "resize", height: Infinity }]) expect(() => parseRequest(r)).toThrow(); expect(parseRequest({ action: "setPreference", key: "showProviderCodex", value: true })).toEqual({ action: "setPreference", key: "showProviderCodex", value: true }); diff --git a/tests/providers.test.ts b/tests/providers.test.ts index eec1110..bc070a7 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -105,7 +105,9 @@ describe("activity and account boundaries", () => { const activity = await new ClaudeActivitySource(root, cache).scan(now); expect(activity.sessions).toEqual(original.sessions); expect(activity.daily).toEqual(original.daily); const history = new HistoryStore(join(root, "history.json")); expect(() => history.record("claude", activity.daily)).not.toThrow(); - expect(history.records("claude")[0].tokens.output).toBe(7); expect(readFileSync(cache, "utf8")).not.toContain("777"); + expect(history.records("claude")[0].tokens.output).toBe(7); + const repaired = JSON.parse(readFileSync(cache, "utf8"))[opaqueID(path)].result; + expect(repaired.tokens.output).toBe(7); expect(repaired.dailyBuckets[0].bucket.tokens.output).toBe(7); }); it("keeps valid Claude sessions when siblings disappear or cannot be read", async () => { const root = temp(), project = join(root, "-fixture-project"); mkdirSync(project); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 3993b2f..16769d1 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -1,7 +1,7 @@ import { it, expect, vi } from "vite-plus/test"; import { createElement, useState } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { App, Meter, ProviderCard } from "../src/renderer/app"; +import { App, Connections, Meter, ProviderCard } from "../src/renderer/app"; import { tokens, primary, quota, value, window } from "../src/domain/models"; import type { ViewState } from "../src/shared/ipc"; import { trayTooltip } from "../src/domain/overview"; @@ -19,6 +19,17 @@ function renderApp(view: ViewState, surface = "flyout") { } const now = Date.parse("2026-10-06T12:00:00Z"); +it("offers simple account setup and truthful Grok availability without directories in the primary flow", () => { + const view = state(); view.snapshot.demo = false; + view.connections = [{ provider: "codex", status: "disconnected" }, { provider: "claude", status: "connected" }, { provider: "grok", status: "unsupported" }]; + const markup = renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} })); + expect(markup).toContain("Sign in to Codex"); expect(markup).toContain("Disconnect"); expect(markup).toContain("Account allowance"); + expect(markup).toContain("Automatic connection is not available yet"); expect(markup).not.toMatch(/directory|terminal|API key/); + view.connections[0].status = "connecting"; + expect(renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} }))).toContain("Cancel"); + view.snapshot.demo = true; view.connections[0].status = "disconnected"; + expect(renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} }))).toContain("disabled"); +}); const w = window("5-hour", 80, now + 4 * 3600000, 300); const session = (id: string, at: number, output: number, cacheRead = 0) => ({ id, projectName: id, model: "fixture", tokens: tokens({ output, cacheRead }), estimatedCostUSD: 0, startedAt: at, messageCount: 10 }); const state = (): ViewState => ({ snapshot: { @@ -40,6 +51,7 @@ it("shows unavailable reset status without a redemption action", () => { q.resetCredits = [{ id: "c", title: "Reset", status }]; const markup = renderApp(view); expect(markup).not.toContain("Use reset"); expect(markup).toContain(status ?? "Status not reported"); } + q.canConsumeReset = true; q.resetCredits = [{ id: "c", title: "Reset", status: "AVAILABLE", expiresAt: now + 1000 }]; expect(renderApp(view)).toContain("Use reset"); q.resetCredits[0].expiresAt = now; expect(renderApp(view)).not.toContain("Use reset"); expect(renderApp(view)).toContain("expired"); }); @@ -47,11 +59,49 @@ it("shows unavailable reset status without a redemption action", () => { it("limits the reset visibility setting to the notch and shared detail, keeping flyout redemption", () => { const view = state(), q = quota("codex", [w], now); q.resetCreditCount = 1; q.resetCredits = [{ id: "fixture-credit", title: "Reset", status: "available", expiresAt: now + 1000 }]; view.snapshot.quotas.codex = value(q); view.snapshot.notchSlots = [primary("codex")]; + q.canConsumeReset = true; expect(renderApp(view)).toContain("Use reset"); expect(renderApp(view, "notch")).not.toContain("Use reset"); expect(renderApp(view, "share")).not.toContain("Use reset"); expect(renderApp(view, "settings")).toContain("Codex limit resets in the side notch"); view.snapshot.appearance.resetButton = true; expect(renderApp(view, "notch")).toContain("Use reset"); }); +it("hides reset actions for allowance-only sources while retaining earned credit details", () => { + const view = state(), q = quota("codex", [w], now); q.resetCreditCount = 1; + q.resetCredits = [{ id: "fixture-credit", title: "Reset", status: "available" }]; + view.snapshot.quotas.codex = value(q); view.snapshot.notchSlots = [primary("codex")]; view.snapshot.appearance.resetButton = true; + for (const capability of [undefined, false, true]) { + q.canConsumeReset = capability; + for (const surface of ["flyout", "notch", "share"]) { + const markup = renderApp(view, surface); expect(markup).toContain("1 earned reset credits"); + expect(markup.includes("Use reset")).toBe(capability === true); + } + } +}); + +it("uses general weekly-only Claude allowance in tray, tooltip and notch without model-specific fallback", () => { + const view = state(), s = view.snapshot, slot = primary("claude"); + s.slots = s.traySlots = s.notchSlots = [slot]; s.appearance.compactTray = false; + s.quotas = { claude: value(quota("claude", [window("Weekly · Fable", 99), window("Weekly · All models", 42)], now)) }; s.activities = {}; + expect(trayTooltip(s)).toBe("Claude: 42% used"); + expect(renderApp(view, "tray")).toContain("42%"); expect(renderApp(view, "notch")).toContain('aria-label="Show Claude details, 42% used"'); + s.quotas.claude = value(quota("claude", [window("Weekly · Fable", 99)], now)); + expect(trayTooltip(s)).toBe("No usage data yet"); expect(renderApp(view, "notch")).not.toContain('class="strip-provider"'); + s.quotas.claude = value(quota("claude", [window("Weekly · All models", 42), window("Session", 7)], now)); + expect(trayTooltip(s)).toBe("Claude: 7% used"); +}); + +it("keeps remote OpenRouter account usage out of explicitly local totals", () => { + const view = state(), s = view.snapshot; s.slots = [primary("codex"), primary("openRouter")]; + s.quotas = {}; s.activities.codex = value({ provider: "codex", scannedAt: now, daily: [], sessions: [session("local", now - 60000, 20)] }); + s.usages.openRouter = value({ provider: "openRouter", sessionCount: 123, messageCount: 456, todaySessionCount: 12, todayMessageCount: 45, todayTokens: tokens({ input: 999999 }), weekTokens: tokens({ input: 999999 }), todayCostUSD: 999, capturedAt: now }); + const markup = renderApp(view), start = markup.indexOf("AI activity on this Mac"), local = markup.slice(start, markup.indexOf("", start)); + expect(local).toContain("Tokens today
20
"); expect(local).toContain("Last 7 days
20
"); expect(local).toContain("$0.00"); + const card = renderToStaticMarkup(createElement(ProviderCard, { slot: primary("openRouter"), state: view, action: async () => {} })); + expect(card).toContain("Account activity"); expect(card).not.toContain("Activity on this Mac"); expect(card).toContain("123"); + s.slots = [primary("openRouter")]; s.activities = {}; + expect(renderApp(view)).not.toContain("AI activity on this Mac"); +}); + it("shows rolling cost shares before token shares and handles an empty 30-day reference", () => { const view = state(); view.snapshot.slots = [primary("openCodeGo")]; view.snapshot.activities = {}; view.snapshot.quotas = {}; const usage = { provider: "openCodeGo" as const, sessionCount: 10, messageCount: 50, todaySessionCount: 1, todayMessageCount: 2, capturedAt: now, From ad090cf84937ab19b9927bc66044975ff204ebca Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:31:29 -0400 Subject: [PATCH 2/8] fix: bound demo notch scrolling and guard destroyed tray --- CHANGELOG.md | 4 ++ docs/KB.md | 2 + docs/SIDE-NOTCH.md | 6 +++ src/main/electron.ts | 1 + src/renderer/app.tsx | 2 +- src/renderer/style.css | 1 + tests/electron-lifecycle.test.ts | 64 ++++++++++++++++++++++++++++++++ tests/renderer.test.ts | 6 +++ 8 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 tests/electron-lifecycle.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e201f7..4dfffff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Bound browser-demo notch cards and strips to the viewport height. Keep + provider rings at full width while the strip scrolls. +- Ignore display and theme publications after the candidate tray is destroyed. + - Reject malformed connection provider values before consent and target the bundled Desktop Keychain helper explicitly at arm64/macOS 13. - Disable cancelled or disconnected account collection before cleanup, recover diff --git a/docs/KB.md b/docs/KB.md index d60e82a..4118e61 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -37,6 +37,8 @@ Last source review: 2026-10-07 - Windows implementation, installers, CI, testing and releases are paused until a test machine is available. The former RN/Tauri Windows-first plan is superseded; shared portable source does not establish Windows support. - Candidate checks use synthetic fixtures. Demo/candidate selection isolates preferences, history, caches and Electron state before composition. Live provider/key discovery, updater installation and login-item changes are disabled in demo. `pnpm package:mac` creates a separate candidate with publication disabled, using existing macOS tools. - Candidate account visibility rechecks directory presence. Sharing freezes the displayed account key. Pacing-off hides forecasts; notch details reuse window-scoped burn metrics and have a bounded scroller. These contracts have synthetic/static-markup proof; native UI acceptance remains open. +- The browser-demo notch uses viewport height when no native work-area height is supplied. The card and strip scroll independently; the strip retains all 44 pixels for its rings. Synthetic browser checks at 294x500 and 294x900 cover scrolling, two card heights and both CSS card sides. These checks do not verify native window placement or display changes. +- Candidate state publication returns before touching a destroyed tray. A synthetic Electron entrypoint regression emits display and theme events after tray destruction; native shutdown/display behavior remains an acceptance check. - The desktop-connection candidate has matching passing source proof for 165 synthetic TypeScript tests, TypeScript checking and production bundling on Linux. Its connection cleanup, account verification, cache/JSON readers, reset capability, alerts and locality labels have regression coverage. Disconnect blocks Claude's post-request credential reread; account-bound collection timeout removes unverified allowance; explicit Codex sign-in retry recovers retained cleanup before a new login. The unchanged migration baseline's headless demo comparison preserves stable schema-1 fields against the Swift demo oracle; that older native receipt does not verify the new connections. Source checks do not establish native parity or a published Electron release. - The first macOS fixture attempt passed 90 cases and failed 29 due to temporary-path aliases and a case-sensitive filename assertion. Canonical roots and exact directory-entry checks now pass all 128 current fixtures on macOS, along with TypeScript checking and production bundling. Candidate symlink rejection is unchanged. - A temporary candidate-local pnpm PATH alias resolved the dependency collector's broken launcher without changing host configuration. At source `193c2af42bd2a8641accbb3f7b5b333ba6cd9ac3`, the Apple silicon ZIP is 116,696,318 bytes with SHA-256 `4ea7a6f297402958a52173696880f8183a05454c2e08966d71ebeaa69f52e634`. Its extracted bundle passes `codesign --verify --deep --strict` with an ad-hoc signature and hardened runtime. It is not notarized, installed or published. diff --git a/docs/SIDE-NOTCH.md b/docs/SIDE-NOTCH.md index f94434d..7519c8e 100644 --- a/docs/SIDE-NOTCH.md +++ b/docs/SIDE-NOTCH.md @@ -87,6 +87,12 @@ explicit "Show details" action rather than a focusable control. ## Verifying a change +The Electron candidate bounds the card and strip independently. The native +window supplies the display work-area height; the browser demo uses the viewport +height when that value is absent. Tall content scrolls inside each column. The +strip hides its scrollbar to retain the full 44-pixel ring width. The shared-card +capture keeps its full height and does not use the notch's scrolling bounds. + Automated tests cover geometry and in-process drag events, but cannot see the rendered result. For any change to side notch layout or motion, capture the panel for at least two providers whose cards differ in height, and confirm the diff --git a/src/main/electron.ts b/src/main/electron.ts index 21fe214..228d84e 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -67,6 +67,7 @@ async function start() { }); const folding = new NotchFold(() => prefs.values.sideNotchPanelPinned === true || sharing || confirmingReset || notch.dragging, () => { notch.expanded = false; placeNotch(); publish(); }); function publish() { + if (tray.isDestroyed()) return; const s = state(); for (const [surface, w] of windows) if (!w.isDestroyed()) w.webContents.send(stateChannel, surface === "settings" || surface === "share" ? state(surface) : s); tray.setToolTip(trayTooltip(s.snapshot)); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index f393ec4..7b9cdfd 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -154,6 +154,6 @@ export function App({ bridge }: { bridge: Bridge }) { const action = async (r: Request) => { setError(""); try { const reply = await bridge.request(r); if (!reply.ok) setError(reply.error); } catch { setError("Could not complete action"); } }; if (!state) return

{error || "Loading usage…"}

; const s = state.snapshot; - const style = { "--accent": accents[s.appearance.accent] ?? accents.blue, "--notch-max-height": `${state.notch.maxHeight ?? 2000}px` } as CSSProperties; + const style = { "--accent": accents[s.appearance.accent] ?? accents.blue, "--notch-max-height": state.notch.maxHeight === undefined ? "100dvh" : `${state.notch.maxHeight}px` } as CSSProperties; return
{error &&
{error}
}{surface === "share" ? (() => { const slot = notchEntries(s).find(e => e.key === state.notch.selected)?.slot; return slot ? :

Card unavailable

; })() : surface === "tray" ? : surface === "settings" ? : surface === "notch" ? :

MeterUsage {s.demo && Demo}

{!s.appearance.onboarding &&

Your usage, at a glance

Connect Codex or Claude Desktop below. Account allowance comes from the provider. Local activity covers this Mac; OpenRouter activity covers its account.

}{state.update &&
MeterUsage {state.update.version} available
}{s.historyError &&

History could not be {s.historyError === "loadFailed" ? "read. Repair the file and relaunch to resume saving" : "saved. Live readings remain available"}.

}{s.archiveWriteFailed &&

Last known quota could not be saved.

}{s.slots.length ? s.slots.map(slot => ) :

Enable a provider in Settings.

}
{s.lastRefreshedAt === undefined ? "Not refreshed yet" : `Updated ${new Date(s.lastRefreshedAt).toLocaleTimeString()}`}
}
; } diff --git a/src/renderer/style.css b/src/renderer/style.css index 5f6d26e..15b776e 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -27,4 +27,5 @@ .surface-share { width: 250px; background: transparent; } .surface-notch .detail-card, .surface-notch .strip { max-height: var(--notch-max-height); overflow-y: auto; } +.surface-notch .strip { scrollbar-width: none; } .notch-eta { font-size: 8px; line-height: 1.2; white-space: nowrap; border-radius: 5px; padding: 1px 2px; background: var(--well); } diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts new file mode 100644 index 0000000..e54f093 --- /dev/null +++ b/tests/electron-lifecycle.test.ts @@ -0,0 +1,64 @@ +import { it, expect, vi } from "vite-plus/test"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +it("ignores display and theme updates after the tray is destroyed", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage-lifecycle-")); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + const application = Object.assign(new EventEmitter(), { + isPackaged: false, setName: vi.fn(), whenReady: () => Promise.resolve(), + requestSingleInstanceLock: () => true, getVersion: () => "0.0.0", quit: vi.fn(), exit: vi.fn(), + }); + const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ workArea: { height: 900 } }) }); + const theme = new EventEmitter(); + let destroyed = false; + const tooltip = vi.fn(() => { if (destroyed) throw new Error("Tray is destroyed"); }); + class TestTray extends EventEmitter { + setToolTip = tooltip; + isDestroyed = () => destroyed; + destroy() { destroyed = true; } + } + class TestWindow extends EventEmitter { + webContents = { id: 1, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; + isDestroyed = () => false; + loadURL = () => Promise.resolve(); + } + const start = vi.fn(async () => {}); + vi.doMock("electron", () => ({ app: application, Tray: TestTray, BrowserWindow: TestWindow, + screen: display, nativeTheme: theme, powerMonitor: new EventEmitter(), ipcMain: { handle: vi.fn() }, + nativeImage: { createFromPath: () => ({ resize: () => ({ setTemplateImage: vi.fn() }) }) }, + session: { defaultSession: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), webRequest: { onBeforeRequest: vi.fn() } } }, + })); + vi.doMock("../src/main/launch", () => ({ launchConfiguration: () => ({ data: root, home: root, demo: false }) })); + vi.doMock("../src/main/preferences", () => ({ Preferences: { load: async () => ({ values: { onboardingCompleted: true }, accounts: [] }) } })); + vi.doMock("../src/main/composition", () => ({ compose: () => [] })); + vi.doMock("../src/main/connections", () => ({ DesktopConnections: class { + state = []; configured = () => false; recover = async () => {}; stop = async () => {}; + } })); + vi.doMock("../src/main/coordinator", () => ({ Coordinator: class { + start = start; stop = vi.fn(); subscribe = vi.fn(); snapshot = () => ({ appearance: {} }); + } })); + vi.doMock("../src/main/updater", () => ({ Updater: class { reset = vi.fn(); } })); + vi.doMock("../src/domain/overview", () => ({ trayTooltip: () => "Synthetic usage" })); + try { + Object.defineProperty(process, "platform", { value: "darwin" }); + vi.stubGlobal("__dirname", root); + await import("../src/main/electron"); + await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + expect(() => display.emit("display-metrics-changed")).not.toThrow(); + expect(tooltip).toHaveBeenCalledWith("Synthetic usage"); + destroyed = true; + expect(() => display.emit("display-metrics-changed")).not.toThrow(); + expect(() => display.emit("display-removed")).not.toThrow(); + expect(() => theme.emit("updated")).not.toThrow(); + } finally { + application.emit("before-quit", { preventDefault: vi.fn() }); + await Promise.resolve(); + Object.defineProperty(process, "platform", platform); + vi.unstubAllGlobals(); + for (const path of ["electron", "../src/main/launch", "../src/main/preferences", "../src/main/composition", "../src/main/connections", "../src/main/coordinator", "../src/main/updater", "../src/domain/overview"]) vi.doUnmock(path); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 16769d1..406c376 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -19,6 +19,12 @@ function renderApp(view: ViewState, surface = "flyout") { } const now = Date.parse("2026-10-06T12:00:00Z"); +it("bounds browser notch content to the viewport while retaining native work-area sizing", () => { + const view = state(); view.snapshot.notchSlots = [primary("codex")]; + expect(renderApp(view, "notch")).toContain("--notch-max-height:100dvh"); + view.notch.maxHeight = 900; + expect(renderApp(view, "notch")).toContain("--notch-max-height:900px"); +}); it("offers simple account setup and truthful Grok availability without directories in the primary flow", () => { const view = state(); view.snapshot.demo = false; view.connections = [{ provider: "codex", status: "disconnected" }, { provider: "claude", status: "connected" }, { provider: "grok", status: "unsupported" }]; From fffb9e5d7b01a09bb3b925d4dbdedd9c1302a639 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:34:08 -0400 Subject: [PATCH 3/8] fix: simplify settings and repair desktop helper launches --- CHANGELOG.md | 12 ++++++++++ docs/DESKTOP-CONNECTIONS.md | 5 ++++- docs/KB.md | 3 +++ docs/PRIVACY.md | 2 +- docs/SIDE-NOTCH.md | 3 ++- src/main/connections.ts | 6 ++--- src/main/electron.ts | 5 +++-- src/main/providers/transport.ts | 2 +- src/renderer/app.tsx | 4 ++-- src/renderer/style.css | 12 +++++++--- tests/connections.test.ts | 38 ++++++++++++++++++++++++++++++-- tests/electron-lifecycle.test.ts | 29 +++++++++++++++++++----- tests/providers.test.ts | 2 +- tests/renderer.test.ts | 27 +++++++++++++++++++++++ 14 files changed, 128 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dfffff..7f8ad88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ follows [Semantic Versioning](https://semver.org/). ### Changed +- Group candidate Settings by task, show six accent color swatches, and label + provider collection separately from menu-bar and side-notch display. + Turning collection off retains the saved display preference. + - Label local activity as activity on this Mac, keep folder-based accounts under advanced settings, and omit local CLI activity from separately connected account cards. Record the new opt-in authentication boundary. @@ -41,9 +45,17 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Launch Codex account helpers with the documented stdio transport. Keep + interpreter search paths available without inheriting credential variables. +- Keep provider display controls available for active additional accounts when + primary collection is off, and isolate connection-test helper discovery. + - Bound browser-demo notch cards and strips to the viewport height. Keep provider rings at full width while the strip scrolls. +- Hide scrollbar indicators throughout the candidate while retaining scrolling. - Ignore display and theme publications after the candidate tray is destroyed. +- Retain each window's document ID before destruction so closing a candidate + window does not access destroyed web contents or remove its replacement. - Reject malformed connection provider values before consent and target the bundled Desktop Keychain helper explicitly at arm64/macOS 13. diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index 8ad0fe4..e7a6e71 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -45,7 +45,10 @@ Claude chat tokens and Grok consumer history are outside this implementation. Codex uses a generated MeterUsage-owned helper profile. Before login or polling, the candidate checks the effective helper configuration for `keyring` storage. -It refuses another mode, including plaintext-file fallback. The browser URL +It refuses another mode, including plaintext-file fallback. Login, allowance +reads and own-profile logout use the helper's `--listen stdio://` transport. +The helper environment keeps interpreter search paths without forwarding +inherited credential variables. The browser URL must use HTTPS on an approved OpenAI host. Disconnect invokes logout only for that generated profile. Cancellation attempts the same cleanup; a cleanup failure is reported. diff --git a/docs/KB.md b/docs/KB.md index 4118e61..bc463b9 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -39,6 +39,9 @@ Last source review: 2026-10-07 - Candidate account visibility rechecks directory presence. Sharing freezes the displayed account key. Pacing-off hides forecasts; notch details reuse window-scoped burn metrics and have a bounded scroller. These contracts have synthetic/static-markup proof; native UI acceptance remains open. - The browser-demo notch uses viewport height when no native work-area height is supplied. The card and strip scroll independently; the strip retains all 44 pixels for its rings. Synthetic browser checks at 294x500 and 294x900 cover scrolling, two card heights and both CSS card sides. These checks do not verify native window placement or display changes. - Candidate state publication returns before touching a destroyed tray. A synthetic Electron entrypoint regression emits display and theme events after tray destruction; native shutdown/display behavior remains an acceptance check. +- Candidate window cleanup uses the document ID captured while the window is alive. Closing a destroyed window cannot read its web contents or remove a replacement window for the same surface. The synthetic lifecycle regression exercises this path; native shutdown remains an acceptance check. +- Candidate Settings groups General, Providers, Appearance, Side notch, Usage details and Diagnostics. Accent color uses six labelled radio swatches. Provider controls distinguish collection from menu-bar and side-notch display; stopping primary collection preserves the saved display preference. The display control stays available when an additional account has an active source slot. Advanced local accounts remain available. Scrollbar indicators are hidden throughout candidate windows, with scrolling and keyboard access retained. These renderer contracts do not establish native UI parity. +- Codex login, allowance and own-profile logout select `app-server --listen stdio://`. Connection subprocesses keep interpreter search paths while excluding inherited credential variables. Connection tests restrict app-helper discovery to generated fixture roots. These are source contracts, not proof of a live helper or cloud account. - The desktop-connection candidate has matching passing source proof for 165 synthetic TypeScript tests, TypeScript checking and production bundling on Linux. Its connection cleanup, account verification, cache/JSON readers, reset capability, alerts and locality labels have regression coverage. Disconnect blocks Claude's post-request credential reread; account-bound collection timeout removes unverified allowance; explicit Codex sign-in retry recovers retained cleanup before a new login. The unchanged migration baseline's headless demo comparison preserves stable schema-1 fields against the Swift demo oracle; that older native receipt does not verify the new connections. Source checks do not establish native parity or a published Electron release. - The first macOS fixture attempt passed 90 cases and failed 29 due to temporary-path aliases and a case-sensitive filename assertion. Canonical roots and exact directory-entry checks now pass all 128 current fixtures on macOS, along with TypeScript checking and production bundling. Candidate symlink rejection is unchanged. - A temporary candidate-local pnpm PATH alias resolved the dependency collector's broken launcher without changing host configuration. At source `193c2af42bd2a8641accbb3f7b5b333ba6cd9ac3`, the Apple silicon ZIP is 116,696,318 bytes with SHA-256 `4ea7a6f297402958a52173696880f8183a05454c2e08966d71ebeaa69f52e634`. Its extracted bundle passes `codesign --verify --deep --strict` with an ad-hoc signature and hardened runtime. It is not notarized, installed or published. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index ef3c66d..0d6cc87 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -14,7 +14,7 @@ cutover acceptance remain pending. See [ADR 0010](adr/0010-macos-typescript-elec | Provider | Read or request | Retained data | | --- | --- | --- | -| Codex | `codex app-server --stdio` initializes the CLI and calls `account/rateLimits/read`. The CLI uses its own existing authentication. Local rollout headers, model context and cumulative token-ledger events are selected from `sessions/**/*.jsonl`. | Quota windows/groups, plan, balances, reset credits, timestamps, numeric tokens and model. Session paths become opaque IDs; workspace directories become basenames. | +| Codex | `codex app-server --listen stdio://` initializes the CLI and calls `account/rateLimits/read`. The CLI uses its own existing authentication. Local rollout headers, model context and cumulative token-ledger events are selected from `sessions/**/*.jsonl`. | Quota windows/groups, plan, balances, reset credits, timestamps, numeric tokens and model. Session paths become opaque IDs; workspace directories become basenames. | | Claude | Local transcript usage fields under `projects/`, three tier keys from `.claude.json`, and optional companion quota JSON. | Numeric tokens/cost estimates, model, dates, project basename, plan tier and quota windows. Transcript message content and identifying tier metadata are skipped. | | OpenRouter | Existing `OPENROUTER_API_KEY` or supported local key file for `/api/v1/key` and `/api/v1/credits`. An existing management key, with configured API-key fallback, is used for `/api/v1/activity`. | Aggregate usage, balances, limits, dates, request counts and token totals. | | OpenCode Go | Existing `opencode-go` key in the local auth store for `https://opencode.ai/zen/go/v1/usage`. Read-only session SQL, with `opencode db --format json` fallback, selects tokens, cost, message counts, dates and directory. | Quota windows, numeric usage and project basename. No message bodies, prompts or tool arguments are selected. | diff --git a/docs/SIDE-NOTCH.md b/docs/SIDE-NOTCH.md index 7519c8e..2664e3d 100644 --- a/docs/SIDE-NOTCH.md +++ b/docs/SIDE-NOTCH.md @@ -90,7 +90,8 @@ explicit "Show details" action rather than a focusable control. The Electron candidate bounds the card and strip independently. The native window supplies the display work-area height; the browser demo uses the viewport height when that value is absent. Tall content scrolls inside each column. The -strip hides its scrollbar to retain the full 44-pixel ring width. The shared-card +candidate hides scrollbar indicators throughout its windows while retaining +scrolling. The strip retains the full 44-pixel ring width. The shared-card capture keeps its full height and does not use the notch's scrolling bounds. Automated tests cover geometry and in-process drag events, but cannot see the diff --git a/src/main/connections.ts b/src/main/connections.ts index 50547d1..c4bceb8 100644 --- a/src/main/connections.ts +++ b/src/main/connections.ts @@ -3,7 +3,7 @@ import { join } from "node:path"; import { randomUUID } from "node:crypto"; import type { Launch } from "./launch"; import { Preferences } from "./preferences"; -import { boundedChild, cliPath, codexRPC, endpoints, httpTransport, runCommand, type Command, type HTTP } from "./providers/transport"; +import { boundedChild, cliEnvironment, cliPath, codexRPC, endpoints, httpTransport, runCommand, type Command, type HTTP } from "./providers/transport"; import { object, parseCodex } from "./providers/parsers"; import { desktopQuota, readDesktopCredential } from "./providers/claude-desktop"; import { selectJSON } from "./select-json"; @@ -18,7 +18,7 @@ export function codexLoginURL(raw: unknown): string { return url.href; } export function connectCodex(binary: string, env: NodeJS.ProcessEnv, open: (url: string) => Promise, signal?: AbortSignal) { - return boundedChild(binary, [...configuration, "app-server", "--stdio"], { env, signal, timeoutMs: 180000, maxBytes: 1048576 }, (child, finish) => { + return boundedChild(binary, [...configuration, "app-server", "--listen", "stdio://"], { env, signal, timeoutMs: 180000, maxBytes: 1048576 }, (child, finish) => { const write = (message: unknown) => child.stdin.write(JSON.stringify(message) + "\n"); let pending = "", loginID: string | undefined, completion: Record | undefined; const completed = () => { @@ -79,7 +79,7 @@ export class DesktopConnections { private env(id: string) { const home = join(this.launch.data, "connections", "codex", id); mkdirSync(home, { recursive: true, mode: 0o700 }); - return { HOME: this.launch.home, PATH: "/usr/bin:/bin", CODEX_HOME: home, TMPDIR: this.launch.env.TMPDIR }; + return { HOME: this.launch.home, PATH: cliEnvironment(this.launch.home, { PATH: this.launch.env.PATH }).PATH, CODEX_HOME: home, TMPDIR: this.launch.env.TMPDIR }; } private async claude(allowUI: boolean, signal?: AbortSignal) { try { diff --git a/src/main/electron.ts b/src/main/electron.ts index 228d84e..47ea0d8 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -89,10 +89,11 @@ async function start() { }); } const document = pathToFileURL(join(__dirname, "../renderer/index.html")); document.searchParams.set("surface", surface); - documents.set(w.webContents.id, document.href); windows.set(surface, w); + const webContentsId = w.webContents.id; + documents.set(webContentsId, document.href); windows.set(surface, w); w.webContents.setWindowOpenHandler(() => ({ action: "deny" })); w.webContents.on("will-navigate", e => e.preventDefault()); w.webContents.on("will-attach-webview", e => e.preventDefault()); - w.on("closed", () => { documents.delete(w.webContents.id); windows.delete(surface); if (panel) finishDrag(); }); + w.on("closed", () => { documents.delete(webContentsId); if (windows.get(surface) === w) windows.delete(surface); if (panel) finishDrag(); }); if (surface === "flyout") w.on("blur", () => { if (!sharing) w.hide(); }); if (panel) { w.setAlwaysOnTop(true, "status"); w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } void w.loadURL(document.href).then(() => { publish(); if (panel) { placeNotch(); w.showInactive(); } }); diff --git a/src/main/providers/transport.ts b/src/main/providers/transport.ts index c2a1cfb..962981c 100644 --- a/src/main/providers/transport.ts +++ b/src/main/providers/transport.ts @@ -50,7 +50,7 @@ export const runCommand: Command = (binary, args, options = {}) => boundedChild( child.stdin.end(); }); export async function codexRPC(binary: string, options: CommandOptions, creditID?: string, configuration: string[] = [], operation: "read" | "logout" = "read"): Promise { - return boundedChild(binary, [...configuration, "app-server", "--stdio"], options, (child, finish) => { + return boundedChild(binary, [...configuration, "app-server", "--listen", "stdio://"], options, (child, finish) => { const write = (v: unknown) => child.stdin.write(JSON.stringify(v) + "\n"); const request = () => write({ jsonrpc: "2.0", id: 2, method: operation === "logout" ? "account/logout" : creditID === undefined ? "account/rateLimits/read" : "account/rateLimitResetCredit/consume", params: creditID === undefined ? {} : { creditId: creditID, idempotencyKey: randomUUID() } }); let pending = "", initialized = false; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 7b9cdfd..b56691f 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -84,8 +84,8 @@ export function Connections({ state, action }: { state: ViewState; action: (r: R function Settings({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { const values = state.settings.values; const set = (key: string, value: string | boolean | number) => void action({ action: "setPreference", key, value }); - const toggle = (key: string, text: string) => ; - return

Settings

General

{["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))}

Providers

{providers.map(p =>
{toggle(providerKey(p), providerNames[p])}{toggle(trayKey(p), "Menu bar and notch")}
)}
Advanced: local accounts{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

Appearance

{Object.entries(labels).filter(([k]) => !["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].includes(k)).map(([k, text]) => toggle(k, text))}

Diagnostics

Provider files and durable history stay intact.

MeterUsage 0.2.41 · Cost figures are local estimates.

; + const toggle = (key: string, text: string, ariaLabel?: string, disabled = false) => ; + return

Settings

General

{["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))}

Providers

Collect usageShow in menu bar and side notch
{providers.map(p => { const enabled = values[providerKey(p)] === true || state.snapshot.slots.some(slot => slot.provider === p && slot.slotID); return
{toggle(providerKey(p), providerNames[p], `Collect ${providerNames[p]} usage`)}
{toggle(trayKey(p), "Show", `Show ${providerNames[p]} in menu bar and side notch`, !enabled)}
; })}
Advanced: local accounts{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

Appearance

Accent color{Object.entries(accents).map(([name, color]) => )}

Side notch

{["sideNotchPanelEnabled", "sideNotchPanelPinned", "showSideNotchResetButton"].map(k => toggle(k, labels[k]))}

Usage details

{["menuBarCompactEnabled", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart"].map(k => toggle(k, labels[k]))}

Diagnostics

Provider files and durable history stay intact.

MeterUsage 0.2.41 · Cost figures are local estimates.

; } function AccountRow({ account: a, action }: { account: ViewState["settings"]["accounts"][number]; action: (r: Request) => Promise }) { const [label, setLabel] = useState(a.label); diff --git a/src/renderer/style.css b/src/renderer/style.css index 15b776e..5d60eac 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -1,12 +1,19 @@ @import "tailwindcss"; :root { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; font-kerning: normal; font-variant-numeric: tabular-nums; line-height: 1.45; color-scheme: light dark; background: transparent; } -* { box-sizing: border-box; } body { margin: 0; } h1,h2,h3,p { margin: 0; } h1 { font-size: 17px; font-weight: 650; } h2 { font-size: 14px; font-weight: 650; } h3 { font-size: 11px; font-weight: 600; color: var(--muted); margin: 12px 0 7px; } p + p { margin-top: 5px; } button,input,select { font: inherit; } button { border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; background: var(--well); color: var(--text); cursor: pointer; } button:active { background: var(--border); } button:disabled { opacity: .45; cursor: default; } button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } input,select { color: var(--text); background: var(--well); border: 1px solid var(--border); border-radius: 5px; padding: 4px 6px; } input[type="checkbox"] { accent-color: var(--accent); width: 16px; height: 16px; } +* { box-sizing: border-box; scrollbar-width: none; } *::-webkit-scrollbar { display: none; } body { margin: 0; } h1,h2,h3,p { margin: 0; } h1 { font-size: 17px; font-weight: 650; } h2 { font-size: 14px; font-weight: 650; } h3 { font-size: 11px; font-weight: 600; color: var(--muted); margin: 12px 0 7px; } p + p { margin-top: 5px; } button,input,select { font: inherit; } button { border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; background: var(--well); color: var(--text); cursor: pointer; } button:active { background: var(--border); } button:disabled { opacity: .45; cursor: default; } button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } input,select { color: var(--text); background: var(--well); border: 1px solid var(--border); border-radius: 5px; padding: 4px 6px; } input[type="checkbox"] { accent-color: var(--accent); width: 16px; height: 16px; } .app { --grok: #1e1e22; --claude: #c25e00; --antigravity: #2563eb; --opencode: #0f766e; --openrouter: #6d28d9; --canvas: #fafafc; --surface: #fff; --well: #ececf1; --border: #00000018; --text: #242426; --muted: #696973; --good: #238636; --warn: #a86800; --alert: #c93636; color: var(--text); background: var(--canvas); } .theme-dark { --grok: #ebebf0; --claude: #d97706; --antigravity: #60a5fa; --opencode: #2dd4bf; --openrouter: #a78bfa; --canvas: #1c1c1e; --surface: #262629; --well: #343438; --border: #ffffff19; --text: #efeff2; --muted: #a3a3ad; --good: #30d158; --warn: #ffb340; --alert: #ff6961; color-scheme: dark; } .theme-light { color-scheme: light; } @media (prefers-color-scheme: dark) { .theme-system { --grok: #ebebf0; --claude: #d97706; --antigravity: #60a5fa; --opencode: #2dd4bf; --openrouter: #a78bfa; --canvas: #1c1c1e; --surface: #262629; --well: #343438; --border: #ffffff19; --text: #efeff2; --muted: #a3a3ad; --good: #30d158; --warn: #ffb340; --alert: #ff6961; } } .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .secondary { color: var(--muted); font-size: 11px; } .stale { font-size: 11px; color: var(--warn); margin: 8px 0; } .empty { padding: 20px; color: var(--muted); } .badge { color: var(--muted); background: var(--well); padding: 2px 6px; border-radius: 5px; font-size: 10px; } .app-header { margin-bottom: 12px; } .app-header button { margin-left: 5px; } .app-header small { color: var(--warn); font-size: 11px; font-weight: 400; } .flyout { padding: 14px; } .provider-card { padding: 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface); margin-bottom: 10px; } .provider-heading { display: flex; align-items: center; gap: 7px; min-width: 0; } .provider-heading h2 { overflow-wrap: anywhere; } .mark { flex-shrink: 0; width: 16px; height: 16px; display: inline-block; mask-size: contain; mask-repeat: no-repeat; mask-position: center; } .pending-symbol { text-align: center; font-weight: 650; } .status { margin: 5px 0; } .quota-window { margin: 10px 0; } .meter { background: var(--well); height: 5px; border-radius: 4px; overflow: hidden; margin: 5px 0; } .meter span { display: block; height: 100%; border-radius: inherit; } .quota-window .secondary { align-items: flex-start; } .quota-window .secondary > :last-child { text-align: right; } .well { background: var(--well); padding: 8px; border-radius: 6px; } .credit { margin: 10px 0; } .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin: 10px 0; } dt { font-size: 10px; color: var(--muted); } dd { font-size: 13px; margin: 0; } .sparkline { width: 100%; height: 45px; margin: 6px 0; } .heatmap { display: grid; grid-template-columns: repeat(15, 1fr); gap: 3px; margin: 8px 0; } .heatmap span { aspect-ratio: 1; border-radius: 2px; } .session { padding: 7px 0; border-bottom: 1px solid var(--border); } .session span { overflow-wrap: anywhere; } summary { color: var(--muted); font-size: 11px; cursor: pointer; padding-top: 8px; } .reset-credit { padding: 6px 0; } footer { margin-top: 12px; } .welcome p { margin: 8px 0; } -.settings { padding: 24px; max-width: 680px; margin: auto; } .settings section { margin: 22px 0; } .settings h2 { border-bottom: 1px solid var(--border); padding-bottom: 7px; margin-bottom: 5px; } .setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; } .provider-setting { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .provider-setting .setting:nth-child(2) { color: var(--muted); font-size: 11px; } .account-row { border: 1px solid var(--border); border-radius: 8px; padding: 8px; margin-bottom: 10px; } .account-row input[type="text"], .account-row input:not([type]) { width: 100%; } .account-row label { flex: 1; } .settings .secondary { margin-top: 6px; } .action-error { background: var(--well); color: var(--alert); padding: 8px; font-size: 11px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.settings { padding: 24px; max-width: 680px; margin: auto; } .settings section { margin: 22px 0; } .settings h2 { border-bottom: 1px solid var(--border); padding-bottom: 7px; margin-bottom: 5px; } .setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; } .provider-setting { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .account-row { border: 1px solid var(--border); border-radius: 8px; padding: 8px; margin-bottom: 10px; } .account-row input[type="text"], .account-row input:not([type]) { width: 100%; } .account-row label { flex: 1; } .settings .secondary { margin-top: 6px; } .action-error { background: var(--well); color: var(--alert); padding: 8px; font-size: 11px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } +.settings .provider-setting.provider-heading { color: var(--muted); font-size: 11px; padding: 4px 0; } +.accent-picker { border: 0; padding: 8px 0 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; } +.accent-picker legend { font-size: 11px; font-weight: 600; padding: 0; } +.accent-picker label { display: flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; text-transform: capitalize; } +.accent-picker input { accent-color: var(--accent); } +.accent-swatch { width: 12px; height: 12px; border-radius: 50%; } +@media (max-width: 620px) { .settings { padding: 16px; } .provider-setting { gap: 8px; } .provider-heading { align-items: flex-start; } } .surface-notch { width: max-content; background: transparent; } .notch { display: flex; align-items: flex-start; width: max-content; background: transparent; } .strip { width: 44px; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-radius: 8px; padding: 6px 0; flex-shrink: 0; touch-action: none; user-select: none; } .strip-settings { display: block; background: none; border: none; padding: 3px; width: 100%; color: var(--muted); } .strip-provider { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 44px; padding: 6px 3px; border: none; background: none; border-radius: 0; } .strip-provider[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 15%, transparent); } .strip-provider .mark { width: 13px; height: 13px; } .strip-provider small { font-size: 8px; max-width: 38px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ring { width: 28px; height: 28px; border-radius: 50%; padding: 3px; } .ring > span { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); font-size: 9px; } .detail-card { width: 250px; flex-shrink: 0; border-radius: 10px; margin: 0; padding: 14px; overflow: auto; } .detail-card h2 { font-size: 12px; } .detail-card .row { gap: 5px; } .detail-card .badge { font-size: 9px; } .detail-card .quota-window strong { font-size: 11px; } .detail-card .secondary { font-size: 10px; } .card-left .detail-card { order: -1; } .detail-card footer button { font-size: 11px; } @media (hover: hover) and (pointer: fine) { button:hover:not(:disabled) { border-color: var(--accent); } } @media (prefers-reduced-motion: reduce) { *,*::before,*::after { animation: none !important; transition: none !important; } } @@ -27,5 +34,4 @@ .surface-share { width: 250px; background: transparent; } .surface-notch .detail-card, .surface-notch .strip { max-height: var(--notch-max-height); overflow-y: auto; } -.surface-notch .strip { scrollbar-width: none; } .notch-eta { font-size: 8px; line-height: 1.2; white-space: nowrap; border-radius: 5px; padding: 1px 2px; background: var(--well); } diff --git a/tests/connections.test.ts b/tests/connections.test.ts index 5d731c8..624ce16 100644 --- a/tests/connections.test.ts +++ b/tests/connections.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { accessSync, chmodSync, constants, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawn } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createCipheriv, createHash, pbkdf2Sync } from "node:crypto"; @@ -14,7 +15,21 @@ import { endpoints, httpTransport, type Command } from "../src/main/providers/tr import * as transport from "../src/main/providers/transport"; import { parseRequest, projectSettings } from "../src/main/ipc"; -const roots: string[] = []; +const { roots } = vi.hoisted(() => ({ roots: [] as string[] })); +vi.mock("node:fs", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, accessSync: vi.fn((path, mode) => { + if (typeof path !== "string" || !roots.some(root => path.startsWith(root + "/"))) throw new Error("Non-fixture executable discovery"); + return actual.accessSync(path, mode); + }) }; +}); +vi.mock("node:child_process", async importOriginal => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn((...args: Parameters) => { + if (!roots.some(root => args[0].startsWith(root + "/"))) throw new Error("Non-fixture provider process"); + return actual.spawn(...args); + }) }; +}); const temp = () => { const root = mkdtempSync(join(tmpdir(), "meterusage-connection-fixture-")); roots.push(root); return root; }; const realHTTPTransport = httpTransport; beforeEach(() => { @@ -57,6 +72,7 @@ async function prefs(home = temp(), demo = false) { function fakeCodex(root: string, failure: boolean | "wait" = false, logoutFailure: boolean | "wait" = false) { const binary = join(root, "codex"); writeFileSync(binary, `#!${process.execPath}\nconst fs = require('node:fs'), path = require('node:path'), rl = require('node:readline').createInterface({input:process.stdin}); +if (JSON.stringify(process.argv.slice(-3)) !== JSON.stringify(['app-server','--listen','stdio://'])) process.exit(64); const send = m => process.stdout.write(JSON.stringify(m)+'\\n'); rl.on('line', line => { const r = JSON.parse(line); fs.appendFileSync(path.join(process.env.CODEX_HOME,'requests'), r.method+'\\n'); @@ -65,6 +81,7 @@ if (r.method==='config/read') send({id:r.id,result:{config:{cli_auth_credentials if (r.method==='account/login/start') { ${failure === true ? "send({id:r.id,error:{code:-1,message:'OMIT_RAW_AUTH_ERROR'}});" : (failure === "wait" ? "" : "send({method:'account/login/completed',params:{loginId:'fixture-login',success:true}});") + "send({id:r.id,result:{type:'chatgpt',loginId:'fixture-login',authUrl:'https://auth.openai.com/authorize?state=fixture'}});"} } + if (r.method==='account/rateLimits/read') send({id:r.id,result:{rateLimits:{primary:{usedPercent:25,windowDurationMins:300}}}}); if (r.method==='account/logout') { ${logoutFailure === "wait" ? "" : `send(${logoutFailure ? "{id:r.id,error:{code:-1}}" : "{id:r.id,result:{}}"});`} } });\n`); @@ -332,15 +349,32 @@ it("blocks demo and unavailable Grok access and never falls back after a configu }); it("keeps Codex helper authentication isolated and removes only its own login on disconnect", async () => { const root = temp(), binary = fakeCodex(root), launch = launchConfiguration([], { PATH: root, OPENAI_API_KEY: "OMIT_INHERITED_API_KEY" }, root); + symlinkSync(process.execPath, join(root, "node")); + writeFileSync(binary, readFileSync(binary, "utf8").replace(`#!${process.execPath}`, "#!/usr/bin/env node")); const preferences = await Preferences.load(launch, async () => ""); const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); expect(await connections.connect("codex", async () => {})).toBe(true); + const env = vi.mocked(spawn).mock.calls.at(-1)![2]!.env!; + expect(env.PATH!.split(":")).toContain("/opt/homebrew/bin"); + expect(env.PATH!.split(":")).toContain(root); + expect(env).not.toHaveProperty("OPENAI_API_KEY"); const id = String(preferences.values.desktopCodexConnection), profile = join(launch.data, "connections/codex", id); expect((await connections.quota("codex")).windows[0].usedPercent).toBe(25); await connections.disconnect("codex"); expect(preferences.values.desktopCodexConnection).toBe("off"); expect(readFileSync(join(profile, "requests"), "utf8")).toContain("account/logout"); expect(binary).toBe(join(root, "codex")); }); +it("discovers a synthetic installed Codex app without permitting a real installed helper to execute", async () => { + const root = temp(), resources = join(root, "Applications/Codex.app/Contents/Resources"); + mkdirSync(resources, { recursive: true }); const binary = fakeCodex(resources); + const launch = launchConfiguration([], {}, root), preferences = await Preferences.load(launch, async () => ""); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}); + expect(() => accessSync("/Applications/Codex.app/Contents/Resources/codex", constants.X_OK)).toThrow("Non-fixture"); + expect(() => spawn("/Applications/Codex.app/Contents/Resources/codex", [])).toThrow("Non-fixture"); + expect(await connections.connect("codex", async () => {})).toBe(true); + expect(vi.mocked(spawn).mock.calls.at(-1)![0]).toBe(binary); + expect(transport.cliPath).not.toHaveBeenCalled(); +}); it("uses the MeterUsage identity and bounded HTTP without provider CLI impersonation", async () => { const calls: RequestInit[] = []; const http = httpTransport(async (_url, init) => { calls.push(init!); return new Response('{"five_hour":{"used_percentage":0}}'); }); diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index e54f093..bcc936e 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -4,25 +4,35 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -it("ignores display and theme updates after the tray is destroyed", async () => { +it("cleans up destroyed windows and ignores updates after tray destruction", async () => { const root = mkdtempSync(join(tmpdir(), "meterusage-lifecycle-")); const platform = Object.getOwnPropertyDescriptor(process, "platform")!; const application = Object.assign(new EventEmitter(), { isPackaged: false, setName: vi.fn(), whenReady: () => Promise.resolve(), requestSingleInstanceLock: () => true, getVersion: () => "0.0.0", quit: vi.fn(), exit: vi.fn(), }); - const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ workArea: { height: 900 } }) }); + const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ workArea: { height: 900 } }), getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1200, height: 900 } }) }); const theme = new EventEmitter(); let destroyed = false; const tooltip = vi.fn(() => { if (destroyed) throw new Error("Tray is destroyed"); }); + let tray!: TestTray; class TestTray extends EventEmitter { + constructor(...args: ConstructorParameters) { super(...args); tray = this; } setToolTip = tooltip; isDestroyed = () => destroyed; + getBounds = () => ({ x: 0, y: 0, width: 22, height: 22 }); destroy() { destroyed = true; } } + const windows: TestWindow[] = []; class TestWindow extends EventEmitter { - webContents = { id: 1, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; - isDestroyed = () => false; + destroyed = false; + contents = { id: 1, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; + constructor(...args: ConstructorParameters) { super(...args); windows.push(this); } + get webContents() { if (this.destroyed) throw new Error("webContents is unavailable after destruction"); return this.contents; } + isDestroyed = () => this.destroyed; + isVisible = () => false; + getBounds = () => ({ x: 0, y: 0, width: 100, height: 100 }); + setPosition = vi.fn(); show = vi.fn(); focus = vi.fn(); loadURL = () => Promise.resolve(); } const start = vi.fn(async () => {}); @@ -38,7 +48,7 @@ it("ignores display and theme updates after the tray is destroyed", async () => state = []; configured = () => false; recover = async () => {}; stop = async () => {}; } })); vi.doMock("../src/main/coordinator", () => ({ Coordinator: class { - start = start; stop = vi.fn(); subscribe = vi.fn(); snapshot = () => ({ appearance: {} }); + start = start; stop = vi.fn(); subscribe = vi.fn(); snapshot = () => ({ appearance: {} }); refreshIfStale = vi.fn(); } })); vi.doMock("../src/main/updater", () => ({ Updater: class { reset = vi.fn(); } })); vi.doMock("../src/domain/overview", () => ({ trayTooltip: () => "Synthetic usage" })); @@ -47,6 +57,15 @@ it("ignores display and theme updates after the tray is destroyed", async () => vi.stubGlobal("__dirname", root); await import("../src/main/electron"); await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + tray.emit("click"); + await vi.waitFor(() => expect(windows).toHaveLength(2)); + const closed = windows[1]; + closed.destroyed = true; + tray.emit("click"); + expect(windows).toHaveLength(3); + expect(() => closed.emit("closed")).not.toThrow(); + tray.emit("click"); + expect(windows).toHaveLength(3); expect(() => display.emit("display-metrics-changed")).not.toThrow(); expect(tooltip).toHaveBeenCalledWith("Synthetic usage"); destroyed = true; diff --git a/tests/providers.test.ts b/tests/providers.test.ts index bc070a7..f16161f 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -158,7 +158,7 @@ describe("activity and account boundaries", () => { describe("bounded transports", () => { it("waits for initialize, redirects one account and tears down the RPC child", async () => { const root = temp(), binary = join(root, "fake-codex"); - writeFileSync(binary, `#!/usr/bin/env node\nconst rl=require('node:readline').createInterface({input:process.stdin});let ready=false;rl.on('line',line=>{const r=JSON.parse(line);if(r.method==='initialize'){setTimeout(()=>{ready=true;process.stdout.write(JSON.stringify({id:1,result:{installationId:'PRIVATE'}})+'\\n')},25)}else if(r.method==='account/rateLimits/read'){if(!ready)process.exit(2);process.stdout.write(JSON.stringify({id:2,result:{home:process.env.CODEX_HOME}})+'\\n')}});`, { mode: 0o700 }); + writeFileSync(binary, `#!/usr/bin/env node\nif(JSON.stringify(process.argv.slice(2))!==JSON.stringify(['app-server','--listen','stdio://']))process.exit(3);const rl=require('node:readline').createInterface({input:process.stdin});let ready=false;rl.on('line',line=>{const r=JSON.parse(line);if(r.method==='initialize'){setTimeout(()=>{ready=true;process.stdout.write(JSON.stringify({id:1,result:{installationId:'PRIVATE'}})+'\\n')},25)}else if(r.method==='account/rateLimits/read'){if(!ready)process.exit(2);process.stdout.write(JSON.stringify({id:2,result:{home:process.env.CODEX_HOME}})+'\\n')}});`, { mode: 0o700 }); const response = await codexRPC(binary, { env: { ...process.env, CODEX_HOME: "synthetic-account" }, timeoutMs: 1000 }); expect(JSON.parse(response).result.home).toBe("synthetic-account"); expect(response).not.toContain("PRIVATE"); }); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 406c376..29b3d04 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -25,6 +25,22 @@ it("bounds browser notch content to the viewport while retaining native work-are view.notch.maxHeight = 900; expect(renderApp(view, "notch")).toContain("--notch-max-height:900px"); }); +it("groups settings and names provider controls while keeping accent choices selectable", () => { + const view = state(); view.settings.values = { showProviderCodex: true, menuBarProviderCodex: true, accentTheme: "violet" }; + const markup = renderApp(view, "settings"); + expect(markup).toContain("Collect usage"); expect(markup).toContain("Show in menu bar and side notch"); + expect(markup).toContain('aria-label="Collect Codex usage"'); expect(markup).toContain('aria-label="Show Codex in menu bar and side notch"'); + expect(markup).not.toMatch(/aria-label="Show Codex in menu bar and side notch"[^>]*disabled/); + expect(markup).toContain("Side notch"); expect(markup).toContain("Usage details"); expect(markup).toContain("Codex limit resets in the side notch"); + expect(markup).not.toContain('Accent]*value="violet")(?=[^>]*checked)/); + view.settings.values.showProviderCodex = false; + const disabledMarkup = renderApp(view, "settings"); + expect(disabledMarkup).toMatch(/aria-label="Show Codex in menu bar and side notch"[^>]*disabled/); + expect(disabledMarkup).not.toMatch(/aria-label="Collect Codex usage"[^>]*checked/); + expect(disabledMarkup).toMatch(/aria-label="Show Codex in menu bar and side notch"[^>]*checked/); +}); it("offers simple account setup and truthful Grok availability without directories in the primary flow", () => { const view = state(); view.snapshot.demo = false; view.connections = [{ provider: "codex", status: "disconnected" }, { provider: "claude", status: "connected" }, { provider: "grok", status: "unsupported" }]; @@ -36,6 +52,17 @@ it("offers simple account setup and truthful Grok availability without directori view.snapshot.demo = true; view.connections[0].status = "disconnected"; expect(renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} }))).toContain("disabled"); }); +it.each(["codex", "claude"] as const)("keeps %s display control available for an active additional account", provider => { + const view = state(), name = provider === "codex" ? "Codex" : "Claude"; + view.settings.values = { [`showProvider${name}`]: false, [`menuBarProvider${name}`]: true }; + view.snapshot.slots = [{ ...primary(provider), slotID: "additional-fixture", label: "Synthetic account" }]; + const control = new RegExp(`aria-label="Show ${name} in menu bar and side notch"[^>]*`); + expect(renderApp(view, "settings").match(control)?.[0]).not.toContain("disabled"); + view.snapshot.slots = []; + view.settings.accounts = [{ id: "absent-fixture", provider, label: "Absent account", pathLabel: "Synthetic absent directory", enabled: true }]; + const disabled = renderApp(view, "settings").match(control)?.[0]; + expect(disabled).toContain("disabled"); expect(disabled).toContain("checked"); +}); const w = window("5-hour", 80, now + 4 * 3600000, 300); const session = (id: string, at: number, output: number, cacheRead = 0) => ({ id, projectName: id, model: "fixture", tokens: tokens({ output, cacheRead }), estimatedCostUSD: 0, startedAt: at, messageCount: 10 }); const state = (): ViewState => ({ snapshot: { From 559ef4e1a1e5d4ff92be4374aff7bf74b622adf3 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:38:34 -0400 Subject: [PATCH 4/8] feat: isolate live Codex candidate profiles --- CHANGELOG.md | 6 ++ docs/DESKTOP-CONNECTIONS.md | 27 +++++++ docs/KB.md | 4 +- docs/PRIVACY.md | 9 ++- docs/adr/0012-codex-live-test-profile.md | 35 +++++++++ docs/adr/README.md | 1 + src/main/composition.ts | 1 + src/main/connections.ts | 10 ++- src/main/electron.ts | 14 ++-- src/main/launch.ts | 37 +++++++--- src/main/preferences.ts | 15 +++- src/main/updater.ts | 6 +- tests/composition.test.ts | 94 ++++++++++++++++++++++++ tests/connections.test.ts | 42 ++++++++++- tests/electron-lifecycle.test.ts | 38 ++++++++-- tests/updater.test.ts | 12 +++ 16 files changed, 312 insertions(+), 39 deletions(-) create mode 100644 docs/adr/0012-codex-live-test-profile.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f8ad88..2b2930b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ follows [Semantic Versioning](https://semver.org/). ### Added +- Add an isolated Codex cloud test profile for the existing candidate UI. It + starts disconnected, reads account allowance after browser sign-in, and + excludes demo data, local activity and other providers. Candidate profiles + cannot check or install updates, change login items or send notifications. + Profile validation rejects nested symlinks before app or helper writes. + - Add candidate account setup for Codex browser sign-in and opt-in Claude Desktop allowance, with automatic refresh, account isolation and disconnect. Grok consumer connection remains unavailable. Native and provider acceptance diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index e7a6e71..90f7ced 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -87,3 +87,30 @@ Demo mode blocks live account connections, Keychain access and helper login. Synthetic tests cover protocol, consent, token selection, account switching, disconnect, numeric validation, stale source results and IPC boundaries. See [privacy](PRIVACY.md) and [ADR 0011](adr/0011-desktop-account-connections.md). + +## Isolated live Codex validation + +The candidate accepts `--codex-test-profile` with an absolute test directory. +This selects the existing UI and connection flow with live Codex allowance. +It cannot be combined with `--demo` or `--candidate-profile`. The directory +must be empty on first use and owned by this test mode on later launches. +Installed-app storage and symlink paths are refused, including nested paths +inside an existing profile. Startup checks file metadata before starting +Electron or the helper, with a limit of 32,768 entries and 24 directory levels. + +Preferences, history, connection profiles and Electron state stay in that +directory. The real OS home remains available for helper discovery and macOS +Keychain. Choose **Sign in to Codex** in the app and finish browser sign-in. +The app starts disconnected, so an unavailable reading before sign-in is +expected. Successful connections remain available across normal quit and +relaunch; **Disconnect** signs out only that MeterUsage connection. + +This mode reads only Codex account allowance. It has no local activity, status +feed, reset redemption or other-provider collection. Update checks, update +installation, login-item changes and notifications are disabled. It does not +change the primary setup for users or replace an installed app. + +Synthetic isolation tests do not prove native sign-in or visible cloud usage. +Live acceptance requires the packaged app, human browser sign-in, fresh +allowance in its actual UI and comparison with the same provider account. +See [ADR 0012](adr/0012-codex-live-test-profile.md). diff --git a/docs/KB.md b/docs/KB.md index bc463b9..e4c181e 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -1,6 +1,6 @@ # Project knowledge -Last source review: 2026-10-07 +Last source review: 2026-10-09 ## Repository state @@ -60,6 +60,8 @@ Last source review: 2026-10-07 - Shared-card sizing uses the full measured height, separate from visible-window bounds. The candidate rejects capture heights above 8,192 pixels, or 4,096 CSS pixels at 2x, with `Card too tall to share`. Native image completeness remains unverified. +- The `--codex-test-profile` candidate mode runs the existing Codex connection UI with isolated preferences, app data and Electron state. It excludes synthetic readings, other providers, local activity and status feeds; updates, login-item changes and notifications are blocked. [ADR 0012](adr/0012-codex-live-test-profile.md) amends the demo-only candidate restriction. Native and provider acceptance remain separate. + ## Verification gaps - The Electron candidate still needs native GUI launch/relaunch, preferences/account/offline journeys, tray/focus/accessibility, notification/login/share/updater paths, drag/display persistence and two different-height notch captures. Verified packaging and headless JSON cannot close migration or cutover. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 0d6cc87..6a4a5e2 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -182,12 +182,19 @@ types. The Electron adapter reads/writes only known keys, without a new storage migration. Native Swift-to-Electron-to-Swift round-trip proof is still required. Demo selects synthetic sources before composition and isolates preferences, -history, caches and Electron state. An explicit candidate profile requires +history, caches and Electron state. The `--candidate-profile` option requires `--demo` and an empty test directory on first use. Demo blocks live provider/key discovery, update installation and login-item changes. It does write its own isolated files. Candidate QA must leave the installed app and provider data untouched. +The separate `--codex-test-profile` mode uses the real UI and opt-in Codex +connection with isolated JSON preferences, app data and Electron state. It +retains the OS home for Keychain and helper discovery. It excludes all other +provider sources, local activity, status feeds and reset redemption, and blocks +updates, login-item changes and notifications. Its Codex credential boundary +is the same keyring-only boundary described above. + ## Installation and enforcement `Scripts/make-app.sh` downloads a pinned public ZIP by default, checks SHA-256 diff --git a/docs/adr/0012-codex-live-test-profile.md b/docs/adr/0012-codex-live-test-profile.md new file mode 100644 index 0000000..5bb9a8b --- /dev/null +++ b/docs/adr/0012-codex-live-test-profile.md @@ -0,0 +1,35 @@ +# ADR 0012: Isolate live Codex validation in the existing UI + +- Status: Accepted implementation direction; native UI acceptance pending +- Date: 2026-10-09 + +## Decision + +Add `--codex-test-profile` for an isolated live Codex candidate. This amends +ADR 0010's demo-only restriction for candidate profiles. Keep the existing UI, +IPC, coordinator and Codex browser connection. No separate test application +stands in for visible account allowance. + +Require an absolute test directory, refuse protected or symlink paths, and +reject conflicts with demo flags. Preferences use profile JSON instead of +installed macOS defaults. App data and Electron state use the test directory; +OS home remains unchanged for Keychain and helper discovery. + +Start disconnected. Compose only an account-bound Codex allowance source. +Do not read local activity or other providers, fall back to legacy sources, +request status feeds, redeem resets or send model prompts. The official helper +owns keyring authentication under the existing connection policy. Normal quit +retains a completed connection for relaunch and inspection. + +Block candidate update checks and installation, login-item changes and +notifications even if stored preferences enable them. Normal and demo modes +retain their existing collection behavior. This decision adds no end-user +terminal step to primary setup. + +## Acceptance + +Synthetic tests cover isolation and rejected inputs. Native proof requires +launching the packaged candidate, human browser sign-in and real allowance in +the app UI. Provider-dashboard accuracy and cross-device coverage require +separate checks. The Swift rollback, Windows hold, installed-app replacement +hold and release gates remain unchanged. diff --git a/docs/adr/README.md b/docs/adr/README.md index 25a8713..e8698ac 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,3 +16,4 @@ This directory records decisions that affect repository work. Read an ADR before | [0009](0009-prebuilt-bundle-default.md) | Download and verify the prebuilt app by default; compile only with `--build-from-source`. | Accepted | | [0010](0010-macos-typescript-electron.md) | Port macOS and the schema-1 JSON CLI to TypeScript/Electron/React; preserve data contracts and pause Windows until a test machine is available. | Accepted architecture; native acceptance pending | | [0011](0011-desktop-account-connections.md) | Add opt-in Codex browser sign-in and Claude Desktop account allowance; separate account allowance from local activity. | Accepted implementation direction; native and provider acceptance pending | +| [0012](0012-codex-live-test-profile.md) | Run isolated live Codex validation in the existing UI while blocking other providers and candidate system effects. | Accepted implementation direction; native UI acceptance pending | diff --git a/src/main/composition.ts b/src/main/composition.ts index c7e623c..428078a 100644 --- a/src/main/composition.ts +++ b/src/main/composition.ts @@ -44,6 +44,7 @@ function openCodeKey(launch: Launch): string | undefined { } export function compose(launch: Launch, prefs: Preferences, options: { command?: Command; http?: HTTP; now?: () => number; connections?: DesktopConnections } = {}): Source[] { const now = options.now ?? Date.now; + if (launch.codexTest) return [{ slot: primary("codex"), accountBound: true, quota: signal => options.connections ? options.connections.quota("codex", signal) : Promise.reject(new Unavailable("notSignedIn", "codex")) }]; if (launch.demo) return demoSources(now, prefs.accounts); const command = options.command ?? runCommand, http = options.http ?? httpTransport(), env = cliEnvironment(launch.home, launch.env); const runtime = new AntigravityRuntime(runtimeCandidates(env), command, env); diff --git a/src/main/connections.ts b/src/main/connections.ts index c4bceb8..bbaffe2 100644 --- a/src/main/connections.ts +++ b/src/main/connections.ts @@ -65,9 +65,9 @@ export class DesktopConnections { private cleanupController = new AbortController(); constructor(readonly launch: Launch, readonly prefs: Preferences, readonly helper: string, readonly changed: () => void, readonly command: Command = runCommand, readonly http: HTTP = httpTransport(), readonly now = Date.now) {} get state(): ConnectionState[] { - return (["codex", "claude", "grok"] as const).map(provider => ({ provider, status: this.pending.has(provider) ? "connecting" : provider === "grok" ? "unsupported" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected" })); + return (["codex", "claude", "grok"] as const).map(provider => ({ provider, status: provider === "grok" || this.launch.codexTest && provider !== "codex" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected" })); } - enabled(provider: "codex" | "claude") { const value = this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; return !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; } + enabled(provider: "codex" | "claude") { const value = this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"]; return (!this.launch.codexTest || provider === "codex") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; } configured(provider: "codex" | "claude") { return this.prefs.values[provider === "codex" ? "desktopCodexConnection" : "desktopClaudeIdentity"] !== ""; } private binary() { for (const root of ["/Applications", join(this.launch.home, "Applications")]) { @@ -87,8 +87,8 @@ export class DesktopConnections { return await readDesktopCredential(this.launch.home, password, this.now()); } catch { throw new Unavailable("notSignedIn", "Claude Desktop"); } } - async quota(provider: "codex" | "claude", signal?: AbortSignal) { - if (this.launch.demo || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider); + async quota(provider: ConnectionProvider, signal?: AbortSignal) { + if (this.launch.demo || provider === "grok" || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider); if (provider === "codex") return parseCodex(await codexRPC(this.binary(), { env: this.env(String(this.prefs.values.desktopCodexConnection)), signal }, undefined, configuration), this.now()); const credential = await this.claude(false, signal); if (credential.identity !== this.prefs.values.desktopClaudeIdentity) throw new Unavailable("notSignedIn", "Claude Desktop"); @@ -104,6 +104,7 @@ export class DesktopConnections { async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => true) { if (this.stopped) throw new Error("Connection unavailable"); if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); + if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode"); if (provider === "grok") throw new Error("Automatic Grok connection is unavailable"); if (this.pending.has(provider) || this.enabled(provider)) throw new Error("Connection already active"); if (provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") { @@ -178,6 +179,7 @@ export class DesktopConnections { async disconnect(provider: ConnectionProvider) { if (this.stopped) throw new Error("Connection unavailable"); if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); + if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode"); if (provider === "grok" || this.pending.has(provider)) throw new Error("Connection unavailable"); const id = String(this.prefs.values.desktopCodexCleanup || this.prefs.values.desktopCodexConnection); const { finish } = this.begin(provider); diff --git a/src/main/electron.ts b/src/main/electron.ts index 47ea0d8..71def0f 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -20,7 +20,7 @@ import { trayTooltip } from "../domain/overview"; const args = process.argv.slice(1), launch = launchConfiguration(args); const desktopHelper = app.isPackaged ? join(process.resourcesPath, "desktop-keychain") : join(__dirname, "../../build/desktop-keychain"); // Isolation is selected before Electron creates a session or any source reads. -if (launch.demo) { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); } +if (launch.profile) { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); } app.setName("MeterUsage"); if (args.includes("json") || args.includes("--json")) { runJSON(args, process.env, desktopHelper).then(json => { process.stdout.write(json + "\n"); app.exit(0); }, () => { process.stderr.write("meterusage: could not read report\n"); app.exit(1); }); @@ -32,7 +32,7 @@ if (args.includes("json") || args.includes("--json")) { async function start() { mkdirSync(launch.data, { recursive: true }); app.dock?.hide(); - if (!app.requestSingleInstanceLock({ candidate: launch.demo })) { app.quit(); return; } + if (!app.requestSingleInstanceLock({ candidate: launch.candidate || launch.demo })) { app.quit(); return; } const prefs = await Preferences.load(launch); const documents = new Map(), windows = new Map(); const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity }; @@ -46,7 +46,7 @@ async function start() { }); const sources = () => compose(launch, prefs, { connections }); const coordinator = new Coordinator(launch, prefs, sources(), Date.now, alert => { - if (!launch.demo && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); + if (!launch.demo && !launch.candidate && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); }, sources); // Do not restore a legacy archive under a newly connected account. for (const p of ["codex", "claude"] as const) if (connections.configured(p)) coordinator.forgetQuota(p); @@ -63,7 +63,7 @@ async function start() { let shareState: ViewState | undefined, shareHeight = 0, shareReady: ((image: Electron.NativeImage) => void) | undefined, shareFailed: ((error: Error) => void) | undefined; const state = (surface?: Surface): ViewState => surface === "share" && shareState ? shareState : ({ connections: connections.state, snapshot: coordinator.snapshot(), settings: projectSettings(prefs.values, prefs.accounts, launch.home, surface), systemDark: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI, notch: { ...notch, maxHeight: Math.floor((anchor ? screen.getDisplayNearestPoint(anchor) : screen.getPrimaryDisplay()).workArea.height) }, update: updater.visible ? { version: updater.visible.version, state: updater.installState } : undefined }); const updater = new Updater(prefs, app.getVersion(), publish, undefined, release => { - if (!launch.demo && Notification.isSupported()) new Notification({ title: "MeterUsage update available", body: `MeterUsage ${release.version} is ready to install.`, silent: true }).show(); + if (!launch.demo && !launch.candidate && Notification.isSupported()) new Notification({ title: "MeterUsage update available", body: `MeterUsage ${release.version} is ready to install.`, silent: true }).show(); }); const folding = new NotchFold(() => prefs.values.sideNotchPanelPinned === true || sharing || confirmingReset || notch.dragging, () => { notch.expanded = false; placeNotch(); publish(); }); function publish() { @@ -105,7 +105,7 @@ async function start() { w.show(); w.focus(); void coordinator.refreshIfStale(); } function showSettings() { - if (!launch.demo && app.isPackaged) prefs.values.launchAtLogin = app.getLoginItemSettings().openAtLogin; + if (!launch.demo && !launch.candidate && app.isPackaged) prefs.values.launchAtLogin = app.getLoginItemSettings().openAtLogin; windows.get("flyout")?.hide(); const w = create("settings"); publish(); w.show(); w.focus(); } function restoreAnchor() { @@ -184,7 +184,7 @@ async function start() { case "copyJSON": clipboard.writeText(coordinator.json()); break; case "setPreference": if (r.key === "launchAtLogin") { - if (launch.demo || !app.isPackaged) throw new Error("Login items require an installed app"); + if (launch.demo || launch.candidate || !app.isPackaged) throw new Error("Login items require an installed app"); app.setLoginItemSettings({ openAtLogin: r.value === true }); if (app.getLoginItemSettings().openAtLogin !== r.value) throw new Error("Could not change login item"); } @@ -274,7 +274,7 @@ async function start() { } case "updateDismiss": await updater.dismiss(); break; case "updateInstall": { - if (launch.demo || !app.isPackaged || !updater.visible) throw new Error("Update installation unavailable"); + if (launch.demo || launch.candidate || !app.isPackaged || !updater.visible) throw new Error("Update installation unavailable"); const confirmedRelease = structuredClone(updater.visible); const accepted = await dialog.showMessageBox(source, { type: "question", title: "Install MeterUsage update?", message: `Install MeterUsage ${confirmedRelease.version}?`, detail: "The app will quit, verify the downloaded bundle, replace this app and relaunch.", buttons: ["Cancel", "Install update"], defaultId: 0, cancelId: 0 }); if (accepted.response === 1) { await updater.install(resolve(app.getPath("exe"), "../../.."), confirmedRelease); app.quit(); } diff --git a/src/main/launch.ts b/src/main/launch.ts index 46cc8a5..7e4de2a 100644 --- a/src/main/launch.ts +++ b/src/main/launch.ts @@ -1,22 +1,37 @@ import { userInfo, tmpdir } from "node:os"; -import { mkdtempSync, mkdirSync, realpathSync, existsSync, readdirSync, writeFileSync } from "node:fs"; -import { isAbsolute, join, resolve, relative } from "node:path"; -export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string } +import { mkdtempSync, mkdirSync, realpathSync, existsSync, readdirSync, writeFileSync, lstatSync } from "node:fs"; +import { isAbsolute, join, resolve, relative, dirname } from "node:path"; +export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string; codexTest?: boolean } export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = process.env, realHome = userInfo().homedir): Launch { - const demo = args.includes("--demo") || env.METERUSAGE_DEMO === "1", index = args.indexOf("--candidate-profile"), profile = index < 0 ? undefined : args[index + 1]; - if (index >= 0 && (!demo || !profile || !isAbsolute(profile))) throw new Error("Candidate profile requires --demo and an absolute test directory"); + const demo = args.includes("--demo") || env.METERUSAGE_DEMO === "1", candidateIndex = args.indexOf("--candidate-profile"), codexIndex = args.indexOf("--codex-test-profile"), codexTest = codexIndex >= 0; + if (codexTest && (demo || candidateIndex >= 0)) throw new Error("Codex test profile flags conflict with demo/candidate mode"); + const index = codexTest ? codexIndex : candidateIndex, profile = index < 0 ? undefined : args[index + 1]; + if (index >= 0 && ((!codexTest && !demo) || !profile || !isAbsolute(profile))) throw new Error("Candidate profile requires an absolute test directory and --demo unless using --codex-test-profile"); if (profile) { const normalized = resolve(profile), liveData = join(realHome, "Library/Application Support/MeterUsage"); const inside = relative(liveData, normalized); if (normalized === "/" || normalized === realHome || inside === "" || (inside !== ".." && !inside.startsWith("../"))) throw new Error("Candidate profile cannot use installed-app storage"); - mkdirSync(normalized, { recursive: true }); - if (realpathSync(normalized) !== normalized) throw new Error("Candidate profile cannot follow a symlink"); - const marker = join(normalized, ".meterusage-candidate"); + let ancestor = normalized; + while (!existsSync(ancestor)) ancestor = dirname(ancestor); + if (realpathSync(ancestor) !== ancestor) throw new Error("Candidate profile cannot follow a symlink"); + mkdirSync(normalized, { recursive: true, mode: 0o700 }); + const marker = join(normalized, codexTest ? ".meterusage-codex-test" : ".meterusage-candidate"); + if (existsSync(marker) && !lstatSync(marker).isFile()) throw new Error("Candidate profile marker must be a regular file, not a symlink"); if (!existsSync(marker) && readdirSync(normalized).length) throw new Error("Candidate profile must be an empty test directory"); - if (!existsSync(marker)) writeFileSync(marker, "Synthetic MeterUsage candidate profile\n", { mode: 0o600 }); + // Validate metadata before Electron or the helper can write into this profile. + const pending = [{ path: normalized, depth: 0 }]; + let entries = 0; + while (pending.length) { + const { path, depth } = pending.pop()!, stat = lstatSync(path); + if (++entries > 32768 || depth > 24) throw new Error("Candidate profile exceeds inspection limits"); + if (stat.isSymbolicLink()) throw new Error("Candidate profile cannot contain a symlink"); + if (stat.isDirectory()) for (const name of readdirSync(path)) pending.push({ path: join(path, name), depth: depth + 1 }); + else if (!stat.isFile()) throw new Error("Candidate profile must contain only regular files and directories"); + } + if (!existsSync(marker)) writeFileSync(marker, codexTest ? "MeterUsage Codex test profile\n" : "Synthetic MeterUsage candidate profile\n", { mode: 0o600 }); } // Demo always isolates preferences, provider sources, caches and Electron's // userData before any provider or coordinator is constructed. - const isolated = demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined; - return { demo, candidate: index >= 0, home: isolated ?? realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: demo ? {} : { ...env } }; + const isolated = codexTest ? resolve(profile!) : demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined; + return { demo, candidate: index >= 0, ...(codexTest ? { codexTest: true } : {}), home: demo ? isolated! : realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: codexTest ? { PATH: env.PATH, TMPDIR: env.TMPDIR } : demo ? {} : { ...env } }; } diff --git a/src/main/preferences.ts b/src/main/preferences.ts index 1fe2538..0d66455 100644 --- a/src/main/preferences.ts +++ b/src/main/preferences.ts @@ -25,6 +25,10 @@ export const initialPreferences: Record = { desktopCodexConnection: "", desktopCodexCleanup: "", desktopClaudeIdentity: "", ...Object.fromEntries(providers.flatMap(p => [[providerPreference(p), ["codex", "openCodeGo", "openRouter"].includes(p)], [trayPreference(p), true]])), }; +const codexTestPreferences: Record = { + launchAtLogin: false, updateCheckEnabled: false, desktopClaudeIdentity: "off", + ...Object.fromEntries(providers.filter(p => p !== "codex").map(p => [providerPreference(p), false])), +}; export function validAccounts(raw: unknown): raw is ManagedAccount[] { return Array.isArray(raw) && raw.every(a => a && typeof a === "object" && typeof a.id === "string" && /^[A-Za-z0-9-]{1,80}$/.test(a.id) && ["codex", "claude"].includes(a.provider) && typeof a.label === "string" && a.label.length <= 100 && !/[\r\n]/.test(a.label) && typeof a.path === "string" && a.path.length <= 4096 && !/[\r\n\0]/.test(a.path) && typeof a.enabled === "boolean") && new Set(raw.map(a => a.id)).size === raw.length; } @@ -47,10 +51,11 @@ export class Preferences { private constructor(readonly launch: Launch, readonly command: Command, readonly domain: string) {} static async load(launch: Launch, command: Command = runCommand, domain = defaultsDomain): Promise { const prefs = new Preferences(launch, command, domain); - if (launch.demo) { + if (launch.codexTest) prefs.values.desktopCodexConnection = "off"; + if (launch.demo || launch.codexTest) { const path = join(launch.data, "preferences.json"); if (existsSync(path)) { try { const raw = JSON.parse(readFileSync(path, "utf8")); for (const [key, v] of Object.entries(raw)) if (validate(key, v)) prefs.values[key] = v; } catch { /* Keep known defaults for a damaged candidate file. */ } } - else for (const p of providers) prefs.values[providerPreference(p)] = true; + else if (launch.demo) for (const p of providers) prefs.values[providerPreference(p)] = true; } else { await Promise.all(Object.keys(kinds).map(async key => { try { @@ -66,11 +71,12 @@ export class Preferences { } catch { /* An absent or mismatched key retains its registered default. */ } })); } + if (launch.codexTest) Object.assign(prefs.values, codexTestPreferences); prefs.values.refreshIntervalSeconds = prefs.refreshInterval; return prefs; } get refreshInterval() { const stored = this.values.refreshIntervalSeconds as number; return Math.max(30, stored > 0 ? stored : 60); } get accounts() { return this.values.managedAccounts as ManagedAccount[]; } - enabled(p: Provider) { return this.values[providerPreference(p)] === true; } + enabled(p: Provider) { return (!this.launch.codexTest || p === "codex") && this.values[providerPreference(p)] === true; } private enqueue(write: () => Promise) { const result = this.writes.then(write); this.writes = result.catch(() => {}); return result; } @@ -80,8 +86,9 @@ export class Preferences { } private async write(key: string, value: unknown) { if (!validate(key, value)) throw new Error("Invalid preference"); + if (this.launch.codexTest && key in codexTestPreferences && value !== codexTestPreferences[key]) throw new Error("Preference unavailable in Codex test mode"); if (key === "refreshIntervalSeconds") value = Math.max(30, (value as number) > 0 ? value as number : 60); - if (this.launch.demo) { + if (this.launch.demo || this.launch.codexTest) { const next = { ...this.values, [key]: value as PreferenceValue }; atomicJSON(join(this.launch.data, "preferences.json"), next); } else { const kind = kinds[key]; const typed = kind === "data" ? ["-data", Buffer.from(JSON.stringify(value)).toString("hex")] : kind === "date" ? ["-date", new Date(value as number).toISOString()] : kind === "boolean" ? ["-bool", value ? "true" : "false"] : kind === "number" ? ["-float", String(value)] : ["-string", value as string]; diff --git a/src/main/updater.ts b/src/main/updater.ts index 1e3fe5e..7490db2 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -85,9 +85,9 @@ export class Updater { private checkController?: AbortController; private installController?: AbortController; constructor(readonly prefs: Preferences, readonly currentVersion: string, readonly changed: () => void, readonly download = fetchUpdate, readonly announce: (release: Release) => void = () => {}) {} - get visible() { return this.prefs.values.updateCheckEnabled === true && this.available?.version !== this.prefs.values.updateDismissedVersion ? this.available : undefined; } + get visible() { return !this.prefs.launch.demo && !this.prefs.launch.candidate && this.prefs.values.updateCheckEnabled === true && this.available?.version !== this.prefs.values.updateDismissedVersion ? this.available : undefined; } checkIfDue(now = Date.now()) { - if (this.prefs.launch.demo || this.prefs.values.updateCheckEnabled !== true || this.check || now - Number(this.prefs.values.updateLastCheckDate ?? 0) < 3600000) return; + if (this.prefs.launch.demo || this.prefs.launch.candidate || this.prefs.values.updateCheckEnabled !== true || this.check || now - Number(this.prefs.values.updateLastCheckDate ?? 0) < 3600000) return; const generation = this.generation, controller = new AbortController(); this.checkController = controller; const pending: Promise = (async () => { await this.prefs.set("updateLastCheckDate", now); @@ -103,7 +103,7 @@ export class Updater { reset() { this.generation++; this.checkController?.abort(); this.installController?.abort(); this.checkController = undefined; this.check = undefined; this.available = undefined; this.changed(); } async dismiss() { if (this.available) await this.prefs.set("updateDismissedVersion", this.available.version); this.changed(); } async install(current: string, release: Release | undefined, command: Command = runCommand) { - if (this.prefs.launch.demo || this.prefs.values.updateCheckEnabled !== true || basename(current) !== "MeterUsage.app" || !release || ["downloading", "installing"].includes(this.installState)) throw new Error("Update installation unavailable"); + if (this.prefs.launch.demo || this.prefs.launch.candidate || this.prefs.values.updateCheckEnabled !== true || basename(current) !== "MeterUsage.app" || !release || ["downloading", "installing"].includes(this.installState)) throw new Error("Update installation unavailable"); const generation = this.generation, controller = new AbortController(); this.installController = controller; const guard = () => { controller.signal.throwIfAborted(); if (generation !== this.generation || this.prefs.values.updateCheckEnabled !== true) throw new Error("Update cancelled"); }; this.installState = "downloading"; this.changed(); diff --git a/tests/composition.test.ts b/tests/composition.test.ts index beea1d6..777559f 100644 --- a/tests/composition.test.ts +++ b/tests/composition.test.ts @@ -7,10 +7,104 @@ import { Preferences, defaultsDomain } from "../src/main/preferences"; import { compose, accountHome } from "../src/main/composition"; import { jsonReport, runJSON } from "../src/main/cli"; import { endpoints, type Command } from "../src/main/providers/transport"; +import { providers, quota, Unavailable } from "../src/domain/models"; +import { DesktopConnections } from "../src/main/connections"; const roots: string[] = []; const temp = () => { const root = mkdtempSync(join(realpathSync(tmpdir()), "meterusage-fixture-")); roots.push(root); return root; }; afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); const now = Date.parse("2026-10-06T12:00:00Z"); const liveFixture = (home: string): Launch => ({ home, data: join(home, "app-data"), env: {}, demo: false, candidate: false }); +describe("Codex live test profile", () => { + it("isolates data while retaining OS home and only helper environment", () => { + const root = temp(), home = temp(); + const launch = launchConfiguration(["--codex-test-profile", root], { HOME: "/foreign/home", CODEX_HOME: "/foreign/codex", OPENAI_API_KEY: "synthetic-secret", METERUSAGE_DEMO: "0", PATH: "/usr/bin:/bin", TMPDIR: "/tmp" }, home); + expect(launch).toMatchObject({ codexTest: true, demo: false, candidate: true, home, data: root, profile: root }); + expect(launch.env).toEqual({ PATH: "/usr/bin:/bin", TMPDIR: "/tmp" }); + expect(launchConfiguration(["--codex-test-profile", root], {}, home).profile).toBe(root); + expect(() => launchConfiguration(["--demo", "--candidate-profile", root], {}, home)).toThrow("empty"); + }); + it("rejects conflicting flags and invalid or foreign profile paths before mutation", () => { + const home = temp(), root = temp(); + for (const [args, env] of [ + [["--demo", "--codex-test-profile", root], {}], + [["--codex-test-profile", root], { METERUSAGE_DEMO: "1" }], + [["--candidate-profile", root, "--codex-test-profile", root], {}], + ] as [string[], NodeJS.ProcessEnv][]) expect(() => launchConfiguration(args, env, home)).toThrow("conflict"); + expect(existsSync(join(root, ".meterusage-codex-test"))).toBe(false); + for (const path of [undefined, "relative"]) expect(() => launchConfiguration(["--codex-test-profile", ...(path ? [path] : [])], {}, home)).toThrow("absolute"); + for (const path of ["/", home, join(home, "Library/Application Support/MeterUsage"), join(home, "Library/Application Support/MeterUsage/nested")]) expect(() => launchConfiguration(["--codex-test-profile", path], {}, home)).toThrow("installed"); + expect(existsSync(join(home, "Library"))).toBe(false); + writeFileSync(join(root, "foreign.txt"), "preserved"); + expect(() => launchConfiguration(["--codex-test-profile", root], {}, home)).toThrow("empty"); + expect(readFileSync(join(root, "foreign.txt"), "utf8")).toBe("preserved"); + const alias = join(temp(), "alias"); symlinkSync(root, alias); + for (const path of [alias, join(alias, "nested")]) expect(() => launchConfiguration(["--codex-test-profile", path], {}, home)).toThrow("symlink"); + const demo = temp(); launchConfiguration(["--demo", "--candidate-profile", demo], {}, home); + expect(() => launchConfiguration(["--codex-test-profile", demo], {}, home)).toThrow("empty"); + }); + it.each(["electron", "electron/Cache", "connections", "connections/codex/account", "preferences.json"])("rejects a nested profile symlink at %s without touching its target", path => { + const root = temp(), home = temp(), foreign = temp(); + launchConfiguration(["--codex-test-profile", root], {}, home); + writeFileSync(join(foreign, "sentinel"), "preserved"); + mkdirSync(join(root, path, ".."), { recursive: true }); + symlinkSync(foreign, join(root, path)); + expect(() => launchConfiguration(["--codex-test-profile", root], {}, home)).toThrow("symlink"); + expect(readFileSync(join(foreign, "sentinel"), "utf8")).toBe("preserved"); + }); + it("rejects dangling nested links and accepts regular populated profile directories", () => { + const root = temp(), home = temp(); + launchConfiguration(["--codex-test-profile", root], {}, home); + mkdirSync(join(root, "electron", "Cache"), { recursive: true }); + writeFileSync(join(root, "electron", "Cache", "entry"), "synthetic"); + expect(launchConfiguration(["--codex-test-profile", root], {}, home).profile).toBe(root); + symlinkSync(join(temp(), "absent"), join(root, "connections")); + expect(() => launchConfiguration(["--codex-test-profile", root], {}, home)).toThrow("symlink"); + }); + it("roundtrips JSON preferences without defaults and keeps collection restricted after reload", async () => { + const root = temp(), home = temp(), launch = launchConfiguration(["--codex-test-profile", root], {}, home); + const command = vi.fn(async () => { throw new Error("Installed defaults must not be used"); }); + const prefs = await Preferences.load(launch, command); + expect(providers.filter(p => prefs.enabled(p))).toEqual(["codex"]); + expect(prefs.values).toMatchObject({ desktopCodexConnection: "off", desktopCodexCleanup: "", desktopClaudeIdentity: "off", launchAtLogin: false, updateCheckEnabled: false }); + const id = "11111111-1111-4111-8111-111111111111"; + await prefs.set("appearanceTheme", "dark"); await prefs.set("desktopCodexConnection", id); + const restored = await Preferences.load(launchConfiguration(["--codex-test-profile", root], {}, home), command); + expect(restored.values).toMatchObject({ appearanceTheme: "dark", desktopCodexConnection: id }); + expect(JSON.parse(readFileSync(join(root, "preferences.json"), "utf8"))).toMatchObject({ appearanceTheme: "dark", desktopCodexConnection: id }); + for (const key of ["showProviderClaude", "showProviderGrok", "showProviderOpenRouter", "showProviderOpenCodeGo", "launchAtLogin", "updateCheckEnabled"]) await expect(restored.set(key, true)).rejects.toThrow("Codex test"); + writeFileSync(join(root, "preferences.json"), JSON.stringify({ ...restored.values, ...Object.fromEntries(providers.map(p => [`showProvider${p[0].toUpperCase() + p.slice(1)}`, true])), launchAtLogin: true, updateCheckEnabled: true, desktopClaudeIdentity: "a".repeat(64) })); + const restricted = await Preferences.load(launch, command); + expect(providers.filter(p => restricted.enabled(p))).toEqual(["codex"]); + expect(restricted.values).toMatchObject({ launchAtLogin: false, updateCheckEnabled: false, desktopClaudeIdentity: "off", desktopCodexConnection: id }); + restricted.values.showProviderClaude = true; expect(restricted.enabled("claude")).toBe(false); + await restricted.set("showProviderCodex", false); expect(restricted.enabled("codex")).toBe(false); + expect(command).not.toHaveBeenCalled(); expect(existsSync(join(home, "Library"))).toBe(false); + }); + it("composes only connected Codex quota and never falls back to local, demo or other providers", async () => { + const launch = launchConfiguration(["--codex-test-profile", temp()], {}, temp()); + const command = vi.fn(async () => { throw new Error("Forbidden command"); }); + const http = vi.fn(async () => { throw new Error("Forbidden HTTP"); }); + const prefs = await Preferences.load(launch, command); + prefs.values.managedAccounts = [{ id: "foreign-account", provider: "claude", label: "Foreign", path: temp(), enabled: true }]; + for (const p of providers) prefs.values[`showProvider${p[0].toUpperCase() + p.slice(1)}`] = true; + const withoutConnection = compose(launch, prefs, { command, http }); + expect(withoutConnection).toHaveLength(1); + expect(withoutConnection[0].slot).toEqual({ provider: "codex", slotID: "", label: "" }); + expect(Object.keys(withoutConnection[0]).sort()).toEqual(["accountBound", "quota", "slot"]); + expect(withoutConnection[0].accountBound).toBe(true); + await expect(withoutConnection[0].quota!()).rejects.toMatchObject({ code: "notSignedIn" }); + const connections = new DesktopConnections(launch, prefs, "/synthetic/helper", () => {}, command, http); + const expected = quota("codex", [{ label: "5-hour", usedPercent: 37 }], now); + const read = vi.spyOn(connections, "quota").mockResolvedValue(expected); + const [source] = compose(launch, prefs, { connections, command, http }); + const signal = new AbortController().signal; + expect(await source.quota!(signal)).toEqual(expected); expect(read).toHaveBeenCalledExactlyOnceWith("codex", signal); + read.mockRejectedValue(new Unavailable("notSignedIn", "codex")); + await expect(source.quota!()).rejects.toMatchObject({ code: "notSignedIn" }); + read.mockRejectedValue(new Unavailable("failed", "codex")); + await expect(source.quota!()).rejects.toMatchObject({ code: "failed" }); + expect(command).not.toHaveBeenCalled(); expect(http).not.toHaveBeenCalled(); + }); +}); describe("candidate isolation before composition", () => { it("rejects incomplete, relative, installed and nonempty profiles", () => { const root = temp(); diff --git a/tests/connections.test.ts b/tests/connections.test.ts index 624ce16..a20820d 100644 --- a/tests/connections.test.ts +++ b/tests/connections.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; -import { accessSync, chmodSync, constants, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { accessSync, chmodSync, constants, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -347,6 +347,46 @@ it("blocks demo and unavailable Grok access and never falls back after a configu expect(source.activity).toBeUndefined(); expect(source.consumeReset).toBeUndefined(); await expect(source.quota!()).rejects.toThrow("Not signed in"); }); +it("denies non-Codex connections and quota in a live test profile even with persisted identity", async () => { + const processes = vi.mocked(spawn).mock.calls.length; + const home = realpathSync(temp()), profile = realpathSync(temp()), launch = launchConfiguration(["--codex-test-profile", profile], {}, home); + const command = vi.fn(async () => { throw new Error("Forbidden helper/defaults"); }); + const http = vi.fn(async () => { throw new Error("Forbidden HTTP"); }); + const preferences = await Preferences.load(launch, command); + preferences.values.desktopClaudeIdentity = "a".repeat(64); + const connections = new DesktopConnections(launch, preferences, "/synthetic/helper", () => {}, command, http); + const consent = vi.fn(async () => true), open = vi.fn(async () => {}); + expect(connections.enabled("claude")).toBe(false); + expect(connections.state).toEqual([{ provider: "codex", status: "disconnected" }, { provider: "claude", status: "unsupported" }, { provider: "grok", status: "unsupported" }]); + for (const provider of ["claude", "grok"] as const) { + await expect(connections.connect(provider, open, consent)).rejects.toThrow("unavailable"); + await expect(connections.quota(provider)).rejects.toMatchObject({ code: "notSignedIn" }); + await expect(connections.disconnect(provider)).rejects.toThrow("unavailable"); + } + await expect(connections.quota("codex")).rejects.toMatchObject({ code: "notSignedIn" }); + expect(command).not.toHaveBeenCalled(); expect(http).not.toHaveBeenCalled(); expect(consent).not.toHaveBeenCalled(); expect(open).not.toHaveBeenCalled(); expect(spawn).toHaveBeenCalledTimes(processes); +}); +it("connects, reloads and disconnects Codex through the isolated JSON test profile and OS home helper", async () => { + const home = realpathSync(temp()), profile = realpathSync(temp()), resources = join(home, "Applications/Codex.app/Contents/Resources"); + mkdirSync(resources, { recursive: true }); const binary = fakeCodex(resources); + const launch = launchConfiguration(["--codex-test-profile", profile], { OPENAI_API_KEY: "OMIT_INHERITED_API_KEY", CODEX_HOME: "/foreign/codex", HOME: "/foreign/home" }, home); + const command = vi.fn(async () => { throw new Error("Forbidden installed defaults"); }); + const preferences = await Preferences.load(launch, command); + const connections = new DesktopConnections(launch, preferences, "/not-a-helper", () => {}, command); + expect(await connections.connect("codex", async () => {})).toBe(true); + const options = vi.mocked(spawn).mock.calls.at(-1)![2]!; + expect(vi.mocked(spawn).mock.calls.at(-1)![0]).toBe(binary); + expect(options.env).toMatchObject({ HOME: home, CODEX_HOME: join(profile, "connections/codex", String(preferences.values.desktopCodexConnection)) }); + expect(options.env).not.toHaveProperty("OPENAI_API_KEY"); expect(options.env!.CODEX_HOME).not.toBe("/foreign/codex"); + const restored = await Preferences.load(launchConfiguration(["--codex-test-profile", profile], {}, home), command); + const restart = new DesktopConnections(launch, restored, "/not-a-helper", () => {}, command); + expect(restart.enabled("codex")).toBe(true); + expect((await compose(launch, restored, { connections: restart })[0].quota!()).windows[0].usedPercent).toBe(25); + await restart.disconnect("codex"); + expect((await Preferences.load(launch, command)).values.desktopCodexConnection).toBe("off"); + await expect(compose(launch, restored, { connections: restart })[0].quota!()).rejects.toMatchObject({ code: "notSignedIn" }); + expect(command).not.toHaveBeenCalled(); expect(transport.cliPath).not.toHaveBeenCalled(); expect(existsSync(join(home, "Library"))).toBe(false); +}); it("keeps Codex helper authentication isolated and removes only its own login on disconnect", async () => { const root = temp(), binary = fakeCodex(root), launch = launchConfiguration([], { PATH: root, OPENAI_API_KEY: "OMIT_INHERITED_API_KEY" }, root); symlinkSync(process.execPath, join(root, "node")); diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index bcc936e..ea20e3d 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -4,11 +4,14 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -it("cleans up destroyed windows and ignores updates after tray destruction", async () => { +it.each([false, true])("cleans up windows and isolates candidate effects (candidate=%s)", async candidate => { + vi.resetModules(); const root = mkdtempSync(join(tmpdir(), "meterusage-lifecycle-")); const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + const resourcesPath = Object.getOwnPropertyDescriptor(process, "resourcesPath"); const application = Object.assign(new EventEmitter(), { - isPackaged: false, setName: vi.fn(), whenReady: () => Promise.resolve(), + isPackaged: true, setName: vi.fn(), setPath: vi.fn(), whenReady: () => Promise.resolve(), + getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })), setLoginItemSettings: vi.fn(), requestSingleInstanceLock: () => true, getVersion: () => "0.0.0", quit: vi.fn(), exit: vi.fn(), }); const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ workArea: { height: 900 } }), getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1200, height: 900 } }) }); @@ -24,39 +27,51 @@ it("cleans up destroyed windows and ignores updates after tray destruction", asy destroy() { destroyed = true; } } const windows: TestWindow[] = []; + const ipc = vi.fn(); + const notify = vi.fn(); class TestWindow extends EventEmitter { destroyed = false; - contents = { id: 1, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; + contents = { id: windows.length + 1, mainFrame: { url: "" }, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; constructor(...args: ConstructorParameters) { super(...args); windows.push(this); } get webContents() { if (this.destroyed) throw new Error("webContents is unavailable after destruction"); return this.contents; } + static fromWebContents(contents: unknown) { return windows.find(w => w.contents === contents); } isDestroyed = () => this.destroyed; isVisible = () => false; getBounds = () => ({ x: 0, y: 0, width: 100, height: 100 }); - setPosition = vi.fn(); show = vi.fn(); focus = vi.fn(); - loadURL = () => Promise.resolve(); + setPosition = vi.fn(); show = vi.fn(); focus = vi.fn(); hide = vi.fn(); + loadURL = (url: string) => { this.contents.mainFrame.url = url; return Promise.resolve(); }; } const start = vi.fn(async () => {}); vi.doMock("electron", () => ({ app: application, Tray: TestTray, BrowserWindow: TestWindow, - screen: display, nativeTheme: theme, powerMonitor: new EventEmitter(), ipcMain: { handle: vi.fn() }, + screen: display, nativeTheme: theme, powerMonitor: new EventEmitter(), ipcMain: { handle: ipc }, + Notification: Object.assign(class { show = notify; }, { isSupported: () => true }), nativeImage: { createFromPath: () => ({ resize: () => ({ setTemplateImage: vi.fn() }) }) }, session: { defaultSession: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), webRequest: { onBeforeRequest: vi.fn() } } }, })); - vi.doMock("../src/main/launch", () => ({ launchConfiguration: () => ({ data: root, home: root, demo: false }) })); + vi.doMock("../src/main/launch", () => ({ launchConfiguration: () => ({ data: root, home: root, demo: false, candidate, profile: candidate ? root : undefined }) })); vi.doMock("../src/main/preferences", () => ({ Preferences: { load: async () => ({ values: { onboardingCompleted: true }, accounts: [] }) } })); vi.doMock("../src/main/composition", () => ({ compose: () => [] })); vi.doMock("../src/main/connections", () => ({ DesktopConnections: class { state = []; configured = () => false; recover = async () => {}; stop = async () => {}; } })); vi.doMock("../src/main/coordinator", () => ({ Coordinator: class { + constructor(_launch: unknown, _prefs: unknown, _sources: unknown, _clock: unknown, alert: (a: unknown) => void) { alert({ title: "Fixture", body: "Fixture" }); } start = start; stop = vi.fn(); subscribe = vi.fn(); snapshot = () => ({ appearance: {} }); refreshIfStale = vi.fn(); } })); vi.doMock("../src/main/updater", () => ({ Updater: class { reset = vi.fn(); } })); vi.doMock("../src/domain/overview", () => ({ trayTooltip: () => "Synthetic usage" })); try { Object.defineProperty(process, "platform", { value: "darwin" }); + Object.defineProperty(process, "resourcesPath", { value: root, configurable: true }); vi.stubGlobal("__dirname", root); await import("../src/main/electron"); await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + if (candidate) { + expect(application.setPath.mock.calls).toEqual([["userData", join(root, "electron")], ["sessionData", join(root, "electron")]]); + expect(notify).not.toHaveBeenCalled(); + } else { + expect(application.setPath).not.toHaveBeenCalled(); expect(notify).toHaveBeenCalledOnce(); + } tray.emit("click"); await vi.waitFor(() => expect(windows).toHaveLength(2)); const closed = windows[1]; @@ -68,6 +83,14 @@ it("cleans up destroyed windows and ignores updates after tray destruction", asy expect(windows).toHaveLength(3); expect(() => display.emit("display-metrics-changed")).not.toThrow(); expect(tooltip).toHaveBeenCalledWith("Synthetic usage"); + const invoke = (w: TestWindow, request: unknown) => ipc.mock.calls[0][1]({ sender: w.contents, senderFrame: w.contents.mainFrame }, request); + await invoke(windows[2], { action: "settings" }); + if (candidate) { + expect(application.getLoginItemSettings).not.toHaveBeenCalled(); + const reply = await invoke(windows[3], { action: "setPreference", key: "launchAtLogin", value: true }); + expect(reply).toEqual({ ok: false, error: "Login items require an installed app" }); + expect(application.setLoginItemSettings).not.toHaveBeenCalled(); + } else expect(application.getLoginItemSettings).toHaveBeenCalledOnce(); destroyed = true; expect(() => display.emit("display-metrics-changed")).not.toThrow(); expect(() => display.emit("display-removed")).not.toThrow(); @@ -76,6 +99,7 @@ it("cleans up destroyed windows and ignores updates after tray destruction", asy application.emit("before-quit", { preventDefault: vi.fn() }); await Promise.resolve(); Object.defineProperty(process, "platform", platform); + if (resourcesPath) Object.defineProperty(process, "resourcesPath", resourcesPath); else Reflect.deleteProperty(process, "resourcesPath"); vi.unstubAllGlobals(); for (const path of ["electron", "../src/main/launch", "../src/main/preferences", "../src/main/composition", "../src/main/connections", "../src/main/coordinator", "../src/main/updater", "../src/domain/overview"]) vi.doUnmock(path); rmSync(root, { recursive: true, force: true }); diff --git a/tests/updater.test.ts b/tests/updater.test.ts index 597206c..e42ff60 100644 --- a/tests/updater.test.ts +++ b/tests/updater.test.ts @@ -23,6 +23,18 @@ describe("verified explicit updates", () => { let calls = 0; const updater = new Updater(prefs, "0.2.41", () => {}, async () => { calls++; return bytes; }); await updater.checkIfDue(); updater.available = parseRelease(payload()); await expect(updater.install("/tmp/MeterUsage.app", updater.visible)).rejects.toThrow("unavailable"); expect(calls).toBe(0); }); + it("blocks updates from a live candidate even when its saved setting enables them", async () => { + const root = temp(); + const prefs = await Preferences.load({ demo: true, candidate: true, home: root, data: root, env: {} }); + prefs.launch.demo = false; + prefs.values.updateCheckEnabled = true; + let calls = 0; + const updater = new Updater(prefs, "0.2.41", () => {}, async () => { calls++; return Buffer.from(payload()); }); + await updater.checkIfDue(); + const release = parseRelease(payload()); updater.available = release; + await expect(updater.install("/tmp/MeterUsage.app", release)).rejects.toThrow("unavailable"); + expect(updater.visible).toBeUndefined(); expect(calls).toBe(0); + }); it("checks hourly, announces once and persists dismissal separately from usage failures", async () => { const root = temp(), prefs = await Preferences.load({ demo: false, candidate: false, home: root, data: root, env: {} }, async (_binary, args) => { if (args[0] !== "write") throw new Error("absent fixture defaults"); return ""; }, "fixture.meterusage"); let calls = 0, announcements = 0; const updater = new Updater(prefs, "0.2.41", () => {}, async url => { expect(url).toBe(releaseEndpoint); calls++; return Buffer.from(payload()); }, () => announcements++); From 06e5c89bd4f371af3fd46228d867c35390ab8a1c Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:03:58 -0400 Subject: [PATCH 5/8] fix: stop display updates during app shutdown --- CHANGELOG.md | 3 + docs/KB.md | 1 + src/main/electron.ts | 3 +- tests/electron-lifecycle.test.ts | 130 ++++++++++++++++++++++++++++++- 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b2930b..efe98e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,9 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Stop display updates when Quit begins so a late usage refresh cannot reopen + the side notch or restart update checks during shutdown. + - Launch Codex account helpers with the documented stdio transport. Keep interpreter search paths available without inheriting credential variables. - Keep provider display controls available for active additional accounts when diff --git a/docs/KB.md b/docs/KB.md index e4c181e..2744af3 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -40,6 +40,7 @@ Last source review: 2026-10-09 - The browser-demo notch uses viewport height when no native work-area height is supplied. The card and strip scroll independently; the strip retains all 44 pixels for its rings. Synthetic browser checks at 294x500 and 294x900 cover scrolling, two card heights and both CSS card sides. These checks do not verify native window placement or display changes. - Candidate state publication returns before touching a destroyed tray. A synthetic Electron entrypoint regression emits display and theme events after tray destruction; native shutdown/display behavior remains an acceptance check. - Candidate window cleanup uses the document ID captured while the window is alive. Closing a destroyed window cannot read its web contents or remove a replacement window for the same surface. The synthetic lifecycle regression exercises this path; native shutdown remains an acceptance check. +- Candidate Quit removes the display subscription before stopping collection and waiting for authentication cleanup. A late collection publication cannot recreate the notch or restart update checks. Lifecycle regressions cover flyout and tray Quit, repeated Quit during cleanup, and notch enabled or disabled. These tests do not establish native process termination. - Candidate Settings groups General, Providers, Appearance, Side notch, Usage details and Diagnostics. Accent color uses six labelled radio swatches. Provider controls distinguish collection from menu-bar and side-notch display; stopping primary collection preserves the saved display preference. The display control stays available when an additional account has an active source slot. Advanced local accounts remain available. Scrollbar indicators are hidden throughout candidate windows, with scrolling and keyboard access retained. These renderer contracts do not establish native UI parity. - Codex login, allowance and own-profile logout select `app-server --listen stdio://`. Connection subprocesses keep interpreter search paths while excluding inherited credential variables. Connection tests restrict app-helper discovery to generated fixture roots. These are source contracts, not proof of a live helper or cloud account. - The desktop-connection candidate has matching passing source proof for 165 synthetic TypeScript tests, TypeScript checking and production bundling on Linux. Its connection cleanup, account verification, cache/JSON readers, reset capability, alerts and locality labels have regression coverage. Disconnect blocks Claude's post-request credential reread; account-bound collection timeout removes unverified allowance; explicit Codex sign-in retry recovers retained cleanup before a new login. The unchanged migration baseline's headless demo comparison preserves stable schema-1 fields against the Swift demo oracle; that older native receipt does not verify the new connections. Source checks do not establish native parity or a published Electron release. diff --git a/src/main/electron.ts b/src/main/electron.ts index 71def0f..1ca8058 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -290,7 +290,7 @@ async function start() { try { return await handle(parseRequest(raw), source, surface); } catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Connections are disabled in demo mode", "Automatic Grok connection is unavailable"].includes(e.message) ? e.message : "Could not complete action" }; } }); - coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); }); + const unsubscribe = coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); }); screen.on("display-metrics-changed", () => { placeNotch(); publish(); }); screen.on("display-removed", () => { placeNotch(); publish(); }); powerMonitor.on("resume", () => { void coordinator.refresh(); }); app.on("second-instance", () => showFlyout()); @@ -303,6 +303,7 @@ async function start() { event.preventDefault(); if (quitting) return; quitting = true; + unsubscribe(); coordinator.stop(); updater.reset(); clearInterval(clockTimer); if (drag) { clearInterval(drag.timer); clearTimeout(drag.timeout); } folding.cancel(); void connections.stop().finally(() => { if (shareDirectory) rmSync(shareDirectory, { recursive: true, force: true }); for (const directory of sharedDirectories) rmSync(directory, { recursive: true, force: true }); tray.destroy(); diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index ea20e3d..2f238b1 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -56,7 +56,7 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid } })); vi.doMock("../src/main/coordinator", () => ({ Coordinator: class { constructor(_launch: unknown, _prefs: unknown, _sources: unknown, _clock: unknown, alert: (a: unknown) => void) { alert({ title: "Fixture", body: "Fixture" }); } - start = start; stop = vi.fn(); subscribe = vi.fn(); snapshot = () => ({ appearance: {} }); refreshIfStale = vi.fn(); + start = start; stop = vi.fn(); subscribe = vi.fn(() => vi.fn()); snapshot = () => ({ appearance: {} }); refreshIfStale = vi.fn(); } })); vi.doMock("../src/main/updater", () => ({ Updater: class { reset = vi.fn(); } })); vi.doMock("../src/domain/overview", () => ({ trayTooltip: () => "Synthetic usage" })); @@ -105,3 +105,131 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid rmSync(root, { recursive: true, force: true }); } }); + +it.each([ + { entry: "flyout", notchEnabled: false }, { entry: "flyout", notchEnabled: true }, + { entry: "tray", notchEnabled: false }, { entry: "tray", notchEnabled: true }, +])("finishes $entry Quit after late collection publication (notch=$notchEnabled)", async ({ entry, notchEnabled }) => { + vi.resetModules(); + const root = mkdtempSync(join(tmpdir(), "meterusage-quit-")); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + const resourcesPath = Object.getOwnPropertyDescriptor(process, "resourcesPath"); + const { Preferences } = await import("../src/main/preferences"); + const { Coordinator } = await import("../src/main/coordinator"); + const launch = { data: root, home: root, demo: true, candidate: false, env: {} }; + const prefs = await Preferences.load(launch); + launch.demo = false; + Object.assign(prefs.values, { onboardingCompleted: true, sideNotchPanelEnabled: notchEnabled, updateCheckEnabled: false }); + let coordinator!: InstanceType; + const subscribe = Coordinator.prototype.subscribe; + vi.spyOn(Coordinator.prototype, "subscribe").mockImplementation(function (this: InstanceType, observer) { + coordinator = this; return subscribe.call(this, observer); + }); + let finishCleanup!: () => void; + const cleanup = new Promise(resolve => { finishCleanup = resolve; }); + const stop = vi.fn(() => cleanup); + const updaterCheck = vi.fn(); + const windows: TestWindow[] = []; + const ipc = vi.fn(), willQuit = vi.fn(), didQuit = vi.fn(); + let running = true; + const application = Object.assign(new EventEmitter(), { + isPackaged: true, setName: vi.fn(), whenReady: () => Promise.resolve(), + requestSingleInstanceLock: () => true, getVersion: () => "0.0.0", exit: vi.fn(), + quit: vi.fn(() => { + const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + application.emit("before-quit", event); + if (event.defaultPrevented) return; + // Replay an in-flight collection's final publication between window closures. + const closing = windows.filter(w => !w.destroyed); + closing.find(w => w.surface === "notch")?.close(); + coordinator.publish(); + for (const w of closing) if (!w.destroyed) w.close(); + if (windows.some(w => !w.destroyed)) return; + const final = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } }; + application.emit("will-quit", final); + if (!final.defaultPrevented) { running = false; application.emit("quit", {}, 0); } + }), + }); + application.on("will-quit", willQuit); application.on("quit", didQuit); + const workArea = { x: 0, y: 25, width: 1200, height: 875 }; + const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1200, height: 900 }, workArea }), getDisplayNearestPoint: () => ({ workArea }) }); + let tray!: TestTray; + class TestTray extends EventEmitter { + destroyed = false; + menu: { items: { label?: string; role?: string }[] } | undefined; + constructor(...args: ConstructorParameters) { super(...args); tray = this; } + isDestroyed = () => this.destroyed; + destroy() { this.destroyed = true; } + setToolTip = vi.fn(); getBounds = () => ({ x: 0, y: 0, width: 22, height: 22 }); + popUpContextMenu(menu: NonNullable) { this.menu = menu; } + } + class TestWindow extends EventEmitter { + destroyed = false; + surface = ""; + contents = { id: windows.length + 1, mainFrame: { url: "" }, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; + constructor(...args: ConstructorParameters) { super(...args); windows.push(this); } + get webContents() { if (this.destroyed) throw new Error("webContents is unavailable after destruction"); return this.contents; } + static fromWebContents(contents: unknown) { return windows.find(w => w.contents === contents); } + isDestroyed = () => this.destroyed; + isVisible = () => false; + getBounds = () => ({ x: 0, y: 25, width: 100, height: 200 }); + setPosition = vi.fn(); setBounds = vi.fn(); setAlwaysOnTop = vi.fn(); setVisibleOnAllWorkspaces = vi.fn(); + show = vi.fn(); showInactive = vi.fn(); focus = vi.fn(); hide = vi.fn(); + loadURL(url: string) { this.contents.mainFrame.url = url; this.surface = new URL(url).searchParams.get("surface")!; return Promise.resolve(); } + close() { this.destroyed = true; this.emit("closed"); } + } + vi.doMock("electron", () => ({ app: application, Tray: TestTray, BrowserWindow: TestWindow, + Menu: { buildFromTemplate: (items: unknown[]) => ({ items }) }, + screen: display, nativeTheme: new EventEmitter(), powerMonitor: new EventEmitter(), ipcMain: { handle: ipc }, + nativeImage: { createFromPath: () => ({ resize: () => ({ setTemplateImage: vi.fn() }) }) }, + session: { defaultSession: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), webRequest: { onBeforeRequest: vi.fn() } } }, + })); + vi.doMock("../src/main/launch", () => ({ launchConfiguration: () => launch })); + vi.spyOn(Preferences, "load").mockResolvedValue(prefs); + vi.doMock("../src/main/composition", () => ({ compose: () => [] })); + vi.doMock("../src/main/connections", () => ({ DesktopConnections: class { + state = []; configured = () => false; recover = async () => {}; stop = stop; + } })); + vi.doMock("../src/main/updater", () => ({ Updater: class { reset = vi.fn(); checkIfDue = updaterCheck; } })); + try { + Object.defineProperty(process, "platform", { value: "darwin" }); + Object.defineProperty(process, "resourcesPath", { value: root, configurable: true }); + vi.stubGlobal("__dirname", root); + await import("../src/main/electron"); + await vi.waitFor(() => expect(coordinator.snapshot().refreshing).toBe(false)); + tray.emit("click"); + const flyout = windows.find(w => w.surface === "flyout")!; + const windowCount = windows.length; + const checks = updaterCheck.mock.calls.length; + if (entry === "flyout") { + expect(await ipc.mock.calls[0][1]({ sender: flyout.contents, senderFrame: flyout.contents.mainFrame }, { action: "quit" })).toEqual({ ok: true }); + } else { + tray.emit("right-click"); + expect(tray.menu?.items.find(item => item.label === "Quit MeterUsage")?.role).toBe("quit"); + application.quit(); + } + application.quit(); + expect(stop).toHaveBeenCalledOnce(); expect(running).toBe(true); expect(tray.destroyed).toBe(false); + expect(windows.filter(w => !w.destroyed)).toHaveLength(windowCount); + finishCleanup(); + await cleanup; + await vi.waitFor(() => expect(application.quit).toHaveBeenCalledTimes(3)); + expect(windows).toHaveLength(windowCount); + expect(windows.every(w => w.destroyed)).toBe(true); + expect(tray.destroyed).toBe(true); expect(running).toBe(false); + expect(willQuit).toHaveBeenCalledOnce(); expect(didQuit).toHaveBeenCalledOnce(); + expect(application.exit).not.toHaveBeenCalled(); + expect(updaterCheck).toHaveBeenCalledTimes(checks); + coordinator.publish(); + expect(windows).toHaveLength(windowCount); + } finally { + finishCleanup(); coordinator?.stop(); + application.emit("before-quit", { preventDefault: vi.fn() }); + await cleanup; await Promise.resolve(); + Object.defineProperty(process, "platform", platform); + if (resourcesPath) Object.defineProperty(process, "resourcesPath", resourcesPath); else Reflect.deleteProperty(process, "resourcesPath"); + vi.restoreAllMocks(); vi.unstubAllGlobals(); + for (const path of ["electron", "../src/main/launch", "../src/main/composition", "../src/main/connections", "../src/main/updater"]) vi.doUnmock(path); + rmSync(root, { recursive: true, force: true }); + } +}); From 07cb9d09bd89f944bbb58d4d3ba1653f12639ea8 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:09:01 -0400 Subject: [PATCH 6/8] feat: add panel sizes and handle candidate reopens --- CHANGELOG.md | 10 ++ docs/DESKTOP-CONNECTIONS.md | 9 +- docs/KB.md | 2 + .../0013-candidate-single-instance-startup.md | 36 +++++ docs/adr/README.md | 1 + src/main/electron.ts | 59 ++++++--- src/main/ipc.ts | 8 +- src/main/launch.ts | 41 +++++- src/main/preferences.ts | 6 +- src/renderer/app.tsx | 16 ++- src/renderer/style.css | 3 + src/shared/ipc.ts | 2 + tests/composition.test.ts | 56 +++++++- tests/electron-lifecycle.test.ts | 123 ++++++++++++++++-- tests/ipc.test.ts | 13 +- tests/preferences.test.ts | 50 +++++++ tests/renderer.test.ts | 59 ++++++++- 17 files changed, 438 insertions(+), 56 deletions(-) create mode 100644 docs/adr/0013-candidate-single-instance-startup.md create mode 100644 tests/preferences.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efe98e1..82c3348 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ follows [Semantic Versioning](https://semver.org/). ### Added +- Add Small, Medium and Large panel sizes in candidate Settings. The choice + persists and applies to main-panel and Settings text and controls. Main-panel + width scales with the choice; its height fits content up to the display edge. + - Add an isolated Codex cloud test profile for the existing candidate UI. It starts disconnected, reads account allowance after browser sign-in, and excludes demo data, local activity and other providers. Candidate profiles @@ -51,6 +55,12 @@ follows [Semantic Versioning](https://semver.org/). ### Fixed +- Handle opening an already-running candidate without an uncaught startup + popup. Validate Electron's singleton metadata before requesting its lock, + retain all other symlink checks, and reveal the existing panel after startup. + Invalid profiles exit with a fixed error; activation cannot reopen a panel + during Quit. + - Stop display updates when Quit begins so a late usage refresh cannot reopen the side notch or restart update checks during shutdown. diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md index 90f7ced..756c664 100644 --- a/docs/DESKTOP-CONNECTIONS.md +++ b/docs/DESKTOP-CONNECTIONS.md @@ -95,8 +95,13 @@ This selects the existing UI and connection flow with live Codex allowance. It cannot be combined with `--demo` or `--candidate-profile`. The directory must be empty on first use and owned by this test mode on later launches. Installed-app storage and symlink paths are refused, including nested paths -inside an existing profile. Startup checks file metadata before starting -Electron or the helper, with a limit of 32,768 entries and 24 directory levels. +inside an existing profile. The macOS GUI permits only Electron's three direct +singleton links after checking their ownership, socket location and matching +metadata. JSON entrypoints retain strict symlink rejection. Startup checks +metadata before requesting Electron's lock or starting the helper, with a +limit of 32,768 entries and 24 directory levels. A second launch reveals the +existing panel; invalid metadata exits without an uncaught popup. See +[ADR 0013](adr/0013-candidate-single-instance-startup.md). Preferences, history, connection profiles and Electron state stay in that directory. The real OS home remains available for helper discovery and macOS diff --git a/docs/KB.md b/docs/KB.md index 2744af3..eb1d2d9 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -41,6 +41,8 @@ Last source review: 2026-10-09 - Candidate state publication returns before touching a destroyed tray. A synthetic Electron entrypoint regression emits display and theme events after tray destruction; native shutdown/display behavior remains an acceptance check. - Candidate window cleanup uses the document ID captured while the window is alive. Closing a destroyed window cannot read its web contents or remove a replacement window for the same surface. The synthetic lifecycle regression exercises this path; native shutdown remains an acceptance check. - Candidate Quit removes the display subscription before stopping collection and waiting for authentication cleanup. A late collection publication cannot recreate the notch or restart update checks. Lifecycle regressions cover flyout and tray Quit, repeated Quit during cleanup, and notch enabled or disabled. These tests do not establish native process termination. +- Candidate macOS GUI startup validates Electron's three direct singleton links as one coherent metadata set before requesting the native lock. Other profile symlinks remain rejected, and JSON entrypoints retain strict validation. A second instance exits before collection starts and asks the primary instance to reveal its panel. Startup failures emit a fixed error; activation during Quit cannot reopen windows. [ADR 0013](adr/0013-candidate-single-instance-startup.md) records the bounded exception and native acceptance requirements. +- Candidate Appearance includes a saved Small, Medium or Large panel size, defaulting to Medium. Main-panel widths are 378, 420 and 483 points before display clamping. Main-panel and Settings content scale together; side-notch, tray and shared-card geometry retain their existing sizes. The main panel measures natural content height, shrinks when content gets shorter, and scrolls within available display space without visible indicators. Synthetic tests cover persistence, invalid values, write failure and display clamping; actual native sizing acceptance is separate. - Candidate Settings groups General, Providers, Appearance, Side notch, Usage details and Diagnostics. Accent color uses six labelled radio swatches. Provider controls distinguish collection from menu-bar and side-notch display; stopping primary collection preserves the saved display preference. The display control stays available when an additional account has an active source slot. Advanced local accounts remain available. Scrollbar indicators are hidden throughout candidate windows, with scrolling and keyboard access retained. These renderer contracts do not establish native UI parity. - Codex login, allowance and own-profile logout select `app-server --listen stdio://`. Connection subprocesses keep interpreter search paths while excluding inherited credential variables. Connection tests restrict app-helper discovery to generated fixture roots. These are source contracts, not proof of a live helper or cloud account. - The desktop-connection candidate has matching passing source proof for 165 synthetic TypeScript tests, TypeScript checking and production bundling on Linux. Its connection cleanup, account verification, cache/JSON readers, reset capability, alerts and locality labels have regression coverage. Disconnect blocks Claude's post-request credential reread; account-bound collection timeout removes unverified allowance; explicit Codex sign-in retry recovers retained cleanup before a new login. The unchanged migration baseline's headless demo comparison preserves stable schema-1 fields against the Swift demo oracle; that older native receipt does not verify the new connections. Source checks do not establish native parity or a published Electron release. diff --git a/docs/adr/0013-candidate-single-instance-startup.md b/docs/adr/0013-candidate-single-instance-startup.md new file mode 100644 index 0000000..cbb8c48 --- /dev/null +++ b/docs/adr/0013-candidate-single-instance-startup.md @@ -0,0 +1,36 @@ +# ADR 0013: Validate candidate singleton metadata before startup + +- Status: Accepted implementation direction; native acceptance pending +- Date: 2026-10-10 + +## Decision + +Amend ADR 0012's blanket symlink rejection for the macOS GUI only. Electron +creates `SingletonSocket`, `SingletonCookie` and `SingletonLock` links directly +under its profile directory. Rejecting those links prevents a second launch +from reaching Electron's single-instance lock. + +Accept the three links only as a coherent set owned by the current user. Check +the local hostname and numeric lock PID, matching numeric cookies, and a UNIX +socket below the OS temporary root. Require direct directory ancestors and a +private socket directory owned by the user. Inspect link metadata without +following profile links or deleting them. Reject every other profile symlink, +incomplete or incompatible metadata, and nonregular profile nodes. JSON +entrypoints retain strict symlink rejection. + +Select isolated Electron paths before requesting the native lock. A secondary +instance exits before preference loading or collection. The primary instance +coalesces activation requests until startup finishes and ignores activation +during Quit. Catch startup errors and exit with a fixed message instead of an +uncaught exception popup. + +This validation is not a sandbox against concurrent filesystem changes by the +same user. It does not add automatic lock cleanup or process termination. + +## Acceptance + +Synthetic tests cover coherent metadata, rejected links and socket targets, +lock ordering, early activation, and activation during Quit. Native acceptance +requires the packaged candidate to reopen the same isolated profile, route a +duplicate launch to the primary window, retain account allowance and quit. +Installed-app, migration and release holds remain unchanged. diff --git a/docs/adr/README.md b/docs/adr/README.md index e8698ac..40290dd 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -17,3 +17,4 @@ This directory records decisions that affect repository work. Read an ADR before | [0010](0010-macos-typescript-electron.md) | Port macOS and the schema-1 JSON CLI to TypeScript/Electron/React; preserve data contracts and pause Windows until a test machine is available. | Accepted architecture; native acceptance pending | | [0011](0011-desktop-account-connections.md) | Add opt-in Codex browser sign-in and Claude Desktop account allowance; separate account allowance from local activity. | Accepted implementation direction; native and provider acceptance pending | | [0012](0012-codex-live-test-profile.md) | Run isolated live Codex validation in the existing UI while blocking other providers and candidate system effects. | Accepted implementation direction; native UI acceptance pending | +| [0013](0013-candidate-single-instance-startup.md) | Validate candidate Electron singleton metadata before locking; preserve other symlink rejection and safe activation during startup and Quit. | Accepted implementation direction; native acceptance pending | diff --git a/src/main/electron.ts b/src/main/electron.ts index 1ca8058..f12f8e1 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -3,7 +3,7 @@ import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { randomUUID } from "node:crypto"; -import { launchConfiguration } from "./launch"; +import { launchConfiguration, type Launch } from "./launch"; import { Preferences } from "./preferences"; import { compose } from "./composition"; import { DesktopConnections } from "./connections"; @@ -11,28 +11,38 @@ import { Coordinator } from "./coordinator"; import { NotchFold } from "./notch-fold"; import { Updater } from "./updater"; import { runJSON } from "./cli"; -import { channel, stateChannel, statusPages, type ViewState, type Surface, type Request, type Reply } from "../shared/ipc"; +import { channel, stateChannel, statusPages, panelScales, type PanelSize, type ViewState, type Surface, type Request, type Reply } from "../shared/ipc"; import { parseRequest, trustedSender, projectSettings, shareSnapshot } from "./ipc"; import { providers, slotKey } from "../domain/models"; import { notchFrame, cardOnRight, stripWidth, cardWidth, minimumShareSize } from "../domain/notch"; import { trayTooltip } from "../domain/overview"; -const args = process.argv.slice(1), launch = launchConfiguration(args); +const args = process.argv.slice(1); const desktopHelper = app.isPackaged ? join(process.resourcesPath, "desktop-keychain") : join(__dirname, "../../build/desktop-keychain"); -// Isolation is selected before Electron creates a session or any source reads. -if (launch.profile) { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); } -app.setName("MeterUsage"); -if (args.includes("json") || args.includes("--json")) { - runJSON(args, process.env, desktopHelper).then(json => { process.stdout.write(json + "\n"); app.exit(0); }, () => { process.stderr.write("meterusage: could not read report\n"); app.exit(1); }); -} else if (process.platform !== "darwin") { - process.stderr.write("MeterUsage desktop candidates currently support macOS only.\n"); app.exit(1); -} else { - void app.whenReady().then(start).catch(() => { process.stderr.write("MeterUsage could not start.\n"); app.exit(1); }); +void main().catch(() => { process.stderr.write("MeterUsage could not start.\n"); app.exit(1); }); +async function main() { + const json = args.includes("json") || args.includes("--json"); + const launch = launchConfiguration(args, process.env, undefined, !json && process.platform === "darwin"); + // Validate isolation before Electron creates a session or contacts a singleton socket. + if (launch.profile) { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); } + app.setName("MeterUsage"); + if (json) { + await runJSON(args, process.env, desktopHelper).then(json => { process.stdout.write(json + "\n"); app.exit(0); }, () => { process.stderr.write("meterusage: could not read report\n"); app.exit(1); }); + } else if (process.platform !== "darwin") { + process.stderr.write("MeterUsage desktop candidates currently support macOS only.\n"); app.exit(1); + } else { + let reveal: (() => void) | undefined, requested = false; + const activate = () => { if (reveal) reveal(); else requested = true; }; + app.on("second-instance", activate); app.on("activate", activate); + if (!app.requestSingleInstanceLock({ candidate: launch.candidate || launch.demo })) { app.quit(); return; } + await app.whenReady(); + reveal = await start(launch); + if (requested) reveal?.(); + } } -async function start() { +async function start(launch: Launch) { mkdirSync(launch.data, { recursive: true }); app.dock?.hide(); - if (!app.requestSingleInstanceLock({ candidate: launch.candidate || launch.demo })) { app.quit(); return; } const prefs = await Preferences.load(launch); const documents = new Map(), windows = new Map(); const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity }; @@ -52,6 +62,8 @@ async function start() { for (const p of ["codex", "claude"] as const) if (connections.configured(p)) coordinator.forgetQuota(p); const notch = { expanded: prefs.values.sideNotchPanelPinned === true, cardOnRight: false, selected: "codex", dragging: false }; let notchHeight = 200, anchor: { x: number; y: number } | undefined; + let flyoutHeight = 700; + const panelScale = () => panelScales[prefs.values.panelSize as PanelSize] ?? 1; let drag: { cursor: Electron.Point; anchor: Electron.Point; timer: ReturnType; timeout: ReturnType } | undefined; let shareMenu: ShareMenu | undefined, shareDirectory: string | undefined, sharing = false, confirmingReset = false, trayWidth = 0; const sharedDirectories = new Set(); @@ -79,7 +91,7 @@ async function start() { function create(surface: Surface) { const existing = windows.get(surface); if (existing && !existing.isDestroyed()) return existing; const panel = surface === "notch", trayImage = surface === "tray", shareImage = surface === "share"; - const w = new BrowserWindow({ width: shareImage ? cardWidth * 2 : trayImage ? 1000 : panel ? stripWidth : surface === "settings" ? 620 : 420, height: trayImage ? 22 : panel ? notchHeight : surface === "settings" ? 680 : 700, show: false, frame: surface === "settings", transparent: panel || trayImage || shareImage, resizable: surface === "settings", minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, ...(panel ? { type: "panel", focusable: false, hasShadow: false } : {}), title: surface === "settings" ? "MeterUsage Settings" : "MeterUsage", backgroundColor: panel || trayImage || shareImage ? "#00000000" : "#242428", webPreferences: { preload: join(__dirname, "../preload/preload.cjs"), offscreen: trayImage || shareImage, zoomFactor: shareImage ? 2 : 1, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, spellcheck: false } }); + const w = new BrowserWindow({ width: shareImage ? cardWidth * 2 : trayImage ? 1000 : panel ? stripWidth : surface === "settings" ? 620 : Math.round(420 * panelScale()), height: trayImage ? 22 : panel ? notchHeight : surface === "settings" ? 680 : Math.ceil(flyoutHeight * panelScale()), show: false, frame: surface === "settings", transparent: panel || trayImage || shareImage, resizable: surface === "settings", minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, ...(panel ? { type: "panel", focusable: false, hasShadow: false } : {}), title: surface === "settings" ? "MeterUsage Settings" : "MeterUsage", backgroundColor: panel || trayImage || shareImage ? "#00000000" : "#242428", webPreferences: { preload: join(__dirname, "../preload/preload.cjs"), offscreen: trayImage || shareImage, zoomFactor: shareImage ? 2 : 1, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, spellcheck: false } }); if (trayImage) { w.webContents.setFrameRate(10); w.webContents.on("paint", (_event, _rect, image) => { if (!tray.isDestroyed() && trayWidth > 0) tray.setImage(image); }); } if (shareImage) { w.webContents.setFrameRate(10); @@ -102,7 +114,13 @@ async function start() { function showFlyout() { const w = create("flyout"), bounds = tray.getBounds(), work = screen.getDisplayNearestPoint({ x: bounds.x, y: bounds.y }).workArea; w.setPosition(Math.round(Math.min(Math.max(bounds.x + bounds.width / 2 - w.getBounds().width / 2, work.x), work.x + work.width - w.getBounds().width)), work.y); - w.show(); w.focus(); void coordinator.refreshIfStale(); + sizeFlyout(); w.show(); w.focus(); void coordinator.refreshIfStale(); + } + function sizeFlyout() { + const w = windows.get("flyout"); if (!w || w.isDestroyed()) return; + const bounds = w.getBounds(), work = screen.getDisplayMatching(bounds).workArea; + const width = Math.min(Math.round(420 * panelScale()), work.width), height = Math.min(Math.ceil(flyoutHeight * panelScale()), work.height); + w.setBounds({ x: Math.round(Math.min(Math.max(bounds.x, work.x), work.x + work.width - width)), y: Math.round(Math.min(Math.max(bounds.y, work.y), work.y + work.height - height)), width, height }, false); } function showSettings() { if (!launch.demo && !launch.candidate && app.isPackaged) prefs.values.launchAtLogin = app.getLoginItemSettings().openAtLogin; @@ -189,6 +207,7 @@ async function start() { if (app.getLoginItemSettings().openAtLogin !== r.value) throw new Error("Could not change login item"); } await prefs.set(r.key, r.value); + if (r.key === "panelSize") sizeFlyout(); if (r.key === "refreshIntervalSeconds") coordinator.restartTimer(); if (r.key === "sideNotchPanelPinned") { notch.expanded = r.value === true || folding.hovered; folding.changed(); } nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light"; @@ -239,9 +258,9 @@ async function start() { Menu.buildFromTemplate([{ label: "Keep open", type: "checkbox", checked: prefs.values.sideNotchPanelPinned === true, click: () => { void handle({ action: "setPreference", key: "sideNotchPanelPinned", value: prefs.values.sideNotchPanelPinned !== true }, source, surface); } }, { label: "Refresh now", click: () => { void coordinator.refresh(); } }, { type: "separator" }, { label: "Hide panel", click: () => { void handle({ action: "setPreference", key: "sideNotchPanelEnabled", value: false }, source, surface); } }]).popup({ window: source }); break; case "resize": - if (surface === "notch") { notchHeight = Math.ceil(r.height); placeNotch(); } + if (surface === "notch") { notchHeight = Math.min(2000, Math.ceil(r.height)); placeNotch(); } else if (surface === "tray" && r.width !== undefined) { trayWidth = Math.ceil(r.width); source.setSize(trayWidth, 22, false); } - else if (surface === "flyout") source.setSize(420, Math.min(Math.ceil(r.height), screen.getDisplayMatching(source.getBounds()).workArea.height)); + else if (surface === "flyout") { flyoutHeight = r.height / panelScale(); sizeFlyout(); } break; case "shareResize": if (surface !== "share" || !shareReady) throw new Error("Invalid request"); @@ -291,9 +310,8 @@ async function start() { catch (e) { return { ok: false, error: e instanceof Error && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Connections are disabled in demo mode", "Automatic Grok connection is unavailable"].includes(e.message) ? e.message : "Could not complete action" }; } }); const unsubscribe = coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); }); - screen.on("display-metrics-changed", () => { placeNotch(); publish(); }); screen.on("display-removed", () => { placeNotch(); publish(); }); + screen.on("display-metrics-changed", () => { placeNotch(); sizeFlyout(); publish(); }); screen.on("display-removed", () => { placeNotch(); sizeFlyout(); publish(); }); powerMonitor.on("resume", () => { void coordinator.refresh(); }); - app.on("second-instance", () => showFlyout()); app.on("window-all-closed", () => {}); const clockTimer = setInterval(publish, 60000); nativeTheme.on("updated", publish); @@ -317,4 +335,5 @@ async function start() { if (quitting) return; syncNotch(); await coordinator.start(); if (!quitting && (launch.candidate || prefs.values.onboardingCompleted !== true)) showFlyout(); + if (!quitting) return () => { if (!quitting) showFlyout(); }; } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 3a369ac..3fc256a 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,7 +4,7 @@ import type { PreferenceValue } from "./preferences"; import type { Surface, SettingsState, ViewState } from "../shared/ipc"; import { notchEntries } from "../domain/notch"; import { providers } from "../domain/models"; -import { editableBooleans, providerKey, trayKey, statusPages, type Request } from "../shared/ipc"; +import { editableBooleans, providerKey, trayKey, statusPages, panelScales, type Request } from "../shared/ipc"; const empty = new Set(["state", "refresh", "settings", "close", "quit", "clearCache", "copyDiagnostics", "copyJSON", "dragStart", "dragEnd", "notchContext", "updateInstall", "updateDismiss"]); const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9-]{1,80}$/.test(v); const key = (v: unknown) => typeof v === "string" && providers.some(p => v === p || (v.startsWith(p + "#") && id(v.slice(p.length + 1)))); @@ -18,7 +18,7 @@ export function parseRequest(raw: unknown): Request { else switch (action) { case "setPreference": fields = ["key", "value"]; - valid = typeof r.key === "string" && (boolKeys.has(r.key) ? typeof r.value === "boolean" : r.key === "refreshIntervalSeconds" ? typeof r.value === "number" && [30, 60, 120, 300, 900].includes(r.value) : r.key === "appearanceTheme" ? typeof r.value === "string" && ["system", "light", "dark"].includes(r.value) : r.key === "accentTheme" && typeof r.value === "string" && ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(r.value)); + valid = typeof r.key === "string" && (boolKeys.has(r.key) ? typeof r.value === "boolean" : r.key === "refreshIntervalSeconds" ? typeof r.value === "number" && [30, 60, 120, 300, 900].includes(r.value) : r.key === "appearanceTheme" ? typeof r.value === "string" && ["system", "light", "dark"].includes(r.value) : r.key === "panelSize" ? typeof r.value === "string" && Object.hasOwn(panelScales, r.value) : r.key === "accentTheme" && typeof r.value === "string" && ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(r.value)); break; case "accountAdd": fields = ["provider"]; valid = r.provider === "codex" || r.provider === "claude"; break; case "connect": case "disconnect": case "connectionCancel": fields = ["provider"]; valid = typeof r.provider === "string" && ["codex", "claude", "grok"].includes(r.provider); break; @@ -29,7 +29,7 @@ export function parseRequest(raw: unknown): Request { case "notchSelect": case "share": fields = ["key"]; valid = key(r.key); break; case "notchHover": fields = ["hovering"]; valid = typeof r.hovering === "boolean"; break; case "shareResize": fields = ["height"]; valid = typeof r.height === "number" && Number.isFinite(r.height) && r.height >= 20; break; - case "resize": fields = ["height", ...("width" in r ? ["width"] : [])]; valid = typeof r.height === "number" && Number.isFinite(r.height) && r.height >= 20 && r.height <= 2000 && (!("width" in r) || typeof r.width === "number" && Number.isFinite(r.width) && r.width >= 1 && r.width <= 10000); break; + case "resize": fields = ["height", ...("width" in r ? ["width"] : [])]; valid = typeof r.height === "number" && Number.isFinite(r.height) && r.height >= 20 && (!("width" in r) || typeof r.width === "number" && Number.isFinite(r.width) && r.width >= 1 && r.width <= 10000); break; } if (!valid || Object.keys(r).some(k => k !== "action" && !fields.includes(k)) || fields.some(k => !(k in r))) throw new Error("Invalid request"); return r as unknown as Request; @@ -48,7 +48,7 @@ export function shareSnapshot(view: ViewState, displayedKey: string): ViewState export function projectSettings(values: Record, accounts: ManagedAccount[], home: string, surface?: Surface): SettingsState { return { - values: Object.fromEntries(["refreshIntervalSeconds", "appearanceTheme", "accentTheme", ...editableBooleans, ...providers.flatMap(p => [providerKey(p), trayKey(p)])].map(k => [k, values[k] as boolean | number | string])), + values: Object.fromEntries(["refreshIntervalSeconds", "appearanceTheme", "accentTheme", "panelSize", ...editableBooleans, ...providers.flatMap(p => [providerKey(p), trayKey(p)])].map(k => [k, values[k] as boolean | number | string])), accounts: accounts.map(a => { const sub = relative(home, a.path), pathLabel = a.path.startsWith("~") ? a.path : !sub.startsWith("..") ? `~/${sub}` : basename(a.path); return { id: a.id, provider: a.provider, label: a.label, enabled: a.enabled, ...(surface === "settings" ? { pathLabel } : {}) }; diff --git a/src/main/launch.ts b/src/main/launch.ts index 7e4de2a..aefc659 100644 --- a/src/main/launch.ts +++ b/src/main/launch.ts @@ -1,8 +1,8 @@ -import { userInfo, tmpdir } from "node:os"; -import { mkdtempSync, mkdirSync, realpathSync, existsSync, readdirSync, writeFileSync, lstatSync } from "node:fs"; +import { userInfo, tmpdir, hostname } from "node:os"; +import { mkdtempSync, mkdirSync, realpathSync, existsSync, readdirSync, writeFileSync, lstatSync, readlinkSync } from "node:fs"; import { isAbsolute, join, resolve, relative, dirname } from "node:path"; export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string; codexTest?: boolean } -export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = process.env, realHome = userInfo().homedir): Launch { +export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = process.env, realHome = userInfo().homedir, electronSingletons = false): Launch { const demo = args.includes("--demo") || env.METERUSAGE_DEMO === "1", candidateIndex = args.indexOf("--candidate-profile"), codexIndex = args.indexOf("--codex-test-profile"), codexTest = codexIndex >= 0; if (codexTest && (demo || candidateIndex >= 0)) throw new Error("Codex test profile flags conflict with demo/candidate mode"); const index = codexTest ? codexIndex : candidateIndex, profile = index < 0 ? undefined : args[index + 1]; @@ -20,14 +20,20 @@ export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = pro if (!existsSync(marker) && readdirSync(normalized).length) throw new Error("Candidate profile must be an empty test directory"); // Validate metadata before Electron or the helper can write into this profile. const pending = [{ path: normalized, depth: 0 }]; + const singletonPaths = ["SingletonSocket", "SingletonCookie", "SingletonLock"].map(name => join(normalized, "electron", name)); + let singletonLinks = 0; let entries = 0; while (pending.length) { const { path, depth } = pending.pop()!, stat = lstatSync(path); if (++entries > 32768 || depth > 24) throw new Error("Candidate profile exceeds inspection limits"); - if (stat.isSymbolicLink()) throw new Error("Candidate profile cannot contain a symlink"); + if (stat.isSymbolicLink()) { + if (!electronSingletons || !singletonPaths.includes(path)) throw new Error("Candidate profile cannot contain a symlink"); + singletonLinks++; continue; + } if (stat.isDirectory()) for (const name of readdirSync(path)) pending.push({ path: join(path, name), depth: depth + 1 }); else if (!stat.isFile()) throw new Error("Candidate profile must contain only regular files and directories"); } + if (singletonLinks) validateElectronSingletons(singletonPaths); if (!existsSync(marker)) writeFileSync(marker, codexTest ? "MeterUsage Codex test profile\n" : "Synthetic MeterUsage candidate profile\n", { mode: 0o600 }); } // Demo always isolates preferences, provider sources, caches and Electron's @@ -35,3 +41,30 @@ export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = pro const isolated = codexTest ? resolve(profile!) : demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined; return { demo, candidate: index >= 0, ...(codexTest ? { codexTest: true } : {}), home: demo ? isolated! : realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: codexTest ? { PATH: env.PATH, TMPDIR: env.TMPDIR } : demo ? {} : { ...env } }; } + +function validateElectronSingletons([socketLink, cookieLink, lockLink]: string[]) { + const invalid = () => { throw new Error("Candidate profile contains invalid Electron singleton metadata"); }; + const uid = process.getuid?.(); + if (uid === undefined) invalid(); + for (const path of [socketLink, cookieLink, lockLink]) { + const stat = lstatSync(path, { throwIfNoEntry: false }); + if (!stat?.isSymbolicLink() || stat.uid !== uid) invalid(); + } + const cookie = readlinkSync(cookieLink), lock = readlinkSync(lockLink), socket = readlinkSync(socketLink); + if (!/^\d{1,32}$/.test(cookie) || !lock.startsWith(`${hostname()}-`) || !/^[1-9]\d{0,9}$/.test(lock.slice(hostname().length + 1))) invalid(); + if (!isAbsolute(socket) || resolve(socket) !== socket || !socket.endsWith("/SingletonSocket")) invalid(); + // Inspect metadata under the trusted temp root without resolving any profile link. + const tempRoot = realpathSync(tmpdir()); + const inside = relative(socket.startsWith(`${tempRoot}/`) ? tempRoot : resolve(tmpdir()), socket); + if (inside === ".." || inside.startsWith("../") || isAbsolute(inside)) invalid(); + const target = join(tempRoot, inside), directory = dirname(target); + let path = tempRoot; + for (const part of relative(tempRoot, directory).split("/")) { + path = join(path, part); + const stat = lstatSync(path, { throwIfNoEntry: false }); + if (!stat?.isDirectory()) invalid(); + } + const parent = lstatSync(directory), endpoint = lstatSync(target, { throwIfNoEntry: false }); + const remoteCookie = join(directory, "SingletonCookie"), remote = lstatSync(remoteCookie, { throwIfNoEntry: false }); + if (directory === tempRoot || parent.uid !== uid || (parent.mode & 0o077) !== 0 || !endpoint?.isSocket() || endpoint.uid !== uid || !remote?.isSymbolicLink() || remote.uid !== uid || readlinkSync(remoteCookie) !== cookie) invalid(); +} diff --git a/src/main/preferences.ts b/src/main/preferences.ts index 0d66455..3ba957c 100644 --- a/src/main/preferences.ts +++ b/src/main/preferences.ts @@ -4,6 +4,7 @@ import { providers, type Provider, type ManagedAccount } from "../domain/models" import { atomicJSON } from "./history"; import { runCommand, type Command } from "./providers/transport"; import { type Launch } from "./launch"; +import { PanelSize, panelScales } from "../shared/ipc"; export const defaultsDomain = "dev.meterusage.app"; const suffix = (p: Provider) => p[0].toUpperCase() + p.slice(1); export const providerPreference = (p: Provider) => `showProvider${suffix(p)}`; @@ -11,13 +12,13 @@ export const trayPreference = (p: Provider) => `menuBarProvider${suffix(p)}`; export type PreferenceValue = boolean | number | string | ManagedAccount[]; type Kind = "boolean" | "number" | "string" | "data" | "date"; const kinds: Record = { - refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", managedAccounts: "data", + refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", panelSize: "string", managedAccounts: "data", sideNotchPanelCorner: "string", updateLastCheckDate: "date", updateDismissedVersion: "string", updateAnnouncedVersion: "string", desktopCodexConnection: "string", desktopCodexCleanup: "string", desktopClaudeIdentity: "string", ...Object.fromEntries(["launchAtLogin", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "quotaAlertsEnabled", "sideNotchPanelEnabled", "sideNotchPanelPinned", "menuBarCompactEnabled", "onboardingCompleted", "updateCheckEnabled", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart", "showSideNotchResetButton", ...providers.flatMap(p => [providerPreference(p), trayPreference(p)])].map(k => [k, "boolean" as const])), }; export const initialPreferences: Record = { - refreshIntervalSeconds: 60, appearanceTheme: "system", accentTheme: "blue", managedAccounts: [], sideNotchPanelCorner: "", + refreshIntervalSeconds: 60, appearanceTheme: "system", accentTheme: "blue", panelSize: PanelSize.Medium, managedAccounts: [], sideNotchPanelCorner: "", launchAtLogin: false, showHeatmap: true, showClaudeHeatmap: true, showCodexHeatmap: true, quotaAlertsEnabled: false, sideNotchPanelEnabled: false, sideNotchPanelPinned: false, menuBarCompactEnabled: true, onboardingCompleted: false, updateCheckEnabled: true, showPacingBurnRate: true, showActivityTelemetry: true, showDailyActivityChart: true, showSideNotchResetButton: true, @@ -40,6 +41,7 @@ function validate(key: string, v: unknown): v is PreferenceValue { if (typeof v !== "string" || v.length > 100 || /[\r\n\0]/.test(v)) return false; if (key === "appearanceTheme") return ["system", "light", "dark"].includes(v); if (key === "accentTheme") return ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(v); + if (key === "panelSize") return Object.hasOwn(panelScales, v); if (key === "sideNotchPanelCorner") return v === "" || /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(v); if (key === "desktopCodexConnection" || key === "desktopCodexCleanup") return v === "" || key === "desktopCodexConnection" && v === "off" || /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/i.test(v); if (key === "desktopClaudeIdentity") return v === "" || v === "off" || /^[a-f\d]{64}$/.test(v); diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index b56691f..1d55ae8 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; -import { type Bridge, type ViewState, type Request, type Surface, providerKey, trayKey, statusPages } from "../shared/ipc"; +import { type Bridge, type ViewState, type Request, type Surface, PanelSize, panelScales, providerKey, trayKey, statusPages } from "../shared/ipc"; import { type Slot, type Provider, type QuotaWindow, type Telemetry, type Daily, type Loaded, type Session, type UsageWindow, clamp, providers, providerNames, primary, slotKey, slotName, headlineWindow, lastBurn, totalTokens, localDay, shiftDay, tokens, resetCreditAvailable, severityNames } from "../domain/models"; import { pace, burnActive, etaSeconds, formatETA, paceText } from "../domain/pacing"; import { attributionSessions, stripTotals, failoverNudge, heatmapCells, type HeatmapMode } from "../domain/overview"; @@ -85,7 +85,7 @@ function Settings({ state, action }: { state: ViewState; action: (r: Request) => const values = state.settings.values; const set = (key: string, value: string | boolean | number) => void action({ action: "setPreference", key, value }); const toggle = (key: string, text: string, ariaLabel?: string, disabled = false) => ; - return

Settings

General

{["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))}

Providers

Collect usageShow in menu bar and side notch
{providers.map(p => { const enabled = values[providerKey(p)] === true || state.snapshot.slots.some(slot => slot.provider === p && slot.slotID); return
{toggle(providerKey(p), providerNames[p], `Collect ${providerNames[p]} usage`)}
{toggle(trayKey(p), "Show", `Show ${providerNames[p]} in menu bar and side notch`, !enabled)}
; })}
Advanced: local accounts{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

Appearance

Accent color{Object.entries(accents).map(([name, color]) => )}

Side notch

{["sideNotchPanelEnabled", "sideNotchPanelPinned", "showSideNotchResetButton"].map(k => toggle(k, labels[k]))}

Usage details

{["menuBarCompactEnabled", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart"].map(k => toggle(k, labels[k]))}

Diagnostics

Provider files and durable history stay intact.

MeterUsage 0.2.41 · Cost figures are local estimates.

; + return

Settings

General

{["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))}

Providers

Collect usageShow in menu bar and side notch
{providers.map(p => { const enabled = values[providerKey(p)] === true || state.snapshot.slots.some(slot => slot.provider === p && slot.slotID); return
{toggle(providerKey(p), providerNames[p], `Collect ${providerNames[p]} usage`)}
{toggle(trayKey(p), "Show", `Show ${providerNames[p]} in menu bar and side notch`, !enabled)}
; })}
Advanced: local accounts{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

Appearance

Accent color{Object.entries(accents).map(([name, color]) => )}

Side notch

{["sideNotchPanelEnabled", "sideNotchPanelPinned", "showSideNotchResetButton"].map(k => toggle(k, labels[k]))}

Usage details

{["menuBarCompactEnabled", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart"].map(k => toggle(k, labels[k]))}

Diagnostics

Provider files and durable history stay intact.

MeterUsage 0.2.41 · Cost figures are local estimates.

; } function AccountRow({ account: a, action }: { account: ViewState["settings"]["accounts"][number]; action: (r: Request) => Promise }) { const [label, setLabel] = useState(a.label); @@ -141,19 +141,21 @@ function Notch({ state, action }: { state: ViewState; action: (r: Request) => Pr export function App({ bridge }: { bridge: Bridge }) { const [state, setState] = useState(), [error, setError] = useState(""); const surface = (new URLSearchParams(location.search).get("surface") ?? "flyout") as Surface; + const panel = surface === "flyout" || surface === "settings"; const content = useRef(null); useEffect(() => { const unsubscribe = bridge.subscribe(setState); void bridge.request({ action: "state" }).then(r => { if (r.ok && r.state) setState(r.state); else if (!r.ok) setError(r.error); }); return unsubscribe; }, [bridge]); useEffect(() => { const node = content.current; if (!node || !state || surface === "settings") return; let last = 0; const observer = new ResizeObserver(() => { - const height = Math.max(20, Math.ceil(node.scrollHeight)), width = surface === "tray" ? Math.ceil(node.scrollWidth) : undefined, size = width ?? height; - if (size !== last) { last = size; void bridge.request(surface === "share" ? { action: "shareResize", height } : { action: "resize", height: Math.min(2000, height), ...(width === undefined ? {} : { width }) }); } + const height = Math.max(20, Math.ceil(surface === "flyout" ? node.getBoundingClientRect().height : node.scrollHeight)), width = surface === "tray" ? Math.ceil(node.scrollWidth) : undefined, size = width ?? height; + if (size !== last) { last = size; void bridge.request(surface === "share" ? { action: "shareResize", height } : { action: "resize", height: surface === "flyout" ? height : Math.min(2000, height), ...(width === undefined ? {} : { width }) }); } }); observer.observe(node); return () => observer.disconnect(); - }, [bridge, !!state, surface]); + }, [bridge, !!state, surface, panel ? state?.settings.values.panelSize : undefined]); const action = async (r: Request) => { setError(""); try { const reply = await bridge.request(r); if (!reply.ok) setError(reply.error); } catch { setError("Could not complete action"); } }; if (!state) return

{error || "Loading usage…"}

; const s = state.snapshot; - const style = { "--accent": accents[s.appearance.accent] ?? accents.blue, "--notch-max-height": state.notch.maxHeight === undefined ? "100dvh" : `${state.notch.maxHeight}px` } as CSSProperties; - return
{error &&
{error}
}{surface === "share" ? (() => { const slot = notchEntries(s).find(e => e.key === state.notch.selected)?.slot; return slot ? :

Card unavailable

; })() : surface === "tray" ? : surface === "settings" ? : surface === "notch" ? :

MeterUsage {s.demo && Demo}

{!s.appearance.onboarding &&

Your usage, at a glance

Connect Codex or Claude Desktop below. Account allowance comes from the provider. Local activity covers this Mac; OpenRouter activity covers its account.

}{state.update &&
MeterUsage {state.update.version} available
}{s.historyError &&

History could not be {s.historyError === "loadFailed" ? "read. Repair the file and relaunch to resume saving" : "saved. Live readings remain available"}.

}{s.archiveWriteFailed &&

Last known quota could not be saved.

}{s.slots.length ? s.slots.map(slot => ) :

Enable a provider in Settings.

}
{s.lastRefreshedAt === undefined ? "Not refreshed yet" : `Updated ${new Date(s.lastRefreshedAt).toLocaleTimeString()}`}
}
; + const style = { ...(panel ? { zoom: panelScales[state.settings.values.panelSize as PanelSize] ?? 1 } : {}), "--accent": accents[s.appearance.accent] ?? accents.blue, "--notch-max-height": state.notch.maxHeight === undefined ? "100dvh" : `${state.notch.maxHeight}px` } as CSSProperties; + const app =
{error &&
{error}
}{surface === "share" ? (() => { const slot = notchEntries(s).find(e => e.key === state.notch.selected)?.slot; return slot ? :

Card unavailable

; })() : surface === "tray" ? : surface === "settings" ? : surface === "notch" ? :

MeterUsage {s.demo && Demo}

{!s.appearance.onboarding &&

Your usage, at a glance

Connect Codex or Claude Desktop below. Account allowance comes from the provider. Local activity covers this Mac; OpenRouter activity covers its account.

}{state.update &&
MeterUsage {state.update.version} available
}{s.historyError &&

History could not be {s.historyError === "loadFailed" ? "read. Repair the file and relaunch to resume saving" : "saved. Live readings remain available"}.

}{s.archiveWriteFailed &&

Last known quota could not be saved.

}{s.slots.length ? s.slots.map(slot => ) :

Enable a provider in Settings.

}
{s.lastRefreshedAt === undefined ? "Not refreshed yet" : `Updated ${new Date(s.lastRefreshedAt).toLocaleTimeString()}`}
}
; + return panel ?
{app}
: app; } diff --git a/src/renderer/style.css b/src/renderer/style.css index 5d60eac..9fdc5f8 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -4,6 +4,9 @@ .app { --grok: #1e1e22; --claude: #c25e00; --antigravity: #2563eb; --opencode: #0f766e; --openrouter: #6d28d9; --canvas: #fafafc; --surface: #fff; --well: #ececf1; --border: #00000018; --text: #242426; --muted: #696973; --good: #238636; --warn: #a86800; --alert: #c93636; color: var(--text); background: var(--canvas); } .theme-dark { --grok: #ebebf0; --claude: #d97706; --antigravity: #60a5fa; --opencode: #2dd4bf; --openrouter: #a78bfa; --canvas: #1c1c1e; --surface: #262629; --well: #343438; --border: #ffffff19; --text: #efeff2; --muted: #a3a3ad; --good: #30d158; --warn: #ffb340; --alert: #ff6961; color-scheme: dark; } .theme-light { color-scheme: light; } +.panel-viewport { height: 100dvh; overflow: auto; } +.panel-viewport:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; } +.surface-flyout, .surface-settings { display: flow-root; } @media (prefers-color-scheme: dark) { .theme-system { --grok: #ebebf0; --claude: #d97706; --antigravity: #60a5fa; --opencode: #2dd4bf; --openrouter: #a78bfa; --canvas: #1c1c1e; --surface: #262629; --well: #343438; --border: #ffffff19; --text: #efeff2; --muted: #a3a3ad; --good: #30d158; --warn: #ffb340; --alert: #ff6961; } } .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .secondary { color: var(--muted); font-size: 11px; } .stale { font-size: 11px; color: var(--warn); margin: 8px 0; } .empty { padding: 20px; color: var(--muted); } .badge { color: var(--muted); background: var(--well); padding: 2px 6px; border-radius: 5px; font-size: 10px; } .app-header { margin-bottom: 12px; } .app-header button { margin-left: 5px; } .app-header small { color: var(--warn); font-size: 11px; font-weight: 400; } .flyout { padding: 14px; } .provider-card { padding: 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface); margin-bottom: 10px; } .provider-heading { display: flex; align-items: center; gap: 7px; min-width: 0; } .provider-heading h2 { overflow-wrap: anywhere; } .mark { flex-shrink: 0; width: 16px; height: 16px; display: inline-block; mask-size: contain; mask-repeat: no-repeat; mask-position: center; } .pending-symbol { text-align: center; font-weight: 650; } .status { margin: 5px 0; } .quota-window { margin: 10px 0; } .meter { background: var(--well); height: 5px; border-radius: 4px; overflow: hidden; margin: 5px 0; } .meter span { display: block; height: 100%; border-radius: inherit; } .quota-window .secondary { align-items: flex-start; } .quota-window .secondary > :last-child { text-align: right; } .well { background: var(--well); padding: 8px; border-radius: 6px; } .credit { margin: 10px 0; } .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin: 10px 0; } dt { font-size: 10px; color: var(--muted); } dd { font-size: 13px; margin: 0; } .sparkline { width: 100%; height: 45px; margin: 6px 0; } .heatmap { display: grid; grid-template-columns: repeat(15, 1fr); gap: 3px; margin: 8px 0; } .heatmap span { aspect-ratio: 1; border-radius: 2px; } .session { padding: 7px 0; border-bottom: 1px solid var(--border); } .session span { overflow-wrap: anywhere; } summary { color: var(--muted); font-size: 11px; cursor: pointer; padding-top: 8px; } .reset-credit { padding: 6px 0; } footer { margin-top: 12px; } .welcome p { margin: 8px 0; } .settings { padding: 24px; max-width: 680px; margin: auto; } .settings section { margin: 22px 0; } .settings h2 { border-bottom: 1px solid var(--border); padding-bottom: 7px; margin-bottom: 5px; } .setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; } .provider-setting { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .account-row { border: 1px solid var(--border); border-radius: 8px; padding: 8px; margin-bottom: 10px; } .account-row input[type="text"], .account-row input:not([type]) { width: 100%; } .account-row label { flex: 1; } .settings .secondary { margin-top: 6px; } .action-error { background: var(--well); color: var(--alert); padding: 8px; font-size: 11px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 9cf91d0..840626d 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -3,6 +3,8 @@ import type { Provider } from "../domain/models"; export const channel = "meterusage:request", stateChannel = "meterusage:state"; export const statusPages = { codex: "https://status.openai.com/", claude: "https://status.claude.com/", cursor: "https://status.cursor.com/", copilot: "https://www.githubstatus.com/" }; export type Surface = "flyout" | "settings" | "notch" | "tray" | "share"; +export enum PanelSize { Small = "small", Medium = "medium", Large = "large" } +export const panelScales = { [PanelSize.Small]: 0.9, [PanelSize.Medium]: 1, [PanelSize.Large]: 1.15 }; export type ConnectionProvider = "codex" | "claude" | "grok"; export interface ConnectionState { provider: ConnectionProvider; status: "disconnected" | "connecting" | "connected" | "failed" | "unsupported" } export interface SettingsState { diff --git a/tests/composition.test.ts b/tests/composition.test.ts index 777559f..a52eccf 100644 --- a/tests/composition.test.ts +++ b/tests/composition.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, afterEach, vi } from "vite-plus/test"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, symlinkSync, utimesSync, realpathSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, readFileSync, symlinkSync, utimesSync, realpathSync, readlinkSync, unlinkSync, chmodSync } from "node:fs"; +import { tmpdir, hostname } from "node:os"; +import { createServer } from "node:net"; import { join } from "node:path"; import { launchConfiguration, type Launch } from "../src/main/launch"; import { Preferences, defaultsDomain } from "../src/main/preferences"; @@ -14,6 +15,57 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: const now = Date.parse("2026-10-06T12:00:00Z"); const liveFixture = (home: string): Launch => ({ home, data: join(home, "app-data"), env: {}, demo: false, candidate: false }); describe("Codex live test profile", () => { + it("accepts only coherent Electron singleton metadata for GUI handoff, preserving every link", async () => { + const root = temp(), home = temp(), socketDirectory = temp(), electron = join(root, "electron"); + const args = ["--codex-test-profile", root]; + launchConfiguration(args, {}, home); + mkdirSync(electron); + const socket = join(socketDirectory, "SingletonSocket"), cookie = "123456789"; + const server = createServer(); + await new Promise(resolve => server.listen(socket, resolve)); + const links = new Map([ + [join(electron, "SingletonSocket"), socket], + [join(electron, "SingletonCookie"), cookie], + [join(electron, "SingletonLock"), `${hostname()}-${process.pid}`], + [join(socketDirectory, "SingletonCookie"), cookie], + ]); + for (const [path, target] of links) symlinkSync(target, path); + try { + expect(() => launchConfiguration(args, {}, home)).toThrow("symlink"); + expect(launchConfiguration(args, {}, home, true).profile).toBe(root); + for (const [path, target] of links) expect(readlinkSync(path)).toBe(target); + for (const path of ["electron/Cache/link", "connections/link", "electron/Cache/SingletonSocket"]) { + mkdirSync(join(root, path, ".."), { recursive: true }); symlinkSync(socketDirectory, join(root, path)); + expect(() => launchConfiguration(args, {}, home, true)).toThrow("symlink"); + unlinkSync(join(root, path)); + } + for (const [path, target] of [ + [join(electron, "SingletonSocket"), join(socketDirectory, "absent")], + [join(electron, "SingletonSocket"), socketDirectory], + [join(electron, "SingletonSocket"), "/foreign/SingletonSocket"], + [join(electron, "SingletonCookie"), "not-a-cookie"], + [join(electron, "SingletonLock"), "foreign-host-123"], + [join(electron, "SingletonLock"), `${hostname()}-../123`], + ]) { + unlinkSync(path); symlinkSync(target, path); + expect(() => launchConfiguration(args, {}, home, true)).toThrow("singleton"); + expect(readlinkSync(path)).toBe(target); + unlinkSync(path); symlinkSync(links.get(path)!, path); + } + const alias = join(temp(), "alias"); symlinkSync(socketDirectory, alias); + unlinkSync(join(electron, "SingletonSocket")); symlinkSync(join(alias, "SingletonSocket"), join(electron, "SingletonSocket")); + expect(() => launchConfiguration(args, {}, home, true)).toThrow("singleton"); + unlinkSync(join(electron, "SingletonSocket")); symlinkSync(socket, join(electron, "SingletonSocket")); + chmodSync(socketDirectory, 0o755); + expect(() => launchConfiguration(args, {}, home, true)).toThrow("singleton"); + chmodSync(socketDirectory, 0o700); + unlinkSync(join(electron, "SingletonLock")); + expect(() => launchConfiguration(args, {}, home, true)).toThrow("singleton"); + symlinkSync(links.get(join(electron, "SingletonLock"))!, join(electron, "SingletonLock")); + unlinkSync(join(socketDirectory, "SingletonCookie")); symlinkSync("987654321", join(socketDirectory, "SingletonCookie")); + expect(() => launchConfiguration(args, {}, home, true)).toThrow("singleton"); + } finally { await new Promise(resolve => server.close(() => resolve())); } + }); it("isolates data while retaining OS home and only helper environment", () => { const root = temp(), home = temp(); const launch = launchConfiguration(["--codex-test-profile", root], { HOME: "/foreign/home", CODEX_HOME: "/foreign/codex", OPENAI_API_KEY: "synthetic-secret", METERUSAGE_DEMO: "0", PATH: "/usr/bin:/bin", TMPDIR: "/tmp" }, home); diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index 2f238b1..3118d6a 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -1,7 +1,8 @@ import { it, expect, vi } from "vite-plus/test"; import { EventEmitter } from "node:events"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { mkdtempSync, rmSync, mkdirSync, symlinkSync, readlinkSync, realpathSync } from "node:fs"; +import { tmpdir, hostname } from "node:os"; +import { createServer } from "node:net"; import { join } from "node:path"; it.each([false, true])("cleans up windows and isolates candidate effects (candidate=%s)", async candidate => { @@ -10,11 +11,12 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid const platform = Object.getOwnPropertyDescriptor(process, "platform")!; const resourcesPath = Object.getOwnPropertyDescriptor(process, "resourcesPath"); const application = Object.assign(new EventEmitter(), { - isPackaged: true, setName: vi.fn(), setPath: vi.fn(), whenReady: () => Promise.resolve(), + isPackaged: true, setName: vi.fn(), setPath: vi.fn(), whenReady: vi.fn(() => Promise.resolve()), getLoginItemSettings: vi.fn(() => ({ openAtLogin: false })), setLoginItemSettings: vi.fn(), - requestSingleInstanceLock: () => true, getVersion: () => "0.0.0", quit: vi.fn(), exit: vi.fn(), + requestSingleInstanceLock: vi.fn(() => true), getVersion: () => "0.0.0", quit: vi.fn(), exit: vi.fn(), }); - const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ workArea: { height: 900 } }), getDisplayNearestPoint: () => ({ workArea: { x: 0, y: 0, width: 1200, height: 900 } }) }); + const workArea = { x: 0, y: 0, width: 1200, height: 900 }; + const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ workArea }), getDisplayNearestPoint: () => ({ workArea }), getDisplayMatching: () => ({ workArea }) }); const theme = new EventEmitter(); let destroyed = false; const tooltip = vi.fn(() => { if (destroyed) throw new Error("Tray is destroyed"); }); @@ -31,14 +33,17 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid const notify = vi.fn(); class TestWindow extends EventEmitter { destroyed = false; + bounds: { x: number; y: number; width: number; height: number }; contents = { id: windows.length + 1, mainFrame: { url: "" }, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; - constructor(...args: ConstructorParameters) { super(...args); windows.push(this); } + constructor(options: { width: number; height: number }) { super(); this.bounds = { x: 0, y: 0, width: options.width, height: options.height }; windows.push(this); } get webContents() { if (this.destroyed) throw new Error("webContents is unavailable after destruction"); return this.contents; } static fromWebContents(contents: unknown) { return windows.find(w => w.contents === contents); } isDestroyed = () => this.destroyed; isVisible = () => false; - getBounds = () => ({ x: 0, y: 0, width: 100, height: 100 }); - setPosition = vi.fn(); show = vi.fn(); focus = vi.fn(); hide = vi.fn(); + getBounds = () => this.bounds; + setBounds = vi.fn((bounds: typeof this.bounds) => { this.bounds = bounds; }); + setSize = vi.fn((width: number, height: number) => { this.bounds.width = width; this.bounds.height = height; }); + setPosition = vi.fn((x: number, y: number) => { this.bounds.x = x; this.bounds.y = y; }); show = vi.fn(); focus = vi.fn(); hide = vi.fn(); loadURL = (url: string) => { this.contents.mainFrame.url = url; return Promise.resolve(); }; } const start = vi.fn(async () => {}); @@ -49,14 +54,18 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid session: { defaultSession: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), webRequest: { onBeforeRequest: vi.fn() } } }, })); vi.doMock("../src/main/launch", () => ({ launchConfiguration: () => ({ data: root, home: root, demo: false, candidate, profile: candidate ? root : undefined }) })); - vi.doMock("../src/main/preferences", () => ({ Preferences: { load: async () => ({ values: { onboardingCompleted: true }, accounts: [] }) } })); + const values: Record = { onboardingCompleted: true, panelSize: "medium" }; + let finishLoad!: () => void; + const loading = new Promise(resolve => { finishLoad = resolve; }); + const load = vi.fn(async () => { await loading; return { values, accounts: [], set: async (key: string, value: boolean | string) => { values[key] = value; } }; }); + vi.doMock("../src/main/preferences", () => ({ Preferences: { load } })); vi.doMock("../src/main/composition", () => ({ compose: () => [] })); vi.doMock("../src/main/connections", () => ({ DesktopConnections: class { state = []; configured = () => false; recover = async () => {}; stop = async () => {}; } })); vi.doMock("../src/main/coordinator", () => ({ Coordinator: class { constructor(_launch: unknown, _prefs: unknown, _sources: unknown, _clock: unknown, alert: (a: unknown) => void) { alert({ title: "Fixture", body: "Fixture" }); } - start = start; stop = vi.fn(); subscribe = vi.fn(() => vi.fn()); snapshot = () => ({ appearance: {} }); refreshIfStale = vi.fn(); + start = start; stop = vi.fn(); publish = vi.fn(); subscribe = vi.fn(() => vi.fn()); snapshot = () => ({ appearance: {} }); refreshIfStale = vi.fn(); } })); vi.doMock("../src/main/updater", () => ({ Updater: class { reset = vi.fn(); } })); vi.doMock("../src/domain/overview", () => ({ trayTooltip: () => "Synthetic usage" })); @@ -65,7 +74,16 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid Object.defineProperty(process, "resourcesPath", { value: root, configurable: true }); vi.stubGlobal("__dirname", root); await import("../src/main/electron"); + await vi.waitFor(() => expect(load).toHaveBeenCalledOnce()); + application.emit("second-instance"); + expect(windows).toHaveLength(0); + finishLoad(); await vi.waitFor(() => expect(start).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(windows).toHaveLength(2)); + expect(windows[1].focus).toHaveBeenCalled(); + application.emit("activate"); + expect(windows[1].focus).toHaveBeenCalledTimes(candidate ? 3 : 2); + expect(application.requestSingleInstanceLock.mock.invocationCallOrder[0]).toBeLessThan(application.whenReady.mock.invocationCallOrder[0]); if (candidate) { expect(application.setPath.mock.calls).toEqual([["userData", join(root, "electron")], ["sessionData", join(root, "electron")]]); expect(notify).not.toHaveBeenCalled(); @@ -84,7 +102,32 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid expect(() => display.emit("display-metrics-changed")).not.toThrow(); expect(tooltip).toHaveBeenCalledWith("Synthetic usage"); const invoke = (w: TestWindow, request: unknown) => ipc.mock.calls[0][1]({ sender: w.contents, senderFrame: w.contents.mainFrame }, request); + const flyout = windows[2]; + expect(await invoke(flyout, { action: "resize", height: 593 })).toEqual({ ok: true }); + expect(flyout.bounds).toMatchObject({ width: 420, height: 593 }); await invoke(windows[2], { action: "settings" }); + expect(await invoke(windows[3], { action: "setPreference", key: "panelSize", value: "large" })).toEqual({ ok: true }); + expect(flyout.bounds).toMatchObject({ width: 483, height: 682 }); + expect(await invoke(flyout, { action: "resize", height: 1200 })).toEqual({ ok: true }); + expect(flyout.bounds).toMatchObject({ width: 483, height: 900 }); + expect(await invoke(windows[3], { action: "setPreference", key: "panelSize", value: "small" })).toEqual({ ok: true }); + expect(flyout.bounds).toMatchObject({ width: 378, height: 900 }); + expect(await invoke(flyout, { action: "resize", height: 400, width: 10000 })).toEqual({ ok: true }); + expect(flyout.bounds).toMatchObject({ width: 378, height: 400 }); + workArea.height = 350; + display.emit("display-metrics-changed"); + expect(flyout.bounds).toMatchObject({ width: 378, height: 350 }); + workArea.height = 900; + display.emit("display-metrics-changed"); + expect(flyout.bounds).toMatchObject({ width: 378, height: 400 }); + expect(await invoke(windows[3], { action: "setPreference", key: "panelSize", value: "huge" })).toEqual({ ok: false, error: "Invalid request" }); + expect(flyout.bounds).toMatchObject({ width: 378, height: 400 }); + workArea.height = 2400; + expect(await invoke(flyout, { action: "resize", height: 2800 })).toEqual({ ok: true }); + expect(flyout.bounds).toMatchObject({ width: 378, height: 2400 }); + expect(await invoke(flyout, { action: "resize", height: 2200 })).toEqual({ ok: true }); + expect(flyout.bounds).toMatchObject({ width: 378, height: 2200 }); + workArea.height = 900; if (candidate) { expect(application.getLoginItemSettings).not.toHaveBeenCalled(); const reply = await invoke(windows[3], { action: "setPreference", key: "launchAtLogin", value: true }); @@ -96,6 +139,7 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid expect(() => display.emit("display-removed")).not.toThrow(); expect(() => theme.emit("updated")).not.toThrow(); } finally { + finishLoad(); application.emit("before-quit", { preventDefault: vi.fn() }); await Promise.resolve(); Object.defineProperty(process, "platform", platform); @@ -106,6 +150,61 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid } }); +it.each(["duplicate", "invalid", "nested", "json"])("handles isolated profile startup safely (%s)", async scenario => { + vi.resetModules(); + const root = mkdtempSync(join(realpathSync(tmpdir()), "meterusage-startup-")); + const socketDirectory = mkdtempSync(join(realpathSync(tmpdir()), "meterusage-socket-")); + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + const argv = process.argv; + const { launchConfiguration } = await import("../src/main/launch"); + const args = ["--codex-test-profile", root]; + launchConfiguration(args, {}, "/synthetic/home"); mkdirSync(join(root, "electron")); + const socket = join(socketDirectory, "SingletonSocket"), server = createServer(); + await new Promise(resolve => server.listen(socket, resolve)); + const links = new Map([ + [join(root, "electron/SingletonSocket"), socket], + [join(root, "electron/SingletonCookie"), "123456789"], + [join(root, "electron/SingletonLock"), `${hostname()}-${process.pid}`], + [join(socketDirectory, "SingletonCookie"), scenario === "invalid" ? "987654321" : "123456789"], + ]); + for (const [path, target] of links) symlinkSync(target, path); + if (scenario === "nested") symlinkSync(socketDirectory, join(root, "connections")); + const application = Object.assign(new EventEmitter(), { + isPackaged: false, setName: vi.fn(), setPath: vi.fn(), + whenReady: vi.fn(() => Promise.resolve()), requestSingleInstanceLock: vi.fn(() => false), + quit: vi.fn(), exit: vi.fn(), + }); + const load = vi.fn(), runJSON = vi.fn(); + const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true); + vi.doMock("electron", () => ({ app: application })); + vi.doMock("../src/main/preferences", () => ({ Preferences: { load } })); + vi.doMock("../src/main/cli", () => ({ runJSON })); + try { + Object.defineProperty(process, "platform", { value: "darwin" }); + process.argv = [argv[0], "meterusage", ...args, ...(scenario === "json" ? ["--json"] : [])]; + vi.stubGlobal("__dirname", root); + await expect(import("../src/main/electron")).resolves.toBeDefined(); + if (scenario === "duplicate") { + await vi.waitFor(() => expect(application.quit).toHaveBeenCalledOnce()); + expect(application.setPath.mock.calls).toEqual([["userData", join(root, "electron")], ["sessionData", join(root, "electron")]]); + expect(application.requestSingleInstanceLock).toHaveBeenCalledExactlyOnceWith({ candidate: true }); + expect(stderr).not.toHaveBeenCalled(); expect(application.exit).not.toHaveBeenCalled(); + } else { + await vi.waitFor(() => expect(application.exit).toHaveBeenCalledExactlyOnceWith(1)); + expect(stderr).toHaveBeenCalledExactlyOnceWith("MeterUsage could not start.\n"); + expect(application.requestSingleInstanceLock).not.toHaveBeenCalled(); expect(application.setPath).not.toHaveBeenCalled(); + } + expect(application.whenReady).not.toHaveBeenCalled(); expect(load).not.toHaveBeenCalled(); expect(runJSON).not.toHaveBeenCalled(); + for (const [path, target] of links) expect(readlinkSync(path)).toBe(target); + } finally { + process.argv = argv; Object.defineProperty(process, "platform", platform); + vi.restoreAllMocks(); vi.unstubAllGlobals(); + for (const path of ["electron", "../src/main/preferences", "../src/main/cli"]) vi.doUnmock(path); + await new Promise(resolve => server.close(() => resolve())); + rmSync(root, { recursive: true, force: true }); rmSync(socketDirectory, { recursive: true, force: true }); + } +}); + it.each([ { entry: "flyout", notchEnabled: false }, { entry: "flyout", notchEnabled: true }, { entry: "tray", notchEnabled: false }, { entry: "tray", notchEnabled: true }, @@ -152,7 +251,7 @@ it.each([ }); application.on("will-quit", willQuit); application.on("quit", didQuit); const workArea = { x: 0, y: 25, width: 1200, height: 875 }; - const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1200, height: 900 }, workArea }), getDisplayNearestPoint: () => ({ workArea }) }); + const display = Object.assign(new EventEmitter(), { getPrimaryDisplay: () => ({ bounds: { x: 0, y: 0, width: 1200, height: 900 }, workArea }), getDisplayNearestPoint: () => ({ workArea }), getDisplayMatching: () => ({ workArea }) }); let tray!: TestTray; class TestTray extends EventEmitter { destroyed = false; @@ -209,6 +308,7 @@ it.each([ application.quit(); } application.quit(); + application.emit("second-instance"); application.emit("activate"); expect(stop).toHaveBeenCalledOnce(); expect(running).toBe(true); expect(tray.destroyed).toBe(false); expect(windows.filter(w => !w.destroyed)).toHaveLength(windowCount); finishCleanup(); @@ -221,6 +321,7 @@ it.each([ expect(application.exit).not.toHaveBeenCalled(); expect(updaterCheck).toHaveBeenCalledTimes(checks); coordinator.publish(); + application.emit("second-instance"); application.emit("activate"); expect(windows).toHaveLength(windowCount); } finally { finishCleanup(); coordinator?.stop(); diff --git a/tests/ipc.test.ts b/tests/ipc.test.ts index 0271866..f0c4e2f 100644 --- a/tests/ipc.test.ts +++ b/tests/ipc.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect } from "vite-plus/test"; import { parseRequest, trustedSender, projectSettings } from "../src/main/ipc"; describe("renderer boundary", () => { + it("accepts and publishes only Small, Medium and Large panel sizes", () => { + for (const value of ["small", "medium", "large"]) { + const request = { action: "setPreference", key: "panelSize", value }; + expect(parseRequest(request)).toEqual(request); + expect(projectSettings({ panelSize: value }, [], "/synthetic").values.panelSize).toBe(value); + } + for (const value of ["", "Small", "extra-large", "__proto__", ["large"], 1, true, null]) expect(() => parseRequest({ action: "setPreference", key: "panelSize", value })).toThrow("Invalid request"); + }); it("requires primitive provider names before connection consent", () => { for (const action of ["connect", "disconnect", "connectionCancel"]) { for (const provider of [["claude"], ["codex"], { toString: () => "claude" }, null, 1]) expect(() => parseRequest({ action, provider })).toThrow("Invalid request"); @@ -41,10 +49,11 @@ it("binds sharing to the displayed account key", () => { for (const raw of [{ action: "share" }, { action: "share", key: "claude#../other" }, { action: "share", key: "claude", path: "/tmp" }]) expect(() => parseRequest(raw)).toThrow(); }); -it("reports full shared-card height separately from bounded window resizing", () => { +it("allows natural panel heights for native display clamping and keeps separate share requests", () => { + expect(parseRequest({ action: "resize", height: 2500 })).toEqual({ action: "resize", height: 2500 }); expect(parseRequest({ action: "shareResize", height: 2500 })).toEqual({ action: "shareResize", height: 2500 }); expect(parseRequest({ action: "shareResize", height: 5000 }).action).toBe("shareResize"); - for (const raw of [{ action: "resize", height: 2500 }, { action: "shareResize", height: Infinity }, { action: "shareResize", height: 0 }, { action: "shareResize", height: 100, width: 250 }]) expect(() => parseRequest(raw)).toThrow(); + for (const raw of [{ action: "resize", height: NaN }, { action: "resize", height: 0 }, { action: "resize", height: "2500" }, { action: "shareResize", height: Infinity }, { action: "shareResize", height: 0 }, { action: "shareResize", height: 100, width: 250 }]) expect(() => parseRequest(raw)).toThrow(); }); it("allows only known public service-status pages, without a renderer-selected URL", () => { diff --git a/tests/preferences.test.ts b/tests/preferences.test.ts new file mode 100644 index 0000000..bbffcee --- /dev/null +++ b/tests/preferences.test.ts @@ -0,0 +1,50 @@ +import { it, expect } from "vite-plus/test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Preferences } from "../src/main/preferences"; +import type { Command } from "../src/main/providers/transport"; + +it("defaults to Medium and persists only the three panel sizes in an isolated profile", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage-size-")); + const launch = { home: root, data: root, demo: true, candidate: true, env: {} }; + try { + const prefs = await Preferences.load(launch); + expect(prefs.values.panelSize).toBe("medium"); + for (const size of ["small", "large", "medium"]) { + await prefs.set("panelSize", size); + expect((await Preferences.load(launch)).values.panelSize).toBe(size); + } + for (const invalid of ["", "Small", "extra-large", "__proto__", 1, true, ["large"], null]) { + await expect(prefs.set("panelSize", invalid)).rejects.toThrow("Invalid preference"); + expect(prefs.values.panelSize).toBe("medium"); + writeFileSync(join(root, "preferences.json"), JSON.stringify({ panelSize: invalid })); + expect((await Preferences.load(launch)).values.panelSize).toBe("medium"); + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("reads and writes panel size as a validated defaults string without changing state on write failure", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage-size-defaults-")); + let stored = "large", failWrite = false; + const command: Command = async (_binary, args) => { + if (args[2] !== "panelSize") throw new Error("Absent fixture key"); + if (args[0] === "read-type") return "Type is string"; + if (args[0] === "read") return stored; + expect(args).toEqual(["write", "dev.meterusage.fixture", "panelSize", "-string", "small"]); + if (failWrite) throw new Error("Fixture write failed"); + stored = args[4]; return ""; + }; + const launch = { home: root, data: root, demo: false, candidate: false, env: {} }; + try { + const prefs = await Preferences.load(launch, command, "dev.meterusage.fixture"); + expect(prefs.values.panelSize).toBe("large"); + failWrite = true; + await expect(prefs.set("panelSize", "small")).rejects.toThrow("Fixture write failed"); + expect(prefs.values.panelSize).toBe("large"); + failWrite = false; await prefs.set("panelSize", "small"); + expect((await Preferences.load(launch, command, "dev.meterusage.fixture")).values.panelSize).toBe("small"); + stored = "enormous"; + expect((await Preferences.load(launch, command, "dev.meterusage.fixture")).values.panelSize).toBe("medium"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 29b3d04..c0f4e59 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -1,5 +1,5 @@ import { it, expect, vi } from "vite-plus/test"; -import { createElement, useState } from "react"; +import { createElement, useEffect, useRef, useState } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { App, Connections, Meter, ProviderCard } from "../src/renderer/app"; import { tokens, primary, quota, value, window } from "../src/domain/models"; @@ -9,7 +9,7 @@ import { readFileSync } from "node:fs"; vi.mock("react", async importOriginal => { const actual = await importOriginal(); - return { ...actual, useState: vi.fn(actual.useState) }; + return { ...actual, useState: vi.fn(actual.useState), useEffect: vi.fn(actual.useEffect), useRef: vi.fn(actual.useRef) }; }); function renderApp(view: ViewState, surface = "flyout") { vi.stubGlobal("location", { search: `?surface=${surface}` }); @@ -19,6 +19,61 @@ function renderApp(view: ViewState, surface = "flyout") { } const now = Date.parse("2026-10-06T12:00:00Z"); +it("offers a labelled size selector and scopes scale and keyboard scrolling to main panel and Settings", () => { + const view = state(); + for (const [size, scale] of [["small", "0.9"], ["medium", "1"], ["large", "1.15"]]) { + view.settings.values.panelSize = size; + for (const surface of ["flyout", "settings"]) { + const markup = renderApp(view, surface); + expect(markup).toContain(`zoom:${scale}`); + expect(markup).toMatch(/class="panel-viewport"[^>]*tabindex="0"/); + if (surface === "settings") { + expect(markup).toMatch(/Panel size<\/span> set(key, e.target.checked)} />; - return

Settings

General

{["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))}

Providers

Collect usageShow in menu bar and side notch
{providers.map(p => { const enabled = values[providerKey(p)] === true || state.snapshot.slots.some(slot => slot.provider === p && slot.slotID); return
{toggle(providerKey(p), providerNames[p], `Collect ${providerNames[p]} usage`)}
{toggle(trayKey(p), "Show", `Show ${providerNames[p]} in menu bar and side notch`, !enabled)}
; })}
Advanced: local accounts{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

Appearance

Accent color{Object.entries(accents).map(([name, color]) => )}

Side notch

{["sideNotchPanelEnabled", "sideNotchPanelPinned", "showSideNotchResetButton"].map(k => toggle(k, labels[k]))}

Usage details

{["menuBarCompactEnabled", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart"].map(k => toggle(k, labels[k]))}

Diagnostics

Provider files and durable history stay intact.

MeterUsage 0.2.41 · Cost figures are local estimates.

; + return
+

Settings

Accounts, appearance and usage preferences.

+ +

Appearance

+
Usage-panel size +
{Object.values(PanelSize).map(size => )}
+

Sizes the usage panel and scales Settings content. The Settings window stays the same size.

+
+ +
Accent color{Object.entries(accents).map(([name, color]) => )}
+
+

General

+ {["launchAtLogin", "quotaAlertsEnabled", "updateCheckEnabled"].map(k => toggle(k, labels[k]))} + +
+

Providers

Choose what to collect and where to show it.

+
ProviderCollect usageShow in menu bar and side notch
+ {providers.map(p => { const enabled = values[providerKey(p)] === true || state.snapshot.slots.some(slot => slot.provider === p && slot.slotID); return
{providerNames[p]}{toggle(providerKey(p), "Collect", `Collect ${providerNames[p]} usage`)}{toggle(trayKey(p), "Show", `Show ${providerNames[p]} in menu bar and side notch`, !enabled)}
; })} +
Advanced: local accounts{state.settings.accounts.map(a => )}

Choose a separate config directory. Each account keeps its own quota and history.

+
+

Side notch

{["sideNotchPanelEnabled", "sideNotchPanelPinned", "showSideNotchResetButton"].map(k => toggle(k, labels[k]))}
+

Usage details

{["menuBarCompactEnabled", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart"].map(k => toggle(k, labels[k]))}
+

Diagnostics

Provider files and durable history stay intact.

+

MeterUsage 0.2.41 · Cost figures are local estimates.

+
; } function AccountRow({ account: a, action }: { account: ViewState["settings"]["accounts"][number]; action: (r: Request) => Promise }) { const [label, setLabel] = useState(a.label); diff --git a/src/renderer/style.css b/src/renderer/style.css index 9fdc5f8..129b75c 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -9,14 +9,62 @@ .surface-flyout, .surface-settings { display: flow-root; } @media (prefers-color-scheme: dark) { .theme-system { --grok: #ebebf0; --claude: #d97706; --antigravity: #60a5fa; --opencode: #2dd4bf; --openrouter: #a78bfa; --canvas: #1c1c1e; --surface: #262629; --well: #343438; --border: #ffffff19; --text: #efeff2; --muted: #a3a3ad; --good: #30d158; --warn: #ffb340; --alert: #ff6961; } } .row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .secondary { color: var(--muted); font-size: 11px; } .stale { font-size: 11px; color: var(--warn); margin: 8px 0; } .empty { padding: 20px; color: var(--muted); } .badge { color: var(--muted); background: var(--well); padding: 2px 6px; border-radius: 5px; font-size: 10px; } .app-header { margin-bottom: 12px; } .app-header button { margin-left: 5px; } .app-header small { color: var(--warn); font-size: 11px; font-weight: 400; } .flyout { padding: 14px; } .provider-card { padding: 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface); margin-bottom: 10px; } .provider-heading { display: flex; align-items: center; gap: 7px; min-width: 0; } .provider-heading h2 { overflow-wrap: anywhere; } .mark { flex-shrink: 0; width: 16px; height: 16px; display: inline-block; mask-size: contain; mask-repeat: no-repeat; mask-position: center; } .pending-symbol { text-align: center; font-weight: 650; } .status { margin: 5px 0; } .quota-window { margin: 10px 0; } .meter { background: var(--well); height: 5px; border-radius: 4px; overflow: hidden; margin: 5px 0; } .meter span { display: block; height: 100%; border-radius: inherit; } .quota-window .secondary { align-items: flex-start; } .quota-window .secondary > :last-child { text-align: right; } .well { background: var(--well); padding: 8px; border-radius: 6px; } .credit { margin: 10px 0; } .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin: 10px 0; } dt { font-size: 10px; color: var(--muted); } dd { font-size: 13px; margin: 0; } .sparkline { width: 100%; height: 45px; margin: 6px 0; } .heatmap { display: grid; grid-template-columns: repeat(15, 1fr); gap: 3px; margin: 8px 0; } .heatmap span { aspect-ratio: 1; border-radius: 2px; } .session { padding: 7px 0; border-bottom: 1px solid var(--border); } .session span { overflow-wrap: anywhere; } summary { color: var(--muted); font-size: 11px; cursor: pointer; padding-top: 8px; } .reset-credit { padding: 6px 0; } footer { margin-top: 12px; } .welcome p { margin: 8px 0; } -.settings { padding: 24px; max-width: 680px; margin: auto; } .settings section { margin: 22px 0; } .settings h2 { border-bottom: 1px solid var(--border); padding-bottom: 7px; margin-bottom: 5px; } .setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; } .provider-setting { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .account-row { border: 1px solid var(--border); border-radius: 8px; padding: 8px; margin-bottom: 10px; } .account-row input[type="text"], .account-row input:not([type]) { width: 100%; } .account-row label { flex: 1; } .settings .secondary { margin-top: 6px; } .action-error { background: var(--well); color: var(--alert); padding: 8px; font-size: 11px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } -.settings .provider-setting.provider-heading { color: var(--muted); font-size: 11px; padding: 4px 0; } -.accent-picker { border: 0; padding: 8px 0 0; margin: 0; display: flex; flex-wrap: wrap; gap: 8px; } -.accent-picker legend { font-size: 11px; font-weight: 600; padding: 0; } -.accent-picker label { display: flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 6px; padding: 5px 7px; text-transform: capitalize; } -.accent-picker input { accent-color: var(--accent); } -.accent-swatch { width: 12px; height: 12px; border-radius: 50%; } -@media (max-width: 620px) { .settings { padding: 16px; } .provider-setting { gap: 8px; } .provider-heading { align-items: flex-start; } } +.provider-setting { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } +.settings { padding: 24px; max-width: 680px; margin: auto; font-size: 13px; } +.settings .settings-header { margin-bottom: 24px; } +.settings h1 { font-size: 22px; font-weight: 650; } +.settings section { margin: 22px 0; } +.settings h2 { font-size: 13px; margin-bottom: 9px; } +.settings .secondary { font-size: 12px; line-height: 1.4; margin-top: 5px; overflow-wrap: anywhere; } +.settings .section-description { margin: -3px 0 10px; } +.settings .settings-group { border: 1px solid var(--border); border-radius: 12px; background: var(--surface); padding: 0 14px; } +.settings .setting { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 42px; padding: 9px 0; } +.settings .settings-group > .setting + .setting, .settings .provider-setting + .provider-setting { border-top: 1px solid var(--border); } +.settings .setting > span { min-width: 0; overflow-wrap: anywhere; } +.settings input[type="checkbox"] { flex-shrink: 0; margin: 0; } +.settings button, .settings select { min-height: 30px; border-radius: 7px; padding: 5px 10px; } +.settings .connections .connection-row { display: block; padding: 14px 0; } +.settings .connection-row button { flex-shrink: 0; max-width: 60%; } +.settings .connect-button { color: var(--text); font-weight: 600; background: color-mix(in srgb, var(--accent) 8%, var(--surface)); border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); } +.settings .provider-setting { display: grid; grid-template-columns: minmax(0, 1fr) 90px 135px; align-items: center; gap: 14px; } +.settings .provider-columns { color: var(--muted); font-size: 11px; line-height: 1.3; padding: 12px 0; } +.settings .provider-columns > :not(:first-child) { text-align: center; } +.settings .provider-name { overflow-wrap: anywhere; } +.settings .provider-setting .setting { justify-content: center; gap: 8px; } +.settings .local-accounts { border-top: 1px solid var(--border); padding: 0 0 12px; } +.settings summary { font-size: 12px; padding: 12px 0 0; } +.settings .local-accounts[open] summary { padding-bottom: 12px; } +.settings .account-row { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; } +.settings .account-row input[type="text"], .settings .account-row input:not([type]) { width: 100%; } +.settings .account-row label { flex: 1; min-width: 0; } +.settings .account-row .secondary > span { min-width: 0; } +.settings .size-picker, .settings .accent-picker { border: 0; padding: 14px 0; margin: 0; min-width: 0; } +.settings .size-picker legend, .settings .accent-picker legend { float: left; width: 100%; padding: 0; margin-bottom: 9px; font-size: 13px; } +.settings .size-options { clear: both; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; background: var(--well); border-radius: 9px; } +.settings .size-options label { position: relative; text-align: center; cursor: pointer; } +.settings .size-options input { position: absolute; opacity: 0; width: 100%; height: 100%; inset: 0; margin: 0; cursor: pointer; } +.settings .size-options span { display: block; border: 1px solid transparent; padding: 6px; border-radius: 6px; } +.settings .size-options input:checked + span { background: var(--surface); border-color: var(--border); box-shadow: 0 1px 3px #00000012; font-weight: 600; } +.settings .size-options input:focus-visible { outline: none; } +.settings .size-options input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 1px; } +.settings .size-picker + .setting, .settings .accent-picker { border-top: 1px solid var(--border); } +.settings .accent-picker { display: flex; flex-wrap: wrap; gap: 6px; } +.settings .accent-picker label { display: flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 7px; padding: 6px 8px; text-transform: capitalize; cursor: pointer; } +.settings .accent-picker label:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface)); } +.settings .accent-picker input { margin: 0; width: 13px; height: 13px; } +.settings .accent-swatch { width: 12px; height: 12px; border-radius: 50%; border: 1px solid #00000018; } +.settings .diagnostics-actions { padding-top: 14px; padding-bottom: 14px; } +.settings .diagnostics-actions .row, .settings .local-accounts > .row { justify-content: flex-start; flex-wrap: wrap; } +.settings .settings-footer { margin-top: 24px; } +.action-error { background: var(--well); color: var(--alert); padding: 8px; font-size: 11px; display: flex; justify-content: space-between; align-items: center; gap: 8px; } +@media (max-width: 620px) { .provider-setting { gap: 8px; } .provider-heading { align-items: flex-start; } } +@media (max-width: 450px) { + .settings { padding: 16px; } + .settings .settings-group { padding: 0 12px; } + .settings .provider-setting { grid-template-columns: minmax(0, 1fr) 72px 92px; gap: 8px; } + .settings .connection-row button { max-width: 65%; } + .settings .account-row .row { flex-wrap: wrap; } +} .surface-notch { width: max-content; background: transparent; } .notch { display: flex; align-items: flex-start; width: max-content; background: transparent; } .strip { width: 44px; background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-radius: 8px; padding: 6px 0; flex-shrink: 0; touch-action: none; user-select: none; } .strip-settings { display: block; background: none; border: none; padding: 3px; width: 100%; color: var(--muted); } .strip-provider { display: flex; flex-direction: column; align-items: center; gap: 4px; width: 44px; padding: 6px 3px; border: none; background: none; border-radius: 0; } .strip-provider[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 15%, transparent); } .strip-provider .mark { width: 13px; height: 13px; } .strip-provider small { font-size: 8px; max-width: 38px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ring { width: 28px; height: 28px; border-radius: 50%; padding: 3px; } .ring > span { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; background: var(--surface); font-size: 9px; } .detail-card { width: 250px; flex-shrink: 0; border-radius: 10px; margin: 0; padding: 14px; overflow: auto; } .detail-card h2 { font-size: 12px; } .detail-card .row { gap: 5px; } .detail-card .badge { font-size: 9px; } .detail-card .quota-window strong { font-size: 11px; } .detail-card .secondary { font-size: 10px; } .card-left .detail-card { order: -1; } .detail-card footer button { font-size: 11px; } @media (hover: hover) and (pointer: fine) { button:hover:not(:disabled) { border-color: var(--accent); } } @media (prefers-reduced-motion: reduce) { *,*::before,*::after { animation: none !important; transition: none !important; } } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 840626d..0648560 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -4,7 +4,7 @@ export const channel = "meterusage:request", stateChannel = "meterusage:state"; export const statusPages = { codex: "https://status.openai.com/", claude: "https://status.claude.com/", cursor: "https://status.cursor.com/", copilot: "https://www.githubstatus.com/" }; export type Surface = "flyout" | "settings" | "notch" | "tray" | "share"; export enum PanelSize { Small = "small", Medium = "medium", Large = "large" } -export const panelScales = { [PanelSize.Small]: 0.9, [PanelSize.Medium]: 1, [PanelSize.Large]: 1.15 }; +export const panelScales = { [PanelSize.Small]: 0.8, [PanelSize.Medium]: 1, [PanelSize.Large]: 1.15 }; export type ConnectionProvider = "codex" | "claude" | "grok"; export interface ConnectionState { provider: ConnectionProvider; status: "disconnected" | "connecting" | "connected" | "failed" | "unsupported" } export interface SettingsState { diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index 3118d6a..bd9b2ee 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -5,7 +5,12 @@ import { tmpdir, hostname } from "node:os"; import { createServer } from "node:net"; import { join } from "node:path"; -it.each([false, true])("cleans up windows and isolates candidate effects (candidate=%s)", async candidate => { +it.each([ + { candidate: false, keyboard: false, retainedZoom: false }, + { candidate: true, keyboard: false, retainedZoom: false }, + { candidate: true, keyboard: true, retainedZoom: false }, + { candidate: true, keyboard: false, retainedZoom: true }, +])("cleans up windows and isolates candidate effects (candidate=$candidate, keyboard=$keyboard, retainedZoom=$retainedZoom)", async ({ candidate, keyboard, retainedZoom }) => { vi.resetModules(); const root = mkdtempSync(join(tmpdir(), "meterusage-lifecycle-")); const platform = Object.getOwnPropertyDescriptor(process, "platform")!; @@ -31,10 +36,12 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid const windows: TestWindow[] = []; const ipc = vi.fn(); const notify = vi.fn(); + const showErrorBox = vi.fn(); class TestWindow extends EventEmitter { destroyed = false; + zoomFactor = 1; bounds: { x: number; y: number; width: number; height: number }; - contents = { id: windows.length + 1, mainFrame: { url: "" }, setFrameRate: vi.fn(), on: vi.fn(), send: vi.fn(), setWindowOpenHandler: vi.fn() }; + contents = Object.assign(new EventEmitter(), { id: windows.length + 1, mainFrame: { url: "" }, setFrameRate: vi.fn(), setZoomFactor: vi.fn((factor: number) => { this.zoomFactor = factor; }), send: vi.fn(), setWindowOpenHandler: vi.fn() }); constructor(options: { width: number; height: number }) { super(); this.bounds = { x: 0, y: 0, width: options.width, height: options.height }; windows.push(this); } get webContents() { if (this.destroyed) throw new Error("webContents is unavailable after destruction"); return this.contents; } static fromWebContents(contents: unknown) { return windows.find(w => w.contents === contents); } @@ -44,20 +51,25 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid setBounds = vi.fn((bounds: typeof this.bounds) => { this.bounds = bounds; }); setSize = vi.fn((width: number, height: number) => { this.bounds.width = width; this.bounds.height = height; }); setPosition = vi.fn((x: number, y: number) => { this.bounds.x = x; this.bounds.y = y; }); show = vi.fn(); focus = vi.fn(); hide = vi.fn(); - loadURL = (url: string) => { this.contents.mainFrame.url = url; return Promise.resolve(); }; + loadURL = (url: string) => { + this.contents.mainFrame.url = url; + if (retainedZoom && ["flyout", "settings"].includes(new URL(url).searchParams.get("surface")!)) this.zoomFactor = 1.25; + this.contents.emit("did-finish-load"); return Promise.resolve(); + }; } const start = vi.fn(async () => {}); vi.doMock("electron", () => ({ app: application, Tray: TestTray, BrowserWindow: TestWindow, - screen: display, nativeTheme: theme, powerMonitor: new EventEmitter(), ipcMain: { handle: ipc }, + screen: display, nativeTheme: theme, powerMonitor: new EventEmitter(), ipcMain: { handle: ipc }, dialog: { showErrorBox }, Notification: Object.assign(class { show = notify; }, { isSupported: () => true }), nativeImage: { createFromPath: () => ({ resize: () => ({ setTemplateImage: vi.fn() }) }) }, session: { defaultSession: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), webRequest: { onBeforeRequest: vi.fn() } } }, })); vi.doMock("../src/main/launch", () => ({ launchConfiguration: () => ({ data: root, home: root, demo: false, candidate, profile: candidate ? root : undefined }) })); const values: Record = { onboardingCompleted: true, panelSize: "medium" }; + const setPreference = vi.fn(async (key: string, value: boolean | string) => { await Promise.resolve(); values[key] = value; }); let finishLoad!: () => void; const loading = new Promise(resolve => { finishLoad = resolve; }); - const load = vi.fn(async () => { await loading; return { values, accounts: [], set: async (key: string, value: boolean | string) => { values[key] = value; } }; }); + const load = vi.fn(async () => { await loading; return { values, accounts: [], set: setPreference }; }); vi.doMock("../src/main/preferences", () => ({ Preferences: { load } })); vi.doMock("../src/main/composition", () => ({ compose: () => [] })); vi.doMock("../src/main/connections", () => ({ DesktopConnections: class { @@ -106,27 +118,79 @@ it.each([false, true])("cleans up windows and isolates candidate effects (candid expect(await invoke(flyout, { action: "resize", height: 593 })).toEqual({ ok: true }); expect(flyout.bounds).toMatchObject({ width: 420, height: 593 }); await invoke(windows[2], { action: "settings" }); + const settings = windows[3]; + if (retainedZoom) { + expect(flyout.zoomFactor).toBe(1); expect(settings.zoomFactor).toBe(1); + flyout.zoomFactor = 1.5; flyout.contents.emit("did-finish-load"); + expect(flyout.zoomFactor).toBe(1); + expect(windows[0].contents.setZoomFactor).not.toHaveBeenCalled(); + } + if (keyboard) { + const key = (w: TestWindow, key: string, modifiers = { control: true, meta: false, alt: false }, type = "keyDown") => { + const event = { preventDefault: vi.fn() }; + w.contents.emit("before-input-event", event, { key, type, ...modifiers }); + return event; + }; + expect(key(flyout, "-").preventDefault).toHaveBeenCalledOnce(); + await vi.waitFor(() => expect(values.panelSize).toBe("small")); + expect(flyout.bounds).toMatchObject({ width: 336, height: 475 }); + expect(settings.contents.send.mock.lastCall?.[1].settings.values.panelSize).toBe("small"); + expect(key(flyout, "-").preventDefault).toHaveBeenCalledOnce(); + await Promise.resolve(); await Promise.resolve(); + expect(values.panelSize).toBe("small"); + // Two inputs before persistence completes must advance twice, not lose a step. + key(flyout, "="); key(flyout, "+", { control: false, meta: true, alt: false }); + await vi.waitFor(() => expect(values.panelSize).toBe("large")); + expect(flyout.bounds).toMatchObject({ width: 483, height: 682 }); + key(settings, "+"); + await Promise.resolve(); await Promise.resolve(); + expect(values.panelSize).toBe("large"); + key(settings, "0", { control: false, meta: true, alt: false }); + await vi.waitFor(() => expect(values.panelSize).toBe("medium")); + expect(flyout.bounds).toMatchObject({ width: 420, height: 593 }); + expect(settings.contents.send.mock.lastCall?.[1].settings.values.panelSize).toBe("medium"); + for (const input of ["+", "-", "=", "0", "a", "z"]) expect(key(settings, input, { control: false, meta: false, alt: false }).preventDefault).not.toHaveBeenCalled(); + expect(key(settings, "a").preventDefault).not.toHaveBeenCalled(); + expect(key(settings, "z").preventDefault).not.toHaveBeenCalled(); + expect(key(settings, "+", { control: true, meta: false, alt: true }).preventDefault).not.toHaveBeenCalled(); + expect(key(settings, "+", undefined, "keyUp").preventDefault).not.toHaveBeenCalled(); + expect(key(windows[0], "+").preventDefault).not.toHaveBeenCalled(); + setPreference.mockRejectedValueOnce(new Error("Synthetic write failure")); + key(settings, "-"); + await vi.waitFor(() => expect(showErrorBox).toHaveBeenCalledOnce()); + expect(values.panelSize).toBe("medium"); + expect(flyout.bounds).toMatchObject({ width: 420, height: 593 }); + key(flyout, "-"); + await vi.waitFor(() => expect(values.panelSize).toBe("small")); + key(flyout, "0"); + await vi.waitFor(() => expect(values.panelSize).toBe("medium")); + expect(flyout.zoomFactor).toBe(1); expect(settings.zoomFactor).toBe(1); + const writes = setPreference.mock.calls.length; + key(closed, "-"); + await new Promise(resolve => setImmediate(resolve)); + expect(setPreference).toHaveBeenCalledTimes(writes); + } expect(await invoke(windows[3], { action: "setPreference", key: "panelSize", value: "large" })).toEqual({ ok: true }); expect(flyout.bounds).toMatchObject({ width: 483, height: 682 }); expect(await invoke(flyout, { action: "resize", height: 1200 })).toEqual({ ok: true }); expect(flyout.bounds).toMatchObject({ width: 483, height: 900 }); expect(await invoke(windows[3], { action: "setPreference", key: "panelSize", value: "small" })).toEqual({ ok: true }); - expect(flyout.bounds).toMatchObject({ width: 378, height: 900 }); + expect(flyout.bounds).toMatchObject({ width: 336, height: 835 }); expect(await invoke(flyout, { action: "resize", height: 400, width: 10000 })).toEqual({ ok: true }); - expect(flyout.bounds).toMatchObject({ width: 378, height: 400 }); + expect(flyout.bounds).toMatchObject({ width: 336, height: 400 }); workArea.height = 350; display.emit("display-metrics-changed"); - expect(flyout.bounds).toMatchObject({ width: 378, height: 350 }); + expect(flyout.bounds).toMatchObject({ width: 336, height: 350 }); workArea.height = 900; display.emit("display-metrics-changed"); - expect(flyout.bounds).toMatchObject({ width: 378, height: 400 }); + expect(flyout.bounds).toMatchObject({ width: 336, height: 400 }); expect(await invoke(windows[3], { action: "setPreference", key: "panelSize", value: "huge" })).toEqual({ ok: false, error: "Invalid request" }); - expect(flyout.bounds).toMatchObject({ width: 378, height: 400 }); + expect(flyout.bounds).toMatchObject({ width: 336, height: 400 }); workArea.height = 2400; expect(await invoke(flyout, { action: "resize", height: 2800 })).toEqual({ ok: true }); - expect(flyout.bounds).toMatchObject({ width: 378, height: 2400 }); + expect(flyout.bounds).toMatchObject({ width: 336, height: 2400 }); expect(await invoke(flyout, { action: "resize", height: 2200 })).toEqual({ ok: true }); - expect(flyout.bounds).toMatchObject({ width: 378, height: 2200 }); + expect(flyout.bounds).toMatchObject({ width: 336, height: 2200 }); workArea.height = 900; if (candidate) { expect(application.getLoginItemSettings).not.toHaveBeenCalled(); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index c0f4e59..074101a 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -21,16 +21,17 @@ function renderApp(view: ViewState, surface = "flyout") { const now = Date.parse("2026-10-06T12:00:00Z"); it("offers a labelled size selector and scopes scale and keyboard scrolling to main panel and Settings", () => { const view = state(); - for (const [size, scale] of [["small", "0.9"], ["medium", "1"], ["large", "1.15"]]) { + for (const [size, scale] of [["small", "0.8"], ["medium", "1"], ["large", "1.15"]]) { view.settings.values.panelSize = size; for (const surface of ["flyout", "settings"]) { const markup = renderApp(view, surface); expect(markup).toContain(`zoom:${scale}`); expect(markup).toMatch(/class="panel-viewport"[^>]*tabindex="0"/); if (surface === "settings") { - expect(markup).toMatch(/Panel size<\/span> set(key, e.target.checked)} />; return

Settings

Accounts, appearance and usage preferences.

+ {!state.snapshot.appearance.onboarding &&

Your usage, at a glance

Connect an account below to see its allowance. Sign-in is optional.

}

Appearance

Usage-panel size @@ -190,6 +191,6 @@ export function App({ bridge }: { bridge: Bridge }) { if (!state) return

{error || "Loading usage…"}

; const s = state.snapshot; const style = { ...(panel ? { zoom: panelScales[state.settings.values.panelSize as PanelSize] ?? 1 } : {}), "--accent": accents[s.appearance.accent] ?? accents.blue, "--notch-max-height": state.notch.maxHeight === undefined ? "100dvh" : `${state.notch.maxHeight}px` } as CSSProperties; - const app =
{error &&
{error}
}{surface === "share" ? (() => { const slot = notchEntries(s).find(e => e.key === state.notch.selected)?.slot; return slot ? :

Card unavailable

; })() : surface === "tray" ? : surface === "settings" ? : surface === "notch" ? :

MeterUsage {s.demo && Demo}

{!s.appearance.onboarding &&

Your usage, at a glance

Connect Codex or Claude Desktop below. Account allowance comes from the provider. Local activity covers this Mac; OpenRouter activity covers its account.

}{state.update &&
MeterUsage {state.update.version} available
}{s.historyError &&

History could not be {s.historyError === "loadFailed" ? "read. Repair the file and relaunch to resume saving" : "saved. Live readings remain available"}.

}{s.archiveWriteFailed &&

Last known quota could not be saved.

}{s.slots.length ? s.slots.map(slot => ) :

Enable a provider in Settings.

}
{s.lastRefreshedAt === undefined ? "Not refreshed yet" : `Updated ${new Date(s.lastRefreshedAt).toLocaleTimeString()}`}
}
; + const app =
{error &&
{error}
}{surface === "share" ? (() => { const slot = notchEntries(s).find(e => e.key === state.notch.selected)?.slot; return slot ? :

Card unavailable

; })() : surface === "tray" ? : surface === "settings" ? : surface === "notch" ? :

MeterUsage {s.demo && Demo}

{state.update &&
MeterUsage {state.update.version} available
}{s.historyError &&

History could not be {s.historyError === "loadFailed" ? "read. Repair the file and relaunch to resume saving" : "saved. Live readings remain available"}.

}{s.archiveWriteFailed &&

Last known quota could not be saved.

}{s.slots.length ? s.slots.map(slot => ) :

Enable a provider in Settings.

}
{s.lastRefreshedAt === undefined ? "Not refreshed yet" : `Updated ${new Date(s.lastRefreshedAt).toLocaleTimeString()}`}
}
; return panel ?
{app}
: app; } diff --git a/src/renderer/style.css b/src/renderer/style.css index 129b75c..b9385f0 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -8,7 +8,7 @@ .panel-viewport:focus-visible { outline: 2px solid Highlight; outline-offset: -2px; } .surface-flyout, .surface-settings { display: flow-root; } @media (prefers-color-scheme: dark) { .theme-system { --grok: #ebebf0; --claude: #d97706; --antigravity: #60a5fa; --opencode: #2dd4bf; --openrouter: #a78bfa; --canvas: #1c1c1e; --surface: #262629; --well: #343438; --border: #ffffff19; --text: #efeff2; --muted: #a3a3ad; --good: #30d158; --warn: #ffb340; --alert: #ff6961; } } -.row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .secondary { color: var(--muted); font-size: 11px; } .stale { font-size: 11px; color: var(--warn); margin: 8px 0; } .empty { padding: 20px; color: var(--muted); } .badge { color: var(--muted); background: var(--well); padding: 2px 6px; border-radius: 5px; font-size: 10px; } .app-header { margin-bottom: 12px; } .app-header button { margin-left: 5px; } .app-header small { color: var(--warn); font-size: 11px; font-weight: 400; } .flyout { padding: 14px; } .provider-card { padding: 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface); margin-bottom: 10px; } .provider-heading { display: flex; align-items: center; gap: 7px; min-width: 0; } .provider-heading h2 { overflow-wrap: anywhere; } .mark { flex-shrink: 0; width: 16px; height: 16px; display: inline-block; mask-size: contain; mask-repeat: no-repeat; mask-position: center; } .pending-symbol { text-align: center; font-weight: 650; } .status { margin: 5px 0; } .quota-window { margin: 10px 0; } .meter { background: var(--well); height: 5px; border-radius: 4px; overflow: hidden; margin: 5px 0; } .meter span { display: block; height: 100%; border-radius: inherit; } .quota-window .secondary { align-items: flex-start; } .quota-window .secondary > :last-child { text-align: right; } .well { background: var(--well); padding: 8px; border-radius: 6px; } .credit { margin: 10px 0; } .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin: 10px 0; } dt { font-size: 10px; color: var(--muted); } dd { font-size: 13px; margin: 0; } .sparkline { width: 100%; height: 45px; margin: 6px 0; } .heatmap { display: grid; grid-template-columns: repeat(15, 1fr); gap: 3px; margin: 8px 0; } .heatmap span { aspect-ratio: 1; border-radius: 2px; } .session { padding: 7px 0; border-bottom: 1px solid var(--border); } .session span { overflow-wrap: anywhere; } summary { color: var(--muted); font-size: 11px; cursor: pointer; padding-top: 8px; } .reset-credit { padding: 6px 0; } footer { margin-top: 12px; } .welcome p { margin: 8px 0; } +.row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .secondary { color: var(--muted); font-size: 11px; } .stale { font-size: 11px; color: var(--warn); margin: 8px 0; } .empty { padding: 20px; color: var(--muted); } .badge { color: var(--muted); background: var(--well); padding: 2px 6px; border-radius: 5px; font-size: 10px; } .app-header { margin-bottom: 12px; } .app-header button { margin-left: 5px; } .app-header small { color: var(--warn); font-size: 11px; font-weight: 400; } .flyout { padding: 14px; } .provider-card { padding: 14px; border-radius: 10px; border: 1px solid var(--border); background: var(--surface); margin-bottom: 10px; } .provider-heading { display: flex; align-items: center; gap: 7px; min-width: 0; } .provider-heading h2 { overflow-wrap: anywhere; } .mark { flex-shrink: 0; width: 16px; height: 16px; display: inline-block; mask-size: contain; mask-repeat: no-repeat; mask-position: center; } .pending-symbol { text-align: center; font-weight: 650; } .status { margin: 5px 0; } .quota-window { margin: 10px 0; } .meter { background: var(--well); height: 5px; border-radius: 4px; overflow: hidden; margin: 5px 0; } .meter span { display: block; height: 100%; border-radius: inherit; } .quota-window .secondary { align-items: flex-start; } .quota-window .secondary > :last-child { text-align: right; } .well { background: var(--well); padding: 8px; border-radius: 6px; } .credit { margin: 10px 0; } .metrics { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 12px; margin: 10px 0; } dt { font-size: 10px; color: var(--muted); } dd { font-size: 13px; margin: 0; } .sparkline { width: 100%; height: 45px; margin: 6px 0; } .heatmap { display: grid; grid-template-columns: repeat(15, 1fr); gap: 3px; margin: 8px 0; } .heatmap span { aspect-ratio: 1; border-radius: 2px; } .session { padding: 7px 0; border-bottom: 1px solid var(--border); } .session span { overflow-wrap: anywhere; } summary { color: var(--muted); font-size: 11px; cursor: pointer; padding-top: 8px; } .reset-credit { padding: 6px 0; } footer { margin-top: 12px; } .provider-setting { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } .settings { padding: 24px; max-width: 680px; margin: auto; font-size: 13px; } .settings .settings-header { margin-bottom: 24px; } diff --git a/tests/electron-lifecycle.test.ts b/tests/electron-lifecycle.test.ts index bd9b2ee..21e3a4f 100644 --- a/tests/electron-lifecycle.test.ts +++ b/tests/electron-lifecycle.test.ts @@ -6,11 +6,12 @@ import { createServer } from "node:net"; import { join } from "node:path"; it.each([ - { candidate: false, keyboard: false, retainedZoom: false }, + { candidate: false, keyboard: false, retainedZoom: false, onboarding: true }, + { candidate: false, keyboard: false, retainedZoom: false, onboarding: false }, { candidate: true, keyboard: false, retainedZoom: false }, { candidate: true, keyboard: true, retainedZoom: false }, { candidate: true, keyboard: false, retainedZoom: true }, -])("cleans up windows and isolates candidate effects (candidate=$candidate, keyboard=$keyboard, retainedZoom=$retainedZoom)", async ({ candidate, keyboard, retainedZoom }) => { +])("cleans up windows and isolates candidate effects (candidate=$candidate, keyboard=$keyboard, retainedZoom=$retainedZoom, onboarding=$onboarding)", async ({ candidate, keyboard, retainedZoom, onboarding = true }) => { vi.resetModules(); const root = mkdtempSync(join(tmpdir(), "meterusage-lifecycle-")); const platform = Object.getOwnPropertyDescriptor(process, "platform")!; @@ -40,6 +41,7 @@ it.each([ class TestWindow extends EventEmitter { destroyed = false; zoomFactor = 1; + surface = ""; bounds: { x: number; y: number; width: number; height: number }; contents = Object.assign(new EventEmitter(), { id: windows.length + 1, mainFrame: { url: "" }, setFrameRate: vi.fn(), setZoomFactor: vi.fn((factor: number) => { this.zoomFactor = factor; }), send: vi.fn(), setWindowOpenHandler: vi.fn() }); constructor(options: { width: number; height: number }) { super(); this.bounds = { x: 0, y: 0, width: options.width, height: options.height }; windows.push(this); } @@ -53,6 +55,7 @@ it.each([ setPosition = vi.fn((x: number, y: number) => { this.bounds.x = x; this.bounds.y = y; }); show = vi.fn(); focus = vi.fn(); hide = vi.fn(); loadURL = (url: string) => { this.contents.mainFrame.url = url; + this.surface = new URL(url).searchParams.get("surface")!; if (retainedZoom && ["flyout", "settings"].includes(new URL(url).searchParams.get("surface")!)) this.zoomFactor = 1.25; this.contents.emit("did-finish-load"); return Promise.resolve(); }; @@ -65,7 +68,7 @@ it.each([ session: { defaultSession: { setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), webRequest: { onBeforeRequest: vi.fn() } } }, })); vi.doMock("../src/main/launch", () => ({ launchConfiguration: () => ({ data: root, home: root, demo: false, candidate, profile: candidate ? root : undefined }) })); - const values: Record = { onboardingCompleted: true, panelSize: "medium" }; + const values: Record = { onboardingCompleted: onboarding, panelSize: "medium" }; const setPreference = vi.fn(async (key: string, value: boolean | string) => { await Promise.resolve(); values[key] = value; }); let finishLoad!: () => void; const loading = new Promise(resolve => { finishLoad = resolve; }); @@ -94,7 +97,7 @@ it.each([ await vi.waitFor(() => expect(windows).toHaveLength(2)); expect(windows[1].focus).toHaveBeenCalled(); application.emit("activate"); - expect(windows[1].focus).toHaveBeenCalledTimes(candidate ? 3 : 2); + expect(windows[1].focus).toHaveBeenCalledTimes(candidate || !onboarding ? 3 : 2); expect(application.requestSingleInstanceLock.mock.invocationCallOrder[0]).toBeLessThan(application.whenReady.mock.invocationCallOrder[0]); if (candidate) { expect(application.setPath.mock.calls).toEqual([["userData", join(root, "electron")], ["sessionData", join(root, "electron")]]); @@ -104,6 +107,20 @@ it.each([ } tray.emit("click"); await vi.waitFor(() => expect(windows).toHaveLength(2)); + if (!onboarding) { + const setup = windows[1]; + expect(setup.surface).toBe("settings"); + application.emit("activate"); + expect(setup.focus).toHaveBeenCalled(); + const invoke = (w: TestWindow, request: unknown) => ipc.mock.calls[0][1]({ sender: w.contents, senderFrame: w.contents.mainFrame }, request); + setPreference.mockRejectedValueOnce(new Error("Synthetic write failure")); + expect(await invoke(setup, { action: "setPreference", key: "onboardingCompleted", value: true })).toEqual({ ok: false, error: "Could not complete action" }); + expect(values.onboardingCompleted).toBe(false); expect(setup.hide).not.toHaveBeenCalled(); + expect(await invoke(setup, { action: "setPreference", key: "onboardingCompleted", value: true })).toEqual({ ok: true }); + expect(values.onboardingCompleted).toBe(true); expect(setup.hide).toHaveBeenCalled(); + expect(windows.some(w => w.surface === "flyout" && w.show.mock.calls.length > 0)).toBe(true); + return; + } const closed = windows[1]; closed.destroyed = true; tray.emit("click"); diff --git a/tests/renderer.test.ts b/tests/renderer.test.ts index 074101a..106bec4 100644 --- a/tests/renderer.test.ts +++ b/tests/renderer.test.ts @@ -113,6 +113,22 @@ it("offers simple account setup and truthful Grok availability without directori view.snapshot.demo = true; view.connections[0].status = "disconnected"; expect(renderToStaticMarkup(createElement(Connections, { state: view, action: async () => {} }))).toContain("disabled"); }); +it("keeps onboarding in Settings and removes it from usage after completion", () => { + const view = state(); view.snapshot.demo = false; + view.connections = [{ provider: "codex", status: "disconnected" }, { provider: "claude", status: "disconnected" }, { provider: "grok", status: "unsupported" }]; + view.snapshot.appearance.onboarding = false; + const setup = renderApp(view, "settings"); + expect(setup).toContain("Your usage, at a glance"); expect(setup).toContain("Choose an account once"); + expect(setup).toContain("Connect your accounts"); expect(setup).toContain(">Done"); + expect(setup).toContain("Sign in to Codex"); expect(setup).toContain("Connect Claude Desktop"); + for (const surface of ["flyout", "notch", "tray", "share"]) { + expect(renderApp(view, surface)).not.toContain("Your usage, at a glance"); + expect(renderApp(view, surface)).not.toContain("Connect your accounts"); + } + view.snapshot.appearance.onboarding = true; + const completed = renderApp(view, "settings"); + expect(completed).not.toContain("Your usage, at a glance"); expect(completed).not.toContain(">Done"); +}); it.each(["codex", "claude"] as const)("keeps %s display control available for an active additional account", provider => { const view = state(), name = provider === "codex" ? "Codex" : "Claude"; view.settings.values = { [`showProvider${name}`]: false, [`menuBarProvider${name}`]: true };