diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 888ca7f..9ec58c1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,24 @@ concurrency: cancel-in-progress: true jobs: + typescript: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24' + - name: Install pnpm + run: npm install --global pnpm@12.9.1 --ignore-scripts + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Typecheck + run: pnpm typecheck + - name: Test + run: pnpm test + - name: Build + run: pnpm build test: runs-on: macos-15 timeout-minutes: 15 diff --git a/.gitignore b/.gitignore index 1927e89..b5f31f1 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,12 @@ NO-LOOPS.md .handoff/ build/ .automation/ + +# TypeScript / Electron candidate output +node_modules/ +.vite-plus/ +release/ +coverage/ + +# Generated from the existing macOS SF Symbol stand-ins. +Resources/*-symbol.png diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c225f..5e9e87e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,19 @@ follows [Semantic Versioning](https://semver.org/). ## [Unreleased] -### Added +### Electron source integration + +- Start a fresh quota activity series when a connection changes. Retain prior + observations outside the replacement account's heatmap, and preserve the + current series on normal relaunch and Muse collection Stop/Start. + +- Preserve the TypeScript 7 upgrade, stale Claude CLI quota rejection and Grok + provider mark centering from the parallel Electron branches. +- Run Electron typecheck, synthetic tests and production build in CI alongside + the retained Swift build and tests. Keep Swift's newer diagnostic reporting + source and document its separate reporting contract. + +### Legacy Swift source - Agents can prepare bounded bug drafts with `report draft`. `report submit` shows the exact text and private Linear destination in a native consent @@ -26,17 +38,145 @@ follows [Semantic Versioning](https://semver.org/). Pending reports and receipts survive leaving and returning to Settings, preventing a second submission while the first is in flight. +## [0.3.0-beta.6] - 2026-10-22 + +### Beta update verification + +- Package the beta 5 executable source as version 0.3.0-beta.6, build 75, + to exercise discovery, download, verification, replacement and relaunch + through the installed beta updater. No provider or UI behavior changes. +- Document the stable Swift updater asset contract, signature changes and + the isolated upgrade and rollback checks still required before cutover. + +## [0.3.0-beta.5] - 2026-10-22 + +### Beta updates + +- Beta builds now check GitHub's release list for newer betas, since the + latest-release endpoint never returns prereleases. They accept + `-mac-arm64` beta assets and order `x.y.z-beta.N` versions, with a final + release ranked above its betas. The existing digest, bundle-identity and + signature checks still gate installation. Beta 4 and earlier need one + manual install. + +## [0.3.0-beta.4] - 2026-10-22 + +### Quota activity heatmaps + +- Show a "Quota activity" heatmap on every provider card with a quota reading. + Each provider keeps its own series of daily percentage-point rises in its + weekly (or longest) window, so desktop apps, the web and other devices count. + The current window's existing usage is spread over its elapsed days and + marked estimated; later rises land on the day observed. Earlier weeks are + not backfilled. Codex keeps account token history when available. +- Quota activity never infers tokens. Tooltips add measured Claude Code or + Codex tokens from this Mac when that day has them. + +### Claude Desktop connection + +- Abort failed provider HTTP requests before awaiting response cleanup, including + size-limit failures. Cleanup rejection preserves the original provider error; + successful requests are not aborted. Native closure remains unverified. + +- Route Claude Desktop bootstrap and organization allowance requests through an + isolated, nonpersistent Electron session after app readiness. Keep the explicit + cookie, request bounds, consent and identity checks. Other endpoints keep their + existing transport. Native cookie handling and live allowance remain unverified. + +- Report explicit provider browser-verification challenges during Connect and + preserve the current source. A failed Claude Desktop method offers **Open + Claude usage** in normal Settings as a manual allowance fallback. Opening + the page does not connect MeterUsage or import allowance. + +- Read Keychain once per allowance attempt and reuse that password only in + attempt-local memory for fresh account and session checks. A one-time + Keychain approval no longer requires a second access during verification. + Keep cancellation, account binding and failed-Connect CLI preservation. + Synthetic regression proof does not establish live provider acceptance. +- Replace OAuth cache selection with explicit Desktop web-session consent, + sandboxed read-only cookie selection and nested bootstrap account/membership + verification before UUID-bound allowance requests. Recheck account, organization + and session before publishing. Old consent requires reconnecting. Preserve + CLI selection on failed Connect and keep account quota separate from CLI history. + No chats, refresh tokens, credential persistence or Desktop writes are used. + Native compatibility and live allowance remain unverified. + +### Added + +- Add opt-in Muse Code subscription allowance through the official CLI's + read-only `usage/read` protocol. Show session and weekly windows with the + original observation time. Do not send prompts or read CLI credentials. + Honor saved opt-in in headless reports, cancel reads when stopped, retain + observation time in JSON and keep allowance out of the shared archive for + compatibility with older app versions on rollback. + Muse.ai Desktop import and native/live validation remain pending. +- Show Grok Bot Desktop quota burn in percentage points per hour from recent + same-account readings. Collect for at least a minute, discard old/reset + observations and show an estimated run-out only when measured. +- Add separate OpenAI API and Anthropic API billing cards backed by projected + owner-only snapshots. A bundled one-run helper uses existing environment-only + organization Admin keys. Keep API billing distinct from subscription quota + and estimates; show report periods, capture age and stale state. Live billing + acceptance remains pending. +- Add an explicit Antigravity Desktop connection in Electron setup and Settings. + Read account allowance from one running, signed-in desktop language server + without requiring `agy`. Keep CLI selection explicit and local activity + separate. Synthetic fixtures cover identity changes, unavailable quota, + transport limits and cancellation. Native and live-provider acceptance remain + pending; the published beta is unchanged. + +### Changed + +- Move API setup to its own Settings sidebar page. OpenAI and Anthropic API + setup now accepts a one-time Admin key and fetches billing directly, with no + Terminal steps. Existing legacy Admin keys can be reused through a read-only + Keychain action; newly pasted keys are not saved. Account connections and + onboarding stay separate. + +- Connect new Grok Bot accounts through **Grok Bot Desktop (Beta)** after + explicit consent. Read only the active account-bound encrypted access token and team + through a fixed Keychain item; use a bounded read-only allowance request with + MeterUsage's own headers. Reject identity/team/store changes and cancelled + results. Keep saved Cursor connections compatible and CLI selection explicit. + No token refresh, app writes or separate xAI sign-in is added. Native store, + Keychain and endpoint compatibility remain unverified; the published build + is unchanged. +- Shorten heatmap hover/focus totals to values such as `42.4B tokens` and + `1.6K sessions` across local and account history. Keep exact grouped counts + in tooltips and accessibility labels. Summary counts compact from 1,000. + ### Fixed -- `Scripts/make-app.sh` now downloads and verifies the prebuilt app by default, - so installation from a Git checkout does not need Swift. Source builds require - `--build-from-source`. Failed downloads or verification never trigger compilation. - Copy and verify the replacement in staging so a failed copy or signature check - preserves the existing generated app. -- Make the prebuilt app download the default installation path. Clarify that - Swift and Xcode Command Line Tools are needed only to build from source, - link directly to the current Apple silicon app ZIP, and keep build commands - in the contributor guide instead of the README. +- Reload saved provider order and managed accounts when macOS abbreviates + defaults data output. Read complete stored bytes and retain the existing + preference format across relaunches and app updates. +- Read known Claude plan labels from the available tier fields without showing + internal identifiers or guessing a plan from an unrelated substring. +- Accept duplicate Claude Desktop cache entries when their eligible access + tokens are identical. Distinct matching tokens still require a clear login; + an ambiguous-token failure preserves the selected CLI source. +- Preload the tray usage window hidden after completed onboarding, before + connection recovery or provider collection. The first click reuses the loaded + renderer instead of starting its initial load. +- Read Grok Bot's account-bound encrypted legacy v10 access values and correct + its fixed Keychain account to Grok Bot Key. Preserve scope, expiry, identity, + cancellation and store checks; reject plaintext and unsupported encryption. +- Give 30-day provider activity the same 26-column, seven-row heatmap layout. + Keep older unavailable dates distinct from measured zero-token days. +- Add **Use local OpenCode** setup for key-free Go session history and restore + executable Bun-install discovery for the read-only SQL fallback. +- Name saved legacy Grok connections as Cursor sources while new connections + use Grok Bot Desktop. + +- Group Antigravity Desktop limits under compact model headings and shorten + remaining-allowance labels to match the CLI presentation. Preserve every + reported bucket, its used percentage and reset. Keep account allowance + separate from CLI activity, and describe unavailable saved connections + without claiming a live connection. +- Exclude Apple's system `CursorUIViewService` from the Cursor editor process + guard. Continue rejecting active Cursor processes and unsafe database state. +- Show the running app bundle's version in Settings instead of the legacy + hardcoded version. Source previews omit the version when none is supplied. ## [0.3.0-beta.3] - 2026-10-17 @@ -112,14 +252,304 @@ follows [Semantic Versioning](https://semver.org/). ## [0.3.0-beta.1] - 2026-10-16 +### Release + +- Publish signed and notarized Electron build 54 as an opt-in Apple silicon beta. Keep Swift 0.2.41 + as the stable download and preserve its updater and installer contracts. +- Replace old product screenshots with current synthetic Codex, Claude, notch + and Settings captures. Refresh download, source-choice and beta-limit docs. +- Disclose the cosmetic 0.2.41 Settings footer and incomplete native acceptance. + The signed build is unchanged; Antigravity/Grok desktop login is follow-up work. + + ### Added -- Publish signed/notarized Electron build 54 as an opt-in Apple silicon beta. - Keep Swift 0.2.41 stable; beta downloads are manual. -- Replace old product screenshots with current synthetic Codex, Claude, side - notch and Settings captures. Document setup, artifact verification and limits. -- Keep the known cosmetic 0.2.41 Settings footer and incomplete native acceptance - visible in the beta guide. Antigravity/Grok desktop login is follow-up work. +- Label the Grok integration **Beta** in usage cards, setup and provider settings. + +- Restore **Send report** in Electron Diagnostics with sanitized status summaries, + duplicate-safe retries and confirmed private support references. + +### Fixed + +- Limit diagnostic report attempts to once every 10 minutes across app relaunches, + with a visible retry time and no transmission if the cooldown cannot be saved. + +- Fit Electron Settings to its active page and expanded controls within the display + work area. Remove the fixed-height blank space and oversized heatmap readout + reservation. Exact daily counts wrap below the grid on hover or focus. +- Match the compact side-notch rail: provider marks inside 22 px usage rings, + small percentages below and a neutral 36 px body. Settings remains in each + expanded card footer; remove the duplicate strip gear and its reserved space. + Keep detail cards on hover and preserve the fixed top and strip anchor. + +- Remove the bright focus outline around the Electron usage and Settings + panels. Keep keyboard scrolling and focus indicators on individual controls. + +### Changed + +- Make buttons smaller throughout the Electron menu, Settings and side-notch + cards. Keep Settings in each card footer and remove the duplicate strip gear. +- Align heatmap menu labels with a fixed selection-checkmark column. + +- Keep Service status at the top of the Electron menu. Switch between provider + and account tabs instead of stacking every usage card. Preserve saved order + and keyboard navigation. +- Compact menu and side-notch usage cards with tighter spacing and shorter + sparklines. Keep quota, reset actions and heatmaps visible. Extra activity + statistics, period comparisons and burn breakdowns expand on demand; shared + cards retain their complete data. + +- Organize Electron Settings into a compact category sidebar with provider + summaries and setup details under Configure. Keep existing settings, account + controls and explicit connection actions. Show the current source, collection + state and reading availability separately. +- Keep service health above usage cards. Use exact grouped heatmap totals on + hover and focus, with one Tab entry per heatmap and arrow-key navigation. + Keep control focus visible without highlighting the panel edge. + +- Make Electron the sole active feature-development target in contributor and + version guidance. Freeze the legacy Swift app for new features and record + staged retirement in ADR 0024. Keep the published Swift download, rollback + source and Electron-required Swift helpers until the applicable checks pass. + +- Split edition setup into Swift and Electron guides with root download routing + and compatibility links. Document source/build/release/installation boundaries, + candidate parity gaps and isolated QA. Record a proposed same-repository + strategy without changing identity, versions, layout, CI or release state. + Explain the updater changes required before beta or edition-specific assets. + +- Keep summary token metrics compact with totals such as `12.5K`, `1.2M` and + `2.4B`. Heatmap hover, focus, tooltips and accessible labels show exact counts. + +- Unify provider heatmaps with compact headings, a shared intensity legend and + exact hover or keyboard readouts, tooltips and accessible labels. + Unreported account days use quiet solid + outlines, distinct from reported zeroes. Keep each source's time range and + token or session units. + +- Report fixed Claude Desktop Connect errors for Keychain access, account + configuration, active-organization cookie, cache decryption, missing or ambiguous + current token, allowance request, identity recheck and preference save in the + existing error banner through the renderer error filter. Messages contain no + account identifiers or token data. Source and synthetic tests only; native and + live acceptance remain open. + +- Narrow the Electron side notch strip from 44 to 36 px and its hover card from + 250 to 220 px. Keep the share capture at 250 px and preserve whole-pixel + typography and the anchored strip edge. + +### Added + +- Choose a usage source during onboarding or in Settings: Codex browser sign-in + or CLI, Claude Desktop or Claude Code CLI, and Grok CLI. Apply CLI selection + explicitly after disconnecting only the MeterUsage account connection. + +- Show provider-reported account token activity in connected Codex main, notch + and shared cards through the existing managed ChatGPT helper. Daily dates and + exact counts remain separate from local usage. Missing history and unreported + days stay unavailable; reported zero remains zero. Native and live coverage + acceptance remain open. + +- Reorder provider groups in Electron Settings with drag handles, arrow keys or + move buttons. The saved order applies to usage cards, menu bar and side notch; + additional accounts stay with their provider. Failed saves keep the saved order. + +- Add a separate Claude cloud test profile for the existing candidate UI. + It starts without consent, reads only connected account allowance, and keeps + preferences and Electron state separate from installed-app and Codex profiles. + Native Keychain, Desktop compatibility and live allowance remain unverified. +- Show **Use reset** beside eligible Codex menu credits for the connected + account, including the isolated Codex test profile. Native confirmation + defaults to Cancel. Redemption rechecks availability and the same connection + profile before using the official helper, then refreshes quota. Synthetic + tests consume no real credits; native and live redemption proof remain open. + +- 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. + Small uses 80% scale and a 336-point main-panel width before display clamping. + Ctrl/Cmd plus and minus select these sizes; Ctrl/Cmd 0 restores Medium. + These shortcuts keep the content scale, saved choice and main-panel fit together. + +- 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 + 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 + the behavior oracle until native acceptance passes. No Electron release or + installed-app replacement is included. + +### Changed + +- Compact candidate Settings onboarding and repeated rows. Explain demo account + restrictions before disabled connections, align provider controls, and stack + labelled controls on narrow windows. Keep action failures visible in Settings + and wrap long account, quota and status text. +- Compact Service status and the three-metric local activity summary. Separate + recorded activity from account/local totals, retain dated quota warnings, and + keep controls out of static shared cards. Live heatmaps retain their exact + hover/focus readout; exports retain data without the unused readout space. +- Use the existing brighter dark secondary tone and 100 ms pointer color + feedback across candidate Usage and Settings. Press and keyboard focus stay + immediate. Existing sizes, notch geometry and data/action behavior remain. + Renderer fixtures do not establish browser or native visual acceptance. + +- Brighten secondary labels in the candidate notch card, separate telemetry + with a border and size-specific padding, and add 100 ms color feedback on + enabled control hover with a fine pointer. Press and keyboard focus stay + immediate; reduced motion disables transitions. Card opening, provider + switching, the 450 ms close delay and integer text sizes are unchanged. + +- Require an explicit consent callback for Claude Desktop connection. Omitting + it denies access before the Keychain helper or allowance request runs. +- Show Codex public service status in the isolated live Codex test profile. + The Service status panel, notch card, tray tooltip and copied diagnostics mark + retained readings as stale or last known with their age and refresh failure. Public-feed errors use + service-status wording while retaining their retry behavior. Requests use no + account credentials and add no other provider source. + +- Move candidate onboarding into Settings. First-time setup reuses account + connections and allows Done without signing in. Done saves completion before + returning to usage; a save failure keeps setup open. The main panel shows usage + without the welcome and account-setup card. + +- 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. + Appearance sits below account connections, with visible size choices, + grouped rows and aligned provider controls. Settings content scales with the + size choice while its window frame stays fixed. + +- 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. +- Verify the current Apple silicon Electron candidate with 128 macOS fixtures, + TypeScript checking, production bundles, extracted ZIP signature checks and + the native schema-1 demo CLI. The candidate is ad-hoc signed and unpublished; + native GUI acceptance and cutover remain open. +- Replace the earlier React Native/Tauri Windows plan with the selected macOS + Electron direction. Windows implementation, installers, CI, testing and + release are paused until a test machine is available. +- Correct privacy documentation for Codex token/model ledger reads, transient + project paths, existing Grok/OpenCode keys, managed accounts and hourly + update checks. Provider files and credentials stay in the main process. + +### Fixed + +- Keep Electron side-notch details open on hover and close them after the + existing 450 ms exit delay. Remove Pin detail card and Keep open controls; + legacy pin data no longer retains a card. The strip always shows usage rings + and percentages, with no design selector or dots option. Disable/hide clears + expanded state so re-enabling starts with the 44 px strip. +- Replace side-notch CSS zoom with integer pixel text, controls and spacing. + Small uses an 11 px body and 10 px secondary text. Small, Medium and Large + keep the strip and card widths fixed. Flyout, Settings and shared cards keep + their existing rendering scales. Browser and native acceptance remain separate. + +- Match Codex Use reset buttons to compact footer controls in the Electron + candidate. Keep eligibility, account labels and Cancel-default confirmation. +- Show exact heatmap counts and dates immediately on hover or keyboard focus. + Weekly and cumulative views name their scope. Count-only data does not claim + measured token totals. The readout reserves space inside the scrolling card. +- Recover missed side-notch pointer exits through a cursor check limited to an + open interaction, then use the existing 450 ms fold delay. Keep the provider + strip anchor, drag and native share/reset guards. + +- Abort confirmed Codex resets when their source is replaced or the coordinator + stops. Replacement during the fresh eligibility check now prevents consume + dispatch, including after Clear local scan cache. Cancellation cannot undo a + request already sent to the provider. + +- 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. + +- 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. +- 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. + Check the packaged executable's exact directory-entry spelling on both + case-sensitive and case-insensitive filesystems. Preserve symlink rejection. +- Remove unknown daily-history fields before saving or publishing renderer + state, while preserving larger historic totals and account separation. +- Keep readable sessions when a sibling disappears or cannot be opened. + Preserve Codex metadata/ledger fallbacks and propagate scan cancellation. +- Scope the reset-button setting to side-notch details and context menus; + retain flyout redemption with the same availability and confirmation checks. +- Restore 30-day rolling cost/token share bars and the existing adaptive + Claude, Antigravity, OpenCode Go and OpenRouter identity colours. +- Validate optional archive metadata and restore only known fields. Reject + reset redemption unless the credit is reported available and unexpired, + including after the confirmation dialog. +- Restore the primary Claude companion-file order and file modification time + fallback. Keep valid required quota when optional OpenRouter credits stall + or additional Codex model limits are malformed. +- Show today's usage counts and reading age, restore textual tray outage/reset + summaries, and adapt the existing Grok mark tint to light and dark appearance. +- Keep candidate service health visible when provider usage is hidden, with + buttons for the existing public status pages. Restore notch ambient ETA + from raw window pace and hide it when pacing is off. +- Invalidate live readings and reset confirmations when a candidate account + source changes. Reject late results and reset completion from the old source + while keeping dated slot archives and durable history. +- Measure shared cards at full height. Reject captures above 8,192 pixels with + an explicit error instead of exporting a cropped image. +- Hide candidate accounts whose configured directory disappears. Bind sharing + to the displayed card, hide forecasts when pacing is off, restore active-window + burn detail and bound tall notch cards to the display work area. + +- `Scripts/make-app.sh` now downloads and verifies the prebuilt app by default, + so installation from a Git checkout does not need Swift. Source builds require + `--build-from-source`. Failed downloads or verification never trigger compilation. + Copy and verify the replacement in staging so a failed copy or signature check + preserves the existing generated app. +- Make the prebuilt app download the default installation path. Clarify that + Swift and Xcode Command Line Tools are needed only to build from source, + link directly to the current Apple silicon app ZIP, and keep build commands + in the contributor guide instead of the README. ## [0.2.41] - 2026-10-05 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e66fa3b..3bbdd63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,27 +1,40 @@ # Contributing to meterusage -New MeterUsage features target Electron. The default branch currently retains -stable Swift source; use the [beta source tag](https://github.com/pekth/meterusage/tree/v0.3.0-beta.1) -and its [Electron contributor instructions](https://github.com/pekth/meterusage/blob/v0.3.0-beta.1/CONTRIBUTING.md#typescript-electron-candidate) -for that implementation. Swift receives critical maintenance and compatibility -fixes. The stable Swift guidance follows. +New MeterUsage features target Electron in `src/` and `tests/`. Retain Swift +source for critical maintenance, compatibility and rollback. Both implementations +remain in this checkout; use the matching validation commands below. - -> ### ⚡ TL;DR +> ### Quick start > 1. Fork & clone the repo. > 2. Enable pre-commit hook: `git config core.hooksPath .githooks` -> 3. Verify: `swift test` & `./Scripts/make-app.sh --build-from-source` +> 3. For Electron, use the declared Node/pnpm versions, then run +> `pnpm install --frozen-lockfile`, `pnpm typecheck`, `pnpm test` and `pnpm build`. > 4. Keep diffs focused, zero secrets or credentials, synthetic fixtures only. Fork the repository, make one focused change, run the checks below, and open a pull request. +## Edition routing + +New features target the [Electron implementation](docs/electron/README.md). +The [legacy Swift app](docs/swift/README.md) is frozen for new features and kept +for critical maintenance, transition compatibility and rollback. Both source +trees remain until the [retirement conditions](docs/adr/0024-electron-primary-swift-retirement.md#retirement-conditions) +pass. Validate both affected paths when a shared contract changes. +`Scripts/make-app.sh` prepares the published Swift app by default and never +builds Electron. Isolated demo/test profiles are required for candidate QA; +shared normal profiles do not support safe side-by-side use. + ## Before you start -meterusage is a macOS 13+ Swift menu-bar app. The package uses Swift tools -version 5.9 and has no third-party package dependencies. Install Xcode Command -Line Tools before building: +Electron development uses the Node and pnpm versions declared in `package.json`. +Follow [the Electron commands](#typescript-electron-candidate). Electron's macOS +packaging still needs Swift for its Keychain helper and asset exporter. + +The published app is a macOS 13+ Swift menu-bar app. A TypeScript/Electron +candidate is under development; see the candidate commands below. The package uses Swift tools +version 5.9 and has no third-party package dependencies. Swift source builds need Xcode Command Line Tools: ```sh xcode-select --install @@ -70,7 +83,8 @@ git fetch upstream git rebase upstream/main ``` -Run the test commands again after a rebase. +After a rebase, rerun checks affected by changed inputs and refresh independent +review. Reuse matching checks for unchanged artifacts and inputs. ## Commit messages @@ -82,13 +96,15 @@ Add OpenRouter balance display Do not mix unrelated fixes in the same commit. -## Build and test + +## Legacy Swift build and test -GitHub Actions runs `swift build` and `swift test` on macOS 15 for pushes to +GitHub Actions runs `swift build` and `swift test` on macOS 15, plus Electron +typecheck, synthetic tests and production bundling on Linux, for pushes to `main` and pull requests. Repository owners must enable Actions under Settings > Actions > General for these checks to run. -Run these checks before a pull request: +For Swift source changes, run these checks before a pull request: ```sh swift build @@ -123,19 +139,103 @@ not move, and include the captures in the pull request. See See [`docs/DEMO.md`](docs/DEMO.md) for the data and privacy rules. +## TypeScript Electron candidate + +The source manifest retains 0.2.41. The public Electron beta is 0.3.0-beta.6, +build 75, with the version explicitly set during packaging. See the +[beta guide](docs/BETA.md) for the signed artifact and incomplete native +acceptance, and the [Electron guide](docs/electron/README.md) for source choices +and parity gaps. + + +Use the Node and pnpm versions declared in `package.json`. Provider accounts +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 +pnpm test +pnpm build +meterusage_demo_root="$(node -p 'require("node:fs").realpathSync(require("node:os").tmpdir())')" +node dist/main/cli.cjs json --force --demo --candidate-profile "$meterusage_demo_root/meterusage-cli-demo" +``` + +The candidate profile must be an empty test directory on first use. It may be +reused for relaunch checks after the candidate creates its marker. All demo +preferences, caches, history and Electron state stay in that profile. +Use the canonical temporary root above. On macOS, `/tmp` and `/var` can be +symlink aliases; candidate mode rejects paths that follow a symlink. + +On macOS, build and launch a separate candidate without installing it: + +```sh +pnpm package:mac +./release/mac-arm64/MeterUsage.app/Contents/MacOS/meterusage --demo --candidate-profile /private/tmp/meterusage-native-demo +``` + +`package:mac` exports the existing SF Symbol stand-ins, builds the app and +packages an Apple silicon ZIP with publication disabled. It does not install +or replace an app. Locally packaged output is not the verified public beta +artifact. Keep the stable release and beta download routes separate. +Windows packaging, CI, native testing and release remain paused. + +electron-builder resolves `pnpm` from PATH when collecting production +dependencies. If an existing launcher incorrectly loads a standalone pnpm +executable as JavaScript, verify the executable directly, then put a `pnpm` +symlink to it in a separate candidate-local directory at the start of PATH. +Keep host launchers and installed dependencies unchanged. This PATH correction +still requires a new native packaging check. + +Changes live in `src/domain/` (shared calculations), `src/main/` (provider +readers, persistence, coordination and native shell), `src/shared/` (IPC DTOs), +`src/renderer/` (React UI) and `tests/` (synthetic fixtures). Provider paths, +credentials and raw responses must stay out of renderer imports. Settings may +show a reduced directory label chosen by the user. + +TypeScript tests and builds do not prove native parity. Before cutover, record +candidate identity/digest/signature, headless JSON, launch and relaunch, +settings/account isolation, offline failures, notification/login behavior, +sharing and updater paths. Capture two different-height provider cards on +macOS and confirm the top edge and strip stay fixed. Use only synthetic reset +credits. Keep the published app and its data untouched during candidate QA. +Retained Swift sources are the behavior oracle and rollback path. Run affected +Swift checks when those inputs change; reuse matching receipts otherwise. + +See [ADR 0010](docs/adr/0010-macos-typescript-electron.md) and +[the migration plan](docs/plans/macos-electron-migration-plan.md). + ## Where to make changes +New providers and data sources belong in Electron. Use the `src/` and `tests/` +map in [Electron development](#typescript-electron-candidate). + +### Legacy Swift maintenance + +Use these paths only for critical fixes, transition compatibility and rollback: + - `Sources/MeterUsage/Services/` contains provider readers, pricing, and service-status sources. - `Sources/MeterUsage/Core/` contains coordination and saved preferences. - `Sources/MeterUsage/Views/` contains the SwiftUI menu-bar interface. - `Tests/MeterUsageTests/` contains unit tests and synthetic fixtures. -When adding a provider or data source, use the existing `QuotaSource`, +When maintaining a legacy provider or data source, use the existing `QuotaSource`, `UsageSource`, `LocalActivitySource`, or `StatusSource` contract in `Sources/MeterUsage/Services/DataSource.swift`. Keep provider failures isolated so one unavailable provider does not hide the others. Add or update the model, -coordinator, settings, and tests only when the feature needs them. +coordinator, settings, and tests only when the maintenance change needs them. + +### Shared contribution rules New providers must have synthetic demo data and tests. Normal tests and screenshots must not require a live login. @@ -162,10 +262,11 @@ positive. ## Generated assets -Run `./Scripts/make-icon.sh` only when the icon design changes. The app bundle -is assembled from source by `./Scripts/make-app.sh --build-from-source`. -Output under `dist/` is ignored -and should not be force-added. +Run `./Scripts/make-icon.sh` only when the icon design changes. Build the legacy +Swift app bundle with `./Scripts/make-app.sh --build-from-source`. For Electron +packaging, follow [Electron development](#typescript-electron-candidate). +Generated output under `dist/` and `release/` is ignored and should not be +force-added. ## Pull requests @@ -173,7 +274,7 @@ A pull request should include: - the user-visible change and why it is needed; - the files or provider paths affected; -- the exact checks you ran, including `swift test`; +- the exact checks you ran, including the affected Swift or TypeScript checks; - any privacy, network, or credential-handling impact; and - demo-mode screenshots when a UI change needs visual review, plus captured side notch frames when the change touches panel layout or motion. diff --git a/README.md b/README.md index db60f98..2d82a67 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,13 @@ Swift app remains the stable download and rollback option during migration. | Edition | Current status | Setup and behavior | | --- | --- | --- | -| Electron 0.3.0-beta.3, build 61 | Public beta for Apple silicon, macOS 13+; signed and notarized | [Download beta](https://github.com/pekth/meterusage/releases/tag/v0.3.0-beta.3) · [Beta guide](docs/BETA.md) | +| Electron 0.3.0-beta.6, build 75 | Public beta for Apple silicon, macOS 13+; signed and notarized | [Download beta](https://github.com/pekth/meterusage/releases/tag/v0.3.0-beta.6) · [Beta guide](docs/BETA.md) | | Legacy Swift 0.2.41 | Frozen for new features; published stable download and migration rollback | [Swift guide](docs/swift/README.md) | Electron is the active app for new features. Swift remains the stable download -and rollback reference. The beta is installed manually; the stable updater does -not offer it. Quit the existing MeterUsage before replacing it, and keep a copy +and rollback reference. Install the beta manually once. From beta 5 it checks +GitHub for newer betas and offers to install them; the stable updater does +not offer betas. Quit the existing MeterUsage before replacing it, and keep a copy for rollback. Both apps share their identity and saved data, so do not run them together against a normal profile. See [beta installation and limits](docs/BETA.md) and the [version strategy](docs/VERSIONS.md). @@ -71,14 +72,14 @@ All nine provider adapters remain in the retained Swift and candidate source: Codex, Claude Code, Antigravity, OpenRouter, Grok, OpenCode Go, Cursor, Copilot CLI and Gemini CLI. Their inputs and available measurements differ. The [Swift provider matrix](docs/swift/README.md#provider-matrix) describes existing -CLI/local collection. The [Electron source choices](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/electron/README.md#source-choices) +CLI/local collection. The [Electron source choices](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/electron/README.md#source-choices) also describe optional Codex browser sign-in and Claude Desktop allowance. Local activity covers this Mac. Provider-reported account data has its own coverage limits. Missing tokens remain unknown. Local token costs are estimates; optional API billing cards label provider-reported amounts separately. Provider dashboards -remain the billing source of record. Read the [beta privacy contract](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/PRIVACY.md) -for Electron or the [Swift privacy contract](docs/PRIVACY.md) before changing a +remain the billing source of record. Read the [beta privacy contract](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/PRIVACY.md) +for Electron or the [retained Swift reporting privacy contract](docs/PRIVACY.md#legacy-swift-source-diagnostic-reports) before changing a data source or sharing screenshots. ## Can both editions coexist? @@ -87,20 +88,20 @@ The beta tag contains the Electron source and retained Swift tree. The default branch retains stable Swift source during migration; the intended end state is one Electron app. Normal-profile side-by-side installation is unsafe: the app name, bundle ID and default preferences/data locations are shared. -Use only the documented [isolated candidate QA profiles](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/electron/README.md#isolated-qa) +Use only the documented [isolated candidate QA profiles](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/electron/README.md#isolated-qa) for testing. A separate app identity and data root require a later decision; -there is no automatic cutover. See [ADR 0010](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/adr/0010-macos-typescript-electron.md). +there is no automatic cutover. See [ADR 0010](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/adr/0010-macos-typescript-electron.md). ## Documentation - [Beta download, verification and limits](docs/BETA.md) - [Current beta screenshots](docs/SCREENSHOTS.md) - [Swift guide](docs/swift/README.md) -- [Electron guide and known gaps](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/electron/README.md) +- [Electron guide and known gaps](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/electron/README.md) - [Versions and staged Swift retirement](docs/VERSIONS.md) - [Contributing](CONTRIBUTING.md) - [Project knowledge](docs/KB.md) and [decision index](docs/adr/README.md) -- [Desktop connections](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/DESKTOP-CONNECTIONS.md), [demo mode](docs/DEMO.md) +- [Desktop connections](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/DESKTOP-CONNECTIONS.md), [demo mode](docs/DEMO.md) and [side notch](docs/SIDE-NOTCH.md) - [Concept mockups](docs/mockups/README.md) and [changelog](CHANGELOG.md) @@ -113,11 +114,11 @@ the Swift guide; product screenshots now show the Electron beta. See [downloads](#download). -See the [Electron candidate guide](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/electron/README.md). +See the [Electron candidate guide](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/electron/README.md). See [Swift additional local accounts](docs/swift/README.md#second-accounts) and -[Electron advanced local accounts](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/electron/README.md#advanced-local-accounts). +[Electron advanced local accounts](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/electron/README.md#advanced-local-accounts). See [Swift requirements](docs/swift/README.md#requirements). @@ -127,7 +128,7 @@ See [contributor setup](CONTRIBUTING.md). See the [Swift Agent Budget CLI](docs/swift/README.md#agent-budget-cli) and -[Electron commands](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/electron/README.md#build-and-test). +[Electron commands](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/electron/README.md#build-and-test). @@ -150,7 +151,7 @@ See [installation details](docs/swift/README.md#download-and-installation). See [beta diagnostics and bug reports](docs/BETA.md#known-limits) and the -[beta reporting guide](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/REPORTING.md). +[beta reporting guide](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/REPORTING.md). See [downloads](#download). diff --git a/Scripts/electron/after-pack.cjs b/Scripts/electron/after-pack.cjs new file mode 100644 index 0000000..deeca49 --- /dev/null +++ b/Scripts/electron/after-pack.cjs @@ -0,0 +1,15 @@ +const { renameSync } = require("node:fs"); +const { join } = require("node:path"); +const { execFileSync } = require("node:child_process"); + +// executableName also changes electron-builder's bundle filename. Keep the +// product bundle name, then rename only its main executable before signing. +function renameExecutable(bundle, command = execFileSync) { + renameSync(join(bundle, "Contents/MacOS/MeterUsage"), join(bundle, "Contents/MacOS/meterusage")); + command("/usr/libexec/PlistBuddy", ["-c", "Set :CFBundleExecutable meterusage", join(bundle, "Contents/Info.plist")]); +} +module.exports = async context => { + if (context.electronPlatformName !== "darwin") throw new Error("Only macOS candidate packaging is enabled"); + renameExecutable(join(context.appOutDir, "MeterUsage.app")); +}; +module.exports.renameExecutable = renameExecutable; diff --git a/Scripts/electron/desktop-keychain.swift b/Scripts/electron/desktop-keychain.swift new file mode 100644 index 0000000..5d44852 --- /dev/null +++ b/Scripts/electron/desktop-keychain.swift @@ -0,0 +1,29 @@ +import Foundation +import LocalAuthentication +import Security + +// Only fixed provider items may be read. Background polling cannot open an auth dialog. +let modes = CommandLine.arguments.dropFirst().filter { $0 != "--allow-ui" } +guard modes.count <= 1, modes.allSatisfy({ ["--grok-bot", "--api-openai", "--api-anthropic"].contains($0) }), + CommandLine.arguments.dropFirst().filter({ $0 == "--allow-ui" }).count <= 1 else { exit(1) } +let context = LAContext() +context.interactionNotAllowed = !CommandLine.arguments.contains("--allow-ui") +SecKeychainSetUserInteractionAllowed(!context.interactionNotAllowed) +let grok = CommandLine.arguments.contains("--grok-bot") +let api = modes.first?.hasPrefix("--api-") == true +var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: api ? "com.meterusage.api-keys" : grok ? "Grok Bot Safe Storage" : "Claude Safe Storage", + kSecAttrAccount as String: api ? modes.first == "--api-openai" ? "openAI" : "anthropic" : grok ? "Grok Bot Key" : "Claude Key", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecUseAuthenticationContext as String: context +] +if api { query[kSecAttrSynchronizable as String] = false } +var result: CFTypeRef? +let status = SecItemCopyMatching(query as CFDictionary, &result) +if api && status == errSecItemNotFound { exit(0) } +guard status == 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/Scripts/electron/export-symbols.swift b/Scripts/electron/export-symbols.swift new file mode 100644 index 0000000..582b99d --- /dev/null +++ b/Scripts/electron/export-symbols.swift @@ -0,0 +1,23 @@ +import AppKit +import Foundation + +// Lossless rasterization of the same SF Symbol stand-ins used by ProviderMark. +// This is build-time asset tooling. The TypeScript app never invokes Swift. +let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) +try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) +for (provider, symbol) in [("openRouter", "arrow.triangle.branch"), ("cursor", "cursorarrow.rays"), ("copilot", "terminal"), ("gemini", "diamond")] { + guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil), + let configured = image.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 24, weight: .medium)), + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 32, pixelsHigh: 32, + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, + isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0), + let context = NSGraphicsContext(bitmapImageRep: rep) else { + fatalError("Existing provider symbol unavailable: \(provider)") + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + configured.draw(in: NSRect(x: 0, y: 0, width: 32, height: 32)) + NSGraphicsContext.restoreGraphicsState() + guard let png = rep.representation(using: .png, properties: [:]) else { fatalError("Cannot export symbol") } + try png.write(to: output.appendingPathComponent("\(provider)-symbol.png")) +} diff --git a/docs/API_USAGE_BETA.md b/docs/API_USAGE_BETA.md index d5409e2..49d0e55 100644 --- a/docs/API_USAGE_BETA.md +++ b/docs/API_USAGE_BETA.md @@ -1,6 +1,6 @@ # API billing snapshots -Electron 0.3.0-beta.3, build 61 includes separate OpenAI API and Anthropic API +Electron 0.3.0-beta.6, build 75 includes separate OpenAI API and Anthropic API billing cards. OpenRouter keeps its existing account balance, key limits and aggregate activity integration. diff --git a/docs/BETA.md b/docs/BETA.md index 32c4cc0..292626f 100644 --- a/docs/BETA.md +++ b/docs/BETA.md @@ -1,13 +1,13 @@ # Electron public beta -MeterUsage **0.3.0-beta.3, build 61** is an opt-in macOS beta. The ZIP supports +MeterUsage **0.3.0-beta.6, build 75** is an opt-in macOS beta. The ZIP supports Apple silicon and macOS 13 or later. It is Developer ID signed, notarized and stapled. Swift `0.2.41` remains the stable release and updater download. ## Install -1. Download [the beta ZIP](https://github.com/pekth/meterusage/releases/download/v0.3.0-beta.3/MeterUsage-0.3.0-beta.3-mac-arm64.zip) - and [SHA256SUMS](https://github.com/pekth/meterusage/releases/download/v0.3.0-beta.3/SHA256SUMS). +1. Download [the beta ZIP](https://github.com/pekth/meterusage/releases/download/v0.3.0-beta.6/MeterUsage-0.3.0-beta.6-mac-arm64.zip) + and [SHA256SUMS](https://github.com/pekth/meterusage/releases/download/v0.3.0-beta.6/SHA256SUMS). 2. Quit MeterUsage and keep a copy of your existing app and its saved data for rollback. The beta shares the app identity and data locations with Swift. 3. Unzip the download, move `MeterUsage.app` to Applications, and open it. @@ -15,8 +15,11 @@ stapled. Swift `0.2.41` remains the stable release and updater download. Selecting a method alone does not connect it. Use its connection button. No Node, pnpm, Swift or Xcode installation is needed to use this prebuilt beta. -Do not run the two editions together against a normal profile. Updating to later -betas is manual. `Scripts/make-app.sh` still prepares the stable Swift download. +Do not run the two editions together against a normal profile. Beta 5 and later check GitHub +hourly while **Check for updates** is on and offer newer betas or a newer +stable release in the app, with a notification. **Install update** downloads the ZIP, verifies +its GitHub SHA-256 digest, bundle identity and signature, then replaces the app +and relaunches. Beta 4 and earlier cannot see betas; install beta 5 manually once. `Scripts/make-app.sh` still prepares the stable Swift download. To check the ZIP in its download directory: @@ -27,7 +30,7 @@ shasum -a 256 -c SHA256SUMS Expected ZIP SHA-256: ```text -6bc074f8753330c7b8bcf975ba3696fa249890230737d8c7580e7b93c3321439 +c8d8c629fdd8a3332efdc2cfe5ae93aeff9e6ab5a6b8c7117450c025efa6775c ``` ## Provider coverage @@ -41,6 +44,14 @@ Expected ZIP SHA-256: read current allowance through a compatibility endpoint. Some accounts, including free accounts, may have no allowance token. This does not sign the user out of Claude Desktop. Desktop chat/token history is not collected. +- Every provider card with a quota reading shows a **Quota activity** heatmap: + the daily rise in that provider's weekly (or longest) allowance, counting + desktop, web and other-device use. Each provider keeps its own history. + On first run, the current window's usage is spread over its elapsed days and + marked estimated; earlier weeks are not backfilled. Days count only while + MeterUsage refreshes; offline usage lands on the next reading's day. Values + are percentage points, never inferred tokens. Tooltips add measured Claude + Code or Codex tokens from this Mac when present. - Claude Code CLI reads local transcript activity. Quota requires an optional companion file. Local activity does not cover other devices. - Antigravity Desktop reads account allowance from one running, signed-in @@ -61,8 +72,8 @@ Expected ZIP SHA-256: preserved and newly pasted keys are not saved. OpenRouter keeps its existing balance and aggregate activity source. Read [API setup and limits](API_USAGE_BETA.md). -See [source choices](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/electron/README.md#source-choices), -[desktop connections](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/DESKTOP-CONNECTIONS.md) and [privacy](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/PRIVACY.md). +See [source choices](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/electron/README.md#source-choices), +[desktop connections](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/DESKTOP-CONNECTIONS.md) and [privacy](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/PRIVACY.md). ## Known limits @@ -85,20 +96,26 @@ See [source choices](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs Use Settings > Diagnostics for sanitized diagnostics and **Send report**. The app limits report attempts to one every ten minutes, including relaunches. -Review the [report contents and privacy limits](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/REPORTING.md) before sending. +Review the [report contents and privacy limits](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/REPORTING.md) before sending. Never include tokens, passwords or raw account files in an issue. ## Build and screenshot identity The signed artifact was built from source -`c2d092cb92180b37a686582a904dbd0129bc371c`. Release documentation and new -screenshots are a documentation-only descendant; executable inputs are unchanged. -Source manifests retain `0.2.41`; beta packaging explicitly set `0.3.0-beta.3`. - -Matching validation recorded 739 tests across 28 files on macOS, TypeScript -checking, native helper compilation and production bundling. Exact-source CI -also passed Swift build and tests. Signing, notarization, stapling, Gatekeeper, -installed bundle identity and ZIP bytes were verified separately. These checks +`023b753cfa1c248774efecb28a1770ee8de0950b`. Release documentation is a documentation-only descendant; executable +inputs are unchanged. Screenshots are from build 61 and do not show the Quota +activity heatmap. Source manifests retain `0.2.41`; beta packaging explicitly +set `0.3.0-beta.6`. + +Matching validation recorded 849 tests across 33 files on macOS, TypeScript +checking and production bundling. Swift build and 394 tests also passed on macOS for this source. The installed build was observed recording quota activity for Claude, +Codex, Antigravity and Grok. Native rendering of filled heatmaps +remains unverified. Signing, notarization, stapling, Gatekeeper, +bundle identity and ZIP bytes were verified separately. Beta 6 changes only +packaging metadata to version 0.3.0-beta.6, build 75; its executable source +matches beta 5. Installed beta 5 recorded discovery of beta 6. Installation +and relaunch through its updater remain pending because native UI automation +was unavailable. These checks do not close the native acceptance gaps above. The [screenshot gallery](SCREENSHOTS.md) uses invented demo data and the actual diff --git a/docs/DESKTOP-CONNECTIONS.md b/docs/DESKTOP-CONNECTIONS.md new file mode 100644 index 0000000..a66035a --- /dev/null +++ b/docs/DESKTOP-CONNECTIONS.md @@ -0,0 +1,400 @@ +# Desktop account connections + +The macOS Electron candidate keeps account connection controls in Settings, +including first-run setup. 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 + +Before setup is complete, launch or app activation opens Settings. Choose a +connection method for each provider, or choose **Done** below the choices to +continue without signing in. Done saves the +existing setup-completion preference before opening usage. If saving fails, +Settings keeps the setup guidance and reports the error. Account connections +remain in Settings after setup; the main usage panel has no welcome card. +See [ADR 0014](adr/0014-settings-onboarding.md). + +The **Connect using** selector offers the methods supported by each provider. +Selecting a method does not start sign-in or change collection. Use the button +below it to apply the choice. Existing profiles keep their current source. + +- Antigravity: choose **Antigravity Desktop**, then **Connect Antigravity Desktop**. + Keep one desktop account open and signed in. The candidate reads account + allowance from its running local service without requiring `agy`. Separate + language servers are ambiguous, even if they belong to the same account; + close extra instances before connecting. **Antigravity CLI** remains an + explicit alternative for the existing local integration. Desktop allowance + includes no CLI-local history. Native and live-provider checks are pending. +- 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. + Alternatively, choose **Codex CLI** and **Use CLI** to use an existing CLI + sign-in for allowance and read local token activity. +- Claude: sign in to Claude Desktop on the Mac, choose **Connect Claude + Desktop**, and approve the new session-cookie explanation and any macOS + Keychain prompt. MeterUsage verifies the nested bootstrap account and unique + intended organization, then requests that organization's allowance using + the captured Desktop web session. Previous access-token approval requires + reconnecting. Conflicting session or organization cookies fail closed and + failed Connect preserves the selected CLI source. This compatibility + interface can change; native and live acceptance remain unverified. + Alternatively, choose **Claude Code CLI** and **Use CLI** to read local Claude + Code activity. CLI allowance requires a companion usage file; this option + does not provide Claude website token history. +- Grok: choose **Grok Bot Desktop (Beta)**. Sign in through Grok Bot Desktop + on this Mac first, then choose **Connect Grok Bot Desktop (Beta)** and approve + access and any macOS Keychain prompt. MeterUsage reads the active encrypted + access token and selected team, then requests allowance. It does not initiate + a separate xAI sign-in or renew access. After expiry, sign in through Bot and + reconnect. Existing **Grok CLI** collection remains an explicit alternative. + The usage card can show measured quota burn after at least one minute of + fresh readings. Units are percentage points per hour, not tokens or dollars. + A reset, reconnect or stale reading starts collection again. Estimated + run-out appears only from a positive measured rate before the reported reset. + X and universal shared-pool coverage remain unverified. + + +**Use CLI** first disconnects only the MeterUsage-owned account connection and +waits for any Codex logout cleanup to succeed. A failed cleanup keeps local +collection off and reports an error. The source choice survives relaunch. The +ordinary **Disconnect** action still keeps account collection off without +switching to local data. Demo and isolated live test profiles cannot select +local collection. [ADR 0023](adr/0023-onboarding-source-choice.md) records this +explicit transition. + +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. + +Claude Desktop **Connect** failures use fixed messages in the existing error +banner. They distinguish foreground Keychain access, account configuration, +the fixed session/organization cookies, cookie decryption, missing or conflicting +sessions, allowance request or response validation, post-request identity verification, +or saving MeterUsage's connection preferences. An identity recheck failure +can include a failed cookie read. Each allowance attempt reads Keychain once +and keeps the password only in that attempt's main-process memory. It still +rereads the local account and cookies after the request to reject changed +accounts, organizations or sessions. Background attempts never request Keychain +interaction. Messages contain no +raw exceptions, responses, tokens, account identifiers or paths. Cancellation +keeps its existing message. Background collection keeps its existing +unavailable and not-signed-in results. +The renderer error filter permits only these fixed diagnostics. Unknown +exceptions still produce the generic action error. Missing or conflicting sessions keep collection disabled. + +If Claude returns HTTP 403 with an explicit `cf-mitigated: challenge` header, +provider browser verification blocks automated allowance access. Connect shows: +"Claude requires browser verification for account allowance. Open Claude usage +to view it in your browser. Your current source is unchanged." Ordinary 403s +keep their existing error handling. MeterUsage does not bypass the challenge. + +After a failed connection, select the **Claude Desktop** method in normal +Settings and choose **Open Claude usage** to view allowance in your browser. +The action opens only `https://claude.ai/settings/usage`. It is a manual +fallback: it does not connect MeterUsage, import allowance or change your +current source. It never opens automatically and is unavailable in demo, +candidate and isolated test profiles. Browser verification does not establish +that a later MeterUsage connection will succeed. + +These diagnostics have source and synthetic-fixture proof only. They do not +identify the cause of a live connection failure or prove that Claude Desktop +connection works. Packaged banner display, Keychain access, Desktop compatibility +and live allowance still require native acceptance. + +## Muse Code CLI + +Unreleased Electron source adds a separate **Muse Code** row in Settings. +Sign in with the [official CLI](https://dev.meta.ai/products/muse-code), then +choose **Use Muse Code CLI**. Collection starts disabled and the choice survives +relaunch. Headless JSON collection also honors this choice. **Stop Muse Code** +cancels active reads and clears the in-memory allowance without signing out the CLI. +Demo mode provides invented allowance and blocks the setup action. Isolated +Codex and Claude test profiles exclude this provider. + +MeterUsage starts `muse serve`, initializes the official experimental MSP +surface and calls [`usage/read`](https://meta-models.github.io/muse-code-sdk/next/generated/msp/methods/usage-read/). +It never starts a model turn or opens CLI credentials. The method returns the +host's last observation, which can be absent. Missing data is unavailable, +never zero. An unsupported CLI must be upgraded separately. Cards show the +original observation time, session and weekly percentages, and reported resets. +They provide no local token history or cost estimate. +The JSON quota report includes `observed_at` for Muse Code, separate from the +report's `generated_at`. Muse allowance stays in memory and is excluded from the +shared quota archive, so older versions can still load other providers on rollback. + +Muse.ai's cloud agent and Desktop app use a separate subscription and session. +The Muse Code connection does not provide Muse.ai allowance. Native Muse.ai +session import remains pending verification of its store and encryption schema. +Source tests do not prove native or live-provider behavior. + +## 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. Claude 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. 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. + +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 Codex cards show **Use reset** beside available, unexpired credits in +the menu. The existing native confirmation names the account and credit and +defaults to Cancel. After confirmation, the candidate rereads allowance and +rejects a missing, spent or expired credit or a changed/disconnected profile. +The official helper consumes once under the same keyring-only profile, then +quota refreshes. Cancelling makes no consume call. A disconnect aborts pending +work; a request already sent to the provider cannot be undone. See +[ADR 0017](adr/0017-connected-codex-menu-reset.md). + +Claude access requires explicit session-cookie consent; a call without a consent +callback denies access. The fixed native Keychain helper allows prompts only +during Connect. The reader selects the configured account UUID and the fixed +`sessionKey` and `lastActiveOrg` cookies. Schema 24, encrypted v10, host binding, +root Secure flags, HttpOnly session and exact expiry checks are required. +Identical cookie values count once; conflicting values fail closed. +Fixed read-only SQLite SELECTs run under a sandbox that denies writes to the +canonical Desktop store root, including WAL and shared-memory files. Reads are +bounded and no database is copied. A sandbox or shared-memory failure stays +unavailable. No OAuth token cache or fallback is read. + +The captured session verifies the nested account and unique intended membership +at GET `https://claude.ai/api/bootstrap`, then reads only the UUID-bound +organization usage route. Bootstrap and local account/org/session stability +are checked after usage, including failed requests. Requests reject redirects, +disable caching and enforce a 30-second timeout, cancellation and a 1 MiB +response limit. No GET `/api/account`, chats, refresh, inference or Desktop +writes are involved. Server usage, not membership, establishes allowance. + +Only a `web-v1:` opaque account/organization hash persists in preferences. +An old untagged identity requires a new Connect approval and cannot enable +private reads or restore account quota. Empty selection still means CLI; +`off` still means disconnected. Failed Connect restores the previous source +and visibility. Account quota stays detached from CLI activity and plan data. +Connection changes and app startup clear old account quota. Revocation, +observed identity/session changes and cancellation reject late results and +prevent another private read. Native compatibility and live acceptance remain +unverified. + +Demo mode blocks live account connections, Keychain access and helper login. +Synthetic tests cover protocol, consent, cookie 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). + +## Codex account daily tokens + +A connected Codex account also reads `account/usage/read` through the same +managed ChatGPT helper. The request omits `threadId`. The helper must support +this method and pass the existing effective keyring-storage guard. A failed or +unsupported read shows "Account history unavailable" and leaves quota separate. + +The main, notch and shared card label this grid "Account token activity" with +"Provider-reported account scope". It shows 26 weeks ending at the latest +reported date. Hover or focus a day for its exact token count. Dashed days are +unavailable, including gaps before or between reported dates. A reported zero +is a measured value. The existing Daily heatmaps and Codex heatmap settings +control this grid. Provider dates remain calendar labels; no timezone is assumed. + +Null or absent daily buckets cannot be reconstructed from summary statistics. +Invalid, duplicate or unsafe buckets fail closed. No token-category breakdown, +session count or cost is invented. History stays in memory and is cleared on +failed reads, source replacement or disconnect. It never merges with local +profile history or local totals. Collection uses the existing deadline and +bounded backoff, with no new retry loop. + +The public contract does not establish complete Codex-only, cross-device or +cloud coverage, day timezone, retention or service polling limits. Live account +availability and coverage require separate proof. Additional local profiles +retain their existing readers. See [ADR 0022](adr/0022-codex-account-token-history.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. 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 +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 Codex account allowance, provider-reported daily tokens and the public OpenAI service-status +feed, and permits explicitly confirmed Codex reset redemption. Status requests +use no account credentials. It has no local activity or other-provider collection. +See [ADR 0015](adr/0015-codex-test-public-status.md) and +[ADR 0017](adr/0017-connected-codex-menu-reset.md). 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. + +After a failed refresh, the Service status panel, notch card and tray tooltip +keep the last successful reading marked as last known with its age and the +refresh failure. Public-feed errors do not use account or quota error text. +Copied diagnostics also marks retained status as stale, with the time since its +last successful check and a fixed service-status error. A successful retry clears +that stale marker. + +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). + +## Isolated live Claude validation + +Use `--claude-test-profile` with a separate absolute test directory. It reuses +the existing Settings consent dialog, fixed Keychain helper, read-only Desktop +reader and allowance request. The directory must be empty on first use. Its +marker differs from Codex and demo markers; those profiles cannot be reused. +The same protected-path, symlink and Electron singleton checks apply. +Do not combine the flag with another test profile, `--demo` or +`--candidate-profile`. + +The app starts disconnected with Claude collection visible. Cancel the access +dialog to leave consent off. After approval, only Claude account allowance is +collected. JSON preferences, archives and Electron state stay in the test +directory; OS home remains available only to the existing Desktop reader. +Codex sign-in, polling and cleanup are blocked. Other providers, local activity, +status feeds and reset redemption are excluded. Updates, login-item changes +and notifications remain blocked by candidate mode. + +Normal quit retains an approved account fingerprint for relaunch. Background +reads cannot prompt. Disconnect saves consent off, clears allowance and leaves +Claude Desktop signed in. An unavailable reading must not fall back to local +CLI or companion files. + +Synthetic tests prove isolation and consent/connection/relaunch/disconnect +against invented Desktop data. Native acceptance still requires a separately +packaged candidate: cancel consent, approve access, compare fresh allowance +with the same Desktop account, relaunch without a prompt, then disconnect and +confirm allowance is removed. Test denied access and account/organization +switches only with the account owner's approval. Never run the Keychain helper +directly or capture its output. See +[ADR 0016](adr/0016-claude-live-test-profile.md). + +## Antigravity Desktop source boundary + +The source adapter discovers one same-user Antigravity desktop language server +and its PID-owned IPv4 loopback listeners. It reads only the candidate process +arguments for its current CSRF token. Fixed `GetUserStatus` and +`RetrieveUserQuotaSummary` requests use HTTPS first; HTTP is limited to the +declared, owned extension-server port. TLS certificate exemption is local to +that verified loopback request. Redirects are rejected. Each reply is limited +to 1 MiB, each request to three seconds, and collection to fifteen seconds. + +Account identity must match before and after allowance collection. Only an +opaque fingerprint enters preferences. CSRF credentials, account email and raw +replies stay in main-process memory, outside settings, diagnostics and logs. +The integration neither writes provider stores nor refreshes credentials. +Changed identity, provider closure, expired buckets, missing fractions and +empty summaries leave allowance unavailable and clear prior account quota. +An empty modern summary never falls back to legacy data. Legacy model quota +is read only when the modern RPC explicitly reports unsupported; it cannot +establish weekly allowance. Disconnect and cancellation reject late results. + +This is source and synthetic-fixture coverage. It is absent from the published +build 54 beta. Native discovery, local transport and live allowance comparison +remain unverified. See [ADR 0026](adr/0026-antigravity-desktop-local-quota.md). + +## Grok Bot Desktop Beta + +New connections use the existing Grok Bot Desktop sign-in after explicit +consent. No CLI, copied credential or separate MeterUsage OAuth flow is needed. +The fixed candidate store is `Library/Application Support/Grok Bot/sand-secrets.json` +under the user's home. MeterUsage selects only the active account's encrypted +access token and optional selected team. Scoped and canonical encrypted legacy +v10 values require the same full JWT-subject hash binding to the active account. +It rejects plaintext, unsupported encryption/versions, duplicate keys, expired +tokens and ambiguous scope. +Refresh tokens and other accounts' credential values are skipped. + +The bundled helper reads only `Grok Bot Safe Storage` / `Grok Bot Key` in macOS +Keychain. Explicit connection can show a Keychain prompt; polling forbids UI. +The reader opens no symlinks, bounds the JSON read, and checks store metadata, +account and team before and after the request. These checks detect observed +changes; they do not lock Bot or exclude other writers. It never modifies Bot +stores, refreshes credentials or copies them to disk. + +The fixed read is Connect JSON POST `{}` to +`https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus`, using +MeterUsage's own User-Agent, a bearer access token and an optional selected-team +header. Redirects are refused, each request has a 30-second timeout and a 1 MiB +response cap. Only numeric quota percentage and a reported reset are displayed. +Pooled enterprise allowance, missing quota and contradictory limit flags stay +unavailable. Trials require a future expiry; trial expiry is not a reset. +No cadence, X coverage or separate Cursor allowance is inferred from this pool. + +Only an opaque account/team fingerprint and internal source choice persist. +Cancellation and disconnect prevent private rereads and reject late results. +Connected quota has no local CLI history attached. Ordinary transport failures +can retain dated allowance only after the account and store are reverified. +Source changes clear account-bound quota. Disconnect changes only MeterUsage. + +This private compatibility contract was traced statically from Grok Bot 0.71.2 +and Electron 42.1.0. Native metadata confirms the encrypted legacy store format +and fixed Keychain label. Successful Keychain access and server acceptance of +JSON with MeterUsage's own headers remain **UNVERIFIED**. No app machine identity +or client impersonation is used. See [ADR 0028](adr/0028-grok-bot-desktop-reader.md) +and its [correction](adr/0030-grok-bot-encrypted-compatibility.md). + +### Saved Cursor connections + +Previously saved Cursor connections retain their original reader until +explicitly disconnected or replaced. A missing internal source marker means +legacy Cursor; all new connections select Bot Desktop. The legacy reader still +requires closed/checkpointed Cursor, immutable SQLite, identity checks and the +fixed Cursor dashboard endpoints. It is never selected as a failed Bot fallback. +See [ADR 0027](adr/0027-grok-cursor-closed-reader.md). + +## OpenCode Go local setup + +Choose **Use local OpenCode** in Connections to collect local Go sessions. +No sign-in or API key is needed for that history. Allowance separately uses an +existing Go key saved by OpenCode. Only `opencode-go` sessions count. The SQL +fallback also discovers executable Bun installs at `~/.bun/bin/opencode`. + +All provider heatmaps share a 26-column, seven-row layout. A 30-day feed keeps +older days unavailable rather than showing invented zero-token readings. + +## API billing + +OpenRouter uses its existing key for balance and aggregate activity. Separate +OpenAI API and Anthropic API billing cards read sanitized snapshots from the +bundled one-run helper. Subscription connections do not authorize API billing. +See [API billing setup](API_USAGE_BETA.md) for Admin-key scope and limitations. diff --git a/docs/DESKTOP-LOGIN-RESEARCH.md b/docs/DESKTOP-LOGIN-RESEARCH.md new file mode 100644 index 0000000..2b5ac0f --- /dev/null +++ b/docs/DESKTOP-LOGIN-RESEARCH.md @@ -0,0 +1,84 @@ +# Desktop login and shared allowance research + +Checked against official documentation on 2026-10-15. This is research, not a +claim that these connections are implemented or live-tested in MeterUsage. + +| Provider | Documented account route | MeterUsage consequence | +| --- | --- | --- | +| Grok / The X Subscription | Eligible Cursor, Grok and X accounts can be unified. Each paid seat has a weekly pool across eligible first-party Grok and Cursor surfaces. | Label the current Grok integration **Beta**. A shared-pool reader needs verified account, workspace and seat scope. | +| Antigravity | Desktop, CLI and IDE extensions use unified local authentication. The CLI can reuse a valid OS-keyring token profile without browser sign-in. | Prefer a provider-owned local helper over implementing Google OAuth or extracting tokens. Current MeterUsage quota collection still uses its existing container path. | +| OpenCode Go | Console subscription supplies an API key; OpenCode connects it as the `opencode-go` provider. | Reuse an already configured Go key from the existing OpenCode auth store. No separate Go desktop OAuth flow was documented in the reviewed sources. | + +## Grok and Cursor + +The official name is **The X Subscription**. The overview was updated on +October 12, 2026. Grok and Cursor share eligible first-party weekly usage for +one paid seat. Team seats do not share weekly pools; the team shares prepaid +Additional Usage credit. Third-party model usage is billed separately. Account +unification is a permanent provider action, so MeterUsage must not perform it. + +Cursor's usage guide says its editor settings display the same remaining usage +as the Grok dashboard. That establishes a desktop display, not a public +third-party quota interface. The reviewed Cursor API overview lists usage and +analytics APIs for Enterprise teams using API keys. It does not establish an +individual subscription quota API authenticated by the desktop session. + +Recommendation: investigate an explicit read-only Cursor account connection, +with verified weekly reset, workspace/seat scope and account-change handling. +Represent a confirmed shared pool once, with Grok and Cursor attribution, so +it is not added twice to totals. Keep legacy Cursor and Grok CLI readings +separate. Do not advertise this connection until its data contract is verified. + +Sources: + +- [Subscription overview](https://docs.x.ai/x-subscription/overview) +- [Usage limits and per-seat pools](https://docs.x.ai/x-subscription/usage-limits) +- [Account unification](https://docs.x.ai/x-subscription/unify-accounts) +- [Cursor usage and editor display](https://cursor.com/help/models-and-usage/usage-limits) +- [Cursor API availability and authentication](https://cursor.com/docs/api) + +## Antigravity + +The official Authenticate guide says signing in on one local surface shares +credentials across the local environment. The CLI attempts silent sign-in +through the native secure keyring. The documented `/usage` command refreshes +model configuration and quotas from the backend and opens a TUI panel. Plans +also document baseline quota visibility in desktop Settings. These establish +shared sign-in and a quota surface, not a stable desktop quota API or a +machine-readable output contract. + +MeterUsage currently launches the existing authenticated container for quota. +A desktop sign-in does not prove that container shares the host keyring. A +native provider-owned helper or documented read-only interface needs separate +discovery, compatibility checks and synthetic/live acceptance before replacing +that path. Do not log out, refresh credentials, inspect secret values or issue +model prompts merely to discover quota. + +Sources: + +- [Quickstart and desktop installation](https://antigravity.google/docs/getting-started/) +- [Authentication and local keyring reuse](https://antigravity.google/docs/install/) +- [IDE unified authentication](https://antigravity.google/docs/ide/extensions/) +- [Model quota command](https://antigravity.google/docs/cli/commands/usage/) +- [Plans and desktop quota display](https://antigravity.google/docs/plans/) + +## OpenCode Go + +The Go guide and provider setup describe Console sign-in, subscription, and +an API key. OpenCode's provider documentation locates saved credentials at +`~/.local/share/opencode/auth.json`. MeterUsage already selects only the +`opencode-go` key there for quota requests. Its local token history is a +separate read-only database source, not a substitute for cloud allowance. + +Recommendation: offer setup guidance to connect Go in OpenCode, then detect +the existing configured Go source. A desktop app installation alone is not +a connected Go subscription. The reviewed Go guide restricts Go workspaces +to one member. It does not document consumer OAuth access for MeterUsage. +The current Go documentation also differs from older endpoint and plan +descriptions; validate those separately before changing provider requests. + +Sources: + +- [OpenCode Go](https://opencode.ai/v2/docs/console/go/) +- [Provider credentials and Go setup](https://opencode.ai/docs/providers/#opencode-go) +- [OpenCode desktop availability](https://opencode.ai/) diff --git a/docs/KB.md b/docs/KB.md index 2b76ea2..10eb822 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -1,37 +1,220 @@ # Project knowledge -## Public beta +Release documentation refresh: 2026-10-22 for build 75. -Electron **0.3.0-beta.3, build 61** is an opt-in Apple silicon prerelease. -[Installation and limits](BETA.md) and [current screenshots](SCREENSHOTS.md) -describe it. Swift 0.2.41 remains stable. The default branch retains Swift -source; Electron source and its full documentation are published at -[`v0.3.0-beta.3`](https://github.com/pekth/meterusage/tree/v0.3.0-beta.3). -The source PR stack is unmerged. The beta is a manual download, not an updater -cutover. New feature development targets Electron. Native acceptance is incomplete -and disclosed in the beta guide. +## Edition documentation -Build 61 adds separate API connections, explicit legacy saved-key recovery, -hidden tray preloading, Antigravity Desktop allowance, Grok Bot Desktop Beta -and key-free OpenCode Go local setup. The beta guide records provider and -billing acceptance limits. - - -Last verified: 2026-09-29 +- [ADR 0024](adr/0024-electron-primary-swift-retirement.md) makes Electron the + sole active feature-development target. Freeze the legacy Swift app for new + features; retain critical maintenance, compatibility and rollback until + native acceptance, deliberate cutover and retrievable archive checks pass. + Source retirement is pending. Electron-required Swift helpers and assets stay. +- [Swift 0.2.41](swift/README.md) is the published default install route. + [Electron 0.3.0-beta.6, build 75](BETA.md) is an opt-in public prerelease, + signed and notarized for Apple silicon. Native acceptance remains incomplete. + Source manifests retain 0.2.41; packaging sets the beta version and Settings + shows the running bundle version. Stable updaters do not offer the beta. +- Both implementations remain in their current source trees. Shared app name, + bundle ID and default data/preferences prevent safe normal-profile coexistence. + Use only documented isolated QA profiles. +- [Versions](VERSIONS.md) records the accepted one-app development direction + and staged retirement. It makes no layout, version, CI, identity or cutover + change. ADR 0010's acceptance and compatibility requirements remain in force. +- Swift and stable Electron updaters use GitHub's latest-release endpoint. + Electron beta builds (version `-beta.N`) read the release list and accept + `-mac-arm64` assets, so betas update in-app from beta 5; see + [update channels](VERSIONS.md#update-channels). +- Installed beta 5 recorded discovery of beta 6 on 2026-10-22. Automated + installation and relaunch were not completed; native UI control was unavailable. +- Swift-to-Electron native upgrade and rollback remain blocked pending a + separate macOS test user or VM. Swift 0.2.41 requires the unsuffixed + `MeterUsage-.zip` release asset and a GitHub SHA-256 digest. Its + install script re-signs the replacement ad hoc, so the installed signature + and native helper behavior need explicit acceptance. Both editions share + the preferences domain, durable history and quota archive without a bulk + migration. Neither updater retains a persistent rollback copy. See the + [stable cutover checklist](VERSIONS.md#stable-cutover-checklist). +- Candidate gaps include Settings account overview, seven-day sparkline, reset + timing display, update feedback and tray refresh. Smaller display omissions + remain distinct. Current source adds AG Desktop/OpenCode setup, restores Bun + discovery and matches activity grid geometry. See the candidate guide. +- Edition documentation was reconciled against revision + build source `6ce695324e54bb09bf6e5ab36fd4733875c94427`. +- Current product screenshots use build 61's renderer and synthetic demo data. + Old product captures are removed; see [capture provenance](SCREENSHOTS.md). ## Repository state - Default branch: `main`. -- Reviewed source revision: `1c4f169`. +- Swift baseline revision: `4b913db3a3a8eb2602296489872af2ecaa5d0530`. The Electron candidate is the accompanying migration change; native acceptance remains open. - This index is public-safe repository documentation. It does not prove current local provider state, runtime behavior, release availability, or external service state. ## Product and source facts +- Claude Connect recognizes HTTP 403 responses with an explicit + `cf-mitigated: challenge` header as provider browser verification blocking + automation. It reports a fixed message and preserves the current source. + The failed Desktop method offers a manual usage-page action in normal + Settings. Opening that page does not connect MeterUsage or report quota. + Synthetic checks do not establish native or live-provider acceptance. + +- Electron Claude plan labels recognize known organization, seat and user tiers + in that order. An unknown higher-priority value permits a known fallback; + unknown-only metadata stays unavailable instead of exposing internal labels. + Claude Desktop now uses consent-tagged web-session cookies instead of OAuth + cache selection. Nested bootstrap identity and unique intended membership + gate UUID-bound usage. Identical cookies deduplicate; conflicting sessions + fail closed. Failed Connect preserves CLI selection. Synthetic checks do + not establish native compatibility or live allowance. + +- Unreleased Electron source adds opt-in Muse Code allowance with `muse serve` + and the official experimental MSP `usage/read` method. The source starts + disabled, reads no credential files and sends no model turn. It preserves + observation age and percentages, including provider values above 100, while + rejecting absent, expired or malformed observations. Settings, demo cards, + menu-bar/notch provider ordering and JSON quota reporting use the separate + `muse` provider. Saved opt-in also gates headless collection; stopping cancels + active reads and invalidates allowance. JSON includes `observed_at`, and + allowance stays in memory, outside the shared archive, to preserve rollback + compatibility. Muse.ai native session import remains unimplemented; no + verified native store path or schema is available. Native/live validation + remains separate. See [ADR 0033](adr/0033-muse-code-subscription-allowance.md). + +- Unreleased Antigravity Desktop setup reuses the existing connection controls, + preferences, IPC and account-bound source. It requires one running same-user + desktop language server with a CSRF token and PID-owned IPv4 loopback port. + Fixed quota/status RPCs use request-scoped TLS handling, redirect rejection, + 1 MiB response limits and bounded time. Account fingerprints are opaque; + credentials stay in main-process memory. Empty modern quota and missing + fractions remain unavailable. Local CLI usage stays detached. Synthetic + fixtures cover account switches, closure, transport failures and connection + races. Native/live-provider proof and release inclusion remain pending. + See [ADR 0026](adr/0026-antigravity-desktop-local-quota.md). + +- Heatmap hover/focus readouts use compact token and session totals, including + local, account, session-only, daily, weekly and cumulative grids. Exact grouped + counts remain in tooltips and accessible labels. Summary counts compact from + 1,000. This follow-up is separate from the published build 54 artifact. + +- Onboarding and Settings offer provider-specific source choices: Codex browser + sign-in or CLI, Claude Desktop or Claude Code CLI, Antigravity Desktop or + Antigravity CLI, and Grok CLI or opt-in Grok Bot Desktop Beta. Choosing + an option does not change collection until its action is used. Use CLI waits + for MeterUsage-owned connection cleanup before selecting existing local + collection. Ordinary Disconnect still prevents automatic local fallback. + CLI data covers this Mac; Claude CLI allowance needs a companion usage file. + Done follows the source choices and remains optional. Demo and isolated live + test profiles cannot switch to local collection. See + [ADR 0023](adr/0023-onboarding-source-choice.md). + +- Candidate Settings uses category navigation for Connections, API connections, Appearance, + General, Providers, Side notch, Usage details and Diagnostics. Connection + summaries show the current source, collection state and reading availability; + Configure reveals the existing setup actions. Category changes preserve pending + source selection, and choosing a method still requires its explicit action. + Existing preferences and additional-account controls remain. Demo account + restrictions appear before disabled connection actions. Provider column + headings replace repeated visual labels on wide windows; at 450 pixels or + narrower, each provider has two labelled control columns below its name. + Input accessible names and full label hit areas remain. Settings action + errors retain their alert role, message and dismissal in a sticky banner. + Advanced account rows wrap without changing their save/remove behavior. +- Settings displays the running Electron bundle version supplied by the main + process. Source previews without bundle metadata omit the version instead + of showing the legacy Swift version. +- Candidate Usage keeps Service status first and visible, before provider usage. + Provider/account tabs show one usage card at a time in saved provider order. + Arrow keys and Home/End switch tabs; removing a selected slot falls back to + the first available slot. Local summary metrics and measured burn remain in + the All-provider activity breakdown disclosure. Summary metrics use three + equal columns, with two columns at 360 pixels or narrower. Recorded + activity labels distinguish telemetry counters from account/local totals + without changing values or adding a time scope. Archived quota retains its + timestamp and failure reason in a warning well. Long labels and metadata + wrap; percentages and reset actions stay together. This is resilience + polish, not a reproduced overflow fix. +- Live menu and side-notch cards show the first two available recorded metrics, + with the remainder under More activity. Usage by period and active-window burn + also use closed disclosures. Quota, eligible reset actions and heatmaps stay + visible. Spacing and sparkline height are reduced without changing font-size + choices or source values. Static exports retain all recorded metrics and full + period/burn data without those disclosures. +- Static shared cards omit footer actions, reset buttons and the heatmap + selector. Credit details and the 26 weeks label remain. Export heatmaps omit + the unused hover readout space. Live readouts also use only their text height. Full-height capture, + frozen account selection and the 250-pixel export width remain unchanged. + Dark secondary text uses `#b0b0b8`; light text and semantic colors retain their + existing rules. Usage, Settings and notch detail buttons use 120 ms background + feedback only for enabled fine-pointer hover with reduced motion off. Press + and keyboard focus remain immediate. Renderer fixture and build proof does + not establish rendered layout, sticky alerts, motion or native export proof. + +- Electron heatmaps show compact counts and readable dates for daily, + weekly or cumulative scope on hover and keyboard focus. Tooltips and accessible + labels retain exact grouped counts. Each live heatmap has one Tab entry; arrow keys move between days and + Home/End reach the first/last available cell. Tab leaves the heatmap. Shared + images have no focusable cells. Account, local-token and session-only + providers share the same heading, caption, cells and intensity legend. + Hover and keyboard focus replace the legend with the compact dated readout + in a natural-height row. Long readouts can grow the card; the top and rail stay anchored. Unreported account days have subtle solid + outlines; reported zeroes have filled neutral cells. Each source retains its + own time range and units. Count-only providers report token totals unavailable. + Reset buttons use the compact control size. An open notch + interaction checks the native cursor to recover a missed pointer exit, then + closes details through the existing delay and temporary action guards. + The strip always shows usage rings and percentages, with no design selector + or dots option. Disable/hide clears expanded state before re-enabling. + Legacy pin data has no effect and pin controls are removed. + Notch sizes use integer pixel text and spacing instead of CSS zoom. + [ADR 0019](adr/0019-single-design-notch.md) records this candidate correction. + Source fixtures do not prove native pointer events, keyboard access in the + non-activating panel or rendered anchors. [Side notch](SIDE-NOTCH.md) records + the behavior and native checks. + +- Connected Codex menu cards expose **Use reset** for available, unexpired + credits. The existing validated IPC and native confirmation use the captured + connection profile. Confirmation rereads allowance before one helper consume; + disconnect, stopped connections and profile changes reject the action. + Coordinator source replacement, including Clear local scan cache, and stop + abort active resets through the existing consumer signal. Cancellation during + fresh eligibility prevents consume dispatch; a request already sent cannot be + undone. The late-result guard still prevents an obsolete reset from changing + pacing or refreshing its replacement. These cancellation checks use synthetic + fixtures, with native and live account proof pending. + Success collects fresh quota under the reset-pacing rules. This also applies + in `--codex-test-profile`; other collection and system-effect restrictions + remain. [ADR 0017](adr/0017-connected-codex-menu-reset.md) narrowly supersedes + ADR 0012's reset prohibition. Synthetic proof is separate from native display, + native confirmation and user-owned live redemption. + +- Candidate onboarding lives in Settings and reuses its account connections. + Incomplete setup opens Settings on launch or app activation. Done persists + `onboardingCompleted` before returning to usage; a failed write leaves setup + open. Sign-in is optional. The main panel has no setup card. Existing completed + profiles retain their preference. [ADR 0014](adr/0014-settings-onboarding.md) + records this placement; native acceptance is separate from source validation. + +- 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, bounded sandboxed read-only schema-24 cookie selection, nested bootstrap verification and UUID-bound web usage after new session-cookie consent. Old untagged identities require reconnecting and authorize no private reads. No OAuth cache fallback remains. New Grok Bot Desktop connections select the active account-bound encrypted access token and optional team through a fixed Keychain item and bounded Connect allowance request. Saved Cursor connections retain their immutable SQLite reader and fixed identity-before-allowance requests. Both use opaque account binding. Nonempty WAL/journal files, ambiguous evidence and observed races fail closed. These are source and synthetic-fixture facts, not native or live-provider proof. [Desktop connections](DESKTOP-CONNECTIONS.md), ADR 0011, [ADR 0027](adr/0027-grok-cursor-closed-reader.md) and [ADR 0028](adr/0028-grok-bot-desktop-reader.md) 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. + +- Each Claude Desktop allowance attempt obtains the Keychain password once + and reuses it only in attempt-local memory. Local account and session stores + are still read before and after requests. This avoids a second Keychain + authorization during one Connect without weakening identity checks. + Synthetic regression coverage does not prove live connection acceptance. + +- Antigravity Desktop quota uses compact model headings and limit labels. + It preserves provider-reported bucket values and resets; duration metadata + is added only for explicit Weekly or 5-hour labels. Missing allowance under + a saved account remains unavailable. This presentation does not establish + that Desktop and the CLI use the same account. The Cursor process guard + excludes only Apple's `CursorUIViewService` under `/System/Library/`, while + retaining active-editor and database guards. + - 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 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. +- 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. -- Provider data sources are implemented under `Sources/MeterUsage/Services/`. `docs/PRIVACY.md` describes the boundary for local files, provider CLIs, documented network endpoints, and data reduction before display. -- Antigravity quota and usage share a bounded Docker/Podman runtime resolver. It prefers a healthy runtime and can start only an already-existing `podman-machine-default` after an inspect check; it never creates a machine or pulls an image. Concurrent recovery calls are serialized; failed starts have a 60-second cooldown. Internet or authentication failures are not repaired by restarting a healthy runtime. +- Swift provider data sources are implemented under `Sources/MeterUsage/Services/`; the candidate adapters live under `src/main/providers/` and `src/main/composition.ts`. `docs/PRIVACY.md` describes the boundary for local files, provider CLIs, documented network endpoints, and data reduction before display. +- Antigravity CLI quota and usage share a bounded Docker/Podman runtime resolver. It prefers a healthy runtime and can start only an already-existing `podman-machine-default` after an inspect check; it never creates a machine or pulls an image. Concurrent recovery calls are serialized; failed starts have a 60-second cooldown. Internet or authentication failures are not repaired by restarting a healthy runtime. - Demo mode uses synthetic data. `README.md` and `docs/DEMO.md` describe it as the path for screenshots and local UI inspection without provider accounts. - `Resources/Info.plist` and `CHANGELOG.md` record version 0.2.41, build 46, dated 2026-10-05. This release collects fresh quota observations after an accepted manual Codex reset, derives pacing and ETA from later usage growth, and removes the redeemed reset credit from archived quota data. The previous release was 0.2.40, which added accent colour themes, Codex cost estimates, and refreshed model prices. Repository version and release-note entries do not prove that a GitHub Release or its downloadable asset has been published. - `CONTRIBUTING.md` requires focused changes, synthetic fixtures, and `swift build`, `swift test`, and `Scripts/make-app.sh --build-from-source` before a code pull request. `Tests/Scripts/make-app-tests.sh` covers default preparation, explicit source selection, and download, verification, argument, and platform failures without network or compiler access. @@ -40,21 +223,100 @@ Last verified: 2026-09-29 - Burn attribution renders only measured burn (`BurnAttributionCalculator.calculate` in `Sources/MeterUsage/Services/BurnAttributionCalculator.swift`): sessions without a token ledger (for example Codex realtime sessions whose rollout records no `token_count` event) are excluded, and a scope with no token-bearing sessions yields no breakdown, so the burn card hides instead of showing all-zero rows. - Successful manual Codex resets start observation-based pacing for the redeemed account slot. `AppCoordinator.consumeCodexReset` invalidates the old estimate only after the provider accepts the reset. The first fresh reading establishes each window's baseline; `QuotaWindow.pace` then uses observed usage growth divided by the time between readings. One reading or unchanged usage yields no pace or ETA. Reads started before the reset cannot seed the new baseline. `QuotaArchive` persists this state and quota metadata across relaunches, while old archives still decode. Cycle or duration changes and usage drops replace the window's baseline. Accepted resets also remove the redeemed credit from the archived snapshot and decrement its known count, so an offline refresh or relaunch cannot offer that credit again. Other accounts and providers keep their existing pacing. ADR 0008 records this decision; `ResetPacingTests` covers the reset path. - Cost figures are local estimates from token counts against the single rate table in `Sources/MeterUsage/Services/Pricing.swift` (`Pricing.snapshotYearMonth` = `2026-09`). That table carries Claude list rates for the current generation (Opus 5.5, Sonnet 5.5, Haiku 4.5, Fable) and OpenAI/Codex Standard list rates for the GPT-6 family (GPT-6.1 Sol, GPT-6 Sol, GPT-6 Luna, GPT-6 Astra) and GPT-5.6 Sol/Terra/Luna plus `gpt-5.3-codex`. `CodexLocalSource` reads the session's model from the rollout's `turn_context` event and prices its tokens; reasoning is billed at the output rate, cached input uses the model's published cached rate (GPT-6.1 Sol's 5% tier), and Codex charges no cache writes, so those rows price cache writes at zero. A session whose model cannot be read prices at the Codex default tier and is flagged as an unrecognised-model guess rather than zero. Claude rows use one rate per family, so an older generation is estimated at the current generation's price. -- GitHub Actions runs `swift build` and `swift test` on macOS 15 for pushes to `main` and pull requests. This repository fact does not prove that a workflow run has passed. +- GitHub Actions runs `swift build` and `swift test` on macOS 15, plus Electron typecheck, synthetic tests and production bundling on Linux, for pushes to `main` and pull requests. This repository fact does not prove that a workflow run has passed. - The app preserves unreadable durable history and suspends history writes until relaunch after the file is repaired. Sanitized history load and save failures are visible in Copy diagnostics. - Providers tray icon in Settings (the small button beside the "Notch" caption) uses the `menubar.rectangle` symbol for both switch states; state is carried by tint. `menubar.rectangle.fill` is absent from the system symbol catalog (returns nil, renders as nothing), so a filled variant must be probed with `NSImage(systemSymbolName:)` before use. - `status.openai.com` runs on incident.io, which serves a Statuspage-compatible `components.json` but uses `full_outage` where Atlassian pages use `major_outage`. `StatusPageSource.severity(for:)` maps both to `.majorOutage`; an unmapped status degrades to `.unknown`, never to a false all-clear. Verified against the live feed on 2026-09-25 during an active Codex outage. - Provider mark tint is identity unless the service check diverges from healthy (`MenuBarLabel.statusTint(_:for:)`): operational keeps `providerColor`, degraded/outage repaint amber/red, unreadable goes neutral grey. Quota headroom tint stays on rings and percents only; the side notch, menu bar, and status rows share this rule per ADR 0002's mark semantics. - The app accent is user-selectable (`AccentTheme`, Settings → Appearance): Blue (default), Violet, Teal, Amber, Rose, and Graphite, persisted under `accentTheme`. `MU.accent` resolves the choice, so the popover heatmap, sparkline, accent text, and interactive control tint, the primary provider's (Codex's) mark, and the side notch chrome (body, card, ring disc/track) all recolour together. Headroom green/amber/red and every other provider identity stay fixed. Dark neutral surfaces are dark greys (popover canvas/surface/well and the notch body/card), not black. - Side notch chrome is derived per theme in `SideNotchPanelView` by an HSV mix pinned to the popover surface's Rec. 709 relative luminance (`blendTinted`), so every accent renders at the same perceived brightness as the window beside it. The notch bands and hues delegate to the shared `headroomColor`/`MU` scale (80/95 thresholds), and the panel window forces `darkAqua` so those tokens resolve their dark variants on the strip. -- Second accounts (multi-account support, ADR 0005): `.codexAlt` / `.claudeAlt` provider slots are keyed by an alternate config directory resolved in `AccountSlots` (`Sources/MeterUsage/Services/DataSource.swift`) from `METERUSAGE_CODEX_ALT_HOME` / `METERUSAGE_CLAUDE_ALT_CONFIG` or the `meterusage.codexAltHome` / `meterusage.claudeAltConfig` defaults keys, which are editable in Settings → Providers. A slot exists only when its directory exists; slots never merge with the primary account, keep per-slot durable history and archive keys, and are named by position only (no account identity is read). The JSON report lists them under `codexAlt` / `claudeAlt`. +- ADR 0006 replaces fixed alternate cases with a managed account list for Codex and Claude. Generated app IDs, provider, user label, directory and enabled state persist as JSON in UserDefaults Data. Primary keys retain their old form; additional history/archive keys use provider plus generated ID, and schema-1 reports add `account` labels. Rename preserves identity; remove stops metering without deleting provider files. No provider account identity is read for labels. +- ADR 0010 selects macOS TypeScript/Electron/React with pnpm, Vite+, Tailwind, Vitest and electron-builder. Main owns provider files/keys, native actions and persistence; a separate sandbox-compatible preload exposes sender-validated IPC to React. The candidate keeps JSON/history/archive and known preference formats. Swift remains the oracle and rollback path. No server or remote control layer is added. +- 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 36-pixel charcoal strip has 22-pixel rings containing provider marks, percentages below. Settings is available in each expanded card footer, with no strip button or reserved slot. The 220-pixel detail card plus strip use 256 pixels total. Synthetic browser checks cover scrolling, two card heights and both CSS card sides, but 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 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 336, 420 and 483 points before display clamping. Main-panel and Settings content use the same scale. Side-notch detail content uses explicit integer pixel sizes without CSS zoom. The notch card is 220 pixels wide beside the fixed 36-pixel strip; its height follows the size-specific content within the work area. Settings measures the scaled content height and fits its active category and expanded controls within the display work area, accounting for native title-bar height. Width and top position remain when on screen. Tray and 250-pixel shared-card capture remain unchanged. Ctrl/Cmd plus or minus selects the next size; Ctrl/Cmd 0 restores Medium. These shortcuts use the same saved preference and main-panel resizing as Settings. Window loads normalize retained Chromium zoom to avoid applying a second scale. 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, rapid keys and display clamping; actual native sizing acceptance is separate. +- Candidate Settings gives Appearance, General, Providers, Side notch, Usage details and Diagnostics direct category access beside Connections. Related options use grouped rows. Panel size uses a compact row of visible Small, Medium and Large choices; accent color uses six labelled radio swatches. Aligned 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. +- 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. +- Synthetic source-scanner timing on the same Apple silicon Mac uses 32 files per provider, including 8,192 Claude messages. Empty/memory/restored-disk Claude cache samples are 30.93/0.56/0.76 ms on native Node and 392.63/1.25/1.48 ms in an optimized Swift harness. Codex first/second scan samples are 4.84/3.53 ms on Node and 3.81/3.39 ms on Swift. Session/token counts and cache-transition results pass. These are separate single samples of matching generated fixture contents, taken at different times outside the packaged apps. Empty cache means application cache, not flushed OS cache; compilation and fixture generation are excluded. Packaged-app performance and idle RSS remain unverified. +- Durable daily-history reads and writes project the known record fields and five token counters before coordinator merge or renderer publication. Unknown JSON fields are discarded; larger historic totals and account keys remain intact. +- Claude skips unreadable or disappearing sessions while keeping readable siblings. Codex retains the Swift metadata, modification-time and token-ledger fallbacks per session; a dated session without a readable ledger contributes no measured burn. Both readers propagate cancellation. +- Invalid Claude cache bucket costs or unsupported bucket/session dates discard only that cache entry and trigger a transcript rescan. Shared provider parsing rejects dates outside JavaScript's representable range before history or report serialization. Malformed optional dates are omitted; required dates retain provider-specific failure handling. +- The side-notch reset preference controls the detail card and context menu; flyout reset redemption remains available for a reported-available, unexpired credit. Main still requires explicit confirmation and rechecks the credit. +- Rolling usage bars show each window's share of `last 30d`, using estimated cost when reference cost is positive, otherwise measured tokens. Missing or zero reference produces zero share. The caption states the reference and pricing snapshot. These renderer contracts have fixture proof; native acceptance remains open. +- Archive reads validate optional plan, group, credit and reset metadata, retain Swift date/null compatibility and project only known fields. Reset preparation, confirmation and UI actions share the same reported-available and unexpired rule; absent status is unavailable. +- Primary Claude quota discovery prioritizes the app-support `claude-usage.json`, then `.claude/claudewatch-usage.json`, then `.claude/meterusage-usage.json`. Additional accounts read only their configured directory. Without `updated_at`, capture time comes from the selected file's modification time. Optional OpenRouter credits have a five-second deadline starting with the required quota request. Malformed optional Codex additional limits never discard valid base windows. +- Usage cards show current-day session/message counts and capture age without inventing token totals. Tray tooltips include non-operational status, reset countdown, stale capture age and last refresh. Claude, Antigravity, OpenCode Go, OpenRouter and Grok keep the Swift light/dark identity pairs, with semantic status overrides. These display contracts have synthetic/static-markup proof; native acceptance remains open. +- Flyout service health uses reported statuses independently of usage visibility. Failed refreshes retain the last successful status as last known, with its age and refresh failure shown in the status panel, notch card and tray tooltip. Copied diagnostics marks the reading stale with its successful-check age and a fixed service-status error; successful retry clears the marker. Public-feed failures use service-status wording while keeping their failure code for retry behavior. Status buttons ask main to open only the existing fixed public provider pages. The compact notch rail shows only provider marks and percentages; pacing and reset timing remain in details. Native rendering and page opening remain unverified. +- Replacing an account source invalidates its live quota, activity, usage, plan and pending reset confirmation. Late reads and reset completion from that source cannot update its replacement. Dated slot archives and durable history remain available; reset requires a current source reading. +- 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 provider sources, local activity and Claude status. It reads the public OpenAI status feed without credentials; updates, login-item changes and notifications are blocked. [ADR 0012](adr/0012-codex-live-test-profile.md) amends the demo-only candidate restriction; [ADR 0015](adr/0015-codex-test-public-status.md) permits this public status request. Native and provider acceptance remain separate. +- The separate `--claude-test-profile` candidate mode starts without consent and runs only the existing Claude Desktop allowance flow. It uses isolated JSON preferences and Electron state, blocks Codex access and cleanup, and retains the candidate system-effect guards. A Claude connection call without a consent callback denies access. Synthetic fixtures cover consent, relaunch, disconnect and profile isolation; native Keychain, Desktop format and live-provider acceptance remain open. See [ADR 0016](adr/0016-claude-live-test-profile.md). -- The reporting candidate adds Settings Report issue, using an HTTPS relay to create a private Linear issue without a user login or bundled Linear credential. Diagnostics use allowlisted state and numbers; success requires a matching server receipt. AppCoordinator retains pending reports and receipts across Settings navigation until the app quits. On 2026-10-08, Workers Free and native-client delivery/retry were verified, and the source bundle endpoint was configured. Native Settings interaction and released-app availability remain unverified. See `docs/REPORTING.md` and ADRs 0011 and 0012. +- Electron notch secondary labels use a scoped `#b0b0b8`; semantic provider and quota colors retain their existing rules. Telemetry adds a border and size-specific top padding. Enabled detail buttons use 120 ms background feedback for fine-pointer hover with reduced motion off. Press and keyboard focus stay immediate; reduced motion disables transitions. Card/window/ring geometry and the 450 ms close delay retain their source behavior. Rendered feedback, scrolling and native anchors still need target proof. See [SIDE-NOTCH.md](SIDE-NOTCH.md). -- The agent feedback CLI accepts short structured bug drafts, then requires a native preview and user consent for each send. It reuses the private reporting client and relay, without polling providers or collecting transcripts. See [REPORTING.md](REPORTING.md) and [ADR 0013](adr/0013-agent-feedback-native-consent.md). +- Electron Settings persists provider group order through validated IPC. Drag handles, arrow keys and move buttons use the same saved order for usage cards, service health, menu bar and side notch. Stored lists retain known providers once and append missing providers in default order. Additional accounts remain grouped, with independent Collect and Show settings. Failed saves and canceled drags retain the saved order. Synthetic proof does not establish rendered or native drag, focus, VoiceOver or notch-anchor behavior. See [ADR 0021](adr/0021-provider-group-order.md). + +- The macOS preferences reader accepts complete legacy hex data and reads + complete NSData through Foundation when `defaults` prints an abbreviated + description. Provider order and managed accounts retain their saved format. + Relaunches and app updates reuse the saved order; newly added providers append + after the retained entries. + +- Every provider card with a quota reading shows a "Quota activity" heatmap + (`src/main/quota-activity.ts`) unless it already shows Codex account token + history. Each slot records the rise in its own tracking window (weekly + all-models when present, else the longest window) per local day, so usage + from desktop apps and other devices appears. The first reading spreads + the current window's usage evenly over its elapsed days, marked estimated; + earlier windows are never backfilled. Connection changes retire the previous + series under an opaque generated key, so replacement accounts never inherit + its days or baseline. Retired observations remain on disk outside active + heatmaps; normal relaunch and Muse collection Stop/Start retain the active + series. + Quota activity stays in percentage points: providers report no account + token totals for desktop or web use, so tokens are never inferred. When + Claude Code (or Codex) ran on this Mac that day, the tooltip adds the + measured local tokens, labelled "on this Mac". A rise seen after the + Mac was offline lands on the next reading's day. A reset-time change over + one hour, or a drop, counts the new window from zero. Demo mode is excluded. + +- Connected Codex account history uses `account/usage/read` without `threadId` + through the existing managed ChatGPT helper and effective keyring guard. It + publishes only valid unique calendar labels, safe nonnegative token counts + and capture time in separate in-memory state. Main, notch and shared cards + show "Account token activity" under existing heatmap preferences. Null or + absent history and unreported days remain unavailable; reported zero stays + zero. Profile replacement, disconnect and failed reads clear the reading; + late results cannot update a replacement source. Account history is excluded + from local rollups, durable history and schema-1 quota JSON. Source and + synthetic proof do not establish live availability or universal cloud, + device or product coverage. See [ADR 0022](adr/0022-codex-account-token-history.md). + +- The retained Swift reporting candidate adds Settings Report issue, using an HTTPS relay to create a private Linear issue without a user login or bundled Linear credential. Diagnostics use allowlisted state and numbers; success requires a matching server receipt. AppCoordinator retains pending reports and receipts across Settings navigation until the app quits. On 2026-10-08, Workers Free and native-client delivery/retry were verified, and the source bundle endpoint was configured. Native Settings interaction and released-app availability remain unverified. See [Swift reporting](swift/REPORTING.md) and the retained Swift reporting ADRs 0011 and 0012. + +- The retained Swift agent feedback CLI accepts short structured bug drafts, then requires a native preview and user consent for each send. It reuses the private reporting client and relay, without polling providers or collecting transcripts. See [Swift reporting](swift/REPORTING.md) and [ADR 0013](adr/0013-agent-feedback-native-consent.md). ## Verification gaps +- Claude Desktop GUI connections and subsequent allowance polling share one HTTP + transport. Only the fixed bootstrap and UUID-bound usage routes use a unique + nonpersistent Electron session with caching disabled and ambient credentials + omitted. Demo, JSON and Codex test paths do not use this native session. Source + and synthetic checks do not establish native cookie handling or provider acceptance. + +- 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. - Repository files do not prove current provider authentication, quota freshness, network responses, local machine state, app installation, signed-bundle state, GitHub Release state, or runtime UI behavior. - Treat cost figures as estimates. `README.md` identifies provider dashboards as the billing source of record. @@ -68,9 +330,10 @@ Last verified: 2026-09-29 - [`docs/PRIVACY.md`](PRIVACY.md): data-handling boundaries and enforcement claims. - [`docs/DEMO.md`](DEMO.md): synthetic demo mode. - [`docs/SIDE-NOTCH.md`](SIDE-NOTCH.md): side notch states, anchor and geometry invariants, interaction, and verification. -- [`docs/adr/0005-multi-account-slots.md`](adr/0005-multi-account-slots.md): second-account slot decision. +- [`docs/adr/0006-managed-account-list.md`](adr/0006-managed-account-list.md): current managed-account decision. +- [`docs/adr/0010-macos-typescript-electron.md`](adr/0010-macos-typescript-electron.md): macOS migration and Windows hold. - [`docs/mockups/README.md`](mockups/README.md): Wave 1–3 concept mockups; provider marks stay. -- [`docs/plans/feature-and-windows-pipeline.md`](plans/feature-and-windows-pipeline.md): macOS feature waves + Windows track. +- [`docs/plans/feature-and-windows-pipeline.md`](plans/feature-and-windows-pipeline.md): superseded platform plan, current macOS direction and Windows hold. - [`Package.swift`](../Package.swift): package targets and platform requirement. - [`CONTRIBUTING.md`](../CONTRIBUTING.md): contribution and validation commands. - [`CHANGELOG.md`](../CHANGELOG.md): repository release-note history. @@ -82,4 +345,59 @@ Last verified: 2026-09-29 - [ci.yml](../.github/workflows/ci.yml) uses full-SHA v7 pins for `actions/checkout`. Application language versions and explicit cache settings are preserved. - Obsolete runs for the same pull request or branch are cancelled. Existing timeout caps are preserved. -Reviewed base: `a393b0bfa8d4fbc0ef533a42eb92c32a236c943e`. Source checks do not prove runtime, deployment or device behavior. +Reviewed Swift base: `4b913db3a3a8eb2602296489872af2ecaa5d0530`. Source checks do not prove runtime, deployment or device behavior. + +- Electron Diagnostics restores explicit **Send report** through the existing private support relay. Main generates the same allowlisted status summary used by Copy diagnostics with the running app version; renderer IPC accepts intent only. In-flight clicks are deduplicated, uncertain retries reuse the report UUID/snapshot, and a matching HTTP201 receipt supplies the support reference. No raw logs, account labels/IDs, paths, credentials or transcripts are uploaded. See [REPORTING.md](REPORTING.md). Transport fixtures do not prove live Linear delivery. + +- Grok is labeled **Beta** in Electron usage cards, setup and provider settings. Official-source desktop research found X Subscription per-seat shared Grok/Cursor usage, unified local Antigravity authentication and API-key-based OpenCode Go setup. Those research findings alone do not establish connections. The source-only Bot Desktop Beta implementation is documented in [ADR 0028](adr/0028-grok-bot-desktop-reader.md), with saved Cursor compatibility in ADR 0027; universal shared-pool coverage remains unverified. See [desktop login research](DESKTOP-LOGIN-RESEARCH.md). + +- Diagnostic report attempts have a 10-minute main-process cooldown persisted as a private native preference before HTTP. Relaunch and failed sends retain the limit. Storage failure prevents transmission; Copy diagnostics remains available. Only the timestamp persists, with no report content, account identifier or receipt. See [REPORTING.md](REPORTING.md). + +- OpenCode Go has an explicit local setup action. Local Go session history needs + no key; allowance uses the existing Go key. CLI fallback includes executable + `~/.bun/bin/opencode`. Coverage remains Go-only. +- Activity heatmaps use one 26-column, seven-row layout. Thirty-day telemetry + leaves older dates unavailable. Local-day and OpenRouter UTC scope are retained. + +Completed-onboarding launches preload the usage window hidden before connection +recovery and provider collection. The first tray click reuses that renderer; +fresh provider data still depends on its source. Incomplete onboarding continues +to open Settings. No window is shown or focused by preload. + +## API billing and Grok compatibility follow-up + +- OpenAI API and Anthropic API are independent billing snapshots, not quota + provider slots. Settings → API connections accepts a one-time masked Admin + key and fetches billing without Terminal. The field clears on submit or + leaving the page; pasted keys are never saved. **Use saved key** reads only the + selected provider's legacy `com.meterusage.api-keys` Keychain entry without + changing it or returning the key to the renderer. It runs only on request. A bundled environment-only helper + remains available. Both paths use fixed cost endpoints and projected + owner-only USD files. Cards retain the + reported UTC period, exact capture time and explicit stale state. Empty + reports do not become zero usage. Real organization billing remains unverified. + See [API billing](API_USAGE_BETA.md) and [ADR 0029](adr/0029-api-billing-snapshots.md). +- The Grok Bot encrypted reader accepts canonical v10 legacy values with the + same full-subject active-account binding as scoped envelopes. Its fixed + Keychain pair is Grok Bot Safe Storage / Grok Bot Key. Native metadata confirms + these shapes; access and live allowance remain separate gates. See + [ADR 0030](adr/0030-grok-bot-encrypted-compatibility.md). + +- Grok Bot Desktop quota burn uses live same-account observations spanning at + least one minute and no more than five minutes. It reports percentage points + per hour and can estimate run-out without assuming a quota-cycle duration. + Missing/stale reads, reconnects, source changes and resets clear measurement. + Archives retain quota but discard these observation baselines. No token or + dollar rate is derived. See [ADR 0031](adr/0031-grok-quota-burn.md). +- API setup has its own Settings category, separate from account connections + and onboarding. Fetch billing updates the snapshot automatically; ordinary + Refresh only rereads it. The advanced helper retains key-free macOS zsh + commands and a hidden prompt. See [ADR 0032](adr/0032-one-time-api-setup.md). + Real organization billing remains unverified. + +## Retained Swift reporting source + +Main's Swift reporting client, native-consent bug draft CLI and their tests +remain in the retained Swift tree. Their diagnostic payload and user flow +differ from Electron. See [the Swift reporting contract](swift/REPORTING.md) +and its activation evidence; this does not add them to Swift 0.2.41. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index d7ecd93..e0ab288 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,69 +1,444 @@ -# Privacy & security design +# Privacy and security design + +MeterUsage reads quota and usage from existing local provider stores, provider +CLIs and aggregate endpoints. It has no telemetry service, analytics or +automatic crash-upload endpoint. Electron Diagnostics has an explicit optional +**Send report** action described below. The Electron candidate adds opt-in account connections; +Codex sign-in occurs in the provider browser page, not a MeterUsage password form. The optional API billing page accepts a one-time organization Admin key as +described below. MeterUsage does not send prompts, code or tool payloads to a provider. + +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 + +Claude Desktop GUI requests use a unique nonpersistent Electron session after +app readiness, with caching disabled and ambient credentials omitted. Only the +existing bootstrap and UUID-bound usage routes receive the explicitly selected +session cookie. Other endpoints keep their existing transport. The same HTTP +function serves Connect and subsequent allowance reads. Demo, JSON and Codex test +paths do not use this native session. Native cookie behavior remains unverified. + +The shared provider HTTP transport aborts failed requests before awaiting body +or reader cancellation. Cleanup rejection does not replace the original provider +error. Successful requests are not aborted. Synthetic tests cover this ordering; +they do not establish native connection closure. + +| Provider | Read or request | Retained data | +| --- | --- | --- | +| 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. | +| Muse Code, unreleased Electron source | Opt-in `muse serve` subprocess using existing official CLI authentication. MSP initialization enables experimental methods, then `usage/read` reads the host's last observation. No model session, prompt, login or logout is started. MeterUsage does not open Muse credential files. | Session and weekly allowance percentages, reset timestamps, session duration, a restricted tier identifier and the provider observation timestamp. Missing or expired observations remain unavailable. No token ledger, cost or Muse.ai allowance is inferred. | +| 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. | +| Grok | Existing OIDC key from `.grok/auth.json`, re-read for each billing refresh, sent to `cli-chat-proxy.grok.com/v1/billing?format=credits`. Session `summary.json` files supply dates and message counts. | Allowance, period/reset, plan and session/count metadata. No chat-history content is decoded. | +| Antigravity | Existing agy container CLI `/usage`; numeric fields from conversation SQLite/protobuf stores, native first and container-volume fallback. Older stores use only history conversation ID/timestamp fields. | Model-group quota, numeric token totals, turn times and aggregate counts. Older history has unknown tokens. No prompt/tool payload or workspace text is decoded. | +| Cursor, Copilot, Gemini | Existing local quota/usage JSON candidates and local presence checks. | Snapshot quota windows when present. These readers do not fetch cloud quota or provide a token ledger. Presence alone never becomes a zero-usage reading. | + +Muse Code allowance stays in memory and is not written to the shared quota +archive. This preserves archive compatibility with older app versions. Its +collection preference remains saved; allowance is read again after relaunch. + +Codex and Claude additional accounts use separate user-selected configuration +directories. Enabled existing directories get distinct sources; an absent or +disabled directory is not polled. An additional Claude source never falls back +to the primary account's plan or companion file. Codex subprocesses receive the +selected `CODEX_HOME`; MeterUsage does not open Codex `auth.json`. + +Primary Claude quota checks `Library/Application Support/MeterUsage/claude-usage.json` +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. + +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 account-selector and session-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 +their own authentication. + +Antigravity runtime recovery may start only an already-existing +`podman-machine-default` after inspection. It does not create a machine or pull +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. + +Setup also offers the existing CLI sources through an explicit **Use CLI** +action. Selecting an option alone performs no account access. Applying CLI +selection disconnects only MeterUsage-owned account state and waits for its +logout cleanup before enabling local collection. Cleanup failure prevents the +switch. Ordinary Disconnect never silently falls back to local data. The +CLI transition adds no credential store, provider endpoint or authentication flow; +CLI sources retain the input and coverage limits above. The separately consented +Grok Bot Desktop Beta account option is described below. + +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, account daily-token read, explicitly confirmed reset redemption 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 gives authentication helpers up to five +seconds, then cancels them. Pending CLI-selection preference saves and rollback +must settle before the app exits. Headless reports +do not sign in or perform cleanup logout. + +Connected Codex daily history uses `account/usage/read` without `threadId` +through that same managed ChatGPT profile and keyring guard. The main process +projects only valid unique date labels, safe nonnegative integer token counts +and a capture timestamp before publication. Summary fields, unknown fields and +provider error text are discarded. No raw response, account identifier or +credential reaches the renderer or a new durable cache. Account token history +stays in memory, clears after failed reads, replacement or disconnect, and never +enters local rollups or durable daily history. Captured-profile and cancellation +checks reject late results. Missing history and unreported dates remain +unavailable. The provider contract does not establish complete product, device +or cloud coverage. See [ADR 0022](adr/0022-codex-account-token-history.md). + +Claude requires explicit consent to use its Desktop web session. Previous +access-token consent does not authorize this reader: an untagged identity +requires Connect again and cannot start private reads or restore account quota. +The bundled native helper reads only `Claude Safe Storage` / `Claude Key`. +Background reads forbid interaction. Helper output stays in bounded main-process +memory, outside logs, settings and diagnostics. Each allowance attempt obtains +the password once and reuses it only within that attempt for the fresh local +reads before and after requests. It is not cached between attempts. + +The reader selects only `lastKnownAccountUuid` from Desktop configuration and +the fixed `sessionKey` and `lastActiveOrg` cookies. It supports schema 24, +encrypted v10 values with their host digest, root-path Secure cookies, an +HttpOnly session cookie and exact integer expiry checks. Identical values are +deduplicated; conflicting sessions or organizations fail closed. Fixed +`/usr/bin/sqlite3` SELECTs run read-only under `/usr/bin/sandbox-exec`, which +denies all file writes beneath the canonical Claude store root. Reads have +row, file, memory and time limits. Missing shared-memory support or sandbox +failure makes collection unavailable. No private database is copied. + +The captured session cookie authenticates GET `https://claude.ai/api/bootstrap`. +Its nested account UUID must match the local account selector, and the intended +organization must appear exactly once in that account's memberships. Only then +can MeterUsage request GET +`https://claude.ai/api/organizations/{organization-uuid}/usage`. +Bootstrap is checked again and the local account, organization and session are +reread before publishing allowance, including after a failed usage request. +Membership alone does not establish allowance entitlement. No GET `/api/account`, +OAuth cache selection, bearer fallback, refresh token, chat or inference request +is used. Claude stores are never modified and credentials are never persisted. + +A Claude HTTP 403 response with `cf-mitigated: challenge` means provider +browser verification blocks the automated request. MeterUsage does not bypass +that challenge or change its TLS, User-Agent, headers or cookies to evade it. +Connect reports a fixed message without exposing response bodies, headers or +session details, and preserves the current source. A failed Claude Desktop +method offers **Open Claude usage**. This user-initiated action opens only +`https://claude.ai/settings/usage` from normal Settings. Demo, candidate and +isolated test profiles cannot open it. The renderer supplies no URL, account +or profile. The page is a manual fallback; opening it does not connect +MeterUsage, import browser data or report quota. + +Cookie authentication is restricted to these bootstrap and UUID-bound usage +routes. Requests use MeterUsage's own User-Agent, reject redirects, disable +caching, support cancellation, time out after 30 seconds and limit each decoded +response to 1 MiB. Only supported numeric allowance windows reach the renderer +or quota archive. Preferences retain a `web-v1:` opaque account/organization +hash, excluded from settings publication. Connected-account archives are cleared +at app startup. Account/session changes, failed verification and cancellation +reject late results; disconnect prevents further private reads. Account quota +has no local CLI history attached. Source and synthetic checks do not prove +native store compatibility or live server acceptance. + +An explicit **Connect Antigravity Desktop** action permits discovery of one +same-user desktop language server and reading only its candidate process +arguments for the current CSRF credential. Multiple matching servers are +rejected. MeterUsage verifies PID-owned IPv4 loopback listeners before each +fixed `GetUserStatus` or `RetrieveUserQuotaSummary` request. HTTPS is preferred; +a certificate exemption applies only to that request to `127.0.0.1`. HTTP is +allowed only on the declared, PID-owned extension-server port. Redirects are +rejected, responses are limited to 1 MiB, each request to three seconds and +collection to fifteen seconds. No cloud credential file is opened or copied. + +The Antigravity account email is checked before and after quota collection. +Only a provider-scoped SHA-256 fingerprint is saved in MeterUsage preferences, +excluded from settings publication and diagnostics. CSRF credentials and raw +responses stay in main-process memory and are not logged, cached or persisted. +No provider store is changed and no credential is refreshed. Connection +changes, cancellation and disconnect invalidate old reads. Missing or expired +quota stays unavailable. Empty modern summaries cannot select stale legacy +quota; legacy model fractions are allowed only for an explicitly unsupported +modern RPC. See [ADR 0026](adr/0026-antigravity-desktop-local-quota.md). + +Connected Codex/Claude/Antigravity 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, plaintext 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. +[ADR 0011](adr/0011-desktop-account-connections.md) amends the Codex/Claude +candidate boundary; it does not change the published Swift release. + +Grok Bot Desktop offers the primary opt-in **Connect Grok Bot Desktop (Beta)** +flow under [ADR 0028](adr/0028-grok-bot-desktop-reader.md). After consent, the main +process opens only the fixed `Grok Bot/sand-secrets.json` store under Application +Support and the helper's fixed `Grok Bot Safe Storage` / `Grok Bot Key` Keychain item. +Only explicit Connect permits Keychain UI; polling forbids it. The bounded JSON +reader rejects symlinks, duplicate keys, unsupported versions and changed stores. +It selects only the active account's encrypted access token and optional team; +refresh tokens and other accounts' credential values remain skipped. Plaintext and unsupported encryption are rejected. Canonical encrypted v10 +legacy values use the same full JWT-subject hash binding as scoped values. Decrypted byte buffers are cleared +when projected; in-memory strings remain subject to garbage collection. + +The full JWT subject hash must match the active account and any present envelope scope, +and expiry must exceed sixty seconds. The optional team must be a safe +nonnegative integer. Both account and team bind the stored opaque fingerprint. +Connect JSON POST `{}` goes only to +`https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus` with a +bearer token, optional `x-cursor-team-id` and MeterUsage's own User-Agent. No Bot +machine identity, client impersonation, app-code invocation, token refresh or +inference is used. Redirects are refused; response and request bounds are 1 MiB +and thirty seconds. Account, team and store stability are checked after every +attempt while consent remains valid. Cancellation prevents further private reads. +Only projected quota and a reported reset reach the renderer/archive. Pooled +enterprise allowance and contradictory limit flags stay unavailable; trial expiry +is never treated as a reset. Native metadata confirms the encrypted legacy store format and fixed Keychain +label. Successful access and server acceptance remain unverified. MeterUsage does not +initiate an xAI OAuth flow or modify Bot's account state. + +Previously saved Grok Bot connections via Cursor retain the legacy reader under +[ADR 0027](adr/0027-grok-cursor-closed-reader.md). New connections always select +Bot Desktop; no failed Bot request falls back to Cursor. Sign in to Cursor, check +allowance, then quit Cursor before polling a saved legacy connection. +Pre/post process and filesystem guards reject +active Cursor, unavailable or ambiguous evidence, nonempty WAL/journal files +and observed changes. These snapshots detect races; they do not exclude other +processes or prove Cursor never ran. The original SQLite database is opened +with `immutable=1&mode=ro`, which disables reader writes, locking and sidecar +bookkeeping regardless of other processes. No credential stores are copied to +temporary files. Read access times are controlled by the filesystem. + +The main process selects only `ItemTable`'s `cursorAuth/accessToken`, requires +JWT expiry beyond 60 seconds, constructs the established +`WorkosCursorSessionToken` cookie, verifies the exact subject through fixed +`https://cursor.com/api/auth/me`, then sends POST `{}` to fixed +`https://cursor.com/api/dashboard/get-sand-usage-status` with +`Origin: https://cursor.com`. Redirects are refused; each request has a +30-second timeout and 1 MiB response cap. Token strings stay in memory and +request headers. They are never rendered, logged or persisted. No refresh or +model requests occur. Only an opaque account fingerprint/source preference and +projected aggregate quota persist; store fingerprints stay in memory. + +The account and store are checked before allowance and after each attempt. +Cancellation or disconnect prevents further credential reads and rejects late +results. Revoked access, missing quota and expiry clear account-bound archives; +ordinary transport failures can retain dated quota only after account/store +verification. After expiry, reopen Cursor and quit it to resume the saved source. +Disconnect and connect again to switch to Bot Desktop. No local CLI +history is attached. Trial expiry is not a replenishment timestamp. Universal +Grok Desktop, X and shared-pool coverage is unverified, as are native/live +compatibility and packaged acceptance. See [setup](DESKTOP-CONNECTIONS.md). + +## Optional API billing snapshots + +OpenAI API and Anthropic API billing use separate normalized snapshots under +`Library/Application Support/MeterUsage/api-usage`. The optional bundled helper +reads the selected organization Admin key from its environment for one fetch, +removes that key from its own environment and calls only the documented +OpenAI `/v1/organization/costs` or Anthropic `/v1/organizations/cost_report` +HTTPS endpoint. The Settings → API connections page also accepts a one-time +Admin key in a masked field. An explicit Fetch billing action passes it through +the registered Settings bridge to the main process, using the same collector. +The field clears on submit; leaving the page clears unsubmitted input. Keys are +not saved in preferences, Keychain, snapshots, diagnostics or renderer state +publications. An explicit **Use saved key** action instead reads the selected +provider's existing `com.meterusage.api-keys` generic-password item, account +`openAI` or `anthropic`, through the fixed native helper. Only that action may +request Keychain UI. The original item is not changed or deleted; the key stays +in main-process/helper memory and never reaches the renderer. Missing, denied +and ambiguous requests do not fall back to another credential. Startup and +polling never read these Keychain items. Fixed errors exclude key values and provider response bodies. +Only one request per provider may run; quit cancels pending fetches. Requests +reject redirects, cap response size and pagination, and never start inference. + +The helper writes only schema/provider, capture/period timestamps, currency and +daily USD totals. Identifiers, headers and raw responses/errors are discarded. +Files and their final directory are owner-only; replacement is atomic and a +failed request preserves the previous file. The app validates the fixed files +and publishes only projected fields. Demo and isolated provider tests never +read live API billing files. Demo, candidate and isolated profiles cannot use +the Settings fetch action. Polling rereads snapshots and never fetches billing. +Reports older than 24 hours or from a prior UTC month are marked stale. +Anthropic Priority Tier costs are excluded. See [setup](API_USAGE_BETA.md) and +[ADR 0029](adr/0029-api-billing-snapshots.md) and +[ADR 0032](adr/0032-one-time-api-setup.md). + +## Selective parsing and display boundary + +The Swift readers use narrow decoding structs. The TypeScript candidate uses +`src/main/select-json.ts` to traverse JSON syntax and materialize only selected +fields. It does not `JSON.parse` a whole Claude transcript or tier object and +then project fields. Tests cover unknown nested values, escaped strings and +keys, malformed input and scalar-materialization tracing. The scanner still +reads file bytes to skip unwanted values; it does not turn skipped prompt or +identifying values into application objects. + +Absolute project paths are transient input to basename reduction. A claim that +workspace paths are never decoded would be inaccurate. The app retains project +names for attribution, opaque local session IDs for list/cache identity, and +provider-reported model names. These values may appear in screenshots. Keep +public screenshots in demo mode. + +Managed accounts have app-generated IDs, user labels, paths and enable state. +Generated IDs key history/archive state; renaming a label does not change the +key. The JSON report includes a label only for additional accounts, with no +provider account identity or config path. Settings may show a reduced label for +the directory the user selected. Diagnostics use fixed provider/error codes +and additional-account markers, without raw paths, labels, payloads or keys. + +Provider files, credentials, transports and raw responses stay in the Electron +main process. Renderer windows have no Node integration and use context +isolation and sandboxing. A narrow preload exposes named requests and sanitized +state; main checks the registered window, main frame, exact loaded document and +request fields. Navigation/new-window restrictions keep that bridge attached to +app documents. Source controls and tests do not replace native runtime proof. + +## What leaves the machine + +- Provider aggregate requests above, authenticated with existing provider keys + or by the provider CLI. They contain no prompts or model-generation requests. +- Public unauthenticated status feeds at + `https://status.openai.com/api/v2/components.json` and + `https://status.claude.com/api/v2/components.json`. +- An explicit service-status button opens the provider's existing public status + page in the default browser. Main selects a fixed Codex, Claude, Cursor or + Copilot URL; the renderer cannot supply a destination. No usage or account + data is added to that URL. +- An unauthenticated GitHub latest-release check, at most hourly while enabled. + It has no usage data or account identifier. GitHub sees the network address + and User-Agent. Disable it in Settings. An explicit Install action downloads + the selected release ZIP and requires its SHA-256, bundle identity/version + and code signature to verify. Automatic checks do not install an update. +- Explicit Codex reset redemption calls + `account/rateLimitResetCredit/consume` after native user confirmation for that + account and credit. Cancel makes no consume call. Tests use synthetic credits + and never consume live credits. +- An explicit Share card action sends the captured card through the chosen + native sharing service. Those services have their own privacy behavior. + +Local usage file reads and SQL do not add an outbound request. Errors from +provider subprocess stderr are drained and not retained or forwarded. + +## Local persistence + +Daily history and quota archive remain JSON under the app's existing support +directory. History reads and writes project only known record fields and the +five numeric token counters before coordinator merge or renderer publication; +unknown JSON fields are discarded. History retains numeric daily summaries; the archive retains quota, +plan/credit/reset metadata and observation timestamps for reset-aware pacing. +Restored quota is shown as dated last-known data. Unreadable durable history is +preserved and further history writes are suspended until repair and relaunch. +Disposable scan caches store selected numeric results under opaque keys. +`quota-activity.json` keeps, per provider slot, the tracking window label, the +last used percentage and reset time, and daily percentage-point rises for up +to 200 days. It is derived only from quota readings the app already makes; no +new request, credential, prompt, or account identifier is involved. Connection +changes retain the previous series under an opaque generated key outside the +active heatmap; replacement accounts start a fresh series. + +The Electron candidate retains temporary card images in the app support +directory until normal quit, so a selected sharing service can read the file +after its menu closes. A crash can leave those images on disk. + +Preferences retain the existing macOS domain and known keys. Managed accounts +remain UserDefaults Data containing JSON. Dates and booleans keep their native +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. 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 and Claude status. Its only status request is +an unauthenticated GET to the public OpenAI status feed above. It blocks updates, +login-item changes and notifications. Explicitly confirmed Codex reset redemption +uses the same captured keyring-only connection profile and a fresh eligibility +check. [ADR 0017](adr/0017-connected-codex-menu-reset.md) supersedes only ADR 0012's +reset prohibition. Its Codex credential boundary is the same keyring-only boundary +described above. + +`--claude-test-profile` uses a separate profile marker and the existing Claude +consent and allowance flow. It starts without consent and collects only Claude +account allowance. It cannot use a Codex or demo profile. JSON preferences and +Electron state remain isolated; OS home is retained for the read-only Desktop +reader. Codex access and cleanup, other providers, local activity, status feeds +and reset redemption are blocked. Candidate update, login-item and notification +guards apply. It adds no credential source, refresh flow or provider request. + +## Installation and enforcement + +`Scripts/make-app.sh` downloads a pinned public ZIP by default, checks SHA-256 +and code signature, and copies/verifies replacement output in staging. Failure +preserves the previous generated app. It does not install an app or developer +tools. Source compilation requires `--build-from-source`. Electron candidate +packaging uses `--publish never` and does not replace that installation path. + +Synthetic tests assert parser/privacy, account, history, schema and IPC +contracts. The repository pre-commit hook scans staged diffs for credential +shapes and personal paths; `.gitignore` excludes credential-shaped files and +live captures. These controls reduce accidental disclosure. They do not prove +provider authentication, live payload correctness or native UI behavior. + +Cost figures are local estimates from the pricing table. A recorded zero is a +value; missing tokens or cost stay unknown. Provider dashboards remain the +billing source of record. + +## Optional diagnostic reports + +Only clicking **Send report** in live Settings submits a generated diagnostic +summary to the existing HTTPS support relay, which creates a private Linear +issue. There is no automatic upload or login requirement. The summary includes +the app version, mode, refresh interval/age, enabled provider names and bounded +status codes for quota, activity, usage, service status, plan and history/cache +failures. It excludes raw logs and errors, personal paths, account labels and +identifiers, credentials, usage contents, prompts and transcripts. + +Main generates the summary; renderer requests cannot supply report text, IDs or +URLs. Reports have a random UUID for duplicate-safe retries. Pending summaries +and confirmed references remain in memory for the app session. A next-allowed +submission time is saved locally to enforce a 10-minute cooldown across app +relaunches. No report content or receipt is persisted. Requests omit +cookies and stored credentials, reject redirects, and enforce byte limits and a +timeout. The relay sees the network source IP for rate limiting; Cloudflare and +Linear process the submission. MeterUsage does not attach provider credentials. +See [REPORTING.md](REPORTING.md) for the delivery contract and proof limits. + +## Legacy Swift source diagnostic reports + +The retained Swift source includes a separate reporting feature absent from +the published Swift 0.2.41 app. Its [reporting contract](swift/REPORTING.md) +also describes the native-consent agent feedback CLI. -meterusage reads AI coding-assistant usage from your own machine. That means it runs in close proximity to live credentials, so its boundaries are deliberate and narrow. This document states exactly what it does and does not do, and how each claim is enforced rather than merely promised. - ---- - -### ⚡ TL;DR - -* 🛡️ **Zero Credential Exposure**: Never touches Claude or Codex authentication tokens, passwords, or macOS Keychain items. OpenRouter uses an existing environment variable/local key strictly for aggregate balances in memory. -* 🚫 **No Prompts, Code, or Message Inspection**: Only reads numeric token tallies and event timestamps from local CLI stores. Message bodies, prompts, tool inputs/outputs, and workspace paths are never decoded or transmitted. -* 🧼 **Sanitized at the Boundary**: User paths (`/Users//`), terminal IDs, hostnames, and emails are structurally dropped before reaching memory or the UI. -* 🔒 **Strict Network Isolation**: No automatic diagnostic uploads or analytics. Connects to provider endpoints and public feeds; the support relay is contacted only when you request a report. -* 🧪 **Enforced by Automated Tests**: Privacy guarantees are asserted by unit tests and locked with a pre-commit git hook that fails closed on credentials or paths. - ---- - -## What meterusage never does - -- **Never reads Codex or Claude credentials.** It does not open `~/.codex/auth.json`, `~/.claude/.credentials.json`, or any macOS Keychain item. OpenRouter is the explicit exception: when configured, it reads an existing `OPENROUTER_API_KEY` or supported local key file in memory only to call OpenRouter's aggregate usage and balance endpoints; it never displays, logs, or stores that key. -- **Never asks you to paste a key.** There is no login screen, no token field, and no account connection flow. -- **Never uses undocumented provider APIs.** It does not reuse another application's OAuth client id, and it does not call private endpoints. -- **Never sends prompts or code anywhere.** Provider requests are limited to the documented Codex/OpenRouter usage calls and public status feeds. There is no automatic telemetry, analytics or crash upload. User-requested diagnostic reports use the support relay described below. -- **Never reads your prompts or code.** It parses only usage and metadata fields from local transcripts. Message content is skipped, not stored. - -## Where the numbers actually come from - -| Source | Mechanism | Network? | -|---|---|---| -| Codex quota | Spawns `codex app-server --stdio` and makes a JSON-RPC `account/rateLimits/read` call with the CLI's experimental rate-limit detail capability enabled. That returns general/model-specific windows and earned reset-credit expiry details when the account provides them. This is a supported CLI surface; the subprocess authenticates itself using your existing `codex login`. meterusage never sees the token. | Yes, by the CLI subprocess | -| Second Codex account | The same subprocess mechanism with `CODEX_HOME` pointed at the alternate account's config directory (`METERUSAGE_CODEX_ALT_HOME` or a stored default). The child authenticates itself from that home; meterusage still never opens any auth file, and only spawns the subprocess when that directory exists. | Yes, by the CLI subprocess | -| OpenRouter quota | Calls the documented `/api/v1/key` and `/api/v1/credits` endpoints with an existing API key and retains only aggregate dollar usage, account balance, optional limit, and reset cadence. It does not send prompts or model requests. | Yes | -| OpenRouter activity | Calls `https://openrouter.ai/api/v1/activity` with an OpenRouter Management Key (`OPENROUTER_MANAGEMENT_KEY` or `~/.cli-proxy-api/openrouter-management-key`) to fetch aggregate daily token volume (input, output, reasoning) over the last 30 days. | Yes | -| Grok quota | Calls the billing endpoint the Grok CLI itself uses (`cli-chat-proxy.grok.com/v1/billing`). The OIDC bearer token is re-read from `~/.grok/auth.json` on every refresh — never cached from launch — and is sent only in the request Authorization header. Only the allowance percent, period type, and reset time are retained; no prompts or model requests are sent. | Yes | -| Claude activity | Streams your own transcript files under `~/.claude/projects/`, summing token-usage fields. | No | -| Second Claude account | The same local-only mechanisms pointed at the alternate account's config directory (`METERUSAGE_CLAUDE_ALT_CONFIG` or a stored default): transcripts under `/projects/`, the plan tier via the same narrow decode of `/.claude.json`, and a companion quota snapshot inside that directory. Nothing is read outside that directory for the second account — in particular, the primary account's `.claude.json` is never consulted for it, because a wrong-but-plausible plan badge is worse than none. | No | -| Codex activity | Counts sessions per day from `~/.codex/sessions/**/*.jsonl`, reading only the start timestamp on each rollout's first event (falling back to the file's modification date). Session payloads are never opened. | No | -| Claude quota *(optional)* | Read-only parse of a local usage snapshot if a companion already wrote one (`~/.claude/claudewatch-usage.json` or `~/.claude/meterusage-usage.json`). Supports legacy `five_hour` / `seven_day` / `weekly` shapes and, when present, a `limits[]` array (session / weekly_all / weekly_scoped, including Fable). meterusage does **not** fetch Anthropic quota and does **not** read Claude credentials. Absent by default and never requested. | No | -| Antigravity quota | Selects a healthy available Docker or Podman runtime, then runs `agy -p "/usage"` inside the same container image and volumes the user's agy wrapper uses. If no runtime is healthy, it may start only an existing `podman-machine-default` after inspecting that machine. It never creates a machine. It reads only each row's model group, window label, percent remaining, and reset timestamp. No prompt or model request is sent; the CLI's own backend quota refresh runs inside the container. No credential leaves the volume. | Yes, by the CLI | -| Antigravity usage | Uses the same bounded runtime selection and existing-default-machine recovery as quota. It decodes only numeric token-usage fields and turn timestamps from agy's per-conversation SQLite stores under `~/.gemini/antigravity-cli/conversations/`, preferring the native location and falling back to the `antigravity-config` container volume when agy is containerised. Prompt text, tool payloads, workspace paths, and identifiers inside the stores are never decoded. Older installs without conversation stores fall back to `history.jsonl`, where only the `conversationId` and `timestamp` fields of each line are read and token totals remain unknown. | No | -| Grok usage | Reads only date and message-count fields from `~/.grok/sessions/**/summary.json`. It does not open chat-history content or context-window signal files. | No | -| OpenCode Go usage | Invokes the local `opencode db --format json` command with a read-only SQL query selecting numeric token/cost fields, message counts, and timestamps from `session`. It never selects message bodies, prompts, tool arguments, or paths. | No | -| Codex service health | Public, unauthenticated Statuspage JSON at `status.openai.com`, filtered to Codex, CLI, and login components. | Yes | -| Claude service health | Public, unauthenticated Statuspage JSON at `status.claude.com`, filtered to Claude/API components. | Yes | - -### Optional Claude quota file (including Fable) - -Claude quota bars are a pure bonus. A companion tool must already have written a small JSON snapshot to disk; meterusage never prompts for it and never creates it. Implementation lives in `Sources/MeterUsage/Services/OptionalQuotaFileSource.swift`. - -Honest limits of that path: - -- **No direct Anthropic quota fetch.** meterusage does not call the usage API and does not open `~/.claude/.credentials.json` or Keychain items for this purpose (or any other). -- **`limits[]` is parsed when present.** When the snapshot includes a non-empty `limits` array, those windows fully replace legacy 5-hour / 7-day / weekly keys so the same window is not drawn twice. A `weekly_scoped` entry with `scope.model.display_name == "Fable"` can render as a real Fable plan-allowance bar. -- **Fable display depends on the writer.** The parser can handle `limits[]`, but the bar only appears if the local companion emits that shape. Current claudewatch JSON may still contain only legacy fields (`five_hour`, `seven_day`, `extra_usage`); until a writer includes `limits[]` (or an equivalent weekly breakdown), no Fable quota bar is shown. -- **Credits stay orthogonal.** Extra-usage / credit balance is separate from plan windows and is not how Fable is labelled. - -### Why there is no "sign in with Claude" button - -Anthropic publishes no supported API for Claude subscription quota. The only sanctioned channel is Claude Code's own statusline. A third-party app could reach the same numbers by lifting Claude Code's OAuth token out of the Keychain and calling an undocumented endpoint with Anthropic's first-party client id — meterusage deliberately does not, because that impersonates the official client and can break without notice. - -The consequence is honest rather than hidden: Codex shows real live quota, including Codex's 2,500-credits-to-$100 display conversion; OpenRouter shows provider-reported dollar usage and remaining account balance, local usage rows show only the fields each provider can prove, and Claude quota bars appear only if a usage file is already present — and only with the windows that file actually contains. Nothing is silently estimated and labelled as authoritative. - -## Cost figures are estimates - -Costs are computed locally from token counts against a rate table in `Sources/MeterUsage/Services/Pricing.swift`. That table covers Claude list rates and OpenAI/Codex Standard list rates (the GPT-6 and GPT-5.6 families and `gpt-5.3-codex`); Codex sessions are priced using the model recorded in the local rollout, and cache writes are free because Codex does not charge for them. Published rates change, and the table can drift. Treat every cost in this app as an approximation for awareness — never as a billing figure. Your provider's dashboard is the only source of truth for what you owe. - -## User-requested diagnostic reports Settings **Report issue** sends a generated diagnostic summary to the configured HTTPS support relay. The relay creates an issue in the maintainers' Linear @@ -86,86 +461,3 @@ Linear under the maintainers' workspace access and retention settings. A failed request is not reported as sent; uncertain retries reuse the report ID and snapshot across Settings navigation. Pending reports and receipts stay in app memory until the app quits. They are not saved to disk. - -## What leaves your machine - -### Installation downloads - -`Scripts/make-app.sh` downloads a pinned public release ZIP from GitHub by -default. It sends no credentials, account data, or local source files. The -script checks the recorded SHA-256 and the app's code signature before replacing -generated output in `dist/`, and deletes its temporary download on exit. It does -not read provider stores or install developer tools. Source compilation is a -separate, explicit `--build-from-source` mode. - -### App requests - -Outbound requests or subprocess-backed provider checks, all of which you can verify in the source: - -1. The `codex` CLI subprocess contacts OpenAI's backend to read your rate limits. This is the same call the CLI makes for itself. -2. meterusage fetches `https://status.openai.com/api/v2/components.json`, a public status feed. No credentials, no identifiers, no usage data is sent. -3. meterusage fetches `https://status.claude.com/api/v2/components.json`, a public status feed. -4. meterusage fetches OpenRouter's documented `/api/v1/key` and `/api/v1/credits` endpoints with the existing key, retaining only aggregate dollar usage and balance fields. When an OpenRouter Management Key is configured, it queries `/api/v1/activity` for 30-day token volumes. -5. meterusage fetches Grok's billing endpoint (`cli-chat-proxy.grok.com/v1/billing`) with the OIDC bearer token re-read from `~/.grok/auth.json`, retaining only the allowance percent, period type, and reset time. No prompts or model requests are sent. -6. At most once a day, meterusage fetches `https://api.github.com/repos/pekth/meterusage/releases/latest` to check for a newer release. The request is unauthenticated, carries no body, no identifier, and no usage data — the server sees only your IP and a User-Agent string, the same as any web visit. The response's version tag is compared to the running build; a failed or rate-limited check is silently ignored. This check can be switched off in Settings → General → "Check for updates". When you click Install, the release zip is downloaded from the same release's asset URL and its SHA-256 is verified against the digest GitHub publishes with the asset; a missing or mismatched digest aborts the install. - -7. When you click Report issue, meterusage posts its diagnostic summary to the configured HTTPS relay. The relay forwards it to the private Linear project. No background diagnostic upload occurs. - -The local usage commands and file reads above add no outbound request. There is no analytics endpoint to disable because there is none. - -## The one file we read that also contains personal data - -To show which plan you're on (Pro, Max 5×, Max 20×), meterusage reads a single value from `~/.claude.json`: `oauthAccount.organizationRateLimitTier`, with `seatTier` and `userRateLimitTier` as fallbacks. - -That file also contains your email address, display name, account UUID, organization UUID, and organization name. None of them are read — and that is structural, not a promise: - -`ClaudePlanSource` decodes with a `Decodable` struct declaring **only** the three tier keys. Swift's `Decodable` silently drops every undeclared key, so the identifying fields are never materialised into a Swift value at all. There is no dictionary decode, no `[String: Any]`, and no code path that could read them. Tests feed a fixture containing an obviously-identifying email and org name, then assert neither can appear in the produced value. - -If you extend that struct, you break this guarantee. The file says so in a header comment for exactly that reason. - -## Identifying data is stripped at the boundary - -Provider payloads and local transcripts both carry material that should not reach the screen, a cache, or a screenshot. `Privacy` in `Sources/MeterUsage/Services/DataSource.swift` is the single chokepoint: - -- Absolute paths contain your OS username, so project directories are reduced to their final component before entering the model layer. -- Session UUIDs are replaced with opaque, non-reversible ids used only for list diffing. -- The Codex RPC handshake returns a machine `installationId`, a hostname, and a user-agent string identifying your terminal. All three are dropped on read. - -Second-account slots follow the same boundary with one addition: a slot is identified by **position only** ("Codex second account", digit "2"). No email, account id, org name, or any provider-issued identity is read to name it — the tools keep multiple accounts in separate config directories, and meterusage keys the slot on the directory you configured, nothing inside it. The directory path is rendered only back to you in Settings, reduced to `~` form, and never enters caches, diagnostics, or the JSON report. - -Because these conversions happen in the source layer rather than the view layer, there is no code path that renders the raw value. - -The supplemental sources apply the same boundary by construction: Antigravity reduces its conversation stores to numeric token fields and turn timestamps, Grok reduces its stores to counts and dates, and OpenCode Go asks its database command for numeric usage columns only. Provider names and metric colours are rendered alongside written labels, so colour is never the only meaningful signal. - -## How this is enforced - -The local quota archive stores the displayed quota windows and groups, -balances, plan and reset-credit metadata, and capture times. After a manual -Codex reset, it also stores the reset time and numeric quota observations used -for pacing. These observations contain percentages and timestamps. The archive -contains no authentication material, prompts, or provider account identity. -Restored readings are displayed as dated, stale data. - -Stated policy is not a control. These are: - -- **Tests** assert that no `SessionSummary` contains a path separator or the substring `Users`, and that parsed quota carries no hostname or installation id. -- **A pre-commit hook** (`.githooks/pre-commit`) scans staged diffs for token prefixes, JWTs, private-key blocks, `/Users//` paths, Apple team ids, and credential-shaped filenames. It fails closed. -- **`.gitignore`** refuses credential-shaped files by name as a second layer. -- **Test fixtures are synthetic.** Captured live payloads are excluded by `.gitignore`; fixtures are hand-written with invented values. - -## Reporting a problem - -If you find a case where meterusage exposes something it should not, please open an issue. If it involves a live credential, do not paste it into the issue — describe the shape and location instead. - -## Agent bug drafts - -The `report draft` and `report submit` commands read bounded descriptions, -steps, expected behavior and actual behavior from stdin. The CLI does not read -arbitrary files, transcripts, provider credentials or saved account state. -It appends app and system metadata from the existing diagnostics builder; -provider and history state are not observed. User prose is not automatically -safe: common paths, links, email addresses, credential patterns and controls -are rejected, but private facts and unrecognized secrets must be omitted by -the agent and checked by the user. The read-only native preview shows all text, -the relay and the fixed private Linear destination before each explicit send. -No confirmation flag or automatic retry exists. See [REPORTING.md](REPORTING.md). diff --git a/docs/REPORTING.md b/docs/REPORTING.md index 53a7e57..9098dad 100644 --- a/docs/REPORTING.md +++ b/docs/REPORTING.md @@ -1,164 +1,52 @@ -# Diagnostic reporting - -Report issue sends app-generated diagnostics to a private Linear project -without requiring the reporter to sign in. Copy diagnostics stays available. -The relay is deployed on Workers Free. Source builds configure its `/report` -endpoint; the released v0.2.41 app does not include this feature. -Settings states that diagnostics are sent only on request; it does not promise -that all data stays local. - -## Data and user flow - -The entry state is Settings with provider readings already loaded or failed. -Clicking Report issue captures one diagnostic snapshot and starts one HTTPS -request. The button is disabled during submission. A matching confirmed Linear -issue reference changes it to Sent. Errors keep the report available for retry; -an uncertain retry reuses the original report ID and snapshot. The app -coordinator retains the pending report, send state and receipt when Settings -closes and reopens. A confirmed receipt keeps Send disabled for the rest of the -app session. Quitting the app clears this in-memory state; no report is saved -to disk. See [ADR 0012](adr/0012-report-state-outlives-settings.md). - -The report includes app/build, numeric OS/architecture, provider states, -aggregate readings and freshness, cache/history state, and recent refresh -outcomes. It does not poll providers or run commands when reporting. Unknown -observations stay unknown; it cannot reconstruct earlier raw errors or crashes. -See [PRIVACY.md](PRIVACY.md) for excluded data and network metadata handling. - -The request is `POST /report`, `Content-Type: application/json`: - -```json -{"schema":1,"id":"68ff7a74-cd68-4f0b-b702-e5fe09a84bd5","diagnostics":"MeterUsage 0.2.41\nmode: demo"} -``` - -The JSON body is limited to 65,536 bytes and diagnostics to 49,152 UTF-8 bytes. -The app rejects oversized reports without truncating them. The relay accepts -only the declared fields. Team, project and issue title are server-controlled. -A confirmed response is HTTP 201 with `id` and `identifier`. The app reads -replies incrementally and cancels reception above 1,024 bytes. Oversized or -invalid replies show delivery as unconfirmed. Upstream error bodies are never -displayed to reporters. - -Diagnostics use a fenced Markdown block because Linear converts indented -blocks to fences. The delimiter is longer than every backtick sequence in the -report. Receipt checks still require the exact description, UUID and destination. - -## Free hosting and configuration - -Use [Cloudflare Workers Free](https://developers.cloudflare.com/workers/platform/pricing/) -and its included workers.dev hostname. The published free allowance is 100,000 -requests per day per account. Exceeding the allowance returns an error. It does -not buy an upgrade. The relay needs no database, object storage or queue. -Use the existing Linear workspace; this setup does not purchase Linear seats -or lift that workspace's own issue or API limits. - -Before deployment, verify that the intended Cloudflare account is on Workers -Free. Do not silently deploy into a paid account or change its billing plan. -Deployment and secret configuration require the project owner's authorization. - -Configure these server-side bindings: - -- `LINEAR_API_KEY`: a secret restricted to the intended Linear team. -- `LINEAR_TEAM_ID`: the intended team UUID. -- `LINEAR_PROJECT_ID`: the existing MeterUsage project UUID in that team. -- `PER_IP` and `GLOBAL`: the rate-limit bindings in `Reporting/wrangler.jsonc`. - -Keep credentials out of source, app bundles, command arguments, logs and files. -Use the hosting provider's secure secret-input mechanism. The app contains only -the public HTTPS endpoint under `MeterUsageReportURL` in `Resources/Info.plist`. -The source bundle sets it to -`https://meterusage-reports.fancy-queen-3301.workers.dev/report`. -Missing configuration returns an explicit unavailable state. - -Limits allow three requests per minute per network address and 30 per minute -per Cloudflare location. Missing rate-limit bindings fail closed. These limits -reduce spam; they do not authenticate an anonymous sender or guarantee a -global ceiling. The Linear UUID prevents duplicate issue creation on a retry. - -## Activation evidence - -On 2026-10-08, the authenticated Cloudflare dashboard showed Workers Free -Active. A synthetic report sent by the unmodified Swift client reached the -MeterUsage Linear project. Repeating the identical request returned the same -issue reference; an independent Linear read confirmed one issue, its exact -diagnostic text and destination. No paid resource was added. - -This proves native client delivery and retry against the deployed service. -It does not prove native Settings interaction or availability in a released app. - -## Required verification - -Run `node --test Reporting/worker.test.mjs` for the relay contract. Run -`swift build`, `swift test` and `Scripts/make-app.sh --build-from-source` on -macOS for the app candidate. Use synthetic inputs only. - -Verify success, lost acknowledgement/retry, double-click, Settings navigation -during a send and after its result, offline failure, -throttling, oversized input, response cancellation before stream completion, -invalid receipts and redirects. Confirm excluded -data stays absent from diagnostics and that arbitrary provider labels cannot -enter the report. A mock server test does not prove deployed Linear delivery. - -Before distribution, use the configured candidate to submit one synthetic -report. Read back the exact Linear issue and project, compare its diagnostic -content and report ID, then retry the same ID and verify only one issue exists. -Record the candidate digest, endpoint, issue reference and executed checks. -Check the actual Settings interaction on the native target. Source inspection -or a static rendered capture does not establish interactive UI behavior. - -## Agent-facing bug feedback - -Use the executable inside a configured MeterUsage app bundle. A bare SwiftPM -binary can draft, but cannot submit without the bundle's existing reporting -endpoint. This feature adds no endpoint override or client credential. - -`meterusage report draft` reads JSON from stdin with exactly four string fields: - -```json -{"description":"Synthetic external link does not open","steps":"Select the example external link","expected":"The browser opens","actual":"Nothing happens"} -``` - -It prints those fields plus a new `id`. Retain that JSON in the agent's current -context. Pass it unchanged on stdin to `meterusage report submit`. Neither -command reads a report file or saves a draft. Description is limited to 512 -UTF-8 bytes; each other field to 2,048 bytes; total stdin to 8,192 bytes. Empty -fields, extra fields, non-v4 IDs, controls, paths, links, email addresses and -common credential patterns fail before preview or network access. A supplied -`confirmed` field is rejected. -The encoded draft including its ID and output newline must also fit the stdin -limit, so every successful draft can be passed unchanged to submit. - -Submission opens a native scrollable preview with the exact report, report ID, -HTTPS relay and MeterUsage support's private Linear destination. Cancel is the -default button. Send this report grants consent for one request. CLI stdout -returns JSON containing `id`, `status`, and a verified `identifier` only on -success. Decline returns `declined` with no request. Delivery failures are -sanitized, and the user can cancel or explicitly retry the unchanged report. -Cancel after a failed acknowledgement returns `unconfirmed`, not unsent. -Unavailable configuration returns `unavailable`. Errors exit nonzero; a fresh -decline or confirmed receipt exits zero. No raw response body or URL error is -printed. - -The appended diagnostics contain app and numeric system metadata only. No -provider is polled and no transcript, arbitrary file or account data is read. -The CLI marks provider/history observations unknown; it does not borrow state -from a running app. The short prose fields describe observed evidence, not raw -logs or conversations. Pattern validation cannot identify every private fact: -the agent must omit them and the real user must review the full preview. - -Keep the same draft ID and text for an uncertain retry, including after process -exit. The relay reconciles exact UUID, content and destination. Changed app or -system metadata with the same ID can produce an unconfirmed result. Do not -mint a new ID automatically after a failure. A deliberate new draft is a new -report and requires new native consent. See [ADR 0013](adr/0013-agent-feedback-native-consent.md). - -The external-link fixture is a synthetic UX example inspired by -[Ben Davis's post](https://x.com/davis7/status/2108756950641254490). -It does not establish that the reported Grok bug was reproduced or fixed. - -Focused proof covers draft validation, exact native preview, cancel without a -request, explicit send through the existing client to an injected synthetic -transport, matching/mismatched receipts, sanitized failures and byte-identical -uncertain retries. Native tests operate on their own modal window and never -contact the deployed relay. Live delivery, installed-app behavior and release -availability require separate proof and authorization. +# Diagnostic reports + +This page describes Electron. The retained Swift source has a separate +[reporting and native-consent CLI contract](swift/REPORTING.md), which is not +included in the published Swift 0.2.41 app. + +In **Settings → Diagnostics**, choose **Send report** to submit a sanitized +status summary to private MeterUsage support in Linear. No account is required. +The button shows progress, then a confirmed reference. Copy diagnostics remains +available if delivery fails. This sends status diagnostics, not a raw log bundle. + +The report includes the running app version, demo/live mode, refresh interval +and age, enabled provider names and status codes for quota, activity, usage, +service status, plan, history and archive failures. It excludes credentials, +account labels and identifiers, file paths, raw errors, prompts and transcripts. +See [privacy](PRIVACY.md#optional-diagnostic-reports). + +## Delivery + +Main generates a random UUID and the report snapshot after the user's click. +Renderer IPC accepts only the send action. The existing HTTPS relay receives +exactly `schema`, `id` and `diagnostics`; support credentials and issue routing +stay on the server. The summary is limited to 49,152 UTF-8 bytes and the JSON +body to 65,536 bytes. Requests omit cookies/credentials, reject redirects and +time out after 20 seconds. Replies are limited to 1,024 bytes. Success requires +HTTP201, the matching UUID and a valid support reference. + +Repeated clicks while sending do nothing. An uncertain retry reuses the UUID +and snapshot for server-side reconciliation. A confirmed reference stays visible +through Settings navigation; further sends are disabled until app relaunch. +Each attempt also reserves a 10-minute cooldown before contacting the relay. +The next allowed time is saved as a private local preference, so app relaunch, +Settings navigation and failed requests do not reset it. The button stays +disabled until that time and shows when retry is available. Main enforces the +same gate even if a renderer sends another request. If saving the cooldown +fails, nothing is sent. Copy diagnostics remains available. + +Only the cooldown time is persisted. Pending report content and receipts are +not saved to disk. Uncertain retries within the same app session keep the +report UUID and snapshot. No automatic retry runs after the cooldown expires. +Rate limits and unknown failures show fixed messages without server responses +or raw errors. + +The legacy relay configuration sets 3 requests per IP per minute and 30 total +per minute before contacting Linear. These Cloudflare counters are local and +eventually consistent, not a strict worldwide cap. Client cooldown protects +against accidental repeated sends; it does not replace server enforcement. +The reviewed source configuration does not establish current deployed limits. + +Mock transport tests cover requests, receipts, limits, retries and redaction. +They do not establish current relay deployment or delivery to Linear. A live +submission creates an external support issue and needs a deliberate user action. diff --git a/docs/SIDE-NOTCH.md b/docs/SIDE-NOTCH.md index f94434d..8887478 100644 --- a/docs/SIDE-NOTCH.md +++ b/docs/SIDE-NOTCH.md @@ -1,10 +1,10 @@ # Side notch panel The side notch is the floating strip of provider rings on the screen edge. This -document describes what it does and the geometry it must hold. The decision +document describes the retained Swift behavior and the Electron candidate. The decision record is [`docs/adr/0004`](adr/0004-side-notch-anchor-invariant.md). -## States +## Retained Swift states | State | Trigger | What shows | | --- | --- | --- | @@ -85,8 +85,87 @@ floats at the status-bar level, and accepts mouse events only on itself. Because the window never takes key focus, each ring is an accessibility element with an explicit "Show details" action rather than a focusable control. +## Electron candidate interaction + +The candidate panel is non-focusable and uses `showInactive`, so it cannot rely +on a window blur event to dismiss a card. Pointer exit hides the detail card +through the existing 450 ms fold delay and leaves the usage strip. +During an open hover or selection interaction, main checks the native cursor +against the panel bounds every 100 ms. A missed renderer exit therefore enters +the same fold path on the next cursor check. The check stops on exit, hide, close, drag or +quit. It does not install a global input hook. + +The Electron strip is a neutral charcoal 36 px rail. Each 22 px usage ring +contains its provider mark, with the used percentage below. The strip has no +Settings button or reserved button slot. Open Settings from the expanded +usage card footer or the menu-bar context menu. +The rail stays visible while details are closed or open. There is one design, with no dots markup or design selector. +Small, Medium and Large change size only. Hover opens details, ring hover +switches providers, and pointer exit closes details after 450 ms. +Disable/hide clears expanded state, so re-enabling with the pointer outside +starts with the 36 px strip and the next hover opens details normally. + +The candidate has no Pin detail card or Keep open control. Legacy saved +`sideNotchPanelPinned=true` remains stored but cannot open or retain details. +The share menu, reset confirmation and drag retain their temporary guards. +Drag completion reads the current cursor rather than restoring stale hover +state. Dismissal does not change a preference. [ADR 0019](adr/0019-single-design-notch.md) +records this candidate correction; the retained Swift implementation is unchanged. + +Heatmap cells show a dated readout immediately on hover or keyboard focus. +Daily cells name their date, weekly cells name the week starting Sunday, and +cumulative cells name the date through which the displayed history is summed. +Counts use the existing history scope and full numeric formatting. OpenRouter +ledger dates retain their UTC calendar day. Count-only +providers show session counts and report token totals unavailable. The readout +uses a natural-height row below the grid. Long exact counts wrap and may grow +the card without moving its top edge or rail. No blank readout area is reserved. +Keyboard focus has a visible outline. The non-activating native notch's keyboard +access still needs native proof; source fixtures cover focus labels and styles. +Notch card actions, including Codex Use reset, use compact 20 px minimum-height buttons with 2 px vertical +and 5 px horizontal padding. Labels use 10 px text in the menu and Small/Medium +notch cards, and 12 px in Large. Heatmap menu options share a fixed checkmark +column and aligned labels. Settings panel size uses explicit integer pixel sizes for notch +text, buttons and spacing, without CSS zoom. Small uses an 11 px body and +10 px secondary text; Medium uses 13 px and 10 px; Large uses 15 px and +12 px. The visible card stays 220 pixels wide and the strip stays 36 pixels +wide. Changing size preserves their horizontal anchor. Content scrolls within +the display work area. Flyout and Settings retain their existing scaling. +Shared cards keep their original scale. Eligibility, account labels and Cancel-default confirmation remain. + +Secondary labels in the Electron notch card use `#b0b0b8`, without changing +provider status or quota colors. Telemetry starts with a border and 6/8/10 px +of top padding for Small/Medium/Large. Existing metric margins remain. +Enabled detail buttons transition only the background color for 120 ms on +fine-pointer hover when reduced motion is off. Press and keyboard focus stay +immediate, and reduced motion disables transitions. Card, window, ring and +provider-switch states do not animate. Browser and native checks must verify +these rendered states and scrolling after the added telemetry spacing. + +Live cards use the natural-height heatmap readout and show Daily, Weekly and +Cumulative choices with their existing values. Quota, eligible reset actions and +heatmaps remain visible. Recorded activity starts with two available metrics; +More activity opens the remaining metrics. Usage by period and Active window +breakdown open the existing period and burn details. These rows start closed. +Tighter spacing and a shorter sparkline reduce card height without changing the +saved font-size choice or the rail. Static exports retain all metrics and full +period/burn data. Recorded activity headings and +Recorded sessions/messages labels distinguish telemetry from account/local +summary counts. Static shared cards omit footer actions, reset buttons and the +heatmap selector while retaining credit details and the 26 weeks label. They +also omit the unused hover readout space. Export width, full-height sizing and +frozen selected-account behavior retain their existing implementation. Native +export completeness remains a separate check. + ## 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 +candidate hides scrollbar indicators throughout its windows while retaining +scrolling. The strip is 36 pixels wide around the provider marks and rings. The shared-card +capture keeps its 250-pixel width, 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/docs/VERSIONS.md b/docs/VERSIONS.md index dc30ee9..3bf0c26 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -1,22 +1,73 @@ # MeterUsage versions -Electron 0.3.0-beta.3, build 61, is the opt-in public beta. Swift 0.2.41 stays +Electron 0.3.0-beta.6, build 75, is the opt-in public beta. Swift 0.2.41 stays stable and remains the updater/installer download. New features target Electron; Swift is retained for critical maintenance and rollback. | Version | Source | Download | | --- | --- | --- | -| Electron beta | [v0.3.0-beta.3](https://github.com/pekth/meterusage/tree/v0.3.0-beta.3) | [Manual signed/notarized Apple silicon ZIP](BETA.md) | +| Electron beta | [v0.3.0-beta.6](https://github.com/pekth/meterusage/tree/v0.3.0-beta.6) | [Manual signed/notarized Apple silicon ZIP](BETA.md) | | Swift stable | Default branch and [v0.2.41](https://github.com/pekth/meterusage/tree/v0.2.41) | [Swift guide](swift/README.md) | The beta source is published through its tag while the Electron PR stack stays -unmerged. The tag includes current documentation and synthetic screenshots; +unmerged. The tag includes current documentation and build 61 synthetic screenshots; its executable inputs match build source -`c2d092cb92180b37a686582a904dbd0129bc371c`. The beta's package version was set +`023b753cfa1c248774efecb28a1770ee8de0950b`. The beta's package version was set at packaging time; its source manifest retains 0.2.41. Settings shows the running bundle version. See [known beta limits](BETA.md#known-limits). The apps share an identity and normal data locations. Quit one before replacing it and preserve a rollback copy. Do not run both against a normal profile. Stable cutover and Swift retirement remain separate work, defined in the -[beta repository strategy](https://github.com/pekth/meterusage/blob/v0.3.0-beta.3/docs/VERSIONS.md). +[beta repository strategy](https://github.com/pekth/meterusage/blob/v0.3.0-beta.6/docs/VERSIONS.md). + +## Update channels + +Swift and stable Electron builds read GitHub's `/releases/latest`, which never +returns prereleases, and expect `MeterUsage-.zip`. Electron builds whose +version ends in `-beta.N` read the release list instead, skip drafts and pick the +newest release with a verified `MeterUsage-.zip` or +`MeterUsage--mac-arm64.zip` asset. A final release outranks its betas, +so beta users are offered the stable release that follows. Beta 4 and earlier +predate this and need one manual install. + +## Stable cutover checklist + +Stable Electron publication is pending. Source inspection establishes the +following requirements; it does not prove an installed Swift upgrade. + +- Publish the intended stable ZIP as `MeterUsage-0.3.0.zip`, with GitHub's + `sha256:` asset digest. The Swift 0.2.41 updater selects only + `MeterUsage-.zip`; an asset named only + `MeterUsage-0.3.0-mac-arm64.zip` cannot install through that updater. + `/releases/latest` must select the stable release. Keep prereleases outside + that endpoint until cutover is approved. +- Preserve `MeterUsage.app`, executable `meterusage`, bundle ID + `dev.meterusage.app`, the native preferences domain and + `Library/Application Support/MeterUsage`. Electron reads existing known + preference keys, including JSON stored as UserDefaults Data for managed + accounts. It shares `durable-daily-history.json` and `quota-archive.json`; + archive dates retain Swift's 2001 reference epoch. There is no bulk data + migration. Electron adds its own state and derived quota-activity data. +- Test in a separate macOS user account or VM with synthetic preferences, + managed accounts and history. Start the published Swift 0.2.41 app, exercise + its actual download/install path against the intended Electron artifact, + then verify launch, settings, account isolation and history after relaunch. + On 2026-10-22 this native test was blocked: the available Mac had no separate + test user or detected VM. An isolated Electron demo profile cannot test + Swift's normal UserDefaults and updater path. +- Check the installed signature after the Swift updater runs. Its install + script clears extended attributes and runs + `codesign --force --deep -s -` on the replacement. This can replace the + Electron Developer ID signature with an ad-hoc signature. Verify hardened + runtime, Gatekeeper and the bundled helpers after the actual transition; + the signed download alone does not prove their installed state. +- Before the transition, retain the Swift ZIP, a copy of the installed app, + and a backup of its preferences and support directory. Quit Electron before + restoring Swift. Verify the restored preferences and history in that same + isolated account. Neither updater provides a persistent rollback backup: + both remove their temporary old-bundle backup after replacement. Do not + assume an older reader accepts every new provider or archive value. +- Keep the default installer, README and release metadata on Swift until the + isolated upgrade and rollback evidence passes and stable publication is + authorized. Merging Electron source does not publish a stable release. diff --git a/docs/adr/0010-macos-typescript-electron.md b/docs/adr/0010-macos-typescript-electron.md new file mode 100644 index 0000000..a895bdc --- /dev/null +++ b/docs/adr/0010-macos-typescript-electron.md @@ -0,0 +1,69 @@ +# ADR 0010: Port macOS to TypeScript and Electron + +- Status: Accepted architecture; native migration acceptance pending +- Date: 2026-10-06 + +## Decision + +Port the macOS app and headless schema-1 JSON CLI to TypeScript, Electron and +React. Use pnpm, Vite+, Tailwind and Vitest with electron-builder for macOS +packaging. Keep one package and a small validated IPC bridge. Main owns +provider files, existing credentials, subprocesses, persistence, refreshes and +native actions. Renderer windows use sandboxing, context isolation and no Node +integration; main validates the sender and request shape. + +Windows implementation, installers, CI, testing and releases are paused because +there is no test machine. Shared source may stay portable. This supersedes the +RN/Tauri and Windows-first direction in the earlier pipeline plan. + +## Compatibility + +Retain history/archive JSON, schema-1 output and macOS preference keys. Preserve +Swift reference-date archive encoding and UserDefaults Data for managed +accounts. Generated account IDs stay stable through rename and enable changes; +labels are display-only. Keep provider marks/status tint (ADR 0002), measured +burn attribution (0003), fixed whole-point notch anchoring (0004), managed +accounts (0006), reset observation pacing (0008) and verified prebuilt +installation (0009). + +Keep `MeterUsage.app`, executable `meterusage`, bundle ID `dev.meterusage.app` +and the `MeterUsage-.zip` asset pattern. Retain the Swift app and source +as the behavior oracle and rollback path until native acceptance passes. Do not +introduce a new durable-data migration or automatic cutover. + +## Candidate isolation and acceptance + +Select demo/candidate configuration before composition. An explicit candidate +profile requires demo mode and an isolated test directory. Preferences, +history, caches and Electron state use that profile; provider sources and reset +consumers are synthetic. Demo mode blocks live provider discovery, update +installation and login-item changes. Candidate packaging does not install or +publish itself. + +Source validation requires executed TypeScript/build/fixture checks and +independent review tied to current inputs. Native acceptance also requires a +macOS bundle digest/signature, headless JSON, launch/relaunch, settings/account +and offline journeys, notifications/login items, sharing/updater behavior and +captures of two different-height notch cards with a fixed top edge and strip. +Record package size, idle RSS and cold/warm scan times. Missing native proof +keeps migration and cutover open; a source-only draft PR cannot claim parity. + +## Consequences + +Electron adds package and process overhead. Measure that cost on the native +candidate. It supplies the desktop shell without adding a server, remote +networking, auth layer, event sourcing, Rust monitor or another application. +Provider discovery and privacy remain constrained by the existing source +contracts. Correct stale documentation instead of treating it as permission to +read more data. + +No workflow changes, release publication, deployment or installed-app +replacement follow from this architecture decision. + +## References + +- [Migration plan](../plans/macos-electron-migration-plan.md) +- [Contributor commands](../../CONTRIBUTING.md#typescript-electron-candidate) +- [Privacy boundaries](../PRIVACY.md) +- [Superseded platform pipeline](../plans/feature-and-windows-pipeline.md) +- [Electron security guidance](https://www.electronjs.org/docs/latest/tutorial/security) diff --git a/docs/adr/0011-desktop-account-connections.md b/docs/adr/0011-desktop-account-connections.md new file mode 100644 index 0000000..e2a10dd --- /dev/null +++ b/docs/adr/0011-desktop-account-connections.md @@ -0,0 +1,60 @@ +# 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. +The 2026-10-20 source amendment replaces OAuth cache selection with a Desktop +web-session reader. A new Connect explanation and approval are required; +previous untagged identities cannot enable it. Consent persists only as a +`web-v1:` opaque account/organization hash in the existing preference. + +After consent, the fixed native Keychain helper supplies the key for main-process +decryption of only the fixed session and organization cookies. Background reads cannot prompt. Schema 24, encrypted +v10 host binding, Secure root cookies, HttpOnly session and exact expiry are +required. Fixed read-only SQLite SELECTs run in a sandbox denying writes under +the canonical Desktop store root. Bounded reads never copy the database. +Unsupported stores, sandbox failures and conflicting values fail closed. + +GET `https://claude.ai/api/bootstrap` must verify the nested account UUID and +unique intended organization membership before a UUID-bound organization usage +GET. The captured cookie authenticates only those routes. Bootstrap and local +account/org/session stability are checked after usage. Do not use GET +`/api/account`, bearer tokens, OAuth cache fallback, refresh tokens, chats, +provider-store writes or inference. Redirect rejection, no-store, cancellation, +30-second requests and a 1 MiB decoded response limit apply. Membership does +not imply allowance entitlement. Native and live-provider acceptance remain +pending; this is an opt-in compatibility interface that 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/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/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/0014-settings-onboarding.md b/docs/adr/0014-settings-onboarding.md new file mode 100644 index 0000000..138d307 --- /dev/null +++ b/docs/adr/0014-settings-onboarding.md @@ -0,0 +1,19 @@ +# ADR 0014: Keep onboarding in Settings + +- Status: Accepted implementation direction; native acceptance pending +- Date: 2026-10-10 + +First-run setup occupied the main usage panel and duplicated Settings account +connections. Keep setup in Settings so the main panel remains focused on usage. +This refines the setup placement in ADR 0011; account access and consent remain +unchanged. + +While `onboardingCompleted` is false, launch and app activation open Settings. +Reuse its account connection controls, add brief first-time guidance and a Done +button, and allow completion without sign-in. Save the existing preference before +hiding Settings and opening usage. A failed save retains setup and shows an error. +Completed profiles keep their existing startup behavior and account controls. + +Do not add a new preference, IPC action, setup window or authentication flow. +The published Swift app and migration, provider and release acceptance gates +remain unchanged. diff --git a/docs/adr/0015-codex-test-public-status.md b/docs/adr/0015-codex-test-public-status.md new file mode 100644 index 0000000..ddb9efb --- /dev/null +++ b/docs/adr/0015-codex-test-public-status.md @@ -0,0 +1,28 @@ +# ADR 0015: Read public Codex status in the isolated test profile + +- Status: Accepted implementation direction; native acceptance pending +- Date: 2026-10-10 +- Amends: ADR 0012, public status collection only + +## Decision + +The isolated Codex test profile reads the existing public OpenAI component +status feed without account credentials. Attach that reader to its existing +Codex source. Keep normal collection limited to the existing Codex and Claude +status readers. + +The Service status panel distinguishes an initial check, an unavailable feed +and a failed refresh with dated last-known status. A failed request does not +become an operational result. Public service status does not prove account +allowance, sign-in health or provider-dashboard accuracy. + +Preserve ADR 0012 as the original decision. This amendment does not enable +other provider sources, Claude status in the Codex test profile, local scans, +reset redemption, model requests, updates, login-item changes or notifications. +The installed app and saved provider credentials remain outside this change. + +## Acceptance + +Synthetic tests cover source selection, unauthenticated requests, refresh and +failure display. Public HTTP responses establish feed availability only. +Packaged macOS display and refresh behavior require separate native proof. diff --git a/docs/adr/0016-claude-live-test-profile.md b/docs/adr/0016-claude-live-test-profile.md new file mode 100644 index 0000000..94bbc43 --- /dev/null +++ b/docs/adr/0016-claude-live-test-profile.md @@ -0,0 +1,37 @@ +# ADR 0016: Isolate Claude allowance validation in the existing UI + +- Status: Candidate implementation; independent review and native acceptance pending +- Date: 2026-10-10 + +## Decision + +Add `--claude-test-profile` for a separate Claude candidate. This extends +ADR 0012's isolated validation pattern to the Claude flow accepted in ADR 0011. +It does not enable Claude in a Codex profile or change its saved consent. + +Reuse the absolute-directory, protected-path, symlink and Electron singleton +checks. Use a distinct marker. Reject another provider test flag, demo flags +and candidate-profile selection. Store preferences, archives and Electron +state in the test directory. Retain OS home for the existing Desktop reader. + +Start without consent. Reuse Settings, its explicit consent dialog, the fixed +Keychain helper and read-only Desktop cache/cookie reader. A connection call +without a consent callback must deny Claude access. Compose only account-bound +Claude allowance, with no local or companion fallback. Block Codex access and +cleanup, other provider collection, status feeds and reset redemption. +Candidate update, login-item and notification guards remain in force. + +Keep the approved account/organization fingerprint across normal quit so +relaunch can test noninteractive collection. Disconnect disables access and +clears allowance without modifying Claude Desktop. No new credential source, +refresh tokens, client impersonation or model request is permitted. + +## Acceptance + +Synthetic fixtures cover isolation, consent, connection, relaunch and +disconnect. Packaged native acceptance must verify the helper, user-owned +permission flow, current Desktop format, fresh allowance, relaunch without +prompts and removal of allowance on disconnect. Account switches and denied +access need their own approved checks. Synthetic proof cannot establish live +provider compatibility or cross-device coverage. Installed-app replacement, +release and Windows holds remain unchanged. diff --git a/docs/adr/0017-connected-codex-menu-reset.md b/docs/adr/0017-connected-codex-menu-reset.md new file mode 100644 index 0000000..63358ad --- /dev/null +++ b/docs/adr/0017-connected-codex-menu-reset.md @@ -0,0 +1,41 @@ +# ADR 0017: Allow confirmed resets for the connected Codex menu + +- Status: Accepted implementation direction; native and live redemption proof pending +- Date: 2026-10-10 + +## Decision + +Show **Use reset** beside available, unexpired Codex reset credits in the menu. +Reuse the existing renderer action, validated IPC, coordinator intent and native +confirmation. Confirmation defaults to Cancel and identifies the credit and +account. No automatic redemption or model request is added. + +Bind the source and consume operation to the generated connection profile that +supplied allowance. After confirmation, reread allowance and require the same +available, unexpired credit. Recheck the enabled profile before helper dispatch +and after completion. Reject overlap; abort pending reset work on disconnect or +quit. Profile replacement invalidates the coordinator's intent and readings. +A request already sent to the provider may consume a credit despite cancellation; +do not retry automatically or claim it was undone. + +Use the existing official helper reset RPC with keyring-only configuration and +real OS home. The helper continues to own authentication and secrets. Require an +explicit reset outcome before resetting pacing and collecting fresh quota. + +This narrowly supersedes ADR 0012's prohibition on reset redemption in +`--codex-test-profile`. That mode retains isolated preferences, app data and +Electron state. Public Codex status remains permitted under +[ADR 0015](0015-codex-test-public-status.md). Other providers, local activity, +other status feeds, updates, login-item changes, notifications and model requests +remain excluded. + +## Acceptance + +Synthetic fixtures cover connected eligibility, the menu request, cancellation, +one confirmed consume, duplicate actions, stale/spent/expired credits, +disconnect/profile changes, failure outcomes and refreshed quota. Static markup +checks cover the accessible control at Small, Medium and Large panel scales. +These checks do not prove native layout, native confirmation or provider RPC +support in an installed helper. Native integration validation is separate. Live +redemption requires the user's own click and confirmation; implementation tests +must not consume real credits. diff --git a/docs/adr/0018-hover-only-notch-details.md b/docs/adr/0018-hover-only-notch-details.md new file mode 100644 index 0000000..22a1e38 --- /dev/null +++ b/docs/adr/0018-hover-only-notch-details.md @@ -0,0 +1,35 @@ +# ADR 0018: Hover-only Electron notch details + +- Status: Superseded by [ADR 0019](0019-single-design-notch.md). The text below preserves the prior candidate decision. +- Date: 2026-10-11 + +## Decision + +The Electron candidate opens provider details on hover and closes them after +450 ms outside the panel. Moving between rings selects that provider. The +native cursor check remains the fallback for a missed renderer exit. + +Settings offers Usage and Compact dots for the resting strip. Usage is the +default for new and existing profiles without a saved choice. It keeps rings +and percentages visible while details are closed. Dots reuses the folded pill. +Invalid saved choices fall back to Usage. + +Remove Pin detail card and Keep open controls. Retain legacy stored pin data +without using it to open or retain details. Temporary share, reset confirmation +and drag guards remain. No account or sign-in state changes. + +Use explicit integer pixel sizes for Small, Medium and Large notch content. +Small has an 11 px body and 10 px secondary text. Do not scale notch content +with CSS zoom. Retain flyout and Settings scaling and original share rendering. + +## Scope and evidence + +This decision supersedes the Electron candidate pin behavior and fractional +notch sizing described in SIDE-NOTCH.md. It leaves the retained Swift behavior +and ADR 0004's anchor invariant unchanged: a 44 px strip, a 250 px card, and a +fixed top and strip edge when details change. + +Synthetic checks must cover old pin=true data, preference validation, both +resting modes, two-provider selection, delayed closure and temporary guards. +Browser captures and native two-provider anchors, cursor events, reset/share +menus and drag recovery remain separate acceptance gates. diff --git a/docs/adr/0019-single-design-notch.md b/docs/adr/0019-single-design-notch.md new file mode 100644 index 0000000..01f362c --- /dev/null +++ b/docs/adr/0019-single-design-notch.md @@ -0,0 +1,33 @@ +# ADR 0019: One Electron side-notch design + +- Status: Accepted implementation direction; independent review and native acceptance pending +- Date: 2026-10-11 +- Supersedes: [ADR 0018](0018-hover-only-notch-details.md) + +## Decision + +The Electron side notch has one usage-ring and percentage strip. Keep it +visible whether details are open or closed. Remove the candidate dots markup, +CSS, Resting strip selector, preference schema, validation and IPC projection. +Do not migrate or reset real preferences. + +Keep hover-only details and the 450 ms leave-close delay. Legacy pin data +cannot retain details. Small, Medium and Large remain size choices with +explicit integer pixel text and controls. Preserve provider logos and ADR 0004's fixed top and strip edges. The current +compact design uses a 36 px neutral charcoal rail with 22 px rings containing +provider marks, percentages below and a Settings gear at the bottom. Details +are 220 px wide; shared captures remain 250 px wide. Pacing stays in details +instead of adding ETA chips to the rail. + +Clear expanded state in the shared disable/hide path. Re-enabling with the +pointer outside must show only the strip; subsequent hover must still open +and close details. Share, reset confirmation and drag guards remain. + +## Evidence and limits + +Synthetic checks cover the single strip for two providers, removed preference +and IPC rejection, unchanged stored fixture bytes, disable/hide/re-enable and +subsequent hover recovery. TypeScript, renderer build and main/preload packing +verify source inputs. Independent review, browser captures and native cursor, +anchor, package and install proof remain separate Controller-owned gates. +The retained Swift implementation is unchanged. diff --git a/docs/adr/0021-provider-group-order.md b/docs/adr/0021-provider-group-order.md new file mode 100644 index 0000000..e4703a4 --- /dev/null +++ b/docs/adr/0021-provider-group-order.md @@ -0,0 +1,29 @@ +# ADR 0021: Save provider group order + +- Status: Accepted implementation direction; rendered and native acceptance pending +- Date: 2026-10-11 + +Electron Settings saves a `providerOrder` list of known provider names. Installed +profiles use JSON encoded as defaults data, as managed accounts do. Isolated +candidate profiles use their existing preferences JSON file. + +Read complete stored data when macOS prints an abbreviated NSData description. +Reuse the saved list after relaunches and app updates. New providers append under +the same normalization rule. + +Stored input is normalized: keep the first occurrence of each known provider, +ignore invalid entries and append missing providers in the default sequence. +IPC writes require a complete, unique list of known providers and come only +from Settings. External drag data is never read. + +Usage cards, service health, menu bar and side notch follow the saved order. +Each primary account precedes its additional accounts, which keep their existing +relative order. Collect and Show settings remain independent. OpenRouter stays +excluded from the menu bar. Ordering does not change polling or account identity. + +Settings saves once on drop or a keyboard move. A canceled drag or failed save +keeps the last saved order and restores handle focus. Arrow keys and named move +buttons provide keyboard access. A line marks the proposed drop position. + +The Swift app retains its existing fixed order. Synthetic tests do not prove +native drag behavior, VoiceOver, focus, or the fixed notch anchors. diff --git a/docs/adr/0022-codex-account-token-history.md b/docs/adr/0022-codex-account-token-history.md new file mode 100644 index 0000000..11d25c1 --- /dev/null +++ b/docs/adr/0022-codex-account-token-history.md @@ -0,0 +1,37 @@ +# ADR 0022: Read provider account token history separately + +- Status: Accepted implementation direction; independent review, native and live acceptance pending +- Date: 2026-10-12 + +The Electron candidate reads daily token history through the existing managed +ChatGPT helper with `account/usage/read`. It omits `threadId`, which selects +account-wide activity instead of a thread estimate. Initialization, effective +keyring storage checks and the generated connection profile remain required. +This extends ADR 0011's read boundary without changing login, quota, reset or +own-profile logout behavior. + +Use a separate in-memory account activity type containing only provider date +labels, safe nonnegative integer token counts and a capture timestamp. Discard +summary and unknown fields before publication. Reject invalid calendar dates, +duplicate dates, unsafe counts and malformed buckets. Preserve reported zero; +null, absent history and unreported dates remain unavailable. + +The card says "Account token activity" and "Provider-reported account scope". +It shows a compact daily grid for 26 weeks ending at the latest reported date. +Hover and keyboard focus expose exact counts. Existing global and Codex heatmap +preferences control main, notch and shared cards. Keep provider dates unchanged +as calendar labels without assigning a timezone. Do not infer token categories, +sessions, cost or daily counts from summary statistics or allowance. + +Account history never enters local Activity, Usage, burn attribution, durable +daily history or the schema-1 quota report. Replacement, disconnect and failed +reads clear the account reading; captured profile checks reject late results. +Reuse the existing collector deadline and bounded backoff. Add no durable +account cache, credential source, raw-response log or additional browser login. +Advanced local profile collection remains unchanged. + +The public app-server contract describes ChatGPT account token activity. It +does not establish universal client, cloud, device or product coverage, token +category definitions, timezone, retention or polling limits. Live availability +and coverage need separate provider proof. Native notch anchor and shared-image +acceptance also remain open. This decision does not authorize a release. diff --git a/docs/adr/0023-onboarding-source-choice.md b/docs/adr/0023-onboarding-source-choice.md new file mode 100644 index 0000000..44eae2c --- /dev/null +++ b/docs/adr/0023-onboarding-source-choice.md @@ -0,0 +1,31 @@ +# ADR 0023: Choose usage sources explicitly in onboarding + +- Status: Accepted implementation direction; native interaction acceptance pending +- Date: 2026-10-13 + +Users need to choose between an existing CLI setup and the account connections +supported by each provider. Keep this choice in Settings alongside onboarding, +as required by ADR 0014. + +Offer Codex browser sign-in or CLI, Claude Desktop or Claude Code CLI, and Grok +CLI only. Explain their data coverage. Do not offer unsupported consumer sign-in +or imply that local activity covers the website or another device. Changing the +selector alone has no account or collection effect. An explicit action applies +the choice, and Done follows the source choices. + +Add one validated `connectionLocal` IPC action. This supersedes only the +no-new-IPC restriction in ADR 0014. Reuse existing connection preferences and +readers. Account-to-CLI switching ends only MeterUsage-owned connection state +and waits for successful cleanup before selecting local collection. Failure +stays closed. Ordinary Disconnect retains the `off` state and never silently +switches to local data. Existing accounts keep their source on upgrade. + +Publish only the source name and whether CLI selection is available, without +account identifiers or profile paths. Reject local selection in demo and isolated +live test profiles. Preserve consent, cancellation, safe error handling, stale +result rejection and the save-before-close onboarding contract. No new credential +source, authentication protocol, provider request or dependency is introduced. + +Verify supported options, explicit application, cleanup and preference-save +failures, concurrent actions, relaunch persistence and Done. Source and synthetic +browser checks do not prove live provider authentication or native interaction. diff --git a/docs/adr/0024-electron-primary-swift-retirement.md b/docs/adr/0024-electron-primary-swift-retirement.md new file mode 100644 index 0000000..aa47c54 --- /dev/null +++ b/docs/adr/0024-electron-primary-swift-retirement.md @@ -0,0 +1,72 @@ +# ADR 0024: Develop Electron and retire the legacy Swift app in stages + +- Status: Accepted development direction; source retirement and public cutover pending +- Date: 2026-10-14 +- Amends: [ADR 0010](0010-macos-typescript-electron.md), for ongoing development and eventual Swift retirement only + +## Decision + +Electron is the sole active target for new MeterUsage features. Freeze the +legacy Swift app as a behavior reference and rollback option. Limit Swift app +changes to critical fixes and compatibility work needed for the transition. +Do not maintain two feature roadmaps or require two permanent app editions. + +Keep both source trees during migration. Remove the legacy app implementation +from the active branch only after the acceptance and cutover conditions below +pass. Preserve a retrievable source revision and its matching release artifact +before removal. Git history and the retained release provide the archive; +an additional repository is unnecessary. + +The retained Swift baseline is +`4b913db3a3a8eb2602296489872af2ecaa5d0530`. The published Swift 0.2.41 ZIP +remains the default download until a verified Electron release replaces it. +The Electron candidate still has source version 0.2.41 and incomplete native +acceptance. This decision does not publish or install a release. + +## Retirement conditions + +1. Finish the agreed provider setup and parity work. Verify the affected paths + against the [candidate gaps](../electron/README.md#known-parity-gaps). + Intentional changes, including removal of pinned details and dot designs, + remain intentional. +2. Complete ADR 0010's source and native acceptance on the exact candidate. + Preserve saved accounts, preferences, history and schema-1 output. Exercise + launch, relaunch, offline behavior, native controls, sharing and updates; + capture different-height notch cards with fixed anchors. Record package + size, idle memory and startup/scan performance without inventing pass limits. +3. Verify a deliberate update from the published Swift app to the intended + Electron artifact, and verify rollback with preserved data. Keep the stable + download and existing client asset contract until the replacement is ready. + Existing publication and cutover holds remain in force. +4. Confirm that the exact legacy source and release remain retrievable. Then + remove old-app-only `Sources/`, `Tests/`, `Package.swift`, build steps and + obsolete CI references in an independently reviewed change. Trace consumers + before removal and retain any shared fixture, asset or contract still used. + Verify Electron build, packaging and the affected user paths afterward. + +## Swift that stays + +Retiring the Swift app does not remove every Swift file. Electron's current +build still uses [desktop-keychain.swift](../../Scripts/electron/desktop-keychain.swift) +and [export-symbols.swift](../../Scripts/electron/export-symbols.swift). +Keep those helpers and their required assets in `Resources/` unless a separate, +verified replacement removes the dependency. Do not delete by file extension. + +## Identity and distribution + +The intended end state is one app. ADR 0010's app identity, data compatibility +and no-automatic-cutover requirements remain intact. Normal-profile parallel +installs remain unsupported. A separate identity and data root are only needed +if simultaneous installs become a separately chosen requirement. + +Beta distribution is optional and still needs explicit updater routing. Both +current updaters request `/releases/latest` and expect +`MeterUsage-.zip`; Electron rejects prerelease tags. Renaming assets +alone does not create a channel. See [version strategy](../VERSIONS.md). + +## Consequences + +New work has one implementation target. Swift remains available while migration +proof is incomplete, but gains no parallel feature roadmap. Documentation +labels development status separately from the published download. This decision +does not itself change source files, workflows, versions, data or installed apps. diff --git a/docs/adr/0025-card-settings-diagnostic-reports.md b/docs/adr/0025-card-settings-diagnostic-reports.md new file mode 100644 index 0000000..f531b0b --- /dev/null +++ b/docs/adr/0025-card-settings-diagnostic-reports.md @@ -0,0 +1,21 @@ +# ADR 0025: Card Settings and explicit diagnostic reports + +Status: Accepted implementation direction + +The compact side-notch strip contains provider usage only. Each expanded card +already offers Settings, and the menu-bar context menu provides another entry. +Remove the duplicate strip gear and its slot. This amends ADR 0019's gear +placement without changing card-side or top-edge anchoring, ring dimensions, +provider ordering or hover behavior. + +Electron Settings → Diagnostics restores the existing support-report contract. +Only an explicit live Settings action sends a main-generated sanitized summary. +The renderer cannot select report content or destination. Reuse the existing +private Linear relay with bounded requests/replies, verified receipts and +duplicate-safe retries. Reserve a private 10-minute cooldown before each +request, including failed attempts, and retain it across relaunches. Saving +that timestamp must succeed before transmission. Never attach raw logs or +provider credentials. + +See [reporting](../REPORTING.md). Transport fixtures and synthetic browser checks +are separate from native visual acceptance and live support delivery. diff --git a/docs/adr/0026-antigravity-desktop-local-quota.md b/docs/adr/0026-antigravity-desktop-local-quota.md new file mode 100644 index 0000000..941f107 --- /dev/null +++ b/docs/adr/0026-antigravity-desktop-local-quota.md @@ -0,0 +1,58 @@ +# ADR 0026: Read Antigravity Desktop quota through its local service + +- Status: Source implementation; native and provider acceptance pending +- Date: 2026-10-16 +- Amends: [ADR 0011](0011-desktop-account-connections.md), for opt-in Antigravity Desktop allowance + +## Decision + +Offer **Connect Antigravity Desktop** beside the existing explicit CLI choice. +Use the running desktop language server's fixed `GetUserStatus` and +`RetrieveUserQuotaSummary` RPCs. Do not require or start `agy`, download a helper, +copy OAuth credentials, refresh tokens or write to provider stores. + +Discover executable names before reading arguments for one matching same-user +PID. Require its CSRF token and its own IPv4 loopback listener. Multiple +matching desktop servers are ambiguous, including multiple windows that start +separate servers. Reject them instead of choosing an account. Recheck process, +listener and token ownership before each request. + +Prefer HTTPS. Limit certificate verification exemption to an individual request +to the verified `127.0.0.1` listener. HTTP is allowed only on the declared, +PID-owned extension-server port. Use fixed RPC paths and bodies, reject +redirects, limit responses to 1 MiB, requests to three seconds and a full read +to fifteen seconds. Never expose provider errors or process output. + +Require `GetUserStatus.userStatus.email` before and after quota collection. +Retain only its provider-scoped SHA-256 fingerprint in MeterUsage preferences; +exclude it from renderer settings and diagnostics. This extends ADR 0011's +opt-in identity exception to Antigravity. Keep CSRF credentials and raw replies +in main-process memory only. Reject changed or missing identity, cancellation, +disconnect and results from an earlier connection, including reconnection to +the same account. + +A successful modern response is authoritative. Empty summaries and missing, +invalid, disabled or expired buckets stay unavailable. Use legacy +`GetUserStatus` model fractions only after an explicit unsupported response +(HTTP 404/501 or RPC code 12/`unimplemented`). Do not infer weekly quota or +turn a missing fraction into depleted allowance. + +Present modern quota under compact model-group headings. Remove the known +remaining-allowance suffix and shorten explicit Five Hour labels. Keep all +reported buckets, values and resets; assign duration metadata only to explicit +Weekly or 5-hour labels. Unknown model names remain visible. A saved identity +with missing allowance is a saved connection, not proof of a live reading. + +Connected allowance has no CLI-local activity attached. Disconnect disables +collection without selecting CLI. **Use CLI** explicitly restores the existing +local source. Clear account quota on source changes, failed identity/data reads +and launch, using the existing connection/coordinator flow. + +## Acceptance + +Synthetic fixtures establish discovery filtering, transport bounds, quota +projection, account changes, archive clearing and cancellation/save races. +They do not establish native process discovery, installed-app compatibility, +local TLS behavior or live allowance accuracy. Those checks remain required +before claiming Antigravity Desktop support in a released artifact. The +published beta and legacy Swift release are unchanged. diff --git a/docs/adr/0027-grok-cursor-closed-reader.md b/docs/adr/0027-grok-cursor-closed-reader.md new file mode 100644 index 0000000..e2bcf19 --- /dev/null +++ b/docs/adr/0027-grok-cursor-closed-reader.md @@ -0,0 +1,49 @@ +# ADR 0027: Read Grok Bot allowance through closed Cursor + +- Status: Implemented in source; native and live-provider acceptance pending +- Date: 2026-10-16 +- New connections: superseded by [ADR 0028](0028-grok-bot-desktop-reader.md); saved Cursor connections retain this reader +- Amends: ADR 0011's unavailable Grok connection; retains existing CLI choice + +Retain previously saved **Connect via Cursor (Beta)** connections. The user +signs in to Cursor, checks Bot allowance, then quits Cursor. MeterUsage never +stops Cursor. After expiry, reopen Cursor and quit it to resume a saved source. +Disconnect and connect again to switch to Bot Desktop under ADR 0028. This is a +compatibility interface, not a universal Grok Desktop, X or shared-pool contract. + +Open the original Cursor SQLite store with `immutable=1&mode=ro`. SQLite must +perform zero reader writes, including sidecar bookkeeping, regardless of other +processes. Do not copy credential stores or files to temporary disk. Reject +nonempty WAL/journal files rather than silently ignoring uncheckpointed data. +Use pre/post process and filesystem guards, and reject unavailable, ambiguous +or changed evidence. These observations detect races, not mutual exclusion; +they cannot prove Cursor never ran between observations. Operating-system +read access-time updates are outside SQLite's byte-write guarantee. + +The process guard excludes Apple's `CursorUIViewService` only when its +executable is under `/System/Library/`. The macOS cursor UI service is unrelated +to the editor. Other Cursor-named processes still fail closed. + +Select only `ItemTable` key `cursorAuth/accessToken`. Parse the JWT's final +pipe-separated subject component and require expiry more than 60 seconds away. +Keep account identity case-sensitive. Construct the established session cookie, +verify the subject through fixed `https://cursor.com/api/auth/me`, then POST +`{}` to fixed `https://cursor.com/api/dashboard/get-sand-usage-status` with +`Origin: https://cursor.com`. Refuse redirects, cap responses at 1 MiB and each +request at 30 seconds. No refresh or inference requests are permitted. + +Persist only an opaque account fingerprint/source preference and projected +quota. Tokens and store fingerprints stay in main-process memory. Revalidate +account and store before allowance and after attempts. Disconnect, cancellation +and source replacement prevent stale publication and further private reads. +Reuse existing coordinator backoff and account archive clearing. Never attach +local CLI history to connected-account quota. + +`includedLimitZero` overrides the legacy included-limit flag. Trials require a +future `sandTrialExpiresAt`; trial expiry is not a reset. Retain only reported +usage percent and optional reset timestamp without inferring a reset cadence. + +Synthetic reader, protocol, lifecycle, IPC and UI tests are required. Native +Cursor format, packaged Electron SQLite behavior, GUI setup and live provider +support remain unverified. The published Swift release and beta build 54 are +unchanged. See [setup](../DESKTOP-CONNECTIONS.md) and [privacy](../PRIVACY.md). diff --git a/docs/adr/0028-grok-bot-desktop-reader.md b/docs/adr/0028-grok-bot-desktop-reader.md new file mode 100644 index 0000000..bdcd2b5 --- /dev/null +++ b/docs/adr/0028-grok-bot-desktop-reader.md @@ -0,0 +1,59 @@ +# ADR 0028: Read Grok Bot Desktop allowance after consent + +- Status: Source implemented; native and live-provider acceptance pending +- Date: 2026-10-16 +- Amends: ADR 0027 for new connections; saved Cursor sources remain compatible +- Amended by: [ADR 0030](0030-grok-bot-encrypted-compatibility.md) for observed encrypted legacy values and the corrected fixed Keychain account + +New Grok connections use **Connect Grok Bot Desktop (Beta)**. The user signs in +through Bot first, then permits MeterUsage's read-only access. MeterUsage does +not add an xAI sign-in flow, invoke app code, impersonate Bot or refresh tokens. + +The compatibility trace is pinned to installed Bot 0.71.2, Electron 42.1.0. +Static SHA-256 identities inside the app archive: + +- `main-app.cjs`: `90ec55edf69be56c6cfd4fffa98fc45210acccf913b6c41402b5b6cca6fd538e` +- `main-core.cjs`: `6422c2c4755d1722a9b6e842fb7117cf8eecce62d6c6a0bbdea53728999a9558` +- `proto.cjs`: `7727f3027a1bc01890cd07ed2aa4bba0f7b77a9962db110ce94056841bd2f168` + +Require version 1 of `Grok Bot/sand-secrets.json` under Application Support. +Select the string-valued `cursor-accounts` container, its active scope, and only +that account's `cursor-access-token` and optional `cursor-selected-team-id`. +Reject duplicate keys, symlinks, unsupported versions and changed files. Bound +the read to 4 MiB, including growth after stat. Never decode a refresh token or +another account's credential value. Require `scoped:v1` envelopes and bounded +Electron `v10` encryption. Reuse the existing Claude PBKDF2/AES decryptor. + +The helper adds one fixed mode for `Grok Bot Safe Storage` / `Grok Bot` while +preserving its Claude default pair. This pair is derived from matching Electron +42.1.0 source, which uses app name plus ` Safe Storage` as service and app name +as account. Explicit Connect may show Keychain UI; background polling cannot. +No arbitrary Keychain service/account selection or enumeration is permitted. + +Require the full JWT subject hash to match the active and envelope scopes, with +expiry beyond sixty seconds. Validate the optional team as a safe nonnegative +integer. Include both account and team in a provider-scoped opaque fingerprint. +Recheck identity, team and store after requests, including transport failures. +A cancellation or revoked connection prevents every subsequent private read. +Failed preference saves disable collection and restore source metadata safely. + +POST empty Connect JSON only to +`https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus` with +`Connect-Protocol-Version: 1`, bearer auth, optional `x-cursor-team-id` and +MeterUsage's own User-Agent. Refuse redirects, bound responses to 1 MiB and +requests to thirty seconds. Project finite 0-100 quota and optional reported +reset. Reject pooled enterprise allowance and contradictory included-limit +flags. A live trial may show quota, but its expiry is not a reset. Do not infer +cadence or create a second reading for Cursor's shared pool. + +Persist only the internal source marker and opaque identity. Missing source +metadata defaults to the legacy Cursor reader for existing saved connections. +New successful connections save Bot source. No failed Bot request falls back +to Cursor. Connected sources retain account-bound archive clearing and omit +local CLI history. Disconnect changes only MeterUsage state. + +Synthetic tests cover parser/decryption, transport bounds, consent, lifecycle, +races, rollback and saved Cursor behavior. Store existence/native format, derived +Keychain naming, packaged helper behavior and server acceptance of JSON without +Bot's internal metadata remain unverified. The published release is unchanged. +See [privacy](../PRIVACY.md) and [setup](../DESKTOP-CONNECTIONS.md). diff --git a/docs/adr/0029-api-billing-snapshots.md b/docs/adr/0029-api-billing-snapshots.md new file mode 100644 index 0000000..d59585d --- /dev/null +++ b/docs/adr/0029-api-billing-snapshots.md @@ -0,0 +1,25 @@ +# ADR 0029: Read separate API billing snapshots + +- Status: Accepted implementation direction; real billing acceptance pending +- Date: 2026-10-16 + +Add OpenAI API and Anthropic API billing as a separate snapshot field and compact +cards. Preserve existing subscription provider identities, quota/archive state, +menu-bar readings, side-notch entries and schema1 quota JSON. Keep OpenRouter's +existing key/account activity adapter unchanged. + +A bundled short-lived helper reads only its selected Admin key from its process +environment. The app has no key-entry field and never receives this credential. +The helper calls fixed documented organization cost endpoints, projects daily +USD amounts and discards identifiers and raw responses. It writes owner-only +files by atomic replacement to the fixed API usage directory. Failures retain +the previous snapshot. There is no background scheduler or credential store. + +Cards show the reported UTC period and capture time. Reports become stale after +24 hours or a UTC-month transition. Empty reports stay unknown; measured zeroes +remain zeroes. Anthropic Priority Tier costs remain excluded. Do not present API +costs as subscription allowance or local token estimates. + +The API usage guide defines setup and retained fields. Synthetic proof does not +establish a real organization's permissions, report completeness or invoice +accuracy. These require separate live acceptance. diff --git a/docs/adr/0030-grok-bot-encrypted-compatibility.md b/docs/adr/0030-grok-bot-encrypted-compatibility.md new file mode 100644 index 0000000..8ab98f4 --- /dev/null +++ b/docs/adr/0030-grok-bot-encrypted-compatibility.md @@ -0,0 +1,22 @@ +# ADR 0030: Accept account-bound encrypted Grok Bot legacy values + +- Status: Accepted bounded correction; live allowance acceptance pending +- Date: 2026-10-16 +- Amends: ADR 0028 encrypted-envelope and fixed Keychain-account assumptions + +Native metadata for Bot0.71.2 establishes that its active account can contain a +canonical base64 encrypted v10 access value without a scoped envelope. The fixed +Grok Bot Safe Storage service uses the account label Grok Bot Key. The initial +source reader required a scoped envelope and queried the wrong account label. + +Read only that fixed service/account pair. Accept canonical encrypted v10 +values with either a matching scoped envelope or no envelope. In both forms, +the decrypted JWT's full subject hash must equal the selected active account, +and expiry must exceed sixty seconds. Preserve all store stability, duplicate, +team, cancellation, request and identity checks from ADR 0028. Plaintext and +unsupported encryption remain rejected. Never search other Keychain items, +refresh credentials, change Bot stores or fall back to Cursor on failure. + +Native store/Keychain metadata establishes the format and fixed item label. +Synthetic decryption and account-binding checks establish source behavior. +Neither proves access permission or successful live allowance collection. diff --git a/docs/adr/0031-grok-quota-burn.md b/docs/adr/0031-grok-quota-burn.md new file mode 100644 index 0000000..94c9b5a --- /dev/null +++ b/docs/adr/0031-grok-quota-burn.md @@ -0,0 +1,36 @@ +# Measure Grok Bot quota burn from recent observations + +- Status: Accepted +- Date: 2026-10-17 + +## Context + +Grok Bot Desktop reports a used percentage and sometimes a reset deadline. It +does not report token counts, billing, or the duration of that quota cycle. +Cycle-based pacing would need to invent a start time. + +## Decision + +Track fresh same-account Bot readings in memory through the existing quota +baseline fields. Require increasing observation timestamps spanning at least +one minute, with baseline and latest readings within five minutes. Divide the +change in percentage points by observed elapsed time. Unchanged usage yields +zero only after the minimum interval. This is a recent measured average, not +an instantaneous token rate. + +Discard measurement on quota decrease, reset/deadline change, expiry, stale or +invalid timestamps, failed collection, reconnect or source/account changes. +Use live readings only; strip baselines from retained and saved Grok archives. +Old archive baselines cannot initialize measurement. Preserve Codex reset pacing. + +Show the rate in menu and notch usage cards while pacing is enabled. A positive +rate can project remaining allowance; suppress projections in the past or after +a reported reset. Missing data displays collection state without invented zero, +tokens, cost, or a quota-cycle duration. No new request or credential access is +needed. Provider percentage rounding can make this short-window estimate uneven. + +## Verification + +Synthetic domain/coordinator and renderer tests cover measurement, zero, +minimum interval, stale/reset/account boundaries, archives and display controls. +Native packaging and live measured-rate acceptance require separate evidence. diff --git a/docs/adr/0032-one-time-api-setup.md b/docs/adr/0032-one-time-api-setup.md new file mode 100644 index 0000000..170fd2f --- /dev/null +++ b/docs/adr/0032-one-time-api-setup.md @@ -0,0 +1,35 @@ +# ADR 0032: Set up API billing with a one-time key + +- Status: Accepted implementation direction; live billing acceptance pending +- Date: 2026-10-17 + +Place API billing setup in **Settings → API connections**, separate from app +account connections and onboarding. OpenAI API and Anthropic API each have a +masked organization Admin key field and an explicit **Fetch billing** action. +**Use saved key** reuses the same provider's Admin key from the previous app. +Project keys and consumer subscription sign-in do not grant billing access. + +This narrowly amends ADR 0029's environment-only, no-app-input boundary. The +registered Settings document may send a validated one-time key to the main +process. The existing collector requests only the selected provider's fixed +cost endpoint and writes the same projected owner-only snapshot. No key is +saved to preferences, Keychain or any file, included in diagnostics/state +publication, or returned in an error. The UI clears input on submit or leaving +the page. The advanced environment helper remains available. + +On explicit saved-key use, the main process reads only the legacy generic +password service `com.meterusage.api-keys`, account `openAI` or `anthropic`, +through the existing native helper. It may request macOS access. It never +changes or deletes the item, copies the key, returns it to the renderer or +fallbacks to another source. Missing and denied reads fail with fixed errors. +No startup or background Keychain access is added. + +Permit one fetch per provider at a time, refuse demo/candidate/isolated profiles, +and cancel pending requests on quit. Failed collection preserves the previous +snapshot. Successful collection updates the displayed snapshot automatically. +Ordinary Refresh continues to read saved snapshots without API access. No +credential store or background billing scheduler is added. + +Synthetic tests and demo layout checks do not establish real organization +permissions, report completeness or invoice accuracy. Keep those acceptance +checks separate from source and installed-app verification. diff --git a/docs/adr/0033-muse-code-subscription-allowance.md b/docs/adr/0033-muse-code-subscription-allowance.md new file mode 100644 index 0000000..51be72b --- /dev/null +++ b/docs/adr/0033-muse-code-subscription-allowance.md @@ -0,0 +1,46 @@ +# ADR 0033: Read Muse Code allowance through the official CLI + +- Status: Source implementation; native and live-provider acceptance pending +- Date: 2026-10-17 + +Add an opt-in Electron provider named Muse Code with the internal key `muse`. +Keep it distinct from Muse.ai's cloud agent and Desktop subscription. Both +products have usage limits, but their credentials and allowances do not establish +a shared account or combined budget. + +Reuse the bounded subprocess transport. Start the installed official +`muse serve`, initialize MSP with experimental methods enabled, then call +`usage/read`. Do not start a session or model turn, change authentication, read +credential files or add an SDK dependency. The official method supplies the +host's last observation without a model call. + +Retain session and weekly percentages, reset times, session duration, a bounded +tier identifier and `observedAtMs`. Preserve percentages above 100 as the wire +contract permits them. Clamp display geometry independently. Reject missing, +expired and invalid values. Show the observation time so polling cannot make +an old observation appear newly measured. No token history or cost is inferred. + +Settings exposes explicit start/stop actions. Existing preference validation, +provider ordering, cards, quota reporting and demo composition serve the new +provider. It remains excluded from isolated Codex/Claude live profiles. Stopping +collection does not sign the user out of Muse Code. +The saved choice also gates headless collection. Source removal cancels active +reads, and a later start uses a new source to reject results from the old one. +Clear in-memory allowance on each change. The schema-1 JSON report adds Muse Code's +`observed_at` without changing other providers. Keep Muse allowance in memory and +exclude it from the shared quota archive. Legacy Swift and Electron decoders +reject an archive containing an unknown provider, so writing Muse there would +discard all cached quota on rollback. Other providers keep their existing format +and reset-pacing state. Muse allowance is collected again after relaunch. + +Muse.ai Desktop import needs independent evidence for the native session store, +active-account selection and encryption. Do not infer those from a bundle ID or +use Muse Code authentication as a substitute. + +Verify with synthetic MSP subprocesses, numeric/projection tests, composition +and renderer checks, TypeScript checking and production bundling. Native app and +live-provider validation remain distinct gates. + +References: [official product](https://dev.meta.ai/products/muse-code), +[`usage/read`](https://meta-models.github.io/muse-code-sdk/next/generated/msp/methods/usage-read/), +[usage window schema](https://meta-models.github.io/muse-code-sdk/next/generated/msp/types/subscriptionusagewindow/). diff --git a/docs/adr/README.md b/docs/adr/README.md index 3aebf22..b8b5239 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,10 +2,17 @@ This directory records decisions that affect repository work. Read an ADR before changing a documented decision. Add a new numbered ADR for a new decision; do not rewrite an accepted decision to fit a later change. +The [version comparison](../VERSIONS.md) separates active Electron development +from frozen legacy Swift support. [ADR 0024](0024-electron-primary-swift-retirement.md) +amends the long-term development direction. ADR 0010's shared identity, +compatibility and acceptance requirements remain in force. The +[Swift](../swift/README.md) and [Electron](../electron/README.md) guides route setup. + ## Index | ADR | Decision | Status | | --- | --- | --- | +| [0024](0024-electron-primary-swift-retirement.md) | Develop new features in Electron; freeze Swift as legacy rollback and retire its app source after verified acceptance and cutover. Keep required Swift helpers. | Accepted development direction; retirement and public cutover pending | | [0001](0001-repository-owned-knowledge.md) | Keep public-safe project knowledge and decisions beside the project and exclude private operational content. | Accepted | | [0002](0002-provider-marks-stay.md) | Keep existing provider logos/marks (`ProviderMark` + `Resources/*-logo.png`); do not redesign them in feature or cross-platform work. | Accepted | | [0003](0003-burn-attribution-scope.md) | Scope burn attribution to the last 7 days across all token-bearing providers, with per-provider aggregates that never count as long chats. | Accepted | @@ -14,6 +21,30 @@ This directory records decisions that affect repository work. Read an ADR before | [0006](0006-managed-account-list.md) | Additional accounts are a managed, unbounded Settings list; `ProviderSlot` (provider + generated id, label display-only) keys every metered surface, with primary-slot persistence formats unchanged. | Accepted | | [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 | +| [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; reset prohibition superseded by [0017](0017-connected-codex-menu-reset.md); 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 | +| [0014](0014-settings-onboarding.md) | Keep first-run setup and account connections in Settings, with optional sign-in and saved completion before opening usage. | Accepted implementation direction; native acceptance pending | +| [0016](0016-claude-live-test-profile.md) | Run isolated Claude allowance validation in the existing UI with explicit consent and no Codex access or cleanup. | Candidate implementation; independent review and native acceptance pending | +| [0015](0015-codex-test-public-status.md) | Read public Codex service status in the isolated test profile and show unavailable or dated status explicitly. | Accepted implementation direction; native acceptance pending | +| [0017](0017-connected-codex-menu-reset.md) | Allow explicitly confirmed Codex menu reset redemption for the same connected keyring-only profile, including isolated Codex mode. | Accepted implementation direction; native and live redemption proof pending | +| [0018](0018-hover-only-notch-details.md) | Use hover-only Electron notch details, Usage or Compact dots at rest, and integer pixel notch sizing. | Superseded by ADR 0019 | +| [0019](0019-single-design-notch.md) | Keep one usage-ring and percentage strip, remove the dots option, and clear expanded state on disable/hide. | Accepted implementation direction; independent review and native acceptance pending | +| [0021](0021-provider-group-order.md) | Persist Electron provider group order across usage, menu bar and side notch, with keyboard moves and saved-order recovery. | Accepted implementation direction; rendered and native acceptance pending | +| [0022](0022-codex-account-token-history.md) | Read provider account daily tokens separately from local activity through the managed ChatGPT helper. | Accepted implementation direction; independent review, native and live acceptance pending | +| [0023](0023-onboarding-source-choice.md) | Offer supported CLI and account methods in onboarding, with explicit cleanup before switching to local collection. | Accepted implementation direction; native interaction acceptance pending | +| [0025](0025-card-settings-diagnostic-reports.md) | Keep Settings in usage cards and restore explicit sanitized support reports in Diagnostics. | Accepted implementation direction; native and live delivery acceptance separate | +| [0026](0026-antigravity-desktop-local-quota.md) | Opt-in Antigravity Desktop quota through its owned local service. | Source implementation; native/provider acceptance pending | +| [0027](0027-grok-cursor-closed-reader.md) | Opt-in Grok Bot via closed/checkpointed Cursor with immutable SQLite and account binding. | Source implemented; native/live unverified | +| [0028](0028-grok-bot-desktop-reader.md) | Use consented Grok Bot Desktop scoped credentials and a fixed read-only Connect allowance request for new Grok connections; retain saved Cursor sources. | Source and synthetic proof; native/live unverified | +| [0029](0029-api-billing-snapshots.md) | Separate OpenAI API and Anthropic API billing snapshots from subscriptions; keep Admin keys in a one-run helper. | Source implementation; real billing acceptance pending | +| [0030](0030-grok-bot-encrypted-compatibility.md) | Accept account-bound encrypted Bot legacy values and use the observed fixed Keychain account. | Bounded source correction; live allowance acceptance pending | + +| [0031](0031-grok-quota-burn.md) | Measure Grok Bot quota burn from recent same-account percentages without inventing token counts or cycle duration. | Source implementation; native and live rate acceptance separate | + +| [0032](0032-one-time-api-setup.md) | Separate API setup from account connections and accept a one-time Admin key for explicit billing fetches, without saving credentials. | Source implementation; real billing acceptance pending | +| [0033](0033-muse-code-subscription-allowance.md) | Read opt-in Muse Code allowance through official MSP without a model turn; preserve observation age and keep Muse.ai separate. | Source implementation; native/live acceptance pending | | [0010](0010-user-triggered-github-diagnostics.md) | Initial GitHub form proposal, withdrawn before implementation because it required login. | Superseded by 0011 | | [0011](0011-login-free-linear-reports.md) | Send user-requested diagnostics through a free relay to a private Linear project without user login. | Accepted; report lifetime amended by 0012; relay activated 2026-10-08 | diff --git a/docs/electron/README.md b/docs/electron/README.md new file mode 100644 index 0000000..15d139c --- /dev/null +++ b/docs/electron/README.md @@ -0,0 +1,245 @@ +# MeterUsage Electron beta + +Electron is the sole active target for new MeterUsage features. The legacy +Swift app is frozen as the current stable download and rollback option until +[staged retirement](../adr/0024-electron-primary-swift-retirement.md) passes. + +The public beta is **0.3.0-beta.1, build 54**, for Apple silicon and macOS 13+. +Use the [beta download and installation guide](../BETA.md). The ZIP is Developer +ID signed and notarized. This is an opt-in manual download; Swift stays stable. +Source manifests and build 54's cosmetic Settings footer still say `0.2.41`. +Packaging explicitly sets the beta version. Full native acceptance remains open. + +See the [current beta screenshots](../SCREENSHOTS.md) for Usage, notch and Settings. + +## Source choices + +Settings contains optional onboarding and the supported source choices. Changing +a selector alone does not access an account or switch collection. Apply the +selected method with its action. **Done** saves setup completion before opening +Usage. See [ADR 0023](../adr/0023-onboarding-source-choice.md). + +| Provider and method | Available source behavior | Coverage limit | +| --- | --- | --- | +| Codex browser sign-in | Existing official app helper, with command-install compatibility, signs in through the provider page using a separate keyring-only profile. Reads allowance and provider-reported daily account tokens. | Account history is separate from CLI tokens. No local sessions, category split, cost or burn attribution comes from this method. Universal device/product coverage is unverified. | +| Codex CLI | Existing authenticated CLI supplies quota/reset metadata; local rollouts supply tokens, sessions and estimated cost. | Activity on this Mac only. | +| Claude Desktop | Explicit consent permits a fixed Keychain helper and read-only Desktop cache/cookie access for current account allowance. | Allowance only. No Desktop chat/token history, local cost, tier or extra-usage credit projection. Compatibility can fail when the active account has no allowance token; paid-account live acceptance remains open. | +| Claude Code CLI | Local transcript counters, tier metadata and optional companion quota files. | Local activity; allowance requires the [companion](../COMPANION.md). | +| Antigravity Desktop | Explicit consent reads allowance from one running, signed-in desktop server. | Account allowance only. Local CLI activity stays separate. Live comparison was confirmed by the owner; broad compatibility is unverified. | +| Grok Bot Desktop (Beta), unreleased source | Explicit consent reads the active account-bound encrypted Bot credential and requests allowance. | Native Keychain/store and endpoint compatibility remain unverified. Existing saved Cursor connections retain their source. | +| OpenCode Go local | **Use local OpenCode** enables local Go sessions without a sign-in or key. Existing Go authentication also supplies allowance. | Go sessions only. Other providers used through OpenCode are excluded. | +| Grok CLI (Beta) | Existing CLI authentication and local session summaries. | Consumer website/mobile sign-in is unsupported. Session/message counts do not establish token totals. | +| Muse Code CLI, unreleased source | Explicit **Use Muse Code CLI** enables read-only subscription observations through an existing official CLI. | Last-observed session/weekly allowance only. No local activity, token totals or Muse.ai cloud allowance. Native/live validation remains pending. | + +**Use CLI** ends only MeterUsage-owned account state and waits for successful +cleanup before enabling local collection. Failed cleanup blocks the switch. +Ordinary **Disconnect** leaves collection off rather than silently falling back. +Other apps remain signed in. Demo and isolated live test profiles cannot switch +to local collection. See [desktop connections](../DESKTOP-CONNECTIONS.md) and +[privacy](../PRIVACY.md) for the exact access and failure contracts. + +### Existing provider inputs + +The original nine adapters and the unreleased Muse Code reader are wired in [composition](../../src/main/composition.ts). +The current source adds an OpenCode Go local setup action and Antigravity +Desktop connection. These changes are separate from the published build 54. + +- Antigravity CLI quota uses existing authenticated `agy` container state, Docker or + Podman, image `antigravity-cli:local` and volumes `antigravity-config` and + `antigravity-bin`. Native database/history usage is independent of that quota + runtime. No separate MeterUsage browser login or API-key form exists. +- OpenCode Go quota reads the existing `opencode-go` key saved by OpenCode + `/connect` in `~/.local/share/opencode/auth.json`. Independently, token history + reads `~/.local/share/opencode/opencode.db`, with read-only CLI SQL fallback. + Only `opencode-go` sessions count, not every provider used through OpenCode. + A quota key alone cannot supply local history. The OpenCode-only CLI fallback + checks executable `~/.bun/bin/opencode` installs as well as the existing paths. +- OpenRouter uses existing keys for account quota/credit and aggregate activity. +- Separate OpenAI API and Anthropic API cards read owner-only billing snapshots + from **Settings → API connections → Fetch billing** or the bundled one-run + helper. Settings can reuse the previous app's saved Keychain Admin key or + accept a masked, one-time key. Neither path saves new credentials. See [API billing](../API_USAGE_BETA.md). + That activity is provider account scope, not a local chat count. +- Cursor, Copilot CLI and Gemini CLI use optional existing local quota snapshots. + Presence alone does not provide cloud quota or a token ledger. + +Enter an API Admin key only in its dedicated Settings field or the advanced +helper prompt. Never paste credentials into diagnostic reports or documentation. The full input and +request list is in [privacy](../PRIVACY.md#provider-inputs-and-requests). + +### Advanced local accounts + +Additional Codex and Claude accounts are user-selected local configuration +directories, not arbitrary cloud subaccount logins. Settings supports adding, +renaming, enabling, changing directory and removing rows. Stable generated slot +IDs keep history separate through rename. Removing a row does not delete provider +files. Source reconstruction takes effect without the legacy relaunch step; +native picker and persistence acceptance remain open. See +[ADR 0006](../adr/0006-managed-account-list.md). + +## Current interface + +The candidate has a Usage panel and a separate Settings window. Small, Medium +and Large sizing applies to panel/Settings text and controls; main-panel width +scales and height fits within the display. Ctrl/Cmd plus or minus changes size; +Ctrl/Cmd 0 restores Medium. Refresh interval, theme/accent, collection/display, +heatmap, pacing, telemetry, chart, alerts, login and update preferences remain. + +Settings has a compact category sidebar: Connections, Appearance, General, +Providers, Side notch, Usage details and Diagnostics. Provider summaries show the +current source, collection state and reading availability. Choose Configure for +setup actions; selecting a method still requires its explicit connection action. +Diagnostics offers **Send report** for an explicit private support submission, +with progress, a confirmed reference and safe retry feedback. **Copy diagnostics** +remains available. See [reporting](../REPORTING.md) for content and privacy limits. +The category controls wrap above the content in narrow windows. Settings fits +the active page and expanded setup content, retains its width and top position +while on screen, and scrolls when content exceeds the available work area. + +Service status appears first in the Usage panel and stays visible. Provider and +account tabs show one card at a time in saved order; arrow keys and Home/End +switch tabs. Quota, eligible reset actions and heatmaps stay visible. More +activity opens secondary recorded metrics, Usage by period opens period shares, +and Active window breakdown opens notch burn contributors. The All-provider +activity breakdown contains local totals and measured burn. Shared cards retain +complete statistics and period/burn data. + +Provider groups can be reordered with drag handles, arrow keys or move buttons. +Saved order applies to Usage, service status, menu bar and side notch. Additional +accounts stay with their provider. Failed saves retain saved order. See +[ADR 0021](../adr/0021-provider-group-order.md). + +The notch has one 36-pixel charcoal rail with provider marks inside 22-pixel +usage rings and percentages below. Settings stays in each usage card footer; +the strip has no Settings button. Details open on hover and +close through the existing delay and temporary action guards. Pin, dots and +multiple-design controls are removed. Current source uses a 220-pixel card and +36-pixel strip; retained Swift uses 250/44 points. Shared-image capture remains +250 pixels wide. ADR 0019 records the current dimensions. Native fixed-edge captures for two different-height +cards are still required. See [side notch](../SIDE-NOTCH.md) and +[ADR 0019](../adr/0019-single-design-notch.md). + +### Heatmap scope + +Local Codex/Claude activity supports 26-week daily, weekly and cumulative views. +Usage telemetry without extended daily activity uses a 30-day grid and preserves +its token or session units. The optional 30-day line chart is separate. Connected +Codex has a daily 26-week account-token grid ending on the latest provider date. +Unreported dates stay unavailable; reported zero is zero. Account history stays +in memory and is excluded from local rollups, durable history and schema-1 JSON. +Live Codex account history has been observed; complete device/product coverage remains unverified. See +[ADR 0022](../adr/0022-codex-account-token-history.md). + +Compact hover/focus readouts are an unreleased source change; published build 54 +retains exact readouts. Hover and keyboard focus show dated compact totals such as `42.4B tokens` and +`1.6K sessions`. Tooltips and accessible labels retain exact grouped counts. Each live heatmap +has one Tab entry. Use arrow keys to move between days, Home/End for the first +or last cell, and Tab to leave the grid. Shared images omit focusable cells. +Feedback about 30-day versus 26-week grid geometry +remains pending. These source contracts do not establish native keyboard access, +layout or anchor acceptance. + +## Known parity gaps + +The source comparison identifies five primary UI omissions/reductions: + +- Settings lacks the legacy read-only slot/plan/source overview. Plan badges + remain on Usage cards. +- The independent per-provider seven-day sparkline is absent; the optional + 30-day chart and aggregate seven-day total remain. +- Reset countdown/date is not independent of projected exhaustion. ETA can + replace the reset date field; notch header countdown is absent. +- Update feedback lacks release notes and visible download/relaunch progress. + Install/Retry exists; native update completion remains unverified. +- The tray context menu lacks **Refresh Now**. Usage and notch refresh remain. + +Smaller display gaps include today's session count in token-unavailable +telemetry, average tokens/turn and aggregate long-chat count on the main burn +card, provider-specific recovery hints, diagnostics copy feedback and a timestamp +on fresh quota-only notch cards. These findings do not establish how many users +are affected. Current source adds Antigravity Desktop and OpenCode local setup, +restores Bun discovery and matches heatmap geometry. Pin/dots removals are +intentional. Native and provider acceptance remain separate. + +## Build and test + +Run commands from the repository root. Node `>=24.13.1` and `pnpm@12.9.1` are +declared in [package.json](../../package.json). + +```sh +pnpm install --frozen-lockfile +pnpm typecheck +pnpm test +pnpm build +``` + +These checks can run on Linux. They prove their source/fixture layer only. +On macOS, `pnpm package:mac` exports marks, compiles the fixed Claude Desktop +helper, builds and packages an Apple silicon ZIP with `--publish never`. +It does not install or publish the candidate. It requires the existing Swift +and signing tools. Do not invoke the Keychain helper during synthetic QA. +See [contributor commands](../../CONTRIBUTING.md#typescript-electron-candidate). + +## Isolated QA + +The default app name, bundle ID and data/preferences overlap Swift. Do not run +both editions against normal profiles. Use an empty isolated directory on first +use, or reuse its matching candidate marker for relaunch checks. Candidate paths +must be canonical and cannot follow symlinks. + +After `pnpm build`, synthetic headless QA uses: + +```sh +meterusage_demo_root="$(node -p 'require("node:fs").realpathSync(require("node:os").tmpdir())')" +node dist/main/cli.cjs json --force --demo --candidate-profile "$meterusage_demo_root/meterusage-cli-demo" +``` + +After packaging on macOS, synthetic GUI QA uses: + +```sh +./release/mac-arm64/MeterUsage.app/Contents/MacOS/meterusage --demo --candidate-profile /private/tmp/meterusage-native-demo +``` + +Demo isolates preferences, history, caches and Electron state and blocks live +provider discovery, updates and login-item changes. It does write isolated files. +The separate opt-in `--codex-test-profile` and `--claude-test-profile` flows are +for isolated live QA, with different provider access limits. They do not grant +normal-profile coexistence or complete native acceptance. Follow +[demo and candidate isolation](../DEMO.md#electron-candidate), +[Codex test boundaries](../adr/0012-codex-live-test-profile.md), +[reset confirmation amendment](../adr/0017-connected-codex-menu-reset.md) and +[Claude test boundaries](../adr/0016-claude-live-test-profile.md). + +## Acceptance and release boundary + +Private installation receipts do not close native acceptance. Remaining proof +includes native setup/account/offline journeys, tray/focus/accessibility, +notifications, login items, sharing, updater replacement/relaunch, drag/display +persistence and two different-height notch captures. Reuse matching source, +packaging and installation receipts only for their recorded layer. Windows +implementation, packaging, CI, testing and release remain paused. + +[ADR 0010](../adr/0010-macos-typescript-electron.md) keeps Swift as the behavior +reference and rollback path. No automatic cutover, data migration, release or +installed-app replacement follows from these docs. The [repository strategy](../VERSIONS.md#proposed-same-repository-strategy) +records the accepted development direction and the checks still required for +source retirement and stable cutover. This opt-in beta does not close those checks. Electron's required Swift helpers remain. + +Desktop account research for Grok/Cursor, Antigravity and OpenCode Go is recorded +in [DESKTOP-LOGIN-RESEARCH.md](../DESKTOP-LOGIN-RESEARCH.md). It separates +documented sign-in from implemented quota collection. + +### Unreleased Antigravity Desktop setup + +Current source adds **Connect Antigravity Desktop** to the existing source +selector. One running, signed-in desktop language server supplies account +allowance without `agy`; local CLI history is excluded. Multiple matching +servers and missing or expired data remain unavailable. **Use CLI** selects +the existing local integration explicitly. This adapter has synthetic-fixture +coverage only and is absent from published build 54. Native process discovery, +local transport and live quota comparison remain acceptance gaps. See +[desktop connections](../DESKTOP-CONNECTIONS.md#antigravity-desktop-source-boundary). + +All activity heatmaps use the same 26-column, seven-row grid. Feeds with only +30 days keep older dates unavailable. The common layout does not extend the +source history or change local versus provider-account coverage. diff --git a/docs/mockups/README.md b/docs/mockups/README.md index d97e528..c1dd101 100644 --- a/docs/mockups/README.md +++ b/docs/mockups/README.md @@ -1,6 +1,10 @@ # Concept mockups -Synthetic UI concepts for the next meterusage feature waves. Numbers are invented. Visuals use the shipping hardware-black notch palette. +Historical synthetic UI concepts for MeterUsage feature waves. Numbers are +invented. These concepts use the Swift hardware-black notch palette; they are +not current Electron screenshots or proof that every concept shipped. Read the +[Swift guide](../swift/README.md) for older demo captures and the +[Electron guide](../electron/README.md) for current candidate source behavior. ## Hard constraint: provider marks stay @@ -15,7 +19,8 @@ Do **not** redesign or replace provider icons/logos. | App icon | `Resources/AppIcon.png`, `Resources/AppIcon.icns` | | Render path | `ProviderMark` in `Sources/MeterUsage/Views/MenuBarLabel.swift` | -Menu bar, popover, settings, and side-notch continue to use `ProviderMark`. New UI (including any cross-platform shell) must reuse these assets and the same mark semantics (service-status tint on the mark, headroom tint on the %). +The retained Swift menu bar, popover, Settings and side notch use `ProviderMark`. +The Electron renderer reuses the provider assets through its own mark renderer. New UI (including any cross-platform shell) must reuse these assets and the same mark semantics (service-status tint on the mark, headroom tint on the %). ## Waves diff --git a/docs/plans/cliproxy-hub.md b/docs/plans/cliproxy-hub.md new file mode 100644 index 0000000..38b1c88 --- /dev/null +++ b/docs/plans/cliproxy-hub.md @@ -0,0 +1,52 @@ +# CLIProxyAPI Hub connection + +Status: Research complete; implementation needs an existing saved-history source. + +## Goal and source contract + +Connect MeterUsage to an existing CLIProxyAPI history source and show retained +token activity grouped by provider and upstream account. Keep proxy activity +separate from local transcripts and subscription allowance to avoid double +counting or assigning activity to an unrelated account. + +CLIProxyAPI 8.0.35 exposes a usage queue that removes records when read. It does +not expose the legacy historical snapshot endpoints. This integration must not +consume that queue, change proxy configuration, or install a new collector. +A saved export or a documented history service must be identified first. + +Sources: [v8 usage contract](https://help.router-for.me/management/apiv8#usage), +[v8.0.35 routes](https://github.com/router-for-me/CLIProxyAPI/blob/ae3ee3be303fb07fb8173279923413005bf32177/internal/api/server_management_v8.go), +[legacy snapshot format](https://github.com/router-for-me/CLIProxyAPI/blob/db63f9b5d60e643ca060901cfe67ffc5ce63ac63/internal/api/handlers/management/usage.go). + +## Proposed minimum + +1. Verify the saved source's format, retention and non-destructive read contract. + Do not advertise complete history where coverage is unknown. +2. Add one explicit Hub connection in Settings. For an authenticated HTTP source, + keep its management key in main-process memory and OS-backed encrypted + storage; fail closed without secure storage. Reject redirects and credentials + embedded in URLs. Source details must precede transport implementation. +3. Read one bounded snapshot per refresh. Join reported `auth_index` values to + projected provider metadata where the source supplies that mapping. Keep + unknown or ambiguous identities in an unattributed group; model aliases are + not evidence of a subscription account. +4. Publish only numeric usage, dates and reduced display labels. Never publish + client API keys, credential files, email addresses, raw errors or headers. + Request counts must remain requests rather than sessions or messages. +5. Reuse the account heatmap's unavailable-day handling. Use reported total + tokens when component counters can overlap. Replace snapshots rather than + adding repeated cumulative totals. Do not merge Hub and local token totals. + +## Verification and limits + +Synthetic checks must cover authentication failures, cancellation, source +replacement, payload limits, identity ambiguity, missing dates, snapshot reset, +overlapping token categories and repeated polling. Renderer checks must verify +provider/account labels, unavailable history and separate local/Hub scope. +Native secure storage and the actual history source need independent execution +proof before runtime support is claimed. + +No Hub adapter or credential store is implemented by this plan. The existing +local Claude heatmap and Desktop allowance remain separate data sources. No +server deployment, queue consumer, provider login or app installation follows +from this plan. diff --git a/docs/plans/feature-and-windows-pipeline.md b/docs/plans/feature-and-windows-pipeline.md index a59a21b..0162824 100644 --- a/docs/plans/feature-and-windows-pipeline.md +++ b/docs/plans/feature-and-windows-pipeline.md @@ -1,74 +1,39 @@ -# Feature + Windows release pipeline - -Ship the three HUD concepts in small macOS releases while proving a Windows path. Prefer React Native / Expo **only if** a tray + floating HUD spike works; otherwise Tauri 2 + React. - -## Non-negotiable: provider icons/logos stay - -Across every wave and any cross-platform shell: - -- Keep shipping assets under `Resources/*-logo.png` and `AppIcon.*`. -- Keep `ProviderMark` semantics (mark = service status tint; `%` / rings = headroom). -- Do not invent replacement brand marks for Codex, Claude, OpenRouter, Grok, Antigravity, or OpenCode Go. -- New platforms must load the same logo files (or lossless copies), not SF-Symbol-only stand-ins where a real logo already ships. - -See [../mockups/README.md](../mockups/README.md). - -## Platforms - -- **In:** macOS (current Swift app) + Windows (new shell after spike). -- **Out for now:** Linux tray redesign. - -## Dual track - -```text -Track A (macOS features) Track B (cross-platform) -───────────────────────── ───────────────────────── -A0 OpenRouter budget fix B0 Spike: RN tray/HUD vs Tauri+React -A1 Wave 1 Ambient ETA ──0.3.x── B0a ADR with winner + idle RSS gate -A2 Wave 2 Burn attribution ──0.4.x── B1 Shared domain contracts -A3 Wave 3 Pace alerts ──0.5.x── B2 Windows path/provider adapters - B3 Windows MVP (tray + flyout) - B4 Port Waves 1–3 to shared UI - B5 macOS migrate or dual-run + sunset -``` - -Track B must not block Track A unless a shared schema break is unavoidable. Version CLI JSON when ETA / burn / alert fields are added. - -## Wave → mockup map - -| Release (suggested) | Wave | Spec | -|---|---|---| -| 0.3.x | Ambient time-to-empty | [../mockups/01-time-to-empty.md](../mockups/01-time-to-empty.md) | -| 0.4.x | Burn attribution | [../mockups/02-burn-attribution.md](../mockups/02-burn-attribution.md) | -| 0.5.x | Pace alerts + failover | [../mockups/03-pace-alerts.md](../mockups/03-pace-alerts.md) | - -One concept per macOS release. No mega-PR that mixes all three. - -## Platform spike gate (Track B0) - -Build two short throwaways (≤5 engineering days total): - -1. React Native for Windows (and macOS if attempted) — system tray icon + always-on-top detail card. -2. Tauri 2 + React — same tray + card. - -Record idle RSS, install size, tray click reliability, and whether **existing logo PNGs** render cleanly in the tray/flyout. - -**Decision rule** - -- Choose RN/Expo if tray + HUD + idle footprint are acceptable on Windows **and** macOS. -- Otherwise choose **Tauri 2 + React** (React-family UI, first-class tray). -- Write `docs/adr/0002-cross-platform-shell.md` with the verdict before B1. - -Windows MVP may ship tray + flyout first; pixel-identical side notch can follow. - -## Privacy - -Unchanged: local numeric metadata only; no prompts/code; path redaction via `Privacy`; documented network endpoints only (`docs/PRIVACY.md`). Windows adapters must use the same rules. - -## Out of scope - -- Linux StatusNotifier redesign -- Team / FinOps / seat reclaim -- Notarized Mac or signed Store builds inside Waves 1–3 -- MITM proxies or credential vaults -- Redesigning provider logos +# Platform pipeline: macOS Electron, Windows paused + +The earlier React Native/Expo spike, Tauri fallback and Windows-first rollout +are superseded by [ADR 0010](../adr/0010-macos-typescript-electron.md), accepted +on 2026-10-06. ADR 0002 is already assigned to provider marks and must remain +unchanged. + +## Current direction + +Port the macOS app and schema-1 JSON CLI to TypeScript, Electron and React with +pnpm, Vite+, Tailwind, Vitest and electron-builder. Keep existing provider +marks, measured burn, pacing/alerts, multiple accounts, JSON/history formats +and verified prebuilt installation. The retained Swift app is the compatibility +oracle and rollback path. + +Windows implementation, installers, CI, testing and releases are paused until +a test machine is available. There is no Windows support claim or release +schedule. Portable shared source does not establish Windows behavior. + +## Migration gates + +1. Source parity: synthetic provider/calculation/account/history/schema tests, + TypeScript checks, production build and independent source review. +2. Native parity: separate macOS candidate with identity/digest/signature, + headless JSON, native launch and user-path proof, including two + different-height notch captures. Record package size, idle RSS and scan + timing. Missing proof leaves this gate open. +3. Repository delivery: exact-base/head independent review, required forge + gates and guarded merge. Source-only draft delivery may record open native + gates; it does not authorize cutover or release. + +The original [ambient ETA](../mockups/01-time-to-empty.md), +[burn attribution](../mockups/02-burn-attribution.md) and +[pace-alert](../mockups/03-pace-alerts.md) concepts remain design history. +They do not create a new feature wave or version schedule for this migration. + +See [the migration plan](macos-electron-migration-plan.md), +[provider marks](../adr/0002-provider-marks-stay.md) and +[privacy](../PRIVACY.md). diff --git a/docs/plans/macos-electron-migration-plan.md b/docs/plans/macos-electron-migration-plan.md new file mode 100644 index 0000000..dd7ecf7 --- /dev/null +++ b/docs/plans/macos-electron-migration-plan.md @@ -0,0 +1,218 @@ +# macOS Electron migration plan + +Port the macOS app and headless JSON CLI to TypeScript without changing provider or persisted-data contracts. Completion requires fixture parity and a native candidate, with each unproven native path recorded explicitly. + +Current direction, 2026-10-14: [ADR 0024](../adr/0024-electron-primary-swift-retirement.md) +makes Electron the sole active feature target. The legacy Swift app is frozen +for new features and remains the published download and rollback until the +retirement conditions pass. Earlier execution receipts below describe their +recorded artifacts; the [candidate guide](../electron/README.md) lists current +source capabilities and remaining acceptance gaps. + +## Human review + +### Goal + +A macOS Electron app and headless CLI provide the current quota, usage, pacing, accounts, history, alerts, settings and update behavior. Windows development is paused. + +### Staged Swift retirement + +- Freeze legacy feature development now; route new work to Electron. Verify + contributor and edition guidance against ADR 0024. +- Finish agreed setup/parity work and the existing source/native acceptance + gates. Reuse exact matching receipts; verify changed inputs and open paths. +- Verify the deliberate Swift-to-Electron update and rollback with preserved + accounts, preferences and history before public cutover. Existing release + holds remain; a documentation decision does not publish the app. +- After a retrievable legacy source revision and matching release are preserved, + remove old-app-only source, tests, build steps and CI references in a reviewed + change. Verify Electron build, packaging and affected native behavior after + removal. Keep the Swift Keychain helper, asset exporter and required assets. + +These retirement steps are pending beyond the documentation freeze. The +intended end state is one app; no permanent dual-edition feature roadmap is +required. + +### Diagram + +```mermaid +flowchart LR + Files[Existing local numeric provider inputs] --> Sources[Main-process provider adapters] + CLI[Provider CLIs and existing aggregate endpoints] --> Sources + Sources --> Domain[TypeScript models and calculations] + Defaults[Existing macOS preferences] --> Main[Electron coordinator] + History[Existing history and archive JSON] <--> Main + Domain --> Main + Domain --> JSON[Headless schema-1 JSON CLI] + Main --> IPC[Validated, sender-bound IPC] + IPC --> React[React tray flyout, settings and fixed-anchor notch] + React --> Confirm[Explicit reset confirmation] + Confirm --> Main +``` + +### Approach + +Use one small package: pnpm, Vite+, TypeScript, React, Tailwind and Vitest, with electron-builder for macOS candidate packaging. Main owns provider subprocesses, files, credentials, scheduling and writes. A sandboxed preload exposes named, validated operations and sanitized snapshots. The renderer has no Node access or provider paths except user-configured account directories in Settings. Reuse the existing provider marks unchanged. + +Translate the Swift domain and source contracts into TypeScript, using the Swift implementation and synthetic fixtures as the parity oracle. Keep Swift source during migration as a reference and rollback path. Do not substitute a retained Swift helper for the TypeScript port. Preserve schema 1, slot identity and existing history/archive shapes, including Swift's reference-date encoding in the quota archive. Keep existing macOS preference keys and managed-account JSON data readable. Candidate QA uses isolated synthetic directories and does not read or write the installed app's data. + +The owner selected Electron. The earlier React Native/Tauri and Windows-first plan is superseded. T3 Code's desktop package demonstrates the selected tools; its server, auth, networking, event sourcing, Rust monitor and Effect dependencies are unnecessary here. + +### Compatibility and candidate isolation + +Use a typed main-process adapter over `/usr/bin/defaults` against the original `dev.meterusage.app` domain. Read/write only known app keys. Preserve booleans/numbers/strings, update Date values and `sideNotchPanelCorner` as its existing AppKit top-right coordinates. Decode `managedAccounts` from UserDefaults Data bytes; write it with `defaults write ... -data` so Swift remains able to read it. Absent keys retain the registered Swift defaults. Synthetic-domain tests must prove old Swift preferences to Electron to Swift without using the live domain. No new preference-file migration is needed. + +The exact safe candidate command is `MeterUsage.app/Contents/MacOS/meterusage --demo --candidate-profile `. Candidate-profile selection happens before preferences, Electron userData, provider sources, archives, snapshots or caches are constructed. It uses isolated file-backed preferences, synthetic transports and reset consumers, with no network or real-home fallback. Tests reject candidate launches without demo mode. Candidate mode rejects updater installation, login-item changes and live subprocess/provider execution. Demo without a profile never writes installed-app state either. + +Claude tier and transcript decoding use a selective JSON scanner: it traverses JSON syntax, materializes only schema-selected scalar values and skips unknown values without `JSON.parse` of the parent. Structural tests spy on decoded scalar values and include escaped keys, nested identifying values, unrelated message content, malformed/truncated inputs and arrays. Output projection is a separate renderer/CLI privacy gate. + +### Parity inventory + +| Contract | Implementation owner and proof | +| --- | --- | +| Codex | Main quota RPC, token ledger/activity, model groups, credits/reset count and explicit per-slot reset action; fixture handshake/account isolation and schema-1 comparison | +| Claude | Main companion quota, narrow plan tier and numeric transcript activity/cache; limits/legacy fixtures, per-account path isolation and selective-decoder tests | +| OpenRouter | Main aggregate key/credits/activity endpoints and existing key discovery; synthetic HTTP, optional credits failure and activity fixtures | +| OpenCode Go | Main Zen quota and read-only session SQL/CLI fallback; numeric usage/window/project fixtures and no message body selection | +| Grok | Main per-refresh token discovery, billing and summary metadata; rotated-key and date/count fixtures | +| Antigravity | Main bounded existing runtime recovery, quota groups, numeric SQLite/protobuf usage and native/container history fallback; synthetic runtime, WAL copy and protobuf fixtures | +| Cursor, Copilot, Gemini | Main existing local numeric quota candidates and presence checks; separate parser/path fixtures for each provider | +| Flyout, Settings, first run | Controller implements React surfaces and main preferences/coordinator; synthetic visibility/theme/relaunch/account/failure journey | +| Tray, notifications, login item | Controller implements Electron native shell; isolated launch and notification/login state proof where permitted; native interaction remains a named gate | +| Notch native behavior | Controller implements non-activating fixed-anchor panel, both card sides, whole-point frames, unanimated resize, folded/pinned hover/drag, release outside, display changes and persisted anchor; geometry tests plus two different-height native captures | +| Notch accessibility and sharing | Controller implements explicit detail action and card-only 2x capture with native sharing; actionable labels, correct crop and native invocation require proof | +| Updater and installer | Controller ports verified downloads and explicit-only install expectations after integrating PR51's merged source; synthetic digest/signature/copy-failure tests, no candidate install | + +### What changes + +The desktop shell and implementation language change. Existing features, account separation, numeric-data boundary, provider marks and compiler-free public installation remain the acceptance targets. Public installation continues to use verified prebuilt bundles. Candidate builds do not install or publish themselves. + +### Risks and rollback + +- Data compatibility: fixture-test old JSON and old preference values before enabling writes. Preserve unreadable history and suspend writes after a corrupt load. +- Privacy: raw provider payloads stay in main; narrow output projection and renderer import checks must reject secrets and identifying metadata. Claude tier parsing must select only tier fields rather than decode identifying values. +- Native parity: tray behavior, login item, notifications, fullscreen/spaces, drag anchoring and two different-height rendered notch cards need macOS proof. Browser screenshots cannot satisfy this gate. +- Update safety: downloads require digest and bundle/signature checks. Automatic update checks cannot install. Explicit update installation is blocked during candidate QA. +- Rollback: the existing Swift app and installer remain usable until native parity passes. JSON files retain their old format; no destructive contraction, provider-data edits or installed-app replacement is part of this change. + +### Decisions needed + +None for the authorized migration. Credential setup, release publication, deployment, workflow edits and installed-app replacement remain separate actions. + +## Implementation plan + +### Context and research + +Read `AGENTS.md`, `CONTRIBUTING.md`, `docs/KB.md`, `docs/PRIVACY.md`, `docs/SIDE-NOTCH.md`, and ADRs 0002, 0003, 0004 and 0006. Source baseline is `55748e2c99e7790d09c45933f07803a4815ce6ab`, with 46 Swift sources and 31 Swift test files. Read `Services/DataSource.swift`, `Models/UsageModels.swift`, `Core/LimitsReport.swift`, `Core/CliJSON.swift`, composition, preferences, durable history and quota archive before planning. + +Official Electron security guidance: https://www.electronjs.org/docs/latest/tutorial/security. Use context isolation, sandboxing, no renderer Node integration, navigation restrictions and IPC sender validation. T3 Code's desktop/web package and Vite+ configuration supplied concrete package versions and bundling patterns. The macOS host has existing Node, pnpm, Swift and codesign; no compiler installation is needed. + +Installer work is separately owned in PR 51. Read its merged result before changing `Scripts/make-app.sh` and related install docs. Integrate only merged source into this existing worktree and preserve its verified prebuilt default, explicit source option and failure-safe output replacement. + +### Assumptions + +- Existing JSON schemas remain sufficient. Falsify by round-trip tests against Swift fixtures, including dates, missing optional fields and zero values. +- Electron APIs can implement native behavior. Falsify with candidate runtime proof; unsupported paths remain blockers, never silently dropped features. +- Authorized T3 tools can drive or capture native candidate interaction. Inspect capabilities; if they only expose web preview and mobile devices, report the exact native interaction gap and continue independent implementation/tests. +- Installer PR51 is merged and integrated. Preserve its prebuilt default and explicit source-build option. + +### Steps + +- [x] S1 Port models, pacing, pricing, telemetry, burn attribution, account identity, history and schema-1 JSON reports · files: `src/domain/`, `src/main/history.ts`, `tests/` · verify: `pnpm typecheck`, `pnpm test` with fixture comparisons to Swift. +- [x] S2 Port every provider's quota/activity/usage/status/plan source and composition · files: `src/main/providers/`, `src/main/composition.ts`, `tests/` · verify: `pnpm test tests/providers.test.ts tests/usage.test.ts tests/composition.test.ts` plus structural parser and transport fixtures. +- [x] S3 Port coordinator, preferences, alerts, diagnostics and explicit reset flow · files: `src/main/`, `src/shared/`, `tests/` · verify: `pnpm test tests/coordinator.test.ts tests/reset-pacing.test.ts tests/ipc.test.ts tests/domain.test.ts` for refresh/backoff, corrupt history and synthetic reset/IPC contracts. +- [ ] S4 Implement Electron main/preload and React flyout/settings/notch with existing assets · files: `src/renderer/`, `src/main/electron.ts`, `src/preload.ts`, build configuration · verify: `pnpm build`, IPC validation/import boundaries, T3 preview synthetic journey and native smoke when available. +- [ ] S5 Build macOS candidate with electron-builder using existing remote tools · files: packaging configuration and candidate smoke script · verify: `pnpm package:mac`, artifact digest and signature, headless demo JSON, native launch, settings/relaunch/offline path, two different-height notch captures and fixed top/strip positions. Record size/RSS. No installation or publication. +- [x] S6 Reconcile merged installer source and update public docs · files: README, CONTRIBUTING, CHANGELOG, KB, privacy, legacy Windows plan, new next-free-number ADR and index · verify: documentation link checks, `python3 scripts/documentation.py --check` if repository provides it, `git diff --check`; retain explicit unavailable-check evidence if absent. +- [ ] S7 Deliver the authorized change only after meaningful executed validation, exact-base/head independent review and required forge gates · verify: Ponytail review/attestation before commit/push, PR linking, app-owned watcher, expected-head guarded merge and separate remote content readback. Workflow changes and release actions remain held. + +### Verification plan + +Entry state: synthetic clean candidate profile, installed app untouched. Launch candidate, read all provider cards, open Settings, change theme/provider/account label, refresh and relaunch, compare retained settings/history. Inject offline, malformed and slow providers; each fails independently and shows unavailable or dated archive rather than fabricated zero/live data. Add two synthetic accounts of one provider, rename/disable/remove one and verify distinct quota/history/alerts/JSON without deleting provider files. Show reset credits, cancel confirmation, then redeem only a synthetic fixture credit once. Switch between two different-height notch cards and capture native rendered top and strip positions. JSON launch exits without a tray/window and preserves schema-1 output. + +Native notifications, login item and update/install behavior require an isolated candidate-safe check or explicit missing-proof record. Do not consume live credits or replace the installed app to test them. + +### Delivery and completion gates + +Source-validation gate requires executed TypeScript/build/fixture parity, unchanged Swift baseline receipt or affected Swift checks, and exact-base/head independent review. Native-parity gate additionally requires a packaged candidate with bundle identifier, executable identity, digest, signature, launch/interactive user-path receipts and native notch captures. Missing native evidence leaves the migration open and prevents a parity/cutover claim; it does not prevent independent source implementation. A source-only draft PR may record unfinished gates, but guarded delivery/merge of the migration waits for required proof and holds to resolve. + +Retain `MeterUsage.app`, executable `meterusage`, bundle id `dev.meterusage.app`, version/build metadata and exact updater ZIP pattern `MeterUsage-.zip`. Candidate filenames/profiles must remain distinguishable without overwriting the installed bundle. Measure candidate package size, idle RSS and synthetic cold/warm scan times alongside the Swift baseline; record the observed tradeoff. The owner-selected Electron direction is standing authority for its footprint, not permission to omit behavior or invent a pass threshold. Any observed regression that breaks the existing path must be resolved before completion. + +The integrated Swift baseline is `4b913db3a3a8eb2602296489872af2ecaa5d0530`. PR51 has merged; its independently reviewed tree has executed macOS `swift build` and 394 passing tests. Migration changes do not alter Swift source, tests or package inputs. Reuse this matching receipt and rerun only invalidated checks. + +### Risk surface + +Provider credentials and local data remain in main. Independent security review covers IPC, endpoint allowlists, subprocess arguments, narrow decoding and output projection. No new auth, billing, production or credential setup is authorized. + +### Out of scope + +Windows source, installers, CI, tests and releases; studio guidance and future app migrations; workflow changes, releases, deployments, OTA, store submissions, installed-app replacement, provider-data writes and live redemption. + +### Review sign-off + +Independent plan review round 1 requested five revisions: typed UserDefaults compatibility, enforced candidate isolation, explicit native parity inventory, structural selective parsing, and separate source/native/cutover gates. The plan now specifies each mechanism. Round 2 passed all review axes against the revised mechanisms. This is plan approval only; implementation and native proof remain open. Review completeness, strategic scope, privacy, native UI invariants, operational safety and security in one bounded pass. Existing owner authorization permits execution after concerns are resolved. + +### Historical execution receipts + +S1 through S3 have synthetic fixture and TypeScript-check proof. All 128 +current fixtures have matching passing execution receipts, including refreshed +affected cases and retained unchanged cases. Production renderer, main and +preload bundles build, and the rebuilt schema-1 demo matches the Swift oracle. Provider and shell +source fixes, including history projection, per-session degradation, reset visibility, +rolling shares, archive/reset status and provider/display parity, have +passing regression checks. Independent complete-diff review accepted source-only +draft readiness at `193c2af42bd2a8641accbb3f7b5b333ba6cd9ac3`, and draft PR 52 +is open. S4 native/T3-rendered proof and S5 native GUI acceptance remain open. +S6 public documentation links and whitespace pass; the repository does not +provide `scripts/documentation.py`. No Electron release, +installation or cutover has occurred. Source-only draft delivery is allowed +when its applicable checks and review pass; merge remains blocked by native +parity. + +The first native candidate attempt passed symbol export, production bundling +and TypeScript checking. Its fixtures passed 90 cases and failed 29: 28 used +temporary-path aliases rejected by candidate isolation, and one assumed a +case-sensitive filesystem. Canonical fixture roots and exact directory-entry +checks first passed all 51 affected cases on Linux. The current macOS run now +passes all 128 fixtures, TypeScript checking and production bundling. The +symlink guard is unchanged. + +Packaging failed when the dependency collector resolved a broken pnpm +launcher. A temporary candidate-local PATH alias to the existing executable +resolved that failure without host configuration changes. The command session +was lost across an interrupted turn, so its exit code is unknown. Separate +verification proves the resulting ZIP extracts, its arm64 bundle passes strict +ad-hoc signature checks, and its native demo CLI exits successfully with stable +schema-1 fields matching the Swift oracle. The comparison excludes +`generated_at`, `resets_at`, `burn_rate`, `eta_seconds` and `eta_text`. + +The ZIP is 116,696,318 bytes, SHA-256 +`4ea7a6f297402958a52173696880f8183a05454c2e08966d71ebeaa69f52e634`, +and the extracted bundle uses 290,148 KiB on disk. This artifact binds source +`193c2af42bd2a8641accbb3f7b5b333ba6cd9ac3`. It is not notarized, installed +or published. Native GUI journeys, two-height notch captures, idle RSS and +packaged-app performance remain open. + +Synthetic source-scanner samples now compare the unchanged TypeScript and +Swift sources on the same Apple silicon Mac. Each provider uses 32 files; +Claude has 256 messages per file, totaling 1,712,128 bytes, and Codex totals +10,048 bytes. Claude empty/memory/restored-disk cache samples are +30.93/0.56/0.76 ms on native Node 26.10.0 and 392.63/1.25/1.48 ms with Swift +6.4 compiled using `-O` and Swift 5 language mode. Codex first/second scan +samples are 4.84/3.53 ms on Node and 3.81/3.39 ms on Swift. Session/token +counts and cache transitions pass. The Node receipt is reused; no unchanged +check was repeated. These separate single samples use matching generated +fixture contents at different times. Empty cache means application cache, +not flushed OS cache. Compilation and fixture generation are excluded. +Swift uses its actual scan clock; Node uses a fixed fixture clock. Session +and daily results are checked, with scan timestamps and time-dependent +telemetry excluded from equality. These source harnesses do not establish +packaged-app performance, idle GUI RSS or native interaction parity. + +The next independent review found invalid dates in disposable Claude cache +entries and shared provider parsing. Cache dates and costs now validate before +reuse; bad entries rescan the transcript. Shared parsing rejects unsupported +timestamps before report serialization. Eight new regression cases failed +before correction; all nine new cases and 54 affected existing cases now pass. +These checks exercise cache-to-history and provider-to-JSON paths. Source +checking and rebuilt bundles pass; native gates remain open. diff --git a/docs/plans/muse-connections.md b/docs/plans/muse-connections.md new file mode 100644 index 0000000..42ce689 --- /dev/null +++ b/docs/plans/muse-connections.md @@ -0,0 +1,31 @@ +# Muse connections + +Add separate opt-in Electron providers for Muse.ai account allowance and Muse +Code subscription allowance. Preserve existing providers, account sources and +the retained Swift app. Success requires synthetic connection regression tests, +TypeScript checking and production bundling. Native sign-in and macOS execution +need separate proof. + +Muse Code uses its existing signed-in `muse serve` process over stdio. Initialize +MSP, then call `usage/read` without starting a model turn or reading credentials. +Keep the provider's observation timestamp. An absent observation remains +unavailable. See the [official protocol](https://meta-models.github.io/muse-code-sdk/next/generated/msp/methods/usage-read/). + +Muse.ai has a separate allowance and authentication boundary. Verify its +desktop session source and subscription response before implementing its reader. +Do not infer a native cookie location from an application identifier. + +1. Add the Muse Code reader under `src/main/providers/`, register its provider + and source, and add an explicit Settings action. Test the protocol exchange, + missing data, invalid numeric fields, timeout and cancellation. +2. Add Muse.ai only against an evidenced session and allowance contract. Reuse + connection consent, cancellation, disconnect and source invalidation. Test + account changes, expired sessions, failed saves and late responses. +3. Add synthetic demo readings and update `docs/PRIVACY.md`, `docs/KB.md`, + connection instructions and `CHANGELOG.md`. Review the complete diff and run + `pnpm typecheck`, `pnpm test` and `pnpm build` with the declared Node version. + +Both providers start disabled. They never share quota windows or imply a token +ledger from a percentage. No CLI installation, provider logout, paid action, +inference, app installation or release is part of this change. Disabling the +providers is the rollback path. diff --git a/docs/swift/README.md b/docs/swift/README.md index 6b55ac2..ec75a26 100644 --- a/docs/swift/README.md +++ b/docs/swift/README.md @@ -24,7 +24,7 @@ This guide describes the published Swift 0.2.41 app and retained Swift sources. The Swift app is frozen for new features. New feature work targets Electron; Swift receives only critical fixes and transition compatibility work. Keep this release as the stable download and rollback until the -[retirement conditions](https://github.com/pekth/meterusage/blob/v0.3.0-beta.1/docs/adr/0024-electron-primary-swift-retirement.md#retirement-conditions) +[retirement conditions](../adr/0024-electron-primary-swift-retirement.md#retirement-conditions) pass. Removing the old app source later will preserve its retrievable history. Current product images are in the [Electron beta gallery](../SCREENSHOTS.md). Old Swift product captures have been removed from the active documentation. @@ -59,7 +59,7 @@ Swift guide retains its behavior and installation reference without old images. | Provider | Live Cloud Quota | Local Activity & Tokens | Reset Countdowns | 26-Week Heatmap | Source Mechanism | |---|:---:|:---:|:---:|:---:|---| -| **Codex** | ✅ | ✅ | ✅ | ✅ | Local JSON-RPC via `codex app-server --stdio` | +| **Codex** | ✅ | ✅ | ✅ | ✅ | Local JSON-RPC via `codex app-server --listen stdio://` | | **Antigravity** | ✅ | ✅ | ✅ |. | CLI `/usage` & local conversation SQLite | | **Claude Code** | Optional* | ✅ | ✅* | ✅ | Local session JSONL streams (`limits[]` file optional) | | **OpenRouter** | ✅ | ✅ | ✅ |. | Public account API & `/api/v1/activity` telemetry | diff --git a/docs/swift/REPORTING.md b/docs/swift/REPORTING.md new file mode 100644 index 0000000..dd94be7 --- /dev/null +++ b/docs/swift/REPORTING.md @@ -0,0 +1,164 @@ +# Legacy Swift source diagnostic reporting + +Report issue sends app-generated diagnostics to a private Linear project +without requiring the reporter to sign in. Copy diagnostics stays available. +The relay is deployed on Workers Free. Source builds configure its `/report` +endpoint; the released v0.2.41 app does not include this feature. +Settings states that diagnostics are sent only on request; it does not promise +that all data stays local. + +## Data and user flow + +The entry state is Settings with provider readings already loaded or failed. +Clicking Report issue captures one diagnostic snapshot and starts one HTTPS +request. The button is disabled during submission. A matching confirmed Linear +issue reference changes it to Sent. Errors keep the report available for retry; +an uncertain retry reuses the original report ID and snapshot. The app +coordinator retains the pending report, send state and receipt when Settings +closes and reopens. A confirmed receipt keeps Send disabled for the rest of the +app session. Quitting the app clears this in-memory state; no report is saved +to disk. See [ADR 0012](../adr/0012-report-state-outlives-settings.md). + +The report includes app/build, numeric OS/architecture, provider states, +aggregate readings and freshness, cache/history state, and recent refresh +outcomes. It does not poll providers or run commands when reporting. Unknown +observations stay unknown; it cannot reconstruct earlier raw errors or crashes. +See [privacy](../PRIVACY.md#legacy-swift-source-diagnostic-reports) for excluded data and network metadata handling. + +The request is `POST /report`, `Content-Type: application/json`: + +```json +{"schema":1,"id":"68ff7a74-cd68-4f0b-b702-e5fe09a84bd5","diagnostics":"MeterUsage 0.2.41\nmode: demo"} +``` + +The JSON body is limited to 65,536 bytes and diagnostics to 49,152 UTF-8 bytes. +The app rejects oversized reports without truncating them. The relay accepts +only the declared fields. Team, project and issue title are server-controlled. +A confirmed response is HTTP 201 with `id` and `identifier`. The app reads +replies incrementally and cancels reception above 1,024 bytes. Oversized or +invalid replies show delivery as unconfirmed. Upstream error bodies are never +displayed to reporters. + +Diagnostics use a fenced Markdown block because Linear converts indented +blocks to fences. The delimiter is longer than every backtick sequence in the +report. Receipt checks still require the exact description, UUID and destination. + +## Free hosting and configuration + +Use [Cloudflare Workers Free](https://developers.cloudflare.com/workers/platform/pricing/) +and its included workers.dev hostname. The published free allowance is 100,000 +requests per day per account. Exceeding the allowance returns an error. It does +not buy an upgrade. The relay needs no database, object storage or queue. +Use the existing Linear workspace; this setup does not purchase Linear seats +or lift that workspace's own issue or API limits. + +Before deployment, verify that the intended Cloudflare account is on Workers +Free. Do not silently deploy into a paid account or change its billing plan. +Deployment and secret configuration require the project owner's authorization. + +Configure these server-side bindings: + +- `LINEAR_API_KEY`: a secret restricted to the intended Linear team. +- `LINEAR_TEAM_ID`: the intended team UUID. +- `LINEAR_PROJECT_ID`: the existing MeterUsage project UUID in that team. +- `PER_IP` and `GLOBAL`: the rate-limit bindings in `Reporting/wrangler.jsonc`. + +Keep credentials out of source, app bundles, command arguments, logs and files. +Use the hosting provider's secure secret-input mechanism. The app contains only +the public HTTPS endpoint under `MeterUsageReportURL` in `Resources/Info.plist`. +The source bundle sets it to +`https://meterusage-reports.fancy-queen-3301.workers.dev/report`. +Missing configuration returns an explicit unavailable state. + +Limits allow three requests per minute per network address and 30 per minute +per Cloudflare location. Missing rate-limit bindings fail closed. These limits +reduce spam; they do not authenticate an anonymous sender or guarantee a +global ceiling. The Linear UUID prevents duplicate issue creation on a retry. + +## Activation evidence + +On 2026-10-08, the authenticated Cloudflare dashboard showed Workers Free +Active. A synthetic report sent by the unmodified Swift client reached the +MeterUsage Linear project. Repeating the identical request returned the same +issue reference; an independent Linear read confirmed one issue, its exact +diagnostic text and destination. No paid resource was added. + +This proves native client delivery and retry against the deployed service. +It does not prove native Settings interaction or availability in a released app. + +## Required verification + +Run `node --test Reporting/worker.test.mjs` for the relay contract. Run +`swift build`, `swift test` and `Scripts/make-app.sh --build-from-source` on +macOS for the app candidate. Use synthetic inputs only. + +Verify success, lost acknowledgement/retry, double-click, Settings navigation +during a send and after its result, offline failure, +throttling, oversized input, response cancellation before stream completion, +invalid receipts and redirects. Confirm excluded +data stays absent from diagnostics and that arbitrary provider labels cannot +enter the report. A mock server test does not prove deployed Linear delivery. + +Before distribution, use the configured candidate to submit one synthetic +report. Read back the exact Linear issue and project, compare its diagnostic +content and report ID, then retry the same ID and verify only one issue exists. +Record the candidate digest, endpoint, issue reference and executed checks. +Check the actual Settings interaction on the native target. Source inspection +or a static rendered capture does not establish interactive UI behavior. + +## Agent-facing bug feedback + +Use the executable inside a configured MeterUsage app bundle. A bare SwiftPM +binary can draft, but cannot submit without the bundle's existing reporting +endpoint. This feature adds no endpoint override or client credential. + +`meterusage report draft` reads JSON from stdin with exactly four string fields: + +```json +{"description":"Synthetic external link does not open","steps":"Select the example external link","expected":"The browser opens","actual":"Nothing happens"} +``` + +It prints those fields plus a new `id`. Retain that JSON in the agent's current +context. Pass it unchanged on stdin to `meterusage report submit`. Neither +command reads a report file or saves a draft. Description is limited to 512 +UTF-8 bytes; each other field to 2,048 bytes; total stdin to 8,192 bytes. Empty +fields, extra fields, non-v4 IDs, controls, paths, links, email addresses and +common credential patterns fail before preview or network access. A supplied +`confirmed` field is rejected. +The encoded draft including its ID and output newline must also fit the stdin +limit, so every successful draft can be passed unchanged to submit. + +Submission opens a native scrollable preview with the exact report, report ID, +HTTPS relay and MeterUsage support's private Linear destination. Cancel is the +default button. Send this report grants consent for one request. CLI stdout +returns JSON containing `id`, `status`, and a verified `identifier` only on +success. Decline returns `declined` with no request. Delivery failures are +sanitized, and the user can cancel or explicitly retry the unchanged report. +Cancel after a failed acknowledgement returns `unconfirmed`, not unsent. +Unavailable configuration returns `unavailable`. Errors exit nonzero; a fresh +decline or confirmed receipt exits zero. No raw response body or URL error is +printed. + +The appended diagnostics contain app and numeric system metadata only. No +provider is polled and no transcript, arbitrary file or account data is read. +The CLI marks provider/history observations unknown; it does not borrow state +from a running app. The short prose fields describe observed evidence, not raw +logs or conversations. Pattern validation cannot identify every private fact: +the agent must omit them and the real user must review the full preview. + +Keep the same draft ID and text for an uncertain retry, including after process +exit. The relay reconciles exact UUID, content and destination. Changed app or +system metadata with the same ID can produce an unconfirmed result. Do not +mint a new ID automatically after a failure. A deliberate new draft is a new +report and requires new native consent. See [ADR 0013](../adr/0013-agent-feedback-native-consent.md). + +The external-link fixture is a synthetic UX example inspired by +[Ben Davis's post](https://x.com/davis7/status/2108756950641254490). +It does not establish that the reported Grok bug was reproduced or fixed. + +Focused proof covers draft validation, exact native preview, cancel without a +request, explicit send through the existing client to an injected synthetic +transport, matching/mismatched receipts, sanitized failures and byte-identical +uncertain retries. Native tests operate on their own modal window and never +contact the deployed relay. Live delivery, installed-app behavior and release +availability require separate proof and authorization. diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..8f244a2 --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,32 @@ +appId: dev.meterusage.app +productName: MeterUsage +afterPack: Scripts/electron/after-pack.cjs +asar: true +buildVersion: "46" +directories: + output: release +files: + - dist/main/** + - dist/preload/** + - dist/renderer/** + - package.json +extraResources: + - from: dist/api-helper/api-usage-helper.cjs + to: api-usage-helper.cjs + - from: build/desktop-keychain + to: desktop-keychain +mac: + target: + - target: zip + arch: [arm64] + artifactName: MeterUsage-${version}.zip + icon: Resources/AppIcon.icns + category: public.app-category.developer-tools + minimumSystemVersion: "13.0" + identity: "-" + hardenedRuntime: true + notarize: false + extendInfo: + LSUIElement: true + NSAppTransportSecurity: + NSAllowsArbitraryLoads: false diff --git a/index.html b/index.html new file mode 100644 index 0000000..9966981 --- /dev/null +++ b/index.html @@ -0,0 +1,2 @@ + +MeterUsage
diff --git a/package.json b/package.json new file mode 100644 index 0000000..216c7a1 --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "meterusage", + "version": "0.2.41", + "private": true, + "type": "module", + "main": "dist/main/electron.cjs", + "packageManager": "pnpm@12.9.1", + "engines": { + "node": ">=24.13.1" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run", + "build": "vp build && vp pack", + "dev": "vp dev --host 127.0.0.1", + "start": "electron .", + "json": "node dist/main/cli.cjs json", + "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": { + "react": "19.2.6", + "react-dom": "19.2.6", + "yauzl": "3.2.0" + }, + "devDependencies": { + "@tailwindcss/vite": "4.3.3", + "@types/node": "^24.0.0", + "@types/react": "~19.2.14", + "@types/react-dom": "~19.2.3", + "@types/yauzl": "2.10.3", + "@vitejs/plugin-react": "6.0.0", + "electron": "43.4.1", + "electron-builder": "26.15.6", + "tailwindcss": "4.3.3", + "typescript": "~7.0.2", + "vite": "npm:@voidzero-dev/vite-plus-core@0.3.0", + "vite-plus": "0.3.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..2d2e120 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4647 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.9.1 + version: 12.9.1 + +packages: + + '@pnpm/exe.android-arm64@12.9.1': + resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.9.1': + resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.9.1': + resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.9.1': + resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.9.1': + resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.9.1': + resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.9.1': + resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.9.1': + resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.9.1': + resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.9.1': + resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.9.1': + resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.9.1': + resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.9.1': + resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.9.1': + resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==} + cpu: [x64] + os: [win32] + + pnpm@12.9.1: + resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.9.1': + optional: true + + '@pnpm/exe.android-x64@12.9.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.9.1': + optional: true + + '@pnpm/exe.darwin-x64@12.9.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.9.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.9.1': + optional: true + + '@pnpm/exe.linux-arm64@12.9.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.9.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.9.1': + optional: true + + '@pnpm/exe.linux-s390x@12.9.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.9.1': + optional: true + + '@pnpm/exe.linux-x64@12.9.1': + optional: true + + '@pnpm/exe.win32-arm64@12.9.1': + optional: true + + '@pnpm/exe.win32-x64@12.9.1': + optional: true + + pnpm@12.9.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.9.1 + '@pnpm/exe.android-x64': 12.9.1 + '@pnpm/exe.darwin-arm64': 12.9.1 + '@pnpm/exe.darwin-x64': 12.9.1 + '@pnpm/exe.freebsd-x64': 12.9.1 + '@pnpm/exe.linux-arm64': 12.9.1 + '@pnpm/exe.linux-arm64-musl': 12.9.1 + '@pnpm/exe.linux-ppc64': 12.9.1 + '@pnpm/exe.linux-riscv64': 12.9.1 + '@pnpm/exe.linux-s390x': 12.9.1 + '@pnpm/exe.linux-x64': 12.9.1 + '@pnpm/exe.linux-x64-musl': 12.9.1 + '@pnpm/exe.win32-arm64': 12.9.1 + '@pnpm/exe.win32-x64': 12.9.1 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + yauzl: + specifier: 3.2.0 + version: 3.2.0 + devDependencies: + '@tailwindcss/vite': + specifier: 4.3.3 + version: 4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@types/react': + specifier: ~19.2.14 + version: 19.2.18 + '@types/react-dom': + specifier: ~19.2.3 + version: 19.2.7(@types/react@19.2.18) + '@types/yauzl': + specifier: 2.10.3 + version: 2.10.3 + '@vitejs/plugin-react': + specifier: 6.0.0 + version: 6.0.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + electron: + specifier: 43.4.1 + version: 43.4.1(supports-color@7.2.0) + electron-builder: + specifier: 26.15.6 + version: 26.15.6(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0) + tailwindcss: + specifier: 4.3.3 + version: 4.3.3 + typescript: + specifier: ~7.0.2 + version: 7.0.2 + vite: + specifier: npm:@voidzero-dev/vite-plus-core@0.3.0 + version: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)' + vite-plus: + specifier: 0.3.0 + version: 0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + + '@electron-internal/extract-zip@1.0.5': + resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} + engines: {node: '>=22.12.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/fuses@1.8.0': + resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} + hasBin: true + + '@electron/get@3.1.0': + resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} + engines: {node: '>=14'} + + '@electron/get@5.1.0': + resolution: {integrity: sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==} + engines: {node: '>=22.12.0'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.3': + resolution: {integrity: sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@4.2.0': + resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==} + engines: {node: '>=22.12.0'} + hasBin: true + + '@electron/universal@2.0.3': + resolution: {integrity: sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==} + engines: {node: '>=16.4'} + + '@electron/windows-sign@1.2.2': + resolution: {integrity: sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==} + engines: {node: '>=14.14'} + hasBin: true + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + + '@oxc-project/runtime@0.146.0': + resolution: {integrity: sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.146.0': + resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + + '@oxfmt/binding-android-arm-eabi@0.64.0': + resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxfmt/binding-android-arm64@0.64.0': + resolution: {integrity: sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxfmt/binding-darwin-arm64@0.64.0': + resolution: {integrity: sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxfmt/binding-darwin-x64@0.64.0': + resolution: {integrity: sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxfmt/binding-freebsd-x64@0.64.0': + resolution: {integrity: sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': + resolution: {integrity: sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm-musleabihf@0.64.0': + resolution: {integrity: sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxfmt/binding-linux-arm64-gnu@0.64.0': + resolution: {integrity: sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-arm64-musl@0.64.0': + resolution: {integrity: sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-ppc64-gnu@0.64.0': + resolution: {integrity: sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-gnu@0.64.0': + resolution: {integrity: sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-riscv64-musl@0.64.0': + resolution: {integrity: sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-linux-s390x-gnu@0.64.0': + resolution: {integrity: sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-gnu@0.64.0': + resolution: {integrity: sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxfmt/binding-linux-x64-musl@0.64.0': + resolution: {integrity: sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxfmt/binding-openharmony-arm64@0.64.0': + resolution: {integrity: sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxfmt/binding-win32-arm64-msvc@0.64.0': + resolution: {integrity: sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxfmt/binding-win32-ia32-msvc@0.64.0': + resolution: {integrity: sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxfmt/binding-win32-x64-msvc@0.64.0': + resolution: {integrity: sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + resolution: {integrity: sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w==} + cpu: [arm64] + os: [darwin] + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + resolution: {integrity: sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw==} + cpu: [x64] + os: [darwin] + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + resolution: {integrity: sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A==} + cpu: [arm64] + os: [linux] + + '@oxlint-tsgolint/linux-x64@7.0.2001': + resolution: {integrity: sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ==} + cpu: [x64] + os: [linux] + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + resolution: {integrity: sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q==} + cpu: [arm64] + os: [win32] + + '@oxlint-tsgolint/win32-x64@7.0.2001': + resolution: {integrity: sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog==} + cpu: [x64] + os: [win32] + + '@oxlint/binding-android-arm-eabi@1.79.0': + resolution: {integrity: sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.79.0': + resolution: {integrity: sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.79.0': + resolution: {integrity: sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.79.0': + resolution: {integrity: sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.79.0': + resolution: {integrity: sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + resolution: {integrity: sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.79.0': + resolution: {integrity: sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.79.0': + resolution: {integrity: sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.79.0': + resolution: {integrity: sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.79.0': + resolution: {integrity: sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.79.0': + resolution: {integrity: sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.79.0': + resolution: {integrity: sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.79.0': + resolution: {integrity: sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.79.0': + resolution: {integrity: sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.79.0': + resolution: {integrity: sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.79.0': + resolution: {integrity: sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.79.0': + resolution: {integrity: sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.79.0': + resolution: {integrity: sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.79.0': + resolution: {integrity: sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxlint/plugins@1.79.0': + resolution: {integrity: sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@peculiar/asn1-schema@2.10.0': + resolution: {integrity: sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==} + engines: {node: '>=14'} + + '@peculiar/json-schema@1.1.12': + resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} + engines: {node: '>=8.0.0'} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/webcrypto@1.7.1': + resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} + engines: {node: '>=14.18.0'} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/pluginutils@1.0.0-rc.7': + resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/react-dom@19.2.7': + resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.0.0': + resolution: {integrity: sha512-Bu5/eP6td3WI654+tRq+ryW1PbgA90y5pqMKpb3U7UpNk6VjI53P/ncPUd192U9dSrepLy7DHnq1XEMDz5H++w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/browser-preview@4.1.11': + resolution: {integrity: sha512-iPKSE6Ibayey6HFgK1V1/aHgyhx7HSRk1YMi+lnBZGmlIiNV5Uc7xRkD9Su8RDylTxDECK23t7kTHdRKoqSYDQ==} + peerDependencies: + vitest: 4.1.11 + + '@vitest/browser@4.1.11': + resolution: {integrity: sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w==} + peerDependencies: + vitest: 4.1.11 + + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + + '@voidzero-dev/vite-plus-core@0.3.0': + resolution: {integrity: sha512-aOqoqIWaF+Q/geDU48pC2rVFEVSvLV1GGj/NdvhUiBhCZntoFNbwI+hjUeG8BMaPG67sOV6ey+/sgkdmGmKqaw==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + peerDependencies: + '@arethetypeswrong/core': ^0.18.1 + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + publint: ^0.3.8 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 + unplugin-unused: ^0.5.0 + unrun: '*' + yaml: ^2.4.2 + peerDependenciesMeta: + '@arethetypeswrong/core': + optional: true + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + publint: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + typescript: + optional: true + unplugin-unused: + optional: true + unrun: + optional: true + yaml: + optional: true + + '@voidzero-dev/vite-plus-darwin-arm64@0.3.0': + resolution: {integrity: sha512-9ADr1egZ8T4tJOqrpQLhoDl95Y74R95+bsvjmin0gy1C0eQVhpmcNnBfb07KFNhJioJp9MMO7F7Dx4fQL5SKsw==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [darwin] + + '@voidzero-dev/vite-plus-darwin-x64@0.3.0': + resolution: {integrity: sha512-GegasVCwNeDOkNyvhLOuwU1+T2JkjY/Tq+SOvwphUpVcqQ6OOAUq9LlpoXviO2QL/Kq2NbMYjiAfPKVSTLUFQw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [darwin] + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.3.0': + resolution: {integrity: sha512-nYI3KNYXkXjRPsSdR4Lr7J2xMxfR1+TplWlG/dV37qVXWAjbyHpoAlbULjZBAVJMyXRNlcADhBrEwXe4g6s48A==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.3.0': + resolution: {integrity: sha512-HRlVA3AOcuGXmOdHhQ+Zv5XAaKbYF9si5rRHoOsKl0UyBo4txA3OoJfmP0WjanfLUNmu85JyO2dO1ptL4C6wgg==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.3.0': + resolution: {integrity: sha512-9A+dFScPfwcrzF/rRR0zH8++2hOf6xtFmN/5LyzyfUywtw9MILXcC72IMcOeL6QRJwKUMsudi1rFeDE59azNvw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@voidzero-dev/vite-plus-linux-x64-musl@0.3.0': + resolution: {integrity: sha512-KfIV3qaPdaOOE8JQMRHRE34FtZocl9O86XLTP6JMjDUlcx8FPgf8/fz/HFqJ8g232vM+JsgLI/YTVeXP8LkTKw==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.3.0': + resolution: {integrity: sha512-KRhdy5K13AYx9KBfCVHRrK7zSZU+bMW9CL6gTai+UkJgAmDJi1kjdSNboZOjO8mrzUnTCrELgMI2tnstcxSTuA==} + engines: {node: '>=20.0.0'} + cpu: [arm64] + os: [win32] + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.3.0': + resolution: {integrity: sha512-7+G+GxGmxdpQO0zjiGnkZFXKGqm0CrVduebRsJd6ccuOuxCQYPxLcoHq4WOaGrh56SrAGS7XjhnQCrXRkzKUVQ==} + engines: {node: '>=20.0.0'} + cpu: [x64] + os: [win32] + + '@xmldom/xmldom@0.8.15': + resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==} + engines: {node: '>=10.0.0'} + + '@yuku-codegen/binding-darwin-arm64@0.5.48': + resolution: {integrity: sha512-yo96Oef12WzqnphInfz/eexVse3+kWgfGS5g2S3rFS3dcGn1ENW9xLFDZUP9rh+yP76DOq38wBoFi1+I9+6qBg==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-darwin-x64@0.5.48': + resolution: {integrity: sha512-aRCTw0EZC4bVosmw//0OMYP5tGWFE0Cu5yUBFkUbhXx/iBzvORcJ2xPNlOp/vtCCo9Ys4vp8b0DigJV6uOVb2g==} + cpu: [x64] + os: [darwin] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-freebsd-x64@0.5.48': + resolution: {integrity: sha512-CA0AQAEApDkbw51PdLWMtKPJ41/7rvXsS3SJs+phG7fHJI+MuFzWuLbkucZfZoEOiDscmcsfYIdgL8BsfuyKKQ==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-gnu@0.5.48': + resolution: {integrity: sha512-DuSQlk8bH4gpmW3/00P0NLagAcMv8jOxjT40cQmxKRkktr+SUOALCfkT89tdDq3qtY95NR2GXOZ7AjNh7KKqCw==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm-musl@0.5.48': + resolution: {integrity: sha512-bxj4Ee+wlaJcWJwft2ReJXWw5sfl1qavDz6+dlRdU1xfTEtjPSNiAWhiCHnJR0R4Ygd57DnzSQmAVGvFv6RcGw==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': + resolution: {integrity: sha512-mk5JVWh+0JOe5ue8k17kbYX8uGBoKt3ZqoCyxNh4nYAAcX7+X1tFUiU7jbjctu4vHeejCBFSTdQ021+V31cUCQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-arm64-musl@0.5.48': + resolution: {integrity: sha512-4q3vkrNghbllyxOm2KesFLxCPKHF7r3JyQ7BWZccY1j2Y05yKoIFhoWCqIuQ2W/dpte9RI0+OVfwyxnrKg6fkA==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-gnu@0.5.48': + resolution: {integrity: sha512-csd4M1EVrGaohM8acM6gq1zpUA/Rwe2ulUMBKUcwQXm/k6n7cq1A++qdew78SOVb4do3JH1WE+WFwoGQAcWc1w==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-linux-x64-musl@0.5.48': + resolution: {integrity: sha512-KcDuEOT+GFoVKdvAWOv1v9iYjwnmvMZlO+j1Rw+5PYdeFLGWGzv/DD11y4SAAdwXIFcil4T0hibeIaF82WStMg==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-arm64@0.5.48': + resolution: {integrity: sha512-HI8qNrI8dWM5BuqIMKsqornRvTNFrE6sm5zToIJ9YIa9zt5+29P7fJ7Nr39EVf6dAWSb6q7JSpScJnRsQ+FgZA==} + cpu: [arm64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-codegen/binding-win32-x64@0.5.48': + resolution: {integrity: sha512-X5YWJLO6EfBZpeBqO0AYESnUizbpFDWArcvVD61w0PEWQ3CaFRLnbQXs+kpM4ZZfGMfIE22zfA08QSY67q7TNQ==} + cpu: [x64] + os: [win32] + deprecated: yuku-codegen is pure JavaScript since 0.14 + + '@yuku-parser/binding-darwin-arm64@0.5.48': + resolution: {integrity: sha512-If8mb7HH3vqghJ2NNZ8SuHfhsnjVzOxJpB8xcNOXS5WjYrs2mUhHIh5KOIvK13hDOzh0htGeGK3A6MsiEqE7HQ==} + cpu: [arm64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-darwin-x64@0.5.48': + resolution: {integrity: sha512-EimvPXfspzxf1K11eB6tCW5oiQEXB8g84T2wP1TwzQagdDKo33bkmmVF0B32vTIpXnk/Ifu5IB61izZ1MylljA==} + cpu: [x64] + os: [darwin] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-freebsd-x64@0.5.48': + resolution: {integrity: sha512-0GcUMrumLHheThY9r5Tp46gaZYzn0irWPS1Zba6WY+vVQfhUtzGiWgXxI6tuXX0N32kEaaEVRpkKctvo6Kx3aQ==} + cpu: [x64] + os: [freebsd] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-gnu@0.5.48': + resolution: {integrity: sha512-8S5T5wjCC73dmmpQeZ49aYsSunIUM3D4Fc6rdK96c+Ayg/p3FmeSPF3xuLZHejcTmqJIIvnbfPlUF+rB6DITjQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm-musl@0.5.48': + resolution: {integrity: sha512-tTmbxvnUHcK2/crS9547vk2SMmsajH1yqJ8ltXhIuHJgqR1v+d9n9KT+kSayo/5CS76LegeYxhMFjEivBH2hFA==} + cpu: [arm] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-gnu@0.5.48': + resolution: {integrity: sha512-KGYCBMqI2zfwyhgq5tpPVNe7jpUeYTBm8DhjdS+zqWNumde/PEC170QE5RHxcOAlsirIDeIUk0jqx+r/axoFSw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-arm64-musl@0.5.48': + resolution: {integrity: sha512-2wTSMsCSXLTc2lZUjMAuU5X4cje55u205WJqfV5NWNF6j9pW/tXyxr15dJeekj8ziLqBXzIsj4DbRh4sY/WcjA==} + cpu: [arm64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-gnu@0.5.48': + resolution: {integrity: sha512-d/6v9UnGglVu1WC2JQyv/5aWSi5fXZeGSlidCfmHp4+N65N1GDKUnFtys5MK5eAPeAjTgSHGGtOc/yCcKTlv3A==} + cpu: [x64] + os: [linux] + libc: [glibc] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-linux-x64-musl@0.5.48': + resolution: {integrity: sha512-gX19gw6u4ApPy7SYMPKfFlEkrtj6WlORvrTKK3sBQqjyV+8+mUAkQgxXNjHw4RnOiAmVYg7TOlZcg8d+Qqod9A==} + cpu: [x64] + os: [linux] + libc: [musl] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-arm64@0.5.48': + resolution: {integrity: sha512-w6cQQLbqj3Jcom5Q7ifm103NUOQ9d+Cb4VU5lkrZDjMnwVJ9Hzzg1vCQR7miJuF44vhCXldbme5UryE3giEKlA==} + cpu: [arm64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-parser/binding-win32-x64@0.5.48': + resolution: {integrity: sha512-4gO0HmG7fzFxrw1rs0dUdnnaY9YgennjETqDWrTSp7x9fmTUOAoN4VsMfP7YyliQeG1WJJHc55O+rOhmsLppow==} + cpu: [x64] + os: [win32] + deprecated: yuku-parser runs on yuku-core since 0.14 + + '@yuku-toolchain/types@0.5.43': + resolution: {integrity: sha512-kSpvPntnXw5+lYjO71ffBEnQ5ycQ74KGIYknh0TS4xeyCuBkOqxyJumxZkMhLBBUCLjDAbx2+Icnr3Zh4ftjpQ==} + + abbrev@4.0.0: + resolution: {integrity: sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==} + engines: {node: ^20.17.0 || >=22.9.0} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + app-builder-lib@26.15.6: + resolution: {integrity: sha512-lN6BliNJLnS2ruBdYd1j1rzd2yl4ZwKqyTtAwVznIkkadOR9cFvRz/tNQ6nd824AobnFZKhcdFrpIGDnr0PRSg==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 26.15.6 + electron-builder-squirrel-windows: 26.15.6 + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + aws4@1.13.2: + resolution: {integrity: sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@26.15.3: + resolution: {integrity: sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==} + engines: {node: '>=14.0.0'} + + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chai@6.3.0: + resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-dirname@0.1.0: + resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@26.15.6: + resolution: {integrity: sha512-nr5vQxEhM0REomp1qiHbc6V99yrfBZy+wUU56VXADfSOlLj8PdLqsHiRe7b+FbqKesiyv4ax+k1GVwGonYKuCg==} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexer2@0.1.4: + resolution: {integrity: sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@26.15.6: + resolution: {integrity: sha512-Vgw0bVXtTjZmD3O0Wl1gbo4eiI0Mqp7FQBsFgMR+wNwySiPJ08jadS0PZiSwP2cKCVaEclBHbKeBVTwil+SJfQ==} + + electron-builder@26.15.6: + resolution: {integrity: sha512-jxlHRjqYrlTgLVo/aoACGpiki3QFYv8s4f2djsqaEbwTBZ9PcTBK03Tj/HMa65kiE0hdZxxbZdmVFo22eou2wA==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@26.15.3: + resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==} + + electron-winstaller@5.4.0: + resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} + engines: {node: '>=8.0.0'} + + electron@43.4.1: + resolution: {integrity: sha512-5b+EuiwkgG5iRcsEL34rimgRpkYp15SsfZOa0pC5kXs0Tb82TH4n95rpQzTZa7yRCbA7tm0WoEbuBL6NaAhAcA==} + engines: {node: '>= 22.12.0'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.26.0: + resolution: {integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==} + engines: {node: '>=10.13.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.1: + resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==} + engines: {node: '>=14.14'} + + fs-extra@11.4.1: + resolution: {integrity: sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==} + engines: {node: '>=14.14'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + isexe@4.0.0: + resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} + engines: {node: '>=20'} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-abi@4.36.0: + resolution: {integrity: sha512-zrvE1Qy5ElbZVqmJFSiNfwzNZzUCepasgp/4dw0Z0fsn40LJkAk9FA9oMh2SKMeNFRRe/3IfeOKvWwWr8u4SmQ==} + engines: {node: '>=22.12.0'} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-gyp@12.4.0: + resolution: {integrity: sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + nopt@9.0.0: + resolution: {integrity: sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + oxfmt@0.64.0: + resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + + oxlint-tsgolint@7.0.2001: + resolution: {integrity: sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg==} + hasBin: true + + oxlint@1.79.0: + resolution: {integrity: sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkijs@3.4.1: + resolution: {integrity: sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==} + engines: {node: '>=16.0.0'} + + plist@3.1.0: + resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} + engines: {node: '>=10.4.0'} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + postject@1.0.0-alpha.6: + resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==} + engines: {node: '>=14.0.0'} + hasBin: true + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.2.0: + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} + engines: {node: '>=16.0.0'} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@19.2.6: + resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} + engines: {node: '>=0.10.0'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar@7.5.22: + resolution: {integrity: sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==} + engines: {node: '>=18'} + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + temp@0.9.4: + resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} + engines: {node: '>=6.0.0'} + + tiny-async-pool@1.3.0: + resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@2.1.0: + resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + engines: {node: ^20.0.0 || >=22.0.0} + + tinyrainbow@3.2.0: + resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} + engines: {node: '>=14.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@6.29.0: + resolution: {integrity: sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==} + engines: {node: '>=18.17'} + + undici@7.30.0: + resolution: {integrity: sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==} + engines: {node: '>=20.18.1'} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unzipper@0.12.5: + resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==} + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-plus@0.3.0: + resolution: {integrity: sha512-GNWbWuWD37frCSFrz6MLzUo62bTv5IOJozHEgZYOkxsLkuQtTwm4TowzpfoGrSsfwhAAtfPd/sK1Y0+v1SwhZA==} + engines: {node: ^20.19.0 || ^22.18.0 || >=24.11.0} + hasBin: true + peerDependencies: + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + peerDependenciesMeta: + '@vitest/browser-playwright': + optional: true + '@vitest/browser-webdriverio': + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + webcrypto-core@1.9.2: + resolution: {integrity: sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@5.0.0: + resolution: {integrity: sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==} + engines: {node: ^18.17.0 || >=20.5.0} + hasBin: true + + which@6.0.1: + resolution: {integrity: sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@3.2.0: + resolution: {integrity: sha512-Ow9nuGZE+qp1u4JIPvg+uCiUr7xGQWdff7JQSk5VGYTAZMDe2q8lxJ10ygv10qmSj031Ty/6FNJpLO4o1Sgc+w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yuku-codegen@0.5.48: + resolution: {integrity: sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw==} + + yuku-parser@0.5.48: + resolution: {integrity: sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@blazediff/core@1.9.1': {} + + '@electron-internal/extract-zip@1.0.5': {} + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/fuses@1.8.0': + dependencies: + chalk: 4.1.2 + fs-extra: 9.1.0 + minimist: 1.2.8 + + '@electron/get@3.1.0(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1(supports-color@7.2.0) + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/get@5.1.0(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.8.5 + sumchecker: 3.0.1(supports-color@7.2.0) + optionalDependencies: + undici: 7.30.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.3(supports-color@7.2.0)': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3(supports-color@7.2.0) + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@4.2.0(supports-color@7.2.0)': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + node-abi: 4.36.0 + node-api-version: 0.2.1 + node-gyp: 12.4.0 + read-binary-file-arch: 1.0.6(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + '@electron/universal@2.0.3(supports-color@7.2.0)': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3(supports-color@7.2.0) + dir-compare: 4.2.0 + fs-extra: 11.4.1 + minimatch: 9.0.9 + plist: 3.1.0 + transitivePeerDependencies: + - supports-color + + '@electron/windows-sign@1.2.2(supports-color@7.2.0)': + dependencies: + cross-dirname: 0.1.0 + debug: 4.4.3(supports-color@7.2.0) + fs-extra: 11.4.1 + minimist: 1.2.8 + postject: 1.0.0-alpha.6 + transitivePeerDependencies: + - supports-color + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0(supports-color@7.2.0)': + dependencies: + debug: 4.4.3(supports-color@7.2.0) + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.4.0': {} + + '@oxc-project/runtime@0.146.0': {} + + '@oxc-project/types@0.146.0': {} + + '@oxfmt/binding-android-arm-eabi@0.64.0': + optional: true + + '@oxfmt/binding-android-arm64@0.64.0': + optional: true + + '@oxfmt/binding-darwin-arm64@0.64.0': + optional: true + + '@oxfmt/binding-darwin-x64@0.64.0': + optional: true + + '@oxfmt/binding-freebsd-x64@0.64.0': + optional: true + + '@oxfmt/binding-linux-arm-gnueabihf@0.64.0': + optional: true + + '@oxfmt/binding-linux-arm-musleabihf@0.64.0': + optional: true + + '@oxfmt/binding-linux-arm64-gnu@0.64.0': + optional: true + + '@oxfmt/binding-linux-arm64-musl@0.64.0': + optional: true + + '@oxfmt/binding-linux-ppc64-gnu@0.64.0': + optional: true + + '@oxfmt/binding-linux-riscv64-gnu@0.64.0': + optional: true + + '@oxfmt/binding-linux-riscv64-musl@0.64.0': + optional: true + + '@oxfmt/binding-linux-s390x-gnu@0.64.0': + optional: true + + '@oxfmt/binding-linux-x64-gnu@0.64.0': + optional: true + + '@oxfmt/binding-linux-x64-musl@0.64.0': + optional: true + + '@oxfmt/binding-openharmony-arm64@0.64.0': + optional: true + + '@oxfmt/binding-win32-arm64-msvc@0.64.0': + optional: true + + '@oxfmt/binding-win32-ia32-msvc@0.64.0': + optional: true + + '@oxfmt/binding-win32-x64-msvc@0.64.0': + optional: true + + '@oxlint-tsgolint/darwin-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/darwin-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/linux-x64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-arm64@7.0.2001': + optional: true + + '@oxlint-tsgolint/win32-x64@7.0.2001': + optional: true + + '@oxlint/binding-android-arm-eabi@1.79.0': + optional: true + + '@oxlint/binding-android-arm64@1.79.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.79.0': + optional: true + + '@oxlint/binding-darwin-x64@1.79.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.79.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.79.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.79.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.79.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.79.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.79.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.79.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.79.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.79.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.79.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.79.0': + optional: true + + '@oxlint/plugins@1.79.0': {} + + '@peculiar/asn1-schema@2.10.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/json-schema@1.1.12': + dependencies: + tslib: 2.8.1 + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + + '@peculiar/webcrypto@1.7.1': + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + tslib: 2.8.1 + webcrypto-core: 1.9.2 + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/pluginutils@1.0.0-rc.7': {} + + '@sindresorhus/is@4.6.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)' + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + + '@types/aria-query@5.0.4': {} + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 24.19.1 + '@types/responselike': 1.0.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 24.19.1 + + '@types/http-cache-semantics@4.2.0': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 24.19.1 + + '@types/ms@2.1.0': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/react-dom@19.2.7(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 24.19.1 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 24.19.1 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.0.0(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.7 + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)' + + '@vitest/browser-preview@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(vitest@4.1.11)': + dependencies: + '@testing-library/dom': 10.4.2 + '@testing-library/user-event': 14.6.7(@testing-library/dom@10.4.2) + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(vitest@4.1.11) + vitest: 4.1.11(@types/node@24.19.1)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(vitest@4.1.11)': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.2.0 + vitest: 4.1.11(@types/node@24.19.1)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.3.0 + tinyrainbow: 3.2.0 + + '@vitest/mocker@4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)' + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.2.0 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.2.0 + + '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)': + dependencies: + '@oxc-project/runtime': 0.146.0 + '@oxc-project/types': 0.146.0 + lightningcss: 1.33.0 + postcss: 8.5.29 + yuku-codegen: 0.5.48 + yuku-parser: 0.5.48 + optionalDependencies: + '@types/node': 24.19.1 + '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.3.0 + fsevents: 2.3.3 + jiti: 2.7.0 + typescript: 7.0.2 + + '@voidzero-dev/vite-plus-darwin-arm64@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-darwin-x64@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-gnu@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-arm64-musl@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-gnu@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-linux-x64-musl@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-win32-arm64-msvc@0.3.0': + optional: true + + '@voidzero-dev/vite-plus-win32-x64-msvc@0.3.0': + optional: true + + '@xmldom/xmldom@0.8.15': {} + + '@yuku-codegen/binding-darwin-arm64@0.5.48': + optional: true + + '@yuku-codegen/binding-darwin-x64@0.5.48': + optional: true + + '@yuku-codegen/binding-freebsd-x64@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm64-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-arm64-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-x64-gnu@0.5.48': + optional: true + + '@yuku-codegen/binding-linux-x64-musl@0.5.48': + optional: true + + '@yuku-codegen/binding-win32-arm64@0.5.48': + optional: true + + '@yuku-codegen/binding-win32-x64@0.5.48': + optional: true + + '@yuku-parser/binding-darwin-arm64@0.5.48': + optional: true + + '@yuku-parser/binding-darwin-x64@0.5.48': + optional: true + + '@yuku-parser/binding-freebsd-x64@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm-musl@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm64-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-arm64-musl@0.5.48': + optional: true + + '@yuku-parser/binding-linux-x64-gnu@0.5.48': + optional: true + + '@yuku-parser/binding-linux-x64-musl@0.5.48': + optional: true + + '@yuku-parser/binding-win32-arm64@0.5.48': + optional: true + + '@yuku-parser/binding-win32-x64@0.5.48': + optional: true + + '@yuku-toolchain/types@0.5.43': {} + + abbrev@4.0.0: {} + + agent-base@7.1.4: {} + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + app-builder-lib@26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0): + dependencies: + '@electron/asar': 3.4.1 + '@electron/fuses': 1.8.0 + '@electron/get': 3.1.0(supports-color@7.2.0) + '@electron/notarize': 2.5.0(supports-color@7.2.0) + '@electron/osx-sign': 1.3.3(supports-color@7.2.0) + '@electron/rebuild': 4.2.0(supports-color@7.2.0) + '@electron/universal': 2.0.3(supports-color@7.2.0) + '@malept/flatpak-bundler': 0.4.0(supports-color@7.2.0) + '@noble/hashes': 2.4.0 + '@peculiar/webcrypto': 1.7.1 + '@types/fs-extra': 9.0.13 + ajv: 8.20.0 + asn1js: 3.0.10 + async-exit-hook: 2.0.1 + builder-util: 26.15.3(supports-color@7.2.0) + builder-util-runtime: 9.7.0(supports-color@7.2.0) + chromium-pickle-js: 0.2.0 + ci-info: 4.3.1 + debug: 4.4.3(supports-color@7.2.0) + dmg-builder: 26.15.6(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 26.15.6(dmg-builder@26.15.6)(supports-color@7.2.0) + electron-publish: 26.15.3(supports-color@7.2.0) + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + isbinaryfile: 5.0.7 + jiti: 2.7.0 + js-yaml: 4.3.2 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.6 + pkijs: 3.4.1 + plist: 3.1.0 + proper-lockfile: 4.1.2 + resedit: 1.7.2 + semver: 7.7.4 + tar: 7.5.22 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + unzipper: 0.12.5 + which: 5.0.0 + transitivePeerDependencies: + - supports-color + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + aws4@1.13.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bluebird@3.7.2: {} + + boolean@3.2.0: + optional: true + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.7: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + builder-util-runtime@9.7.0(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + sax: 1.6.1 + transitivePeerDependencies: + - supports-color + + builder-util@26.15.3(supports-color@7.2.0): + dependencies: + '@types/debug': 4.1.13 + builder-util-runtime: 9.7.0(supports-color@7.2.0) + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2(supports-color@7.2.0) + https-proxy-agent: 7.0.6(supports-color@7.2.0) + js-yaml: 4.3.2 + sanitize-filename: 1.6.4 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + tiny-async-pool: 1.3.0 + transitivePeerDependencies: + - supports-color + + bytestreamjs@2.0.1: {} + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.3.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chai@6.3.0: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chownr@3.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@4.3.1: {} + + ci-info@4.4.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@5.1.0: {} + + commander@9.5.0: + optional: true + + compare-version@0.1.2: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + core-util-is@1.0.3: {} + + cross-dirname@0.1.0: + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node@2.1.0: + optional: true + + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@26.15.6(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0): + dependencies: + app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0) + builder-util: 26.15.3(supports-color@7.2.0) + fs-extra: 10.1.0 + js-yaml: 4.3.2 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + dom-accessibility-api@0.5.16: {} + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexer2@0.1.4: + dependencies: + readable-stream: 2.3.8 + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@26.15.6(dmg-builder@26.15.6)(supports-color@7.2.0): + dependencies: + app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0) + builder-util: 26.15.3(supports-color@7.2.0) + electron-winstaller: 5.4.0(supports-color@7.2.0) + transitivePeerDependencies: + - dmg-builder + - supports-color + + electron-builder@26.15.6(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0): + dependencies: + app-builder-lib: 26.15.6(dmg-builder@26.15.6)(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0) + builder-util: 26.15.3(supports-color@7.2.0) + builder-util-runtime: 9.7.0(supports-color@7.2.0) + chalk: 4.1.2 + ci-info: 4.4.0 + dmg-builder: 26.15.6(electron-builder-squirrel-windows@26.15.6)(supports-color@7.2.0) + fs-extra: 10.1.0 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - electron-builder-squirrel-windows + - supports-color + + electron-publish@26.15.3(supports-color@7.2.0): + dependencies: + '@types/fs-extra': 9.0.13 + aws4: 1.13.2 + builder-util: 26.15.3(supports-color@7.2.0) + builder-util-runtime: 9.7.0(supports-color@7.2.0) + chalk: 4.1.2 + form-data: 4.0.6 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-winstaller@5.4.0(supports-color@7.2.0): + dependencies: + '@electron/asar': 3.4.1 + debug: 4.4.3(supports-color@7.2.0) + fs-extra: 7.0.1 + lodash: 4.18.1 + temp: 0.9.4 + optionalDependencies: + '@electron/windows-sign': 1.2.2(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + electron@43.4.1(supports-color@7.2.0): + dependencies: + '@electron-internal/extract-zip': 1.0.5 + '@electron/get': 5.1.0(supports-color@7.2.0) + '@types/node': 24.19.1 + transitivePeerDependencies: + - supports-color + + emoji-regex@8.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + env-paths@2.2.1: {} + + env-paths@3.0.0: {} + + err-code@2.0.3: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es6-error@4.1.1: + optional: true + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: + optional: true + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + exponential-backoff@3.1.3: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.3.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.4.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.4 + serialize-error: 7.0.1 + optional: true + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + + gopd@1.2.0: {} + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + http-cache-semantics@4.3.0: {} + + http-proxy-agent@7.0.2(supports-color@7.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@7.0.6(supports-color@7.2.0): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-fullwidth-code-point@3.0.0: {} + + isarray@1.0.0: {} + + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + isexe@4.0.0: {} + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@1.0.0: {} + + json-stringify-safe@5.0.1: + optional: true + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + lazy-val@1.0.5: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lodash@4.18.1: {} + + lowercase-keys@2.0.0: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.7 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.7 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + node-abi@4.36.0: + dependencies: + semver: 7.7.4 + + node-api-version@0.2.1: + dependencies: + semver: 7.7.4 + + node-gyp@12.4.0: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + graceful-fs: 4.2.11 + nopt: 9.0.0 + proc-log: 6.1.0 + semver: 7.7.4 + tar: 7.5.22 + tinyglobby: 0.2.17 + undici: 6.29.0 + which: 6.0.1 + + node-int64@0.4.0: {} + + nopt@9.0.0: + dependencies: + abbrev: 4.0.0 + + normalize-url@6.1.0: {} + + object-keys@1.1.1: + optional: true + + obug@2.2.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2)): + dependencies: + tinypool: 2.1.0 + optionalDependencies: + '@oxfmt/binding-android-arm-eabi': 0.64.0 + '@oxfmt/binding-android-arm64': 0.64.0 + '@oxfmt/binding-darwin-arm64': 0.64.0 + '@oxfmt/binding-darwin-x64': 0.64.0 + '@oxfmt/binding-freebsd-x64': 0.64.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.64.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.64.0 + '@oxfmt/binding-linux-arm64-gnu': 0.64.0 + '@oxfmt/binding-linux-arm64-musl': 0.64.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.64.0 + '@oxfmt/binding-linux-riscv64-musl': 0.64.0 + '@oxfmt/binding-linux-s390x-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-gnu': 0.64.0 + '@oxfmt/binding-linux-x64-musl': 0.64.0 + '@oxfmt/binding-openharmony-arm64': 0.64.0 + '@oxfmt/binding-win32-arm64-msvc': 0.64.0 + '@oxfmt/binding-win32-ia32-msvc': 0.64.0 + '@oxfmt/binding-win32-x64-msvc': 0.64.0 + vite-plus: 0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2) + + oxlint-tsgolint@7.0.2001: + optionalDependencies: + '@oxlint-tsgolint/darwin-arm64': 7.0.2001 + '@oxlint-tsgolint/darwin-x64': 7.0.2001 + '@oxlint-tsgolint/linux-arm64': 7.0.2001 + '@oxlint-tsgolint/linux-x64': 7.0.2001 + '@oxlint-tsgolint/win32-arm64': 7.0.2001 + '@oxlint-tsgolint/win32-x64': 7.0.2001 + + oxlint@1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2)): + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.79.0 + '@oxlint/binding-android-arm64': 1.79.0 + '@oxlint/binding-darwin-arm64': 1.79.0 + '@oxlint/binding-darwin-x64': 1.79.0 + '@oxlint/binding-freebsd-x64': 1.79.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.79.0 + '@oxlint/binding-linux-arm-musleabihf': 1.79.0 + '@oxlint/binding-linux-arm64-gnu': 1.79.0 + '@oxlint/binding-linux-arm64-musl': 1.79.0 + '@oxlint/binding-linux-ppc64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-gnu': 1.79.0 + '@oxlint/binding-linux-riscv64-musl': 1.79.0 + '@oxlint/binding-linux-s390x-gnu': 1.79.0 + '@oxlint/binding-linux-x64-gnu': 1.79.0 + '@oxlint/binding-linux-x64-musl': 1.79.0 + '@oxlint/binding-openharmony-arm64': 1.79.0 + '@oxlint/binding-win32-arm64-msvc': 1.79.0 + '@oxlint/binding-win32-ia32-msvc': 1.79.0 + '@oxlint/binding-win32-x64-msvc': 1.79.0 + oxlint-tsgolint: 7.0.2001 + vite-plus: 0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2) + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pe-library@0.4.1: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkijs@3.4.1: + dependencies: + '@noble/hashes': 1.8.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.2.0 + tslib: 2.8.1 + + plist@3.1.0: + dependencies: + '@xmldom/xmldom': 0.8.15 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + pngjs@7.0.0: {} + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + postject@1.0.0-alpha.6: + dependencies: + commander: 9.5.0 + optional: true + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + proc-log@6.1.0: {} + + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.2.0: {} + + quick-lru@5.1.1: {} + + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react@19.2.6: {} + + read-binary-file-arch@1.0.6(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + retry@0.12.0: {} + + rimraf@2.6.3: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + + safe-buffer@5.1.2: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.1: {} + + scheduler@0.27.0: {} + + semver-compare@1.0.0: + optional: true + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.2: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.1.3: + optional: true + + stackback@0.0.2: {} + + stat-mode@1.0.0: {} + + std-env@4.3.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + sumchecker@3.0.1(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tar@7.5.22: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + temp@0.9.4: + dependencies: + mkdirp: 0.5.6 + rimraf: 2.6.3 + + tiny-async-pool@1.3.0: + dependencies: + semver: 5.7.2 + + tinybench@2.9.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@2.1.0: {} + + tinyrainbow@3.2.0: {} + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + + totalist@3.0.1: {} + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + tslib@2.8.1: {} + + type-fest@0.13.1: + optional: true + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.24.6: {} + + undici@6.29.0: {} + + undici@7.30.0: + optional: true + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + unzipper@0.12.5: + dependencies: + bluebird: 3.7.2 + duplexer2: 0.1.4 + fs-extra: 11.3.1 + graceful-fs: 4.2.11 + node-int64: 0.4.0 + + utf8-byte-length@1.0.5: {} + + util-deprecate@1.0.2: {} + + vite-plus@0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2): + dependencies: + '@oxc-project/types': 0.146.0 + '@oxlint/plugins': 1.79.0 + '@vitest/browser': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(vitest@4.1.11) + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(vitest@4.1.11) + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + '@voidzero-dev/vite-plus-core': 0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2) + oxfmt: 0.64.0(vite-plus@0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2)) + oxlint: 1.79.0(oxlint-tsgolint@7.0.2001)(vite-plus@0.3.0(@types/node@24.19.1)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(jiti@2.7.0)(typescript@7.0.2)) + oxlint-tsgolint: 7.0.2001 + vitest: 4.1.11(@types/node@24.19.1)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + optionalDependencies: + '@voidzero-dev/vite-plus-darwin-arm64': 0.3.0 + '@voidzero-dev/vite-plus-darwin-x64': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-arm64-musl': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-gnu': 0.3.0 + '@voidzero-dev/vite-plus-linux-x64-musl': 0.3.0 + '@voidzero-dev/vite-plus-win32-arm64-msvc': 0.3.0 + '@voidzero-dev/vite-plus-win32-x64-msvc': 0.3.0 + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - vite + - yaml + + vitest@4.1.11(@types/node@24.19.1)(@vitest/browser-preview@4.1.11)(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.2.0 + vite: '@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2)' + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.19.1 + '@vitest/browser-preview': 4.1.11(@voidzero-dev/vite-plus-core@0.3.0(@types/node@24.19.1)(jiti@2.7.0)(typescript@7.0.2))(vitest@4.1.11) + transitivePeerDependencies: + - msw + + webcrypto-core@1.9.2: + dependencies: + '@peculiar/asn1-schema': 2.10.0 + '@peculiar/json-schema': 1.1.12 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@5.0.0: + dependencies: + isexe: 3.1.5 + + which@6.0.1: + dependencies: + isexe: 4.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + ws@8.22.0: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yallist@4.0.0: {} + + yallist@5.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@3.2.0: + dependencies: + buffer-crc32: 0.2.13 + pend: 1.2.0 + + yocto-queue@0.1.0: {} + + yuku-codegen@0.5.48: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-codegen/binding-darwin-arm64': 0.5.48 + '@yuku-codegen/binding-darwin-x64': 0.5.48 + '@yuku-codegen/binding-freebsd-x64': 0.5.48 + '@yuku-codegen/binding-linux-arm-gnu': 0.5.48 + '@yuku-codegen/binding-linux-arm-musl': 0.5.48 + '@yuku-codegen/binding-linux-arm64-gnu': 0.5.48 + '@yuku-codegen/binding-linux-arm64-musl': 0.5.48 + '@yuku-codegen/binding-linux-x64-gnu': 0.5.48 + '@yuku-codegen/binding-linux-x64-musl': 0.5.48 + '@yuku-codegen/binding-win32-arm64': 0.5.48 + '@yuku-codegen/binding-win32-x64': 0.5.48 + + yuku-parser@0.5.48: + dependencies: + '@yuku-toolchain/types': 0.5.43 + optionalDependencies: + '@yuku-parser/binding-darwin-arm64': 0.5.48 + '@yuku-parser/binding-darwin-x64': 0.5.48 + '@yuku-parser/binding-freebsd-x64': 0.5.48 + '@yuku-parser/binding-linux-arm-gnu': 0.5.48 + '@yuku-parser/binding-linux-arm-musl': 0.5.48 + '@yuku-parser/binding-linux-arm64-gnu': 0.5.48 + '@yuku-parser/binding-linux-arm64-musl': 0.5.48 + '@yuku-parser/binding-linux-x64-gnu': 0.5.48 + '@yuku-parser/binding-linux-x64-musl': 0.5.48 + '@yuku-parser/binding-win32-arm64': 0.5.48 + '@yuku-parser/binding-win32-x64': 0.5.48 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..bc41a6c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + electron: true + electron-winstaller: false diff --git a/src/domain/alerts.ts b/src/domain/alerts.ts new file mode 100644 index 0000000..26e25eb --- /dev/null +++ b/src/domain/alerts.ts @@ -0,0 +1,38 @@ +import { type Slot, type Quota, type Loaded, slotKey, slotName, headlineWindow } from "./models"; +import { pace, effectivePace, etaSeconds } from "./pacing"; +export interface Alert { slot: Slot; kind: "threshold" | "paceCliff" | "paceSoftWarning" | "expiringCredit"; title: string; body: string; sound: boolean } +export class AlertEvaluator { + private highWater = new Map(); + 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) { + const state = quotas[slotKey(slot)]; if (state?.status !== "value") continue; + const q = state.value, name = slotName(slot); + const options = slots.filter(s => slotKey(s) !== slotKey(slot)).flatMap(s => { const q = quotas[slotKey(s)]; const w = q?.status === "value" ? headlineWindow(s.provider, q.value.windows) : undefined; return w && w.usedPercent < 50 ? [`${slotName(s)} ${Math.trunc(w.usedPercent)}%`] : []; }); + const nudge = options.length ? ` Switch suggestion: ${options.join(" · ")}` : ""; + for (const w of q.windows) { + const key = `${slotKey(slot)}/${w.label}`, previous = this.highWater.get(key) ?? 0; + if (w.usedPercent < previous - 0.001) { this.highWater.set(key, 0); this.cliffs.delete(key); this.soft.delete(key); } + const rearmed = this.highWater.get(key) ?? 0, threshold = w.usedPercent >= 95 ? 95 : w.usedPercent >= 80 ? 80 : undefined; + if (threshold !== undefined && w.usedPercent > rearmed && rearmed < threshold) events.push({ slot, kind: "threshold", title: threshold === 95 ? "Quota almost gone" : "Quota getting low", body: `${name} ${w.label} window is at ${Math.round(w.usedPercent)}% used.${nudge}`, sound: threshold === 95 }); + this.highWater.set(key, Math.max(rearmed, w.usedPercent)); + const raw = pace(w, now), p = raw && effectivePace(raw, burns[slotKey(slot)], now); + if (p?.status !== "deficit") continue; + if (w.usedPercent >= 50 && !this.soft.has(key)) { this.soft.add(key); events.push({ slot, kind: "paceSoftWarning", title: `${name} pace warning`, body: `${w.label} window at ${Math.round(w.usedPercent)}% used and burning faster than pace.${nudge}`, sound: false }); } + const eta = etaSeconds(p, w.resetsAt, now); + if (eta !== undefined && eta < 1800 && !this.cliffs.has(key)) { this.cliffs.add(key); events.push({ slot, kind: "paceCliff", title: `${name} burning fast`, body: `${w.label} window projected to empty in ~${Math.max(1, Math.trunc(eta / 60))}m before reset.${nudge}`, sound: true }); } + } + for (const credit of q.resetCredits) { + const key = `${slotKey(slot)}/${credit.id}`; + if (!this.credits.has(key) && credit.expiresAt !== undefined && credit.expiresAt > now && credit.expiresAt - now <= 86400000) { this.credits.add(key); events.push({ slot, kind: "expiringCredit", title: "Reset credit expiring", body: `A ${name} reset credit (${credit.title}) expires within 24 hours.`, sound: false }); } + } + } + return events; + } +} diff --git a/src/domain/api-billing.ts b/src/domain/api-billing.ts new file mode 100644 index 0000000..493540e --- /dev/null +++ b/src/domain/api-billing.ts @@ -0,0 +1,8 @@ +export const apiBillingProviders = ["openai", "anthropic"] as const; +export type ApiBillingProvider = typeof apiBillingProviders[number]; +export const apiBillingNames = { openai: "OpenAI API", anthropic: "Anthropic API" } as const; +export interface ApiBillingSnapshot { + schema: 1; provider: ApiBillingProvider; capturedAt: number; monthStart: number; periodEnd: number; currency: "USD"; + daily: { start: number; end: number; amountUSD: number }[]; +} +export type ApiBillingReading = { status: "value"; value: ApiBillingSnapshot; stale: boolean } | { status: "missing"; code: "notConfigured" | "unavailable" }; diff --git a/src/domain/models.ts b/src/domain/models.ts new file mode 100644 index 0000000..98cf3ae --- /dev/null +++ b/src/domain/models.ts @@ -0,0 +1,86 @@ +export const providers = ["codex", "antigravity", "grok", "openCodeGo", "openRouter", "claude", "cursor", "copilot", "gemini", "muse"] as const; +export type Provider = typeof providers[number]; +export const providerNames: Record = { + codex: "Codex", antigravity: "Antigravity", grok: "Grok", openCodeGo: "OpenCode Go", + openRouter: "OpenRouter", claude: "Claude", cursor: "Cursor", copilot: "Copilot CLI", gemini: "Gemini CLI", muse: "Muse Code", +}; +export interface Slot { provider: Provider; slotID: string; label: string } +export interface ManagedAccount { id: string; provider: "codex" | "claude"; label: string; path: string; enabled: boolean } +export const primary = (provider: Provider): Slot => ({ provider, slotID: "", label: "" }); +export const slotKey = (s: Slot) => s.slotID ? `${s.provider}#${s.slotID}` : s.provider; +export const slotName = (s: Slot) => providerNames[s.provider] + (s.slotID ? ` · ${s.label || "Account"}` : ""); +export const accountSlot = (a: ManagedAccount): Slot => ({ provider: a.provider, slotID: a.id, label: a.label }); +export interface QuotaWindow { label: string; usedPercent: number; resetsAt?: number; windowDurationMins?: number; pacingBaseline?: { usedPercent: number; capturedAt?: number; observedAt?: number } } +export interface QuotaGroup { id: string; title: string; windows: QuotaWindow[] } +export interface ResetCredit { id: string; title: string; status?: string; expiresAt?: number } +export const resetCreditAvailable = (credit: ResetCredit, now: number) => credit.status?.toLowerCase() === "available" && (credit.expiresAt === undefined || credit.expiresAt > now); +export interface Credits { + balance: number; hasCredits: boolean; unlimited: boolean; unit: "credits" | "dollars"; + 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; +} +export const clamp = (n: number, lo = 0, hi = 100) => Math.min(hi, Math.max(lo, n)); +export function window(label: string, usedPercent: number, resetsAt?: number, windowDurationMins?: number): QuotaWindow { + if (!Number.isFinite(usedPercent)) throw new Error("Invalid percentage"); + return { label, usedPercent: clamp(usedPercent), ...(resetsAt === undefined ? {} : { resetsAt }), ...(windowDurationMins === undefined ? {} : { windowDurationMins }) }; +} +export const quota = (provider: Provider, windows: QuotaWindow[], capturedAt = Date.now()): Quota => ({ provider, windows, groups: [], resetCredits: [], capturedAt }); +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" || w.label === "Weekly · All models"); + if (provider === "muse") return windows.find(isSessionWindow); + 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); +} +export interface Tokens { input: number; output: number; reasoning: number; cacheRead: number; cacheWrite: number } +export const tokens = (t: Partial = {}): Tokens => ({ input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, ...t }); +export const totalTokens = (t: Tokens) => t.input + t.output + t.reasoning + t.cacheRead + t.cacheWrite; +export const addTokens = (a: Tokens, b: Tokens): Tokens => ({ input: a.input + b.input, output: a.output + b.output, reasoning: a.reasoning + b.reasoning, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite }); +export interface Session { + id: string; projectName: string; model: string; tokens: Tokens; estimatedCostUSD: number; + startedAt: number; lastActivityAt?: number; messageCount: number; isAggregate?: boolean; isAutomation?: boolean; +} +export const activeUntil = (s: Session) => Math.max(s.lastActivityAt ?? s.startedAt, s.startedAt); +export const lastBurn = (sessions: Session[]) => sessions.length ? Math.max(...sessions.map(activeUntil)) : undefined; +export interface Daily { day: number; tokens: Tokens; estimatedCostUSD: number; sessionCount: number } +export interface AccountActivity { dailyUsageBuckets: { startDate: string; tokens: number }[] | null; capturedAt: number } +export interface Activity { provider: Provider; sessions: Session[]; daily: Daily[]; scannedAt: number; telemetry?: Telemetry } +export interface UsageWindow { label: string; sessionCount: number; messageCount: number; tokens: Tokens; estimatedCostUSD: number } +export interface Usage { + provider: Provider; sessionCount: number; messageCount: number; tokens?: Tokens; estimatedCostUSD?: number; + todaySessionCount: number; todayMessageCount: number; todayTokens?: Tokens; weekTokens?: Tokens; + projectBreakdown?: { project: string; tokens: Tokens }[]; todayCostUSD?: number; usageWindows?: UsageWindow[]; + telemetry?: Telemetry; capturedAt: number; +} +export interface Telemetry { + lifetimeTokens?: number; peakDailyTokens?: number; longestChatSeconds?: number; + currentStreakDays: number; longestStreakDays: number; todayTokens?: number; last30DaysTokens?: number; + totalSessions?: number; totalMessages?: number; todaySessions?: number; todayMessages?: number; + dailyHistory: { day: number; tokens: number; sessionCount: number }[]; +} +export type Severity = "operational" | "unknown" | "degraded" | "partialOutage" | "majorOutage"; +export const severityNames: Record = { operational: "Operational", unknown: "Unknown", degraded: "Degraded", partialOutage: "Partial outage", majorOutage: "Major outage" }; +export interface ServiceStatus { provider: Provider; severity: Severity; description: string; checkedAt: number; stale?: boolean; refreshError?: string } +export type MissingCode = "cliNotFound" | "notSignedIn" | "offline" | "failed" | "noData" | "dataNotFound"; +export class Unavailable extends Error { + constructor(public code: MissingCode, public subject?: string) { + super(code === "cliNotFound" ? `${subject} CLI not found` : code === "notSignedIn" ? `Not signed in to ${subject}` : code === "offline" ? "Offline" : code === "failed" ? `Couldn't read ${subject} usage` : code === "dataNotFound" ? `${subject} not found` : "No usage yet"); + } +} +export const failed = (p: Provider) => new Unavailable("failed", providerNames[p]); +export type Loaded = { status: "idle" } | { status: "value"; value: T } | { status: "missing"; code: MissingCode; reason: string }; +export const value = (v: T): Loaded => ({ status: "value", value: v }); +export const missing = (e: unknown, p: Provider): Loaded => { + const u = e instanceof Unavailable ? e : failed(p); + return { status: "missing", code: u.code, reason: u.message }; +}; +export const utcDay = (at: number) => Math.floor(at / 86400000) * 86400000; +export function localDay(at: number) { const d = new Date(at); d.setHours(0, 0, 0, 0); return d.getTime(); } +export function shiftDay(at: number, n: number) { const d = new Date(at); d.setDate(d.getDate() + n); return d.getTime(); } +export const weekStart = (now: number) => shiftDay(localDay(now), -6); diff --git a/src/domain/notch.ts b/src/domain/notch.ts new file mode 100644 index 0000000..bd56162 --- /dev/null +++ b/src/domain/notch.ts @@ -0,0 +1,38 @@ +import { type Quota, type QuotaWindow, window, slotKey, headlineWindow } from "./models"; +import type { Snapshot } from "../shared/state"; +export interface Rect { x: number; y: number; width: number; height: number } +export const stripWidth = 36, cardWidth = 220; +// Coordinates here use Electron's top-left origin. Persistence converts the +// saved AppKit top-right corner at the native boundary. +export function notchFrame(anchor: { x: number; y: number }, size: { width: number; height: number }, cardOnRight: boolean, screen: Rect): Rect { + const width = Math.ceil(size.width), height = Math.min(Math.ceil(size.height), Math.floor(screen.height)); + const x = cardOnRight ? anchor.x - stripWidth : anchor.x - width; + return { x: Math.round(Math.min(Math.max(x, screen.x), Math.max(screen.x + screen.width - width, screen.x))), y: Math.round(Math.min(Math.max(anchor.y, screen.y), Math.max(screen.y + screen.height - height, screen.y))), width, height }; +} +export function cardOnRight(anchorX: number, screen: Rect) { + const left = anchorX - stripWidth - screen.x, right = screen.x + screen.width - anchorX; + return left < cardWidth && right > left; +} + +// Account spend is a notch display fallback, not a new report quota window. +export function effectiveWindows(q: Quota): QuotaWindow[] { + if (q.windows.length || q.provider !== "openRouter") return q.windows; + const c = q.credits; + return c?.unit === "dollars" && c.usedDollars !== undefined && c.limitDollars !== undefined && c.limitDollars > 0 ? [window("Account balance", c.usedDollars / c.limitDollars * 100)] : []; +} +export function notchEntries(s: Snapshot) { + const counts = new Map(); + return s.notchSlots.flatMap(slot => { + let digit: number | undefined; + if (slot.slotID) { digit = (counts.get(slot.provider) ?? 1) + 1; counts.set(slot.provider, digit); } + const key = slotKey(slot), state = s.quotas[key], fresh = state?.status === "value", q = fresh ? state.value : s.archived[key], w = q && headlineWindow(slot.provider, effectiveWindows(q)); + return w ? [{ slot, key, window: w, fresh, digit }] : []; + }); +} +export function minimumShareSize(width: number, height: number, nativeScale: number) { + const scale = Math.max(2, nativeScale); + const size = { width: Math.ceil(width * scale), height: Math.ceil(height * scale) }; + // Bound capture allocation. Never substitute a cropped image for a full card. + if (!Number.isFinite(size.height) || size.height > 8192) throw new Error("Card too tall to share"); + return size; +} diff --git a/src/domain/overview.ts b/src/domain/overview.ts new file mode 100644 index 0000000..65869b5 --- /dev/null +++ b/src/domain/overview.ts @@ -0,0 +1,89 @@ +import { type Activity, type Usage, type Slot, type Quota, type Loaded, type Session, type Daily, totalTokens, activeUntil, localDay, shiftDay, utcDay, weekStart, slotKey, slotName, headlineWindow, lastBurn, severityNames } from "./models"; +import { burnActive, pace, formatETA } from "./pacing"; +import type { Snapshot } from "../shared/state"; + +export function trayTooltip(s: Snapshot): string { + const entries = s.traySlots.flatMap(slot => { + const key = slotKey(slot), q = s.quotas[key], fresh = q?.status === "value", reading = fresh ? q.value : s.archived[key], w = reading && headlineWindow(slot.provider, reading.windows); + const parts: string[] = []; + if (w) { + parts.push(`${Math.round(w.usedPercent)}% used`); + if (w.resetsAt !== undefined && w.resetsAt > s.clock) parts.push(`resets in ${formatETA((w.resetsAt - s.clock) / 1000, true)}`); + if (!fresh) parts.push(`last reading ${formatETA((s.clock - reading!.capturedAt) / 1000, true)} ago`); + } + const status = s.statuses[slot.provider]; + if (status?.status === "value" && (status.value.severity !== "operational" || status.value.stale)) parts.push(`${status.value.stale ? "Last known " : ""}${severityNames[status.value.severity]}${status.value.stale ? ` · checked ${formatETA((s.clock - status.value.checkedAt) / 1000, true)} ago · ${status.value.refreshError}` : ""}`); + return parts.length ? [`${slotName(slot)}: ${parts.join(" · ")}`] : []; + }); + if (!entries.length) entries.push("No usage data yet"); + if (s.lastRefreshedAt !== undefined) entries.push(`Updated ${formatETA((s.clock - s.lastRefreshedAt) / 1000, true)} ago`); + return entries.join("\n"); +} + +// Swift StripTotals uses overlapping session instants for local today, and +// UTC day buckets for the seven-day history. Those boundaries differ. +export function stripTotals(activities: Activity[], usages: Usage[], now: number) { + const today = localDay(now), tomorrow = shiftDay(today, 1), week = utcDay(now) - 6 * 86400000; + let todayTokens = 0, weekTokens = 0, todayCost = 0; + for (const a of activities) { + const sessions = a.sessions.filter(s => s.startedAt < tomorrow && activeUntil(s) >= today); + todayTokens += sessions.reduce((n, s) => n + totalTokens(s.tokens), 0); todayCost += sessions.reduce((n, s) => n + s.estimatedCostUSD, 0); + weekTokens += a.daily.length ? a.daily.filter(d => d.day >= week).reduce((n, d) => n + totalTokens(d.tokens), 0) : a.sessions.filter(s => s.startedAt >= weekStart(now)).reduce((n, s) => n + totalTokens(s.tokens), 0); + } + for (const u of usages) { if (u.todayTokens) todayTokens += totalTokens(u.todayTokens); if (u.weekTokens) weekTokens += totalTokens(u.weekTokens); todayCost += u.todayCostUSD ?? 0; } + const measured = activities.some(a => a.sessions.some(s => totalTokens(s.tokens) > 0) || a.daily.some(d => totalTokens(d.tokens) > 0)) || usages.some(u => u.todayTokens !== undefined || u.weekTokens !== undefined); + return { todayTokens: measured ? todayTokens : undefined, weekTokens: measured ? weekTokens : undefined, todayCost: measured ? todayCost : undefined }; +} +export function attributionSessions(slots: Slot[], activities: Record>, usages: Record>, now: number): Session[] { + return slots.flatMap(slot => { + const key = slotKey(slot), a = activities[key], u = usages[key]; + const sessions = a?.status === "value" ? a.value.sessions : []; + if (u?.status !== "value") return sessions; + const splits = u.value.projectBreakdown?.length ? u.value.projectBreakdown : u.value.weekTokens && totalTokens(u.value.weekTokens) > 0 ? [{ project: slotName(slot), tokens: u.value.weekTokens }] : []; + return [...sessions, ...splits.map((split, i) => ({ id: `aggregate-${key}-${i}`, projectName: split.project, model: "", tokens: split.tokens, estimatedCostUSD: 0, startedAt: now, messageCount: 0, isAggregate: true }))]; + }).sort((a, b) => b.startedAt - a.startedAt); +} +export function failoverNudge(slots: Slot[], quotas: Record>, activities: Record>, now: number) { + const rows = slots.flatMap(slot => { + const key = slotKey(slot), q = quotas[key], a = activities[key], w = q?.status === "value" ? headlineWindow(slot.provider, q.value.windows) : undefined; + if (!w) return []; + return [{ slot, w, burning: pace(w, now)?.status === "deficit" && burnActive(a?.status === "value" ? lastBurn(a.value.sessions) : undefined, now) }]; + }); + const hot = rows.filter(r => r.burning || r.w.usedPercent >= 80), alternatives = rows.filter(r => !hot.includes(r) && r.w.usedPercent < 50); + const first = hot.find(r => r.burning) ?? hot[0]; if (!first || !alternatives.length) return; + const label = first.w.label.toLowerCase(), limit = label.includes("limit") ? label : `${label} limit`; + return `${slotName(first.slot)} ${first.burning ? "burning fast" : `near its ${limit}`}. Switch to ${alternatives.map(r => slotName(r.slot)).join(", ")} for headroom.`; +} +export type HeatmapMode = "daily" | "weekly" | "cumulative"; +export function heatmapCells(daily: Daily[], now: number, mode: HeatmapMode) { + const today = localDay(now), weekday = new Date(today).getDay(), start = shiftDay(today, -(25 * 7 + weekday)); + const totals = new Map(); + for (const d of daily) { const day = localDay(d.day), old = totals.get(day) ?? { tokens: 0, sessionCount: 0 }; totals.set(day, { tokens: old.tokens + totalTokens(d.tokens), sessionCount: old.sessionCount + d.sessionCount }); } + let runningTokens = 0, runningSessions = 0; + return Array.from({ length: 26 }, (_, week) => { + const days = Array.from({ length: 7 }, (_, i) => shiftDay(start, week * 7 + i)); + const sum = days.filter(d => d <= today).reduce((sum, day) => { const t = totals.get(day); return { tokens: sum.tokens + (t?.tokens ?? 0), sessionCount: sum.sessionCount + (t?.sessionCount ?? 0) }; }, { tokens: 0, sessionCount: 0 }); + return days.map(day => { + if (day > today) return null; + const t = totals.get(day) ?? { tokens: 0, sessionCount: 0 }; runningTokens += t.tokens; runningSessions += t.sessionCount; + return { day, ...(mode === "weekly" ? sum : mode === "cumulative" ? { tokens: runningTokens, sessionCount: runningSessions } : t) }; + }); + }).flat(); +} + +/** 26 weeks of one slot's daily quota rise, aggregated like `heatmapCells`. */ +export function quotaHeatmapCells(days: { day: number; percent: number; estimated?: boolean }[], now: number, mode: HeatmapMode) { + const today = localDay(now), weekday = new Date(today).getDay(), start = shiftDay(today, -(25 * 7 + weekday)); + const totals = new Map(), estimated = new Set(); + for (const d of days) { const day = localDay(d.day); totals.set(day, (totals.get(day) ?? 0) + d.percent); if (d.estimated) estimated.add(day); } + let running = 0; + return Array.from({ length: 26 }, (_, week) => { + const span = Array.from({ length: 7 }, (_, i) => shiftDay(start, week * 7 + i)); + const sum = span.filter(d => d <= today).reduce((n, day) => n + (totals.get(day) ?? 0), 0); + return span.map(day => { + if (day > today) return null; + const percent = totals.get(day) ?? 0; running += percent; + return { day, percent: mode === "weekly" ? sum : mode === "cumulative" ? running : percent, estimated: mode === "daily" && estimated.has(day) }; + }); + }).flat(); +} diff --git a/src/domain/pacing.ts b/src/domain/pacing.ts new file mode 100644 index 0000000..76a4088 --- /dev/null +++ b/src/domain/pacing.ts @@ -0,0 +1,89 @@ +import type { QuotaWindow, Quota } from "./models"; +import { clamp } from "./models"; + +export interface Pace { + usedPercent: number; remainingPercent: number; elapsedPercent: number; deficitPercent: number; + burnRate: number; projectedExhaustion?: number; status: "deficit" | "surplus" | "onPace"; +} +const observationInterval = 60000, observationFreshness = 5 * 60000; +const freshObservation = (at: number | undefined, now: number): at is number => at !== undefined && Number.isFinite(at) && Number.isFinite(now) && at <= now && now - at <= observationFreshness; +const validPercent = (used: number) => Number.isFinite(used) && used >= 0 && used <= 100; +const activeWindow = (w: QuotaWindow, now: number) => validPercent(w.usedPercent) && (w.resetsAt === undefined || Number.isFinite(w.resetsAt) && w.resetsAt > now); + +export function measuredBurn(w: QuotaWindow, now: number): { percentPerHour: number; projectedExhaustion?: number } | undefined { + const b = w.pacingBaseline; + if (!b || !activeWindow(w, now) || !validPercent(b.usedPercent) || w.usedPercent < b.usedPercent || !freshObservation(b.capturedAt, now) || !freshObservation(b.observedAt, now) || b.observedAt - b.capturedAt < observationInterval) return; + const percentPerHour = (w.usedPercent - b.usedPercent) * 3600000 / (b.observedAt - b.capturedAt); + const exhaustion = percentPerHour > 0 && w.usedPercent < 100 ? b.observedAt + (100 - w.usedPercent) * 3600000 / percentPerHour : undefined; + return { percentPerHour, ...(exhaustion !== undefined && Number.isFinite(exhaustion) && exhaustion > now && (w.resetsAt === undefined || exhaustion < w.resetsAt) ? { projectedExhaustion: exhaustion } : {}) }; +} + +// Callers supply only live observations from the same account/source, never archives. +export function observeQuota(current: Quota, previous: Quota | undefined, now: number): Quota { + const sample = (w: QuotaWindow, prior?: QuotaWindow): QuotaWindow => { + const clean = { ...w, pacingBaseline: undefined }; + if (!freshObservation(current.capturedAt, now) || !activeWindow(w, now) || previous && current.capturedAt <= previous.capturedAt) return clean; + const b = prior?.pacingBaseline; + const keep = prior && previous && previous.provider === current.provider && freshObservation(previous.capturedAt, now) && activeWindow(prior, now) && prior.resetsAt === w.resetsAt && prior.windowDurationMins === w.windowDurationMins && w.usedPercent >= prior.usedPercent && b && b.observedAt === previous.capturedAt && freshObservation(b.capturedAt, now) && b.capturedAt <= previous.capturedAt && validPercent(b.usedPercent) && b.usedPercent <= prior.usedPercent; + return { ...clean, pacingBaseline: { usedPercent: keep ? b.usedPercent : w.usedPercent, capturedAt: keep ? b.capturedAt : current.capturedAt, observedAt: current.capturedAt } }; + }; + return { ...current, windows: current.windows.map(w => sample(w, previous?.windows.find(p => p.label === w.label))), groups: current.groups.map(g => ({ ...g, windows: g.windows.map(w => sample(w, previous?.groups.find(p => p.id === g.id)?.windows.find(p => p.label === w.label))) })) }; +} +export function durationMins(w: QuotaWindow): number | undefined { + if (w.windowDurationMins && w.windowDurationMins > 0) return w.windowDurationMins; + const l = w.label.toLowerCase(); + if (/weekly|7-day|7 day/.test(l)) return 10080; + if (/5-hour|5 hour|session/.test(l)) return 300; + if (/monthly|30-day|30 day/.test(l)) return 43200; + if (/rolling|daily|24h/.test(l)) return 1440; +} +export function pace(w: QuotaWindow, now: number): Pace | undefined { + const mins = durationMins(w); + if (w.resetsAt === undefined || w.resetsAt <= now || !mins) return; + const baseline = w.pacingBaseline; + if (baseline) { + const start = baseline.capturedAt, observed = baseline.observedAt; + if (start === undefined || observed === undefined || observed <= start || observed > now || w.usedPercent <= baseline.usedPercent || w.resetsAt <= observed || w.resetsAt <= start) return; + const elapsed = observed - start, delta = w.usedPercent - baseline.usedPercent; + const rate = delta / elapsed, budget = (100 - baseline.usedPercent) / (w.resetsAt - start); + if (budget <= 0) return; + const expected = budget * elapsed, deficit = delta - expected; + const exhaustion = observed + (100 - w.usedPercent) / rate; + return { usedPercent: w.usedPercent, remainingPercent: 100 - w.usedPercent, elapsedPercent: expected, deficitPercent: deficit, burnRate: rate / budget, projectedExhaustion: w.usedPercent < 100 && exhaustion < w.resetsAt ? exhaustion : undefined, status: Math.abs(deficit) <= 2 ? "onPace" : deficit > 2 ? "deficit" : "surplus" }; + } + const duration = mins * 60000, start = w.resetsAt - duration, elapsed = now - start; + if (elapsed < 0) return; + const elapsedFraction = clamp(elapsed / duration, 0, 1); + const elapsedPercent = elapsedFraction * 100, deficitPercent = w.usedPercent - elapsedPercent; + const burnRate = elapsedFraction > 0.005 ? w.usedPercent / 100 / elapsedFraction : 1; + const projected = start + 100 / w.usedPercent * elapsed; + return { + usedPercent: w.usedPercent, remainingPercent: Math.max(0, 100 - w.usedPercent), elapsedPercent, deficitPercent, burnRate, + ...(burnRate > 1 && w.usedPercent < 100 && w.usedPercent > 0.5 && projected < w.resetsAt ? { projectedExhaustion: projected } : {}), + status: Math.abs(deficitPercent) <= 2 ? "onPace" : deficitPercent > 2 ? "deficit" : "surplus", + }; +} +export function resetSample(current: QuotaWindow, previous: QuotaWindow | undefined, capturedAt?: number): QuotaWindow { + const prior = previous?.pacingBaseline; + const keep = capturedAt !== undefined && previous?.label === current.label && previous.resetsAt === current.resetsAt && durationMins(previous) === durationMins(current) && current.usedPercent >= previous.usedPercent && prior?.capturedAt !== undefined; + return { ...current, pacingBaseline: capturedAt === undefined ? { usedPercent: current.usedPercent } : { usedPercent: keep ? prior!.usedPercent : current.usedPercent, capturedAt: keep ? prior!.capturedAt : capturedAt, observedAt: capturedAt } }; +} +export function resetPacing(current: Quota, previous: Quota | undefined, since: number, capturedAt?: number, creditID?: string): Quota { + const windows = (ws: QuotaWindow[], prior: QuotaWindow[] = []) => ws.map(w => resetSample(w, prior.find(p => p.label === w.label), capturedAt)); + return { ...current, resetPacingSince: since, windows: windows(current.windows, previous?.windows), groups: current.groups.map(g => ({ ...g, windows: windows(g.windows, previous?.groups.find(p => p.id === g.id)?.windows) })), resetCreditCount: current.resetCreditCount === undefined ? undefined : Math.max(0, current.resetCreditCount - (creditID === undefined ? 0 : 1)), resetCredits: current.resetCredits.filter(c => c.id !== creditID) }; +} +export const burnActive = (lastBurn: number | undefined, now: number) => lastBurn !== undefined && now - lastBurn >= 0 && now - lastBurn < 1800000; +export function effectivePace(p: Pace, lastBurn: number | undefined, now: number): Pace { + if (p.status !== "deficit" || burnActive(lastBurn, now)) return p; + return { ...p, deficitPercent: 0, burnRate: 1, projectedExhaustion: undefined, status: "onPace" }; +} +export const paceText = (p: Pace) => p.usedPercent >= 100 ? "exhausted early" : p.status === "deficit" ? "burning fast" : p.status === "surplus" ? "well paced" : "on pace"; +export function etaSeconds(p: Pace, resetsAt: number | undefined, now: number): number | undefined { + if (p.projectedExhaustion !== undefined && p.projectedExhaustion > now) return (p.projectedExhaustion - now) / 1000; + if (resetsAt !== undefined && resetsAt > now && (p.burnRate <= 1 || p.status !== "deficit" || p.usedPercent >= 100)) return (resetsAt - now) / 1000; +} +export function formatETA(seconds: number, short = false): string { + const n = Math.max(0, Math.trunc(seconds)), m = Math.floor(n / 60), h = Math.floor(m / 60), d = Math.floor(h / 24); + const text = n < 60 ? "< 1m" : m < 60 ? `${m}m` : h < 24 ? `${h}h${m % 60 ? ` ${m % 60}m` : ""}` : `${d}d${h % 24 ? ` ${h % 24}h` : ""}`; + return text + (short ? "" : " left"); +} diff --git a/src/domain/pricing.ts b/src/domain/pricing.ts new file mode 100644 index 0000000..a6ce7d6 --- /dev/null +++ b/src/domain/pricing.ts @@ -0,0 +1,15 @@ +import type { Tokens } from "./models"; +export const pricingSnapshot = "2026-09"; +// Same USD-per-million table and ordering as Services/Pricing.swift. +const rates: [string, number[]][] = [ + ["6.1-sol", [2, 10, .10, 0]], ["6-astra", [10, 50, 1, 0]], + ["5.6-sol", [4, 20, .4, 0]], ["5.6-terra", [2, 12, .2, 0]], ["5.6-luna", [.2, 1.2, .02, 0]], + ["6-luna", [.1, .5, .01, 0]], ["6-sol", [2, 10, .2, 0]], ["5.3-codex", [1.75, 14, .175, 0]], + ["fable", [10, 50, 1, 12.5]], ["opus", [4, 20, .4, 5]], ["haiku", [1, 5, .1, 1.25]], ["sonnet", [2, 10, .2, 2.5]], +]; +export function estimate(model: string, t: Tokens): { costUSD: number; isFallback: boolean } { + const lower = model.toLowerCase(), openAI = /gpt|codex/.test(lower); + const match = rates.slice(openAI ? 0 : 8, openAI ? 8 : undefined).find(([name]) => lower.includes(name)); + const r = match?.[1] ?? (openAI ? rates[0][1] : rates[11][1]); + return { costUSD: (t.input * r[0] + (t.output + t.reasoning) * r[1] + t.cacheRead * r[2] + t.cacheWrite * r[3]) / 1e6, isFallback: !match }; +} diff --git a/src/domain/provider-order.ts b/src/domain/provider-order.ts new file mode 100644 index 0000000..e2dd91f --- /dev/null +++ b/src/domain/provider-order.ts @@ -0,0 +1,16 @@ +import { providers, type Provider, type Slot } from "./models"; +export function normalizeProviderOrder(raw: unknown): Provider[] { + const known = Array.isArray(raw) ? raw.filter((p): p is Provider => providers.includes(p)) : []; + return [...new Set([...known, ...providers])]; +} +export function validProviderOrder(raw: unknown): raw is Provider[] { + return Array.isArray(raw) && raw.length === providers.length && providers.every(p => raw.includes(p)); +} +export function orderedSlots(slots: readonly Slot[], order: unknown): Slot[] { + return normalizeProviderOrder(order).flatMap(p => slots.filter(s => s.provider === p)); +} +export function moveProvider(order: readonly Provider[], provider: Provider, offset: -1 | 1): Provider[] { + const next = [...order], from = next.indexOf(provider), to = from + offset; + if (from >= 0 && to >= 0 && to < next.length) [next[from], next[to]] = [next[to], next[from]]; + return next; +} diff --git a/src/domain/report.ts b/src/domain/report.ts new file mode 100644 index 0000000..9482e25 --- /dev/null +++ b/src/domain/report.ts @@ -0,0 +1,38 @@ +import { type Slot, type Quota, type Loaded, type Provider, slotKey } from "./models"; +import { pace, effectivePace, etaSeconds, formatETA, paceText } from "./pacing"; + +export const iso = (at: number) => new Date(at).toISOString().replace(/\.\d{3}Z$/, "Z"); +export interface WindowReport { label: string; used_percent: number; remaining_percent: number; resets_at?: string; eta_seconds?: number; eta_text?: string; pacing?: string; burn_rate?: number } +export interface ProviderReport { provider: Provider; account?: string; status: "ok" | "unavailable"; reason?: string; plan?: string; observed_at?: string; windows: WindowReport[]; credits?: { balance: number; unit: "credits" | "dollars"; dollar_balance?: number } } +export interface LimitsReport { schema: 1; generated_at: string; providers: ProviderReport[] } +export function limitsReport(order: Slot[], quotas: Record>, burns: Record = {}, now = Date.now()): LimitsReport { + return { + schema: 1, + generated_at: iso(now), + providers: order.map(s => { + const state = quotas[slotKey(s)]; + const account = s.slotID ? { account: s.label } : {}; + if (state?.status !== "value") return { provider: s.provider, ...account, status: "unavailable", ...(state?.status === "missing" ? { reason: state.reason } : {}), windows: [] }; + const q = state.value; + return { + provider: s.provider, ...account, status: "ok", ...(q.planType === undefined ? {} : { plan: q.planType }), + ...(s.provider === "muse" ? { observed_at: iso(q.capturedAt) } : {}), + windows: q.windows.map(w => { + const raw = pace(w, now), p = raw && effectivePace(raw, burns[slotKey(s)], now); + const eta = p && etaSeconds(p, w.resetsAt, now); + return { + label: w.label, used_percent: w.usedPercent, remaining_percent: Math.max(0, 100 - w.usedPercent), + ...(w.resetsAt === undefined ? {} : { resets_at: iso(w.resetsAt) }), + ...(eta === undefined ? {} : { eta_seconds: eta, eta_text: formatETA(eta) }), + ...(p ? { pacing: paceText(p), burn_rate: p.burnRate } : {}), + }; + }), + ...(q.credits ? { credits: { balance: q.credits.balance, unit: q.credits.unit, ...(q.credits.dollarBalance === undefined ? {} : { dollar_balance: q.credits.dollarBalance }) } } : {}), + }; + }), + }; +} +export function canonicalJSON(v: unknown): string { + const sorted = (x: unknown): unknown => Array.isArray(x) ? x.map(sorted) : x !== null && typeof x === "object" ? Object.fromEntries(Object.keys(x).sort().map(k => [k, sorted((x as Record)[k])])) : x; + return JSON.stringify(sorted(v)); +} diff --git a/src/domain/telemetry.ts b/src/domain/telemetry.ts new file mode 100644 index 0000000..7a27dbb --- /dev/null +++ b/src/domain/telemetry.ts @@ -0,0 +1,52 @@ +import { type Session, type Daily, type Telemetry, type Tokens, type QuotaWindow, totalTokens, tokens, addTokens, localDay, shiftDay, utcDay } from "./models"; +import { durationMins } from "./pacing"; + +export function telemetry(sessions: { startedAt: number; endedAt?: number; tokens: number; messageCount: number }[], daily: Daily[], now: number, utc = false): Telemetry { + const startOfDay = utc ? utcDay : localDay; + const nextDay = utc ? (at: number, n: number) => at + n * 86400000 : shiftDay; + const today = startOfDay(now), stats = new Map(); + for (const d of daily.length ? daily.map(d => ({ day: d.day, tokens: totalTokens(d.tokens), sessions: d.sessionCount })) : sessions.map(s => ({ day: s.startedAt, tokens: s.tokens, sessions: 1 }))) { + const k = startOfDay(d.day), prior = stats.get(k) ?? { tokens: 0, sessions: 0 }; + stats.set(k, { tokens: prior.tokens + d.tokens, sessions: prior.sessions + d.sessions }); + } + const measured = sessions.some(s => s.tokens > 0) || [...stats.values()].some(s => s.tokens > 0); + const sum = sessions.some(s => s.tokens > 0) ? sessions.reduce((n, s) => n + s.tokens, 0) : [...stats.values()].reduce((n, s) => n + s.tokens, 0); + const active = [...stats].filter(([, s]) => s.tokens > 0 || s.sessions > 0).map(([d]) => d).sort((a, b) => a - b); + const daySet = new Set(active); let longest = 0, run = 0, previous: number | undefined; + for (const d of active) { run = previous !== undefined && nextDay(previous, 1) === d ? run + 1 : 1; longest = Math.max(longest, run); previous = d; } + let current = 0, d = daySet.has(today) ? today : nextDay(today, -1); + while (daySet.has(d)) { current++; d = nextDay(d, -1); } + const history = Array.from({ length: 30 }, (_, i) => { const day = nextDay(today, i - 29), s = stats.get(day); return { day, tokens: s?.tokens ?? 0, sessionCount: s?.sessions ?? 0 }; }); + const messageSum = sessions.reduce((n, s) => n + s.messageCount, 0), durations = sessions.filter(s => s.endedAt !== undefined).map(s => Math.max(0, (s.endedAt! - s.startedAt) / 1000)); + const peak = Math.max(0, ...[...stats.values()].map(s => s.tokens)); + return { + currentStreakDays: current, longestStreakDays: Math.max(longest, current), dailyHistory: history, + ...(measured ? { lifetimeTokens: sum, ...(peak > 0 ? { peakDailyTokens: peak } : {}), todayTokens: stats.get(today)?.tokens ?? 0, last30DaysTokens: history.reduce((n, s) => n + s.tokens, 0) } : {}), + ...(durations.length ? { longestChatSeconds: Math.max(...durations) } : {}), + ...(sessions.length ? { totalSessions: sessions.length, ...(stats.has(today) ? { todaySessions: stats.get(today)!.sessions } : {}) } : {}), + ...(sessions.length && messageSum > 0 ? { totalMessages: messageSum, todayMessages: sessions.filter(s => startOfDay(s.startedAt) === today).reduce((n, s) => n + s.messageCount, 0) } : {}), + }; +} +export function burnBreakdown(sessions: Session[], now: number, options: { since?: number; window?: QuotaWindow; fallbackToRecent?: boolean } = {}) { + const w = options.window, mins = w && durationMins(w); + const since = options.since ?? (w?.resetsAt !== undefined && mins ? w.resetsAt - mins * 60000 : undefined); + const filtered = since === undefined ? [] : sessions.filter(s => s.startedAt >= since && s.startedAt <= now); + const relevant = (filtered.length ? filtered : options.fallbackToRecent === false ? [] : sessions.slice(-8)).filter(s => totalTokens(s.tokens) > 0 && !s.isAutomation); + if (!relevant.length) return; + const groups = new Map(); + let sum = tokens(), turns = 0, longChatCount = 0; + for (const s of relevant) { + const n = Math.max(1, s.messageCount), key = `${s.projectName}|${s.model}`; + const g = groups.get(key) ?? { projectName: s.projectName, model: s.model, turns: 0, tokens: tokens(), hasRealSession: false }; + g.turns += n; g.tokens = addTokens(g.tokens, s.tokens); g.hasRealSession ||= !s.isAggregate; groups.set(key, g); + sum = addTokens(sum, s.tokens); turns += n; + if ((n >= 10 || totalTokens(s.tokens) >= 100000) && !s.isAggregate) longChatCount++; + } + const total = totalTokens(sum), denominator = sum.input + sum.cacheRead + sum.cacheWrite; + return { + windowLabel: w?.label ?? "Active window", totalTokens: total, longChatCount, + cacheHitRate: denominator > 0 ? sum.cacheRead / denominator * 100 : undefined, + avgTokensPerTurn: turns > 0 ? Math.trunc(total / turns) : undefined, + contributors: [...groups.values()].map(g => ({ projectName: g.projectName, model: g.model, turns: g.turns, tokens: g.tokens, totalTokens: totalTokens(g.tokens), shareOfWindow: totalTokens(g.tokens) / Math.max(1, total) * 100, isLongChat: (g.turns >= 10 || totalTokens(g.tokens) >= 100000) && g.hasRealSession })).sort((a, b) => b.totalTokens - a.totalTokens).slice(0, 3), + }; +} diff --git a/src/main/api-billing-fetch.ts b/src/main/api-billing-fetch.ts new file mode 100644 index 0000000..e65ded9 --- /dev/null +++ b/src/main/api-billing-fetch.ts @@ -0,0 +1,43 @@ +import type { ApiBillingProvider } from "../domain/api-billing"; +import type { Launch } from "./launch"; +import { collectApiBilling, writeApiBilling } from "./providers/api-billing"; + +export const apiBillingFetchErrors = { + settings: "Open Settings to fetch API billing.", + profile: "API billing fetch is unavailable in demo or isolated profiles.", + busy: "API billing fetch is already in progress for this provider.", + auth: "API billing authentication failed", + unavailable: "API billing data unavailable", + savedMissing: "No saved Admin key for this provider. Paste an organization Admin key to fetch billing.", + savedAccess: "Could not read the saved Admin key. Approve MeterUsage access in macOS Keychain and try again.", +} as const; + +export class ApiBillingFetch { + private readonly busy = new Map(); + private stopped = false; + constructor(private readonly launch: Launch, private readonly refresh: () => Promise, private readonly fetcher: typeof fetch = fetch, private readonly savedKey?: (provider: ApiBillingProvider, signal: AbortSignal) => Promise) {} + + async run(provider: ApiBillingProvider, adminKey?: string) { + if (this.launch.demo || this.launch.candidate || this.launch.profile || this.launch.codexTest || this.launch.claudeTest) throw new Error(apiBillingFetchErrors.profile); + if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); + if (this.busy.has(provider)) throw new Error(apiBillingFetchErrors.busy); + const controller = new AbortController(); + this.busy.set(provider, controller); + try { + if (adminKey === undefined) { + try { adminKey = (await this.savedKey?.(provider, controller.signal))?.trim(); } + catch { throw new Error(apiBillingFetchErrors.savedAccess); } + if (!adminKey) throw new Error(apiBillingFetchErrors.savedMissing); + } + if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); + const snapshot = await collectApiBilling(provider, adminKey, Date.now(), (url, init) => this.fetcher(url, { ...init, signal: AbortSignal.any([controller.signal, ...(init?.signal ? [init.signal] : [])]) })); + if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); + writeApiBilling(this.launch.home, snapshot); + await this.refresh(); + } catch (error) { + throw new Error(!this.stopped && error instanceof Error && [apiBillingFetchErrors.auth, apiBillingFetchErrors.savedMissing, apiBillingFetchErrors.savedAccess].some(message => message === error.message) ? error.message : apiBillingFetchErrors.unavailable); + } finally { this.busy.delete(provider); } + } + + stop() { this.stopped = true; for (const controller of this.busy.values()) controller.abort(); } +} diff --git a/src/main/api-usage-helper.ts b/src/main/api-usage-helper.ts new file mode 100644 index 0000000..c508c56 --- /dev/null +++ b/src/main/api-usage-helper.ts @@ -0,0 +1,20 @@ +import { userInfo } from "node:os"; +import { collectApiBilling, writeApiBilling } from "./providers/api-billing"; + +export async function runApiUsageHelper(args: string[], env: NodeJS.ProcessEnv = process.env, home = userInfo().homedir, fetcher: typeof fetch = fetch) { + const provider = args[0]; + if (args.length !== 1 || provider !== "openai" && provider !== "anthropic") throw new Error("Usage: api-usage-helper openai|anthropic"); + const name = provider === "openai" ? "OPENAI_ADMIN_KEY" : "ANTHROPIC_ADMIN_KEY", key = env[name]; + // The one-run helper owns the key; MeterUsage reads only the projected file. + delete env[name]; + if (!key) throw new Error("API billing admin key missing from helper environment"); + const snapshot = await collectApiBilling(provider, key, Date.now(), fetcher); + writeApiBilling(home, snapshot); +} + +if (process.argv[1]?.endsWith("api-usage-helper.cjs")) { + runApiUsageHelper(process.argv.slice(2)).then(() => process.stdout.write("API billing snapshot updated\n"), error => { + const allowed = ["Usage: api-usage-helper openai|anthropic", "API billing admin key missing from helper environment", "API billing authentication failed"]; + process.stderr.write((error instanceof Error && allowed.includes(error.message) ? error.message : "API billing data unavailable") + "\n"); process.exitCode = 1; + }); +} diff --git a/src/main/cli.ts b/src/main/cli.ts new file mode 100644 index 0000000..ab66e12 --- /dev/null +++ b/src/main/cli.ts @@ -0,0 +1,31 @@ +import { launchConfiguration } from "./launch"; +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 => { + const key = slotKey(source.slot), controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 30000); + const aborted = new Promise((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(new Error("Source timeout")), { once: true })); + try { + await Promise.all([ + source.quota ? Promise.race([source.quota(controller.signal), aborted]).then(q => { quotas[key] = value(q); }, e => { quotas[key] = missing(e, source.slot.provider); }) : Promise.resolve(), + source.activity ? Promise.race([source.activity(controller.signal), aborted]).then(a => { const at = lastBurn(a.sessions); if (at !== undefined) burns[key] = at; }, () => {}) : Promise.resolve(), + ]); + } finally { clearTimeout(timeout); } + })); + return limitsReport(sources.filter(s => s.quota).map(s => s.slot), quotas, burns, now); +} +export async function runJSON(args: string[], env: NodeJS.ProcessEnv = process.env, helper?: string): Promise { + const launch = launchConfiguration(args, env), preferences = await Preferences.load(launch); + 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")) { + if (!process.argv.slice(2).some(a => a === "json" || a === "--json")) { process.stderr.write("Usage: meterusage json [--force] [--demo] [--candidate-profile DIR]\n"); process.exitCode = 1; } + else runJSON(process.argv.slice(2)).then(json => process.stdout.write(json + "\n"), () => { process.stderr.write("meterusage: could not read report\n"); process.exitCode = 1; }); +} diff --git a/src/main/composition.ts b/src/main/composition.ts new file mode 100644 index 0000000..d14e4a4 --- /dev/null +++ b/src/main/composition.ts @@ -0,0 +1,144 @@ +import { validClaudeIdentity } from "./providers/claude-desktop"; +import { readFileSync, existsSync, statSync } from "node:fs"; +import { join, isAbsolute, resolve } from "node:path"; +import { type Quota, type AccountActivity, type Activity, type Usage, type ServiceStatus, type Slot, type ManagedAccount, primary, accountSlot, Unavailable, failed } from "../domain/models"; +import { selectJSON } from "./select-json"; +import { type Launch } from "./launch"; +import { Preferences } from "./preferences"; +import { ClaudeActivitySource, scanCodex } from "./providers/activity"; +import { parseCodex, parseClaudeQuota, parseClaudePlan, parseLocalQuota, parseOpenRouter, parseOpenCode, parseGrok, parseStatus, object, text } from "./providers/parsers"; +import { cliPath, cliEnvironment, codexRPC, runCommand, httpTransport, endpoints, type Command, type HTTP } from "./providers/transport"; +import { AntigravityRuntime, runtimeCandidates } from "./providers/antigravity"; +import { scanGrok, openRouterUsage, openCodeUsage, parseOpenCodeRows, openCodeQuery } from "./providers/usage"; +import { demoSources } from "./demo"; +import { museCodeQuota, museCodeRPC } from "./providers/muse-code"; +import type { DesktopConnections } from "./connections"; +export interface Source { + accountBound?: boolean; + accountActivity?: (signal?: AbortSignal) => Promise; + slot: Slot; + quota?: (signal?: AbortSignal) => Promise; + activity?: (signal?: AbortSignal) => Promise; + usage?: (signal?: AbortSignal) => Promise; + plan?: (signal?: AbortSignal) => Promise; + status?: (signal?: AbortSignal) => Promise; + consumeReset?: (id: string, signal?: AbortSignal) => Promise; +} +export function accountHome(account: ManagedAccount, home: string): string | undefined { + const raw = account.path.trim(); if (!raw) return; + const path = raw === "~" ? home : raw.startsWith("~/") ? join(home, raw.slice(2)) : resolve(raw); + try { return statSync(path).isDirectory() ? path : undefined; } catch { return; } +} +function read(path: string): string | undefined { try { return readFileSync(path, "utf8"); } catch { return; } } +function firstFile(paths: string[]): string | undefined { const path = paths.find(existsSync); return path && read(path); } +export function openRouterKey(launch: Launch, management = false): string | undefined { + if (launch.demo) throw new Error("Live key discovery is disabled in demo mode"); + const keyName = management ? "OPENROUTER_MANAGEMENT_KEY" : "OPENROUTER_API_KEY", filename = management ? "management-key" : "api-key"; + const key = text(launch.env[keyName]) ?? [`.cli-proxy-api/openrouter-${filename}`, `.openrouter/${filename}`, `.config/openrouter/${filename}`].map(p => text(read(join(launch.home, p)))).find(Boolean); + return key ?? (management ? openRouterKey(launch) : undefined); +} +function grokToken(launch: Launch): string | undefined { + const raw = read(join(launch.home, ".grok/auth.json")); if (!raw) return; + try { const root = object(selectJSON(raw, { '*': { key: true } })); for (const [scope, value] of Object.entries(root)) if (scope.includes("auth.x.ai")) return text(object(value).key); } catch { return; } +} +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; connections?: DesktopConnections } = {}): Source[] { + const now = options.now ?? Date.now; + if (launch.claudeTest) return [{ slot: primary("claude"), accountBound: true, quota: signal => options.connections ? options.connections.quota("claude", signal) : Promise.reject(new Unavailable("notSignedIn", "claude")) }]; + const http = options.http ?? httpTransport(); + const connectedCodex = (): Source => { + const profile = String(prefs.values.desktopCodexConnection), connections = options.connections; + return { slot: primary("codex"), accountBound: true, + accountActivity: signal => connections ? connections.accountActivity(signal, profile) : Promise.reject(new Unavailable("notSignedIn", "codex")), + quota: signal => connections ? connections.quota("codex", signal, profile) : Promise.reject(new Unavailable("notSignedIn", "codex")), + ...(connections?.enabled("codex") ? { consumeReset: (id: string, signal?: AbortSignal) => connections.consumeReset(profile, id, signal) } : {}), + }; + }; + if (launch.codexTest) return [{ ...connectedCodex(), status: async signal => parseStatus("codex", await http(endpoints.codexStatus, "codex", undefined, signal), now()) }]; + if (launch.demo) return demoSources(now, prefs.accounts); + const command = options.command ?? runCommand, env = cliEnvironment(launch.home, launch.env); + const runtime = new AntigravityRuntime(runtimeCandidates(env), command, env); + const makeCodex = (slot: Slot, home?: string): Source => { + const rpc = (signal?: AbortSignal, id?: string) => codexRPC(cliPath("codex", launch.home, env), { env: { ...env, ...(home ? { CODEX_HOME: home } : {}) }, signal }, id); + return { slot, quota: async signal => parseCodex(await rpc(signal), now()), activity: async signal => scanCodex(join(home ?? join(launch.home, ".codex"), "sessions"), now(), signal), consumeReset: async (id, signal) => { + if (!id || id.length > 200) throw failed("codex"); + const raw = await rpc(signal, id), root = object(selectJSON(raw, { result: { outcome: true }, error: { code: true, message: true } })); + if (root.error) { parseCodex(raw, now()); throw failed("codex"); } + return object(root.result).outcome === "reset"; + } }; + }; + const makeClaude = (slot: Slot, home: string, alternate = false): Source => { + const activity = new ClaudeActivitySource(join(home, "projects"), join(launch.data, alternate ? `claude-local-scan-cache-${slot.slotID}.json` : "claude-local-scan-cache.json")); + return { slot, quota: async () => { + const paths = alternate ? [join(home, "meterusage-usage.json"), join(home, "claudewatch-usage.json")] : [join(launch.data, "claude-usage.json"), join(home, "claudewatch-usage.json"), join(home, "meterusage-usage.json")]; + const path = paths.find(existsSync), raw = path && read(path); if (!raw) throw new Unavailable("noData"); + try { + const q = parseClaudeQuota(raw, statSync(path!).mtimeMs), clock = now(); + // A CLI companion must be observed within Claude's shortest quota cycle. + if (q.capturedAt > clock || clock - q.capturedAt >= 5 * 3600000 || q.windows.some(w => w.resetsAt !== undefined && w.resetsAt <= clock)) throw new Unavailable("noData"); + return q; + } catch { throw new Unavailable("noData"); } + }, 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 = prefs.values.desktopCodexConnection !== "" ? connectedCodex() : makeCodex(primary("codex")), claude = makeClaude(primary("claude"), join(launch.home, ".claude")); + if (prefs.values.desktopClaudeIdentity !== "") { + claude.accountBound = true; + claude.quota = signal => validClaudeIdentity(prefs.values.desktopClaudeIdentity) && options.connections ? options.connections.quota("claude", signal) : Promise.reject(new Unavailable("notSignedIn", "claude")); + // Local CLI activity cannot be attributed to a separately connected account. + delete claude.activity; delete claude.plan; + } + codex.status = async signal => parseStatus("codex", await http(endpoints.codexStatus, "codex", undefined, signal), now()); + if (prefs.values.desktopClaudeIdentity === "" || prefs.values.desktopClaudeIdentity === "off" || validClaudeIdentity(prefs.values.desktopClaudeIdentity)) claude.status = async signal => parseStatus("claude", await http(endpoints.claudeStatus, "claude", undefined, signal), now()); + const antigravityIdentity = String(prefs.values.desktopAntigravityIdentity); + const antigravity: Source = antigravityIdentity !== "" ? { + slot: primary("antigravity"), accountBound: true, + quota: signal => options.connections ? options.connections.quota("antigravity", signal, antigravityIdentity) : Promise.reject(new Unavailable("notSignedIn", "Antigravity Desktop")), + } : { slot: primary("antigravity"), quota: signal => runtime.quota(now(), signal), usage: signal => runtime.usage(join(launch.home, ".gemini/antigravity-cli"), now(), signal) }; + const grokIdentity = String(prefs.values.desktopGrokIdentity); + const muse: Source[] = prefs.enabled("muse") ? [{ + slot: primary("muse"), accountBound: true, quota: async signal => museCodeQuota(await museCodeRPC(cliPath("muse", launch.home, env), { env, signal }), now()), + }] : []; + const sources: Source[] = [codex, ...muse, { + slot: primary("openRouter"), quota: async signal => { + const key = openRouterKey(launch); if (!key) throw new Unavailable("dataNotFound", "OpenRouter API key"); + // Start the optional deadline with the required request, inside its 30s budget. + const controller = new AbortController(), creditsSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + const timeout = setTimeout(() => controller.abort(), 5000); + const aborted = new Promise(resolve => { if (creditsSignal.aborted) resolve(undefined); else creditsSignal.addEventListener("abort", () => resolve(undefined), { once: true }); }); + try { + const [raw, supplement] = await Promise.all([http(endpoints.openRouterKey, "openRouter", key, signal), Promise.race([http(endpoints.openRouterCredits, "openRouter", key, creditsSignal).catch(() => undefined), aborted])]); + return parseOpenRouter(raw, supplement, now()); + } finally { clearTimeout(timeout); controller.abort(); } + }, + usage: async signal => { const key = openRouterKey(launch, true); if (!key) throw new Unavailable("dataNotFound", "OpenRouter Management Key"); return openRouterUsage(await http(endpoints.openRouterActivity, "openRouter", key, signal), now()); }, + }, { + slot: primary("openCodeGo"), quota: async signal => { const key = openCodeKey(launch); if (!key) throw new Unavailable("dataNotFound", "OpenCode Go API key"); return parseOpenCode(await http(endpoints.openCodeGo, "openCodeGo", key, signal), now()); }, + usage: async signal => { + const path = join(launch.home, ".local/share/opencode/opencode.db"); + if (existsSync(path)) { try { const { DatabaseSync } = await import("node:sqlite"), db = new DatabaseSync(path, { readOnly: true }); try { const rows = parseOpenCodeRows(JSON.stringify(db.prepare(openCodeQuery).all())); if (rows.length) return openCodeUsage(rows, now()); } finally { db.close(); } } catch { /* The supported CLI is the existing fallback. */ } } + return openCodeUsage(parseOpenCodeRows(await command(cliPath("opencode", launch.home, env), ["db", "--format", "json", openCodeQuery], { env, signal })), now()); + }, + }, antigravity, grokIdentity !== "" ? { + slot: primary("grok"), accountBound: true, + quota: signal => options.connections ? options.connections.quota("grok", signal, grokIdentity) : Promise.reject(new Unavailable("notSignedIn", "Grok Bot")), + } : { + slot: primary("grok"), quota: async signal => { const key = grokToken(launch); if (!key) throw new Unavailable("dataNotFound", "Grok credentials"); return parseGrok(await http(endpoints.grok, "grok", key, signal), now()); }, usage: async signal => { const configured = text(env.GROK_HOME), root = configured ? isAbsolute(configured) ? configured : join(launch.home, configured) : join(launch.home, ".grok"); return scanGrok(join(root, "sessions"), now(), signal); }, + }, claude]; + for (const p of ["cursor", "copilot", "gemini"] as const) { + const root = join(launch.home, `.${p}`), fallback = p === "cursor" ? join(launch.home, "Library/Application Support/Cursor") : join(launch.home, `.config/${p === "copilot" ? "github-copilot" : "gemini"}`); + sources.push({ slot: primary(p), quota: async () => { + const raw = firstFile([join(root, "quota.json"), join(root, "usage.json"), join(fallback, "quota.json")]); + if (raw) { try { return parseLocalQuota(p, raw, now()); } catch { /* Match the local presence classification. */ } } + const present = p === "cursor" ? existsSync(root) || existsSync(fallback) : p === "copilot" ? existsSync(join(root, "config.json")) || existsSync(join(fallback, "hosts.json")) : existsSync(join(root, "google_accounts.json")) || existsSync(join(root, "oauth_creds.json")); + throw present ? new Unavailable("dataNotFound", `${p === "cursor" ? "Cursor" : p === "copilot" ? "Copilot" : "Gemini"} credentials`) : new Unavailable("cliNotFound", p === "cursor" ? "Cursor" : p === "copilot" ? "Copilot CLI" : "Gemini CLI"); + } }); + } + for (const account of prefs.accounts.filter(a => a.enabled)) { + const home = accountHome(account, launch.home); if (!home) continue; + sources.push(account.provider === "codex" ? makeCodex(accountSlot(account), home) : makeClaude(accountSlot(account), home, true)); + } + return sources; +} diff --git a/src/main/connections.ts b/src/main/connections.ts new file mode 100644 index 0000000..a426085 --- /dev/null +++ b/src/main/connections.ts @@ -0,0 +1,417 @@ +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 { ClaudeBrowserVerification, boundedChild, cliEnvironment, cliPath, codexRPC, endpoints, claudeUsageURL, httpTransport, runCommand, type Command, type HTTP } from "./providers/transport"; +import { object, parseCodex } from "./providers/parsers"; +import { parseCodexAccountUsage } from "./providers/codex-account-usage"; +import { desktopQuota, readDesktopCredential, desktopReadErrors, validClaudeIdentity, verifyDesktopAccount } from "./providers/claude-desktop"; +import { readAntigravityDesktop, antigravityRPC, type AntigravityRPC } from "./providers/antigravity-desktop"; +import { cursorGrokQuota, cursorIdentity, readCursorCredential } from "./providers/cursor-grok"; +import { readGrokBotCredential, grokBotQuota, grokBotTransport, grokBotConnectionErrors, GrokBotFailure, type GrokBotRPC } from "./providers/grok-bot-desktop"; +import { selectJSON } from "./select-json"; +import { Unavailable, failed, resetCreditAvailable } from "../domain/models"; +import { providerKey, type ConnectionState, type 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", "--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 = () => { + 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; settleBeforeQuit: boolean }>(); + private errors = new Set(); + private stopped = false; + private antigravityController = new AbortController(); + private resetController?: AbortController; + private stopping?: Promise; + private cleanupController = new AbortController(); + private claudeController = new AbortController(); + private grokController = 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, readonly antigravity: AntigravityRPC = antigravityRPC, readonly grokBot: GrokBotRPC = grokBotTransport()) {} + get state(): ConnectionState[] { + return (["codex", "claude", "antigravity", "grok"] as const).map(provider => ({ provider, status: this.launch.codexTest && provider !== "codex" || this.launch.claudeTest && provider !== "claude" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected", source: this.configured(provider) ? "account" : "cli", ...(provider === "grok" && /^[a-f\d]{64}$/.test(String(this.prefs.values.desktopGrokIdentity)) ? { accountMethod: this.prefs.values.desktopGrokSource === "bot" ? "bot" as const : "cursor" as const } : {}), localAvailable: !this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest })); + } + enabled(provider: ConnectionProvider) { const value = this.prefs.values[this.key(provider)]; return (!this.launch.codexTest || provider === "codex") && (!this.launch.claudeTest || provider === "claude") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && (provider !== "claude" || validClaudeIdentity(value)) && typeof value === "string" && value !== "" && value !== "off"; } + configured(provider: ConnectionProvider) { return this.prefs.values[this.key(provider)] !== ""; } + private key(provider: ConnectionProvider) { return provider === "codex" ? "desktopCodexConnection" : provider === "claude" ? "desktopClaudeIdentity" : provider === "antigravity" ? "desktopAntigravityIdentity" : "desktopGrokIdentity"; } + 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: cliEnvironment(this.launch.home, { PATH: this.launch.env.PATH }).PATH, CODEX_HOME: home, TMPDIR: this.launch.env.TMPDIR }; + } + private async claudePassword(allowUI: boolean, signal?: AbortSignal, guard: () => void = () => {}) { + try { + guard(); signal?.throwIfAborted(); + const password = await this.command(this.helper, allowUI ? ["--allow-ui"] : [], { signal, timeoutMs: allowUI ? 60000 : 5000, maxBytes: 4096 }); + guard(); signal?.throwIfAborted(); + return password; + } catch { throw new Unavailable("notSignedIn", "Claude Desktop"); } + } + private async claudeQuota(allowUI: boolean, signal: AbortSignal, guard: () => void, expected?: string, reading?: (stage: keyof typeof desktopReadErrors) => void) { + const check = () => { guard(); signal.throwIfAborted(); }; + check(); + const password = await this.claudePassword(allowUI, signal, check); + check(); + const credential = await readDesktopCredential(this.launch.home, password, this.now(), reading, this.command, signal); + check(); + if (expected && credential.identity !== expected) throw new Unavailable("notSignedIn", "Claude Desktop"); + let raw: string, verified = false; + const verify = async () => { + check(); + try { verifyDesktopAccount(await this.http(endpoints.claudeBootstrap, "claude", credential.cookie, signal), credential); } + catch (error) { throw error instanceof ClaudeBrowserVerification ? error : new Unavailable("notSignedIn", "Claude Desktop"); } + check(); + }; + try { + reading?.("request"); + await verify(); verified = true; + check(); + raw = await this.http(claudeUsageURL(credential.organization), "claude", credential.cookie, signal); + check(); + } finally { + reading?.("verification"); + // Revoked consent or cancellation must prevent another private read. + check(); + try { if (verified) await verify(); } + finally { + check(); + const after = await readDesktopCredential(this.launch.home, password, this.now(), undefined, this.command, signal); + check(); + if (after.identity !== credential.identity || after.cookie !== credential.cookie) throw new Unavailable("notSignedIn", "Claude Desktop"); + } + reading?.("request"); + } + reading?.("response"); + return { identity: credential.identity, quota: desktopQuota(raw, this.now()) }; + } + private checkCodexProfile(profile: string) { + if (this.launch.demo || !this.enabled("codex") || this.prefs.values.desktopCodexConnection !== profile) throw new Unavailable("notSignedIn", "codex"); + } + private async cursor(signal: AbortSignal, guard: () => void, expected?: string) { + guard(); signal.throwIfAborted(); + const credential = await readCursorCredential(this.launch.home, this.now(), this.command, signal); + const check = () => { + guard(); signal.throwIfAborted(); + if (credential.expiresAt <= this.now() + 60000) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + }; + check(); + if (expected && credential.identity !== expected) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + let raw: string; + try { + cursorIdentity(await this.http(endpoints.cursorIdentity, "grok", credential.cookie, signal), credential); + check(); + const verified = await readCursorCredential(this.launch.home, this.now(), this.command, signal); + check(); + if (verified.identity !== credential.identity || verified.store !== credential.store) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + raw = await this.http(endpoints.cursorGrok, "grok", credential.cookie, signal); + } finally { + // Never reread a private store after cancellation or consent revocation. + check(); + const after = await readCursorCredential(this.launch.home, this.now(), this.command, signal); + if (after.identity !== credential.identity || after.store !== credential.store) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + check(); + } + return { identity: credential.identity, quota: cursorGrokQuota(raw, this.now()) }; + } + private async bot(allowUI: boolean, signal: AbortSignal, guard: () => void, expected?: string) { + const check = () => { guard(); signal.throwIfAborted(); }; + check(); + const credential = await readGrokBotCredential(this.launch.home, this.helper, this.command, this.now(), allowUI, signal, check); + const current = () => { check(); if (credential.expiresAt <= this.now() + 60000) throw new GrokBotFailure("credentials"); }; + current(); + if (expected && credential.identity !== expected) throw new GrokBotFailure("credentials"); + let raw: string; + try { current(); raw = await this.grokBot(credential.token, credential.team, signal); } + catch (error) { throw error instanceof GrokBotFailure ? error : new GrokBotFailure("request", error instanceof Unavailable ? error.code : "failed"); } + finally { + try { + current(); + const after = await readGrokBotCredential(this.launch.home, this.helper, this.command, this.now(), false, signal, current); + current(); + if (after.identity !== credential.identity || after.store !== credential.store) throw new GrokBotFailure("verification"); + } catch { throw new GrokBotFailure("verification"); } + } + return { identity: credential.identity, quota: grokBotQuota(raw, this.now()) }; + } + async quota(provider: ConnectionProvider, signal?: AbortSignal, profile = String(this.prefs.values[provider === "antigravity" ? "desktopAntigravityIdentity" : provider === "grok" ? "desktopGrokIdentity" : provider === "claude" ? "desktopClaudeIdentity" : "desktopCodexConnection"])) { + if (this.launch.demo || !this.enabled(provider)) throw new Unavailable("notSignedIn", provider); + if (provider === "grok") { + const generation = this.grokController; + const guard = () => { if (!this.enabled("grok") || this.grokController !== generation || this.prefs.values.desktopGrokIdentity !== profile) throw new Unavailable("notSignedIn", "Grok Bot"); }; + const source = this.prefs.values.desktopGrokSource; + const read = source === "bot" ? this.bot.bind(this, false) : this.cursor.bind(this); + const result = await read(signal ? AbortSignal.any([signal, generation.signal]) : generation.signal, () => { guard(); if (this.prefs.values.desktopGrokSource !== source) throw new Unavailable("notSignedIn", "Grok Bot Desktop"); }, profile); + guard(); return result.quota; + } + if (provider === "codex") { + this.checkCodexProfile(profile); + const raw = await codexRPC(this.binary(), { env: this.env(profile), signal }, undefined, configuration); + this.checkCodexProfile(profile); signal?.throwIfAborted(); + return parseCodex(raw, this.now()); + } + if (provider === "antigravity") { + const controller = this.antigravityController; + const active = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + if (this.prefs.values.desktopAntigravityIdentity !== profile) throw new Unavailable("notSignedIn", "Antigravity Desktop"); + const result = await readAntigravityDesktop(this.command, this.antigravity, this.now(), active, profile); + active.throwIfAborted(); + if (!this.enabled(provider) || this.prefs.values.desktopAntigravityIdentity !== profile) throw new Unavailable("notSignedIn", "Antigravity Desktop"); + return result.quota; + } + const generation = this.claudeController; + const guard = () => { if (!this.enabled("claude") || this.claudeController !== generation || this.prefs.values.desktopClaudeIdentity !== profile) throw new Unavailable("notSignedIn", "Claude Desktop"); }; + const active = signal ? AbortSignal.any([signal, generation.signal]) : generation.signal; + return (await this.claudeQuota(false, active, guard, profile)).quota; + } + + async accountActivity(signal?: AbortSignal, profile = String(this.prefs.values.desktopCodexConnection)) { + this.checkCodexProfile(profile); + const raw = await codexRPC(this.binary(), { env: this.env(profile), signal }, undefined, configuration, "usage"); + this.checkCodexProfile(profile); signal?.throwIfAborted(); + return parseCodexAccountUsage(raw, this.now()); + } + async consumeReset(profile: string, id: string, signal?: AbortSignal) { + this.checkCodexProfile(profile); + if (!id || id.length > 200 || this.resetController) throw failed("codex"); + const controller = new AbortController(); this.resetController = controller; + const resetSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal; + try { + const q = await this.quota("codex", resetSignal, profile); + if (!q.resetCredits.some(c => c.id === id && resetCreditAvailable(c, this.now()))) throw failed("codex"); + this.checkCodexProfile(profile); resetSignal.throwIfAborted(); + const raw = await codexRPC(this.binary(), { env: this.env(profile), signal: resetSignal }, id, configuration); + this.checkCodexProfile(profile); resetSignal.throwIfAborted(); + const root = object(selectJSON(raw, { result: { outcome: true }, error: { code: true, message: true } })); + if (root.error) { parseCodex(raw, this.now()); throw failed("codex"); } + return object(root.result).outcome === "reset"; + } finally { this.resetController = undefined; } + } + async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => provider === "codex" || provider === "antigravity") { + 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 (this.launch.claudeTest && provider !== "claude") throw new Error("Connection unavailable in Claude test mode"); + 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; + const previousClaudeIdentity = this.prefs.values.desktopClaudeIdentity; + const previousClaudeVisible = this.prefs.values.showProviderClaude; + const previousGrokSource = this.prefs.values.desktopGrokSource; + let grokSourceSaved = false; + let grokError = "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."; + let claudeError = "Open Claude Desktop and sign in, then try connecting again"; + 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 if (provider === "antigravity") { + const result = await readAntigravityDesktop(this.command, this.antigravity, this.now(), controller.signal); + controller.signal.throwIfAborted(); savedIdentity = result.identity; + await this.prefs.set("desktopAntigravityIdentity", result.identity); + controller.signal.throwIfAborted(); + } else if (provider === "grok") { + const result = await this.bot(true, controller.signal, () => { if (this.stopped || this.pending.get("grok")?.controller !== controller) throw new Unavailable("notSignedIn", "Grok Bot Desktop"); }); + controller.signal.throwIfAborted(); savedIdentity = result.identity; + grokError = grokBotConnectionErrors.preferences; + grokSourceSaved = true; await this.prefs.set("desktopGrokSource", "bot"); + controller.signal.throwIfAborted(); + await this.prefs.set("desktopGrokIdentity", result.identity); + controller.signal.throwIfAborted(); + } else { + claudeError = "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again."; + const result = await this.claudeQuota(true, controller.signal, () => { + if (this.stopped || this.pending.get("claude")?.controller !== controller) throw new Unavailable("notSignedIn", "Claude Desktop"); + }, undefined, stage => { claudeError = desktopReadErrors[stage]; }); + claudeError = "Could not save the Claude Desktop connection preferences. Try connecting again."; + controller.signal.throwIfAborted(); savedIdentity = result.identity; + await this.prefs.set("desktopClaudeIdentity", result.identity); + controller.signal.throwIfAborted(); + } + await this.prefs.set(providerKey(provider), 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 (error) { + if (provider === "claude" && !controller.signal.aborted && error instanceof ClaudeBrowserVerification) claudeError = desktopReadErrors.challenge; + if (provider === "grok" && !controller.signal.aborted && error instanceof GrokBotFailure) grokError = grokBotConnectionErrors[error.stage]; + 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 (provider === "claude" && savedIdentity) { + for (const [key, value] of [["desktopClaudeIdentity", previousClaudeIdentity], ["showProviderClaude", previousClaudeVisible]] as const) { + this.prefs.values[key] = value; + try { await this.prefs.set(key, value); } catch { this.prefs.values[key] = value; } + } + if (!this.stopped) this.changed(); + } + if (provider !== "claude" && savedIdentity && this.prefs.values[this.key(provider)] === savedIdentity) { + // Disabling clears consent in memory first; keep a failed rollback write private. + try { await this.disable(provider); } catch { /* Report the fixed preference-save message. */ } + } + if (provider === "grok" && grokSourceSaved) { + try { await this.prefs.set("desktopGrokSource", previousGrokSource); } + catch { this.prefs.values.desktopGrokSource = previousGrokSource; } + } + this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : provider === "antigravity" ? "Open one Antigravity Desktop account and sign in, then try connecting again" : provider === "grok" ? grokError : controller.signal.aborted ? "Open Claude Desktop and sign in, then try connecting again" : claudeError); + } + finally { finish(); } + } + private begin(provider: ConnectionProvider, local = false) { + if (provider === "claude") { this.claudeController.abort(); this.claudeController = new AbortController(); } + if (provider === "grok") { this.grokController.abort(); this.grokController = new AbortController(); } + if (provider === "codex") this.resetController?.abort(); + if (provider === "antigravity") { this.antigravityController.abort(); this.antigravityController = new AbortController(); } + const controller = new AbortController(); + let settle!: () => void; + const done = new Promise(resolve => { settle = resolve; }); + this.pending.set(provider, { controller, done, settleBeforeQuit: local || provider === "claude" || provider === "antigravity" || provider === "grok" }); 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: ConnectionProvider, cleanupID?: string) { + const key = this.key(provider); + 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"); + } + disconnect(provider: ConnectionProvider) { return this.clearConnection(provider, false); } + async useLocal(provider: ConnectionProvider) { + if (this.launch.codexTest || this.launch.claudeTest) throw new Error("Connection unavailable in test mode"); + await this.clearConnection(provider, true); + } + private async clearConnection(provider: ConnectionProvider, local: boolean) { + 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 (this.launch.claudeTest && provider !== "claude") throw new Error("Connection unavailable in Claude test mode"); + if ((provider === "grok" || provider === "claude") && this.pending.has(provider)) { const operation = this.pending.get(provider)!; operation.controller.abort(); await operation.done; } + if (this.stopped || this.pending.has(provider)) throw new Error("Connection unavailable"); + const wasVisible = this.prefs.values[providerKey(provider)] === true; + const id = String(this.prefs.values.desktopCodexCleanup || this.prefs.values.desktopCodexConnection); + const { controller, finish } = this.begin(provider, local); + try { + await this.disable(provider, provider === "codex" && id !== "" && id !== "off" ? id : undefined); + if (provider === "codex" && id !== "" && id !== "off") await this.cleanup(id); + if (local) { + controller.signal.throwIfAborted(); + await this.prefs.set(providerKey(provider), true); + controller.signal.throwIfAborted(); + await this.prefs.set(this.key(provider), ""); + controller.signal.throwIfAborted(); + } + this.errors.delete(provider); + } catch { + if (local && provider === "grok" && controller.signal.aborted) { + try { await this.prefs.set(providerKey(provider), wasVisible); } + catch { this.prefs.values[providerKey(provider)] = wasVisible; } + } + if (local && !this.configured(provider)) { + try { await this.disable(provider); } catch { /* Disabling in memory keeps a failed rollback private. */ } + } + this.errors.add(provider); throw new Error(provider === "codex" ? "Could not disconnect Codex" : provider === "claude" ? "Could not disconnect Claude Desktop" : provider === "antigravity" ? "Could not disconnect Antigravity Desktop" : local ? "Could not select Grok CLI" : "Could not disconnect Grok Bot Desktop"); + } finally { finish(); } + } + async recover() { + const id = String(this.prefs.values.desktopCodexCleanup); + if (this.launch.demo || this.launch.claudeTest || !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; this.resetController?.abort(); this.grokController.abort(); this.claudeController.abort(); this.antigravityController.abort(); + const pending = [...this.pending.values()]; + for (const operation of pending) operation.controller.abort(); + let timer: ReturnType; + const expired = new Promise(resolve => { timer = setTimeout(() => { this.cleanupController.abort(); resolve(); }, timeoutMs); }); + // The helper deadline must not cut off a queued source or desktop-consent rollback. + this.stopping = Promise.race([Promise.all(pending.map(p => p.done)).then(() => {}), expired]) + .then(() => Promise.all(pending.filter(p => p.settleBeforeQuit).map(p => p.done))).then(() => {}) + .finally(() => clearTimeout(timer)); + return this.stopping; + } +} diff --git a/src/main/coordinator.ts b/src/main/coordinator.ts new file mode 100644 index 0000000..04ed523 --- /dev/null +++ b/src/main/coordinator.ts @@ -0,0 +1,240 @@ +import { apiBillingProviders, type ApiBillingProvider, type ApiBillingReading } from "../domain/api-billing"; +import { readApiBilling } from "./providers/api-billing"; +import { join } from "node:path"; +import { readdir, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { type Loaded, type Quota, type AccountActivity, type Activity, type Usage, type ServiceStatus, type Slot, type Provider, providers, primary, slotKey, lastBurn, totalTokens, utcDay, value, missing, Unavailable, resetCreditAvailable } from "../domain/models"; +import { orderedSlots } from "../domain/provider-order"; +import { AlertEvaluator, type Alert } from "../domain/alerts"; +import { telemetry } from "../domain/telemetry"; +import { observeQuota, resetPacing } from "../domain/pacing"; +import { type Snapshot } from "../shared/state"; +import { Preferences, trayPreference } from "./preferences"; +import { type Launch } from "./launch"; +import { type Source, compose, accountHome } from "./composition"; +import { HistoryStore, readArchive, saveArchive } from "./history"; +import { QuotaActivityStore } from "./quota-activity"; +import { limitsReport, canonicalJSON } from "../domain/report"; +type Kind = "accountActivity" | "quota" | "activity" | "usage" | "plan" | "status"; +// Grok observations belong to the live source session; dated quota may survive it. +function archiveQuota(q: Quota): Quota { + const windows = (ws: Quota["windows"]) => ws.map(w => ({ ...w, pacingBaseline: undefined })); + return q.provider === "grok" ? { ...q, windows: windows(q.windows), groups: q.groups.map(g => ({ ...g, windows: windows(g.windows) })) } : q; +} +export const backoffDelay = (failures: number) => Math.min(2 ** (Math.min(Math.max(failures, 1), 6) - 1), 30) * 60000; +export class Coordinator { + apiBilling?: Partial>; + accountActivities: Record> = Object.create(null); + quotas: Record> = Object.create(null); + activities: Record> = Object.create(null); + usages: Record> = Object.create(null); + plans: Record> = Object.create(null); + statuses: Partial>> = Object.create(null); + archived: Record; + readonly history: HistoryStore; + readonly quotaActivity: QuotaActivityStore; + archiveWriteFailed = false; + clearingCache = false; + lastRefreshedAt?: number; + private inflight?: Promise; + private pendingForced = false; + private backoffs = new Map(); + private observers = new Set<(state: Snapshot) => void>(); + private evaluator = new AlertEvaluator(); + private timer?: ReturnType; + private stopped = false; + private controllers = new Map(); + private confirmations = new Map(); + private resets = new Map(); + private currentSources: readonly Source[]; + 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.quotaActivity = new QuotaActivityStore(join(launch.data, "quota-activity.json")); this.archived = readArchive(join(launch.data, "quota-archive.json")); + for (const key of Object.keys(this.archived)) this.archived[key] = archiveQuota(this.archived[key]); + } + get sources(): readonly Source[] { return this.currentSources; } + set sources(next: readonly Source[]) { + for (const source of this.currentSources) if (!next.includes(source)) { + const key = slotKey(source.slot); + for (const [controller, owner] of this.controllers) if (owner === source) controller.abort(); + this.resets.get(key)?.abort(); + for (const readings of [this.accountActivities, this.quotas, this.activities, this.usages, this.plans]) delete readings[key]; + for (const kind of ["accountActivity", "quota", "activity", "usage", "plan"]) this.backoffs.delete(`${kind}-${key}`); + for (const [token, intent] of this.confirmations) if (intent.source === source) this.confirmations.delete(token); + } + // Durable slot history and dated archives survive directory/label edits. + this.currentSources = next; + if (this.inflight) this.pendingForced = true; + this.publish(); + } + visible(source: Source) { + const s = source.slot; + if (!s.slotID) return this.preferences.enabled(s.provider); + if (this.launch.demo && s.slotID === "demo-second") return true; + return this.preferences.accounts.some(a => a.id === s.slotID && a.provider === s.provider && a.enabled && accountHome(a, this.launch.home) !== undefined); + } + get slots(): Slot[] { + const byKey = new Map(this.sources.filter(s => this.visible(s)).map(s => [slotKey(s.slot), s.slot])); + return orderedSlots([...providers.map(primary), ...this.sources.filter(s => s.slot.slotID).map(s => s.slot)].filter(s => byKey.has(slotKey(s))).map(s => byKey.get(slotKey(s))!), this.preferences.providerOrder); + } + 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); + 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])); + const statuses = { ...this.statuses }; + for (const source of this.sources) if (source.status && !statuses[source.slot.provider]) statuses[source.slot.provider] = { status: "idle" }; + return structuredClone({ ...(this.apiBilling ? { apiBilling: this.apiBilling } : {}), 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, accountActivities: Object.fromEntries(this.sources.filter(s => s.accountActivity).map(s => [slotKey(s.slot), this.accountActivities[slotKey(s.slot)] ?? { status: "idle" }])), activities: this.activities, quotaActivity: this.launch.demo ? {} : Object.fromEntries(slots.map(s => [slotKey(s), this.quotaActivity.days(slotKey(s))]).filter(([, days]) => days.length)), usages: this.usages, plans: this.plans, 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: false, 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); } + start() { this.stopped = false; this.restartTimer(); return this.refresh(); } + restartTimer() { if (this.timer) clearInterval(this.timer); this.timer = setInterval(() => { void this.refresh(true); }, this.preferences.refreshInterval * 1000); } + stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); this.timer = undefined; this.pendingForced = false; for (const c of this.controllers.keys()) c.abort(); for (const c of this.resets.values()) c.abort(); this.confirmations.clear(); } + refreshIfStale() { return this.lastRefreshedAt === undefined || this.now() - this.lastRefreshedAt > 20000 ? this.refresh() : Promise.resolve(); } + refresh(scheduled = false, onlyKey?: string): Promise { + if (this.stopped) return Promise.resolve(); + if (onlyKey && !this.sources.some(s => slotKey(s.slot) === onlyKey && this.visible(s))) return Promise.reject(new Error("Unknown account")); + if (this.inflight) { if (!scheduled) this.pendingForced = true; return this.inflight; } + this.inflight = Promise.resolve().then(async () => { + let force = !scheduled, key = onlyKey; + do { + this.pendingForced = false; + await this.sweep(force, key); force = true; key = undefined; + } while (this.pendingForced && !this.stopped); + }).finally(() => { this.inflight = undefined; this.publish(); }); + this.publish(); return this.inflight; + } + private async sweep(force: boolean, onlyKey?: string) { + if (!this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest) { + this.apiBilling = Object.fromEntries(apiBillingProviders.map(provider => [provider, readApiBilling(this.launch.home, provider, this.now())])); + } + const tasks: Promise[] = []; + for (const source of this.sources) { + const key = slotKey(source.slot); if (onlyKey && key !== onlyKey) continue; + if (this.visible(source)) { + if (source.accountActivity) tasks.push(this.load("accountActivity", source, force, source.accountActivity, this.accountActivities)); + if (source.quota) tasks.push(this.load("quota", source, force, source.quota, this.quotas)); + if (source.activity) tasks.push(this.load("activity", source, force, source.activity, this.activities)); + if (source.usage) tasks.push(this.load("usage", source, force, source.usage, this.usages)); + if (source.plan) tasks.push(this.load("plan", source, force, source.plan, this.plans)); + } + if (source.status) tasks.push(this.load("status", source, force, source.status, this.statuses as Record>)); + } + await Promise.all(tasks); + if (this.stopped) return; + for (const source of this.sources.filter(s => this.visible(s))) { + const key = slotKey(source.slot), q = this.quotas[key], a = this.activities[key]; + if (q?.status === "value") { this.archived[key] = archiveQuota(q.value); if (!this.launch.demo) this.quotaActivity.record(key, q.value, q.value.capturedAt); } + if (a?.status === "value") { + this.history.record(key, a.value.daily, q?.status === "value" ? Math.max(0, ...q.value.windows.map(w => w.usedPercent)) : undefined); + const durable = this.history.records(key); + if (durable.length) { + const merged = new Map(durable.map(d => [utcDay(d.day), d])); + for (const d of a.value.daily) { const key = utcDay(d.day), old = merged.get(key); if (!old || totalTokens(old.tokens) <= totalTokens(d.tokens)) merged.set(key, d); } + a.value.daily = [...merged.values()].sort((a, b) => a.day - b.day); a.value.telemetry = telemetry(a.value.sessions.map(s => ({ startedAt: s.startedAt, tokens: s.tokens.input + s.tokens.output + s.tokens.reasoning + s.tokens.cacheRead + s.tokens.cacheWrite, messageCount: s.messageCount })), a.value.daily, this.now()); + } + if (!a.value.sessions.length && !a.value.daily.length) this.activities[key] = missing(new Unavailable("noData"), source.slot.provider); + } + } + this.persistArchive(); + this.lastRefreshedAt = this.now(); + if (this.preferences.values.quotaAlertsEnabled === true) for (const alert of this.evaluator.events(this.slots, this.quotas, this.burns, this.now())) this.notify(alert); + this.publish(); + } + private persistArchive() { + try { + const slotMap = new Map(this.sources.map(s => [slotKey(s.slot), s.slot])); + saveArchive(join(this.launch.data, "quota-archive.json"), Object.entries(this.archived).map(([key, quota]) => ({ quota: archiveQuota(quota), slot: slotMap.get(key) ?? { provider: quota.provider, slotID: key.includes("#") ? key.slice(key.indexOf("#") + 1) : "", label: "" } }))); + this.archiveWriteFailed = false; + } catch { this.archiveWriteFailed = true; } + } + forgetQuota(key: string, preserveActivity = false) { + if (!preserveActivity) this.quotaActivity.retire(key); + delete this.accountActivities[key]; 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; + const resetAtStart = this.archived[key]?.resetPacingSince; + const controller = new AbortController(); this.controllers.set(controller, source); + const timeout = setTimeout(() => controller.abort(), 30000); + const aborted = new Promise((_resolve, reject) => controller.signal.addEventListener("abort", () => reject(new Error("Source cancelled")), { once: true })); + let result: Loaded; + try { result = value(await Promise.race([read(controller.signal), aborted])); } + catch (e) { result = missing(e, source.slot.provider); } + finally { clearTimeout(timeout); this.controllers.delete(controller); } + if (this.stopped || !this.sources.includes(source) || (kind !== "status" && !this.visible(source))) return; + if (kind === "quota") { + const previous = this.archived[key]; + if (previous?.resetPacingSince !== resetAtStart) return; + if (result.status === "value" && source.slot.provider === "grok" && source.accountBound && this.preferences.values.desktopGrokSource === "bot") { + const live = this.quotas[key]; + result = value(observeQuota(result.value as Quota, live?.status === "value" ? live.value : undefined, this.now()) as T); + } else if (result.status === "value" && previous?.resetPacingSince !== undefined) { + const q = result.value as Quota; + if (q.capturedAt <= previous.resetPacingSince || q.capturedAt <= previous.capturedAt) return; + result = value(resetPacing(q, previous, previous.resetPacingSince, q.capturedAt) as T); + } + } + const priorStatus = kind === "status" ? target[key] as Loaded | undefined : undefined; + if (kind === "status" && result.status === "missing") { + const failure = "Service status unavailable"; + target[key] = priorStatus?.status === "value" + ? value({ ...priorStatus.value, stale: true, refreshError: failure }) as Loaded + : { ...result, reason: failure }; + } else 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); + } + prepareReset(key: string, creditID: string): { token: string; account: string; title: string } { + const source = this.sources.find(s => slotKey(s.slot) === key && this.visible(s)), q = this.quotas[key], credit = q?.status === "value" ? q.value.resetCredits.find(c => c.id === creditID) : undefined; + if (!source?.consumeReset || !credit || !resetCreditAvailable(credit, this.now())) throw new Error("Reset credit unavailable"); + for (const [token, prior] of this.confirmations) if (prior.key === key || prior.expires <= this.now()) this.confirmations.delete(token); + const token = randomUUID(); this.confirmations.set(token, { key, creditID, expires: this.now() + 60000, source }); + return { token, account: source.slot.label || "Codex", title: credit.title }; + } + cancelReset(token: string) { this.confirmations.delete(token); } + async confirmReset(token: string) { + const intent = this.confirmations.get(token); this.confirmations.delete(token); + if (!intent || intent.expires <= this.now() || this.resets.has(intent.key)) throw new Error("Reset confirmation expired"); + const source = this.sources.find(s => slotKey(s.slot) === intent.key && this.visible(s)); const q = this.quotas[intent.key]; + if (source !== intent.source || !source?.consumeReset || q?.status !== "value" || !q.value.resetCredits.some(c => c.id === intent.creditID && resetCreditAvailable(c, this.now()))) throw new Error("Reset credit unavailable"); + const controller = new AbortController(); this.resets.set(intent.key, controller); + try { + if (!await source.consumeReset(intent.creditID, controller.signal)) throw new Unavailable("failed", "Codex"); + if (this.stopped || !this.sources.includes(source) || !this.visible(source)) throw new Error("Reset source replaced"); + const acceptedAt = this.now(); + const latest = this.quotas[intent.key]; + this.archived[intent.key] = resetPacing(latest?.status === "value" ? latest.value : this.archived[intent.key] ?? q.value, undefined, acceptedAt, undefined, intent.creditID); + this.quotas[intent.key] = { status: "missing", code: "noData", reason: "Collecting usage after reset" }; + this.persistArchive(); + this.publish(); await this.refresh(false, intent.key); + } + catch { throw new Error("Couldn't redeem Codex reset"); } + finally { this.resets.delete(intent.key); } + } + async clearCache() { + if (this.clearingCache) return; this.clearingCache = true; this.publish(); + try { + 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 = this.sourceFactory(); this.backoffs.clear(); await this.refresh(); + } finally { this.clearingCache = false; this.publish(); } + } + diagnostics(version = "0.2.41"): string { + const describe = (s: Loaded | undefined) => !s || s.status === "idle" ? "not checked yet" : s.status === "value" ? "ok" : `unavailable (${s.code})`; + const describeStatus = (s: Loaded | undefined) => s?.status === "value" && s.value.stale + ? `stale (last successful check: ${Math.max(0, Math.trunc((this.now() - s.value.checkedAt) / 1000))}s ago; Service status unavailable)` : describe(s); + const slots = this.slots, lines = [`MeterUsage ${version}`, `mode: ${this.launch.demo ? "demo" : "live"}`, `refresh interval: ${this.preferences.refreshInterval}s`, `last refresh: ${this.lastRefreshedAt === undefined ? "never" : Math.max(0, Math.trunc((this.now() - this.lastRefreshedAt) / 1000)) + "s ago"}`, `enabled: ${slots.map(s => s.provider + (s.slotID ? "#additional" : "")).join(", ")}`]; + if (this.history.error) lines.push(`history: ${this.history.error}`); + if (this.archiveWriteFailed) lines.push("archive: writeFailed"); + for (const [index, s] of slots.entries()) { const key = slotKey(s); lines.push(`\n[${s.provider}${s.slotID ? ` account ${index + 1}` : ""}]`, ` quota: ${describe(this.quotas[key])}`, ` activity: ${describe(this.activities[key])}`, ` usage: ${describe(this.usages[key])}`, ` status: ${describeStatus(this.statuses[s.provider])}`, ` plan: ${describe(this.plans[key])}`); } + return lines.join("\n"); + } + json() { return canonicalJSON(limitsReport(this.slots.filter(s => this.sources.some(src => src.quota && slotKey(src.slot) === slotKey(s))), this.quotas, this.burns, this.now())); } +} diff --git a/src/main/demo.ts b/src/main/demo.ts new file mode 100644 index 0000000..89daebf --- /dev/null +++ b/src/main/demo.ts @@ -0,0 +1,42 @@ +import { type Source } from "./composition"; +import { type Provider, type Slot, type ManagedAccount, type Quota, type Activity, primary, accountSlot, window, quota, tokens, totalTokens, localDay, shiftDay, Unavailable } from "../domain/models"; +import { telemetry } from "../domain/telemetry"; +import { estimate } from "../domain/pricing"; +const second = (provider: Provider): Slot => ({ provider, slotID: "demo-second", label: "Second account" }); +function demoQuota(p: Provider, now: number): Quota { + const w = (label: string, used: number, seconds?: number, mins?: number) => window(label, used, seconds === undefined ? undefined : now + seconds * 1000, mins); + if (p === "claude") return quota(p, [w("5-hour", 34, 3.25 * 3600), w("7-day", 61, 2.25 * 86400)], now); + if (p === "codex") { + const q = quota(p, [w("5-hour", 82, 1.75 * 3600), w("Weekly", 72, 4.5 * 86400)], now); + q.planType = "plus"; q.credits = { balance: 41.6, hasCredits: true, unlimited: false, unit: "credits", dollarBalance: 41.6 * 0.04 }; + q.groups = [{ id: "codex", title: "General usage limits", windows: q.windows }, { id: "codex_bengalfox", title: "GPT-5.3-Codex-Spark usage limits", windows: [w("Weekly", 0, 4.5 * 86400)] }]; + q.resetCreditCount = 2; q.resetCredits = [1, 2].map(i => ({ id: `demo-reset-${i}`, title: "Full reset", status: "available", expiresAt: now + (7 + i) * 86400000 })); return q; + } + if (p === "openRouter") return { ...quota(p, [w("Monthly", 25.5)], now), credits: { balance: 74.5, hasCredits: true, unlimited: false, unit: "dollars", usedDollars: 25.5, limitDollars: 100 } }; + if (p === "openCodeGo") return { ...quota(p, [w("Rolling", 87, 1.9 * 3600), w("Weekly", 77, 26 * 3600), w("Monthly", 44, 22.4 * 86400)], now), planType: "go" }; + if (p === "grok") return { ...quota(p, [w("Weekly", 31, 2.2 * 86400)], now), planType: "X Premium" }; + if (p === "muse") return { ...quota(p, [w("Session", 24, 3 * 3600, 300), w("Weekly", 46, 3 * 86400, 10080)], now - 60000), planType: "power" }; + if (p === "antigravity") { + const q = quota(p, [w("Gemini Weekly", 89, 22 * 3600 + 24 * 60, 10080), w("Gemini 5-hour", 9, 4 * 3600 + 26 * 60, 300), w("Claude/GPT Weekly", 1, 22 * 3600 + 19 * 60, 10080), w("Claude/GPT 5-hour", 0, 4 * 3600 + 59 * 60, 300)], now); + q.groups = ["Gemini Models", "Claude and GPT models"].map((title, i) => ({ title, id: title.toLowerCase(), windows: q.windows.slice(i * 2, i * 2 + 2).map((w, index) => ({ ...w, label: index ? "5-hour limit" : "Weekly limit" })) })); return q; + } + throw new Unavailable("noData"); +} +function demoActivity(p: "codex" | "claude", now: number): Activity { + const daily = Array.from({ length: 30 }, (_, i) => ({ day: shiftDay(localDay(now), i - 29), tokens: tokens({ input: 5000 + i * 1000, output: 2000 + i * 100, cacheRead: 50000 + i * 10000 }), estimatedCostUSD: 0.12 + i * 0.04, sessionCount: i % 4 + 1 })); + const sessions = p === "codex" ? [] : ["web-app", "api-server", "notes-cli"].map((projectName, i) => { const t = tokens({ input: 18000 + i * 4000, output: 9000, cacheRead: 350000, cacheWrite: 48000 }); return { id: `demo-${i}`, projectName, model: "claude-sonnet-4-6", tokens: t, estimatedCostUSD: estimate("claude-sonnet-4-6", t).costUSD, startedAt: now - (i + 1) * 900000, lastActivityAt: now - i * 300000, messageCount: 12 + i }; }); + return { provider: p, sessions, daily, scannedAt: now, telemetry: telemetry(sessions.map(s => ({ startedAt: s.startedAt, tokens: totalTokens(s.tokens), messageCount: s.messageCount })), daily, now) }; +} +export function demoSources(now: () => number, accounts: ManagedAccount[] = []): Source[] { + const slots = [primary("claude"), second("claude"), primary("codex"), second("codex"), ...(["openRouter", "openCodeGo", "grok", "antigravity", "muse"] as const).map(primary), ...accounts.filter(a => a.enabled).map(accountSlot)]; + return slots.map(slot => { + const consumed = new Set(); + const source: Source = { slot, quota: async () => { const q = demoQuota(slot.provider, now()); if (slot.provider === "codex") { q.resetCredits = q.resetCredits.filter(c => !consumed.has(c.id)); q.resetCreditCount = q.resetCredits.length; if (consumed.size) { q.windows = q.windows.map(w => ({ ...w, usedPercent: 0 })); q.groups[0].windows = q.windows; } } return q; } }; + if (slot.provider === "claude" || slot.provider === "codex") { const p = slot.provider; source.activity = async () => demoActivity(p, now()); } + if (slot.provider === "claude") source.plan = async () => slot.slotID ? "Max 20×" : "Max 5×"; + if (!slot.slotID && (slot.provider === "claude" || slot.provider === "codex")) source.status = async () => ({ provider: slot.provider, severity: "operational", description: "All systems operational", checkedAt: now() - 90000 }); + if (slot.provider === "codex") source.consumeReset = async id => { if (!["demo-reset-1", "demo-reset-2"].includes(id) || consumed.has(id)) return false; consumed.add(id); return true; }; + if (["openRouter", "openCodeGo", "grok", "antigravity"].includes(slot.provider)) source.usage = async () => ({ provider: slot.provider, sessionCount: slot.provider === "grok" ? 124 : 26, messageCount: slot.provider === "grok" ? 1840 : 492, todaySessionCount: 3, todayMessageCount: 71, ...(slot.provider === "grok" ? {} : { tokens: tokens({ input: 1420000, output: 386000, reasoning: 118000, cacheRead: 9840000 }), todayTokens: tokens({ input: 260000, output: 74000, reasoning: 18000, cacheRead: 1420000 }), weekTokens: tokens({ input: 980000, output: 240000, reasoning: 74000, cacheRead: 6100000 }), ...(slot.provider === "openCodeGo" || slot.provider === "openRouter" ? { estimatedCostUSD: 16.33, todayCostUSD: 2.1 } : {}) }), telemetry: demoActivity("claude", now()).telemetry, capturedAt: now() - 55000 }); + return source; + }); +} diff --git a/src/main/electron.ts b/src/main/electron.ts new file mode 100644 index 0000000..cf58063 --- /dev/null +++ b/src/main/electron.ts @@ -0,0 +1,419 @@ +import { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain, screen, nativeTheme, dialog, clipboard, Notification, ShareMenu, session, powerMonitor, shell } from "electron"; +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, type Launch } from "./launch"; +import { Preferences } from "./preferences"; +import { compose } from "./composition"; +import { desktopHTTP } from "./native-http"; +import { DesktopConnections } from "./connections"; +import { desktopReadErrors } from "./providers/claude-desktop"; +import { grokBotConnectionErrors } from "./providers/grok-bot-desktop"; +import { runCommand } from "./providers/transport"; +import { Coordinator } from "./coordinator"; +import { IssueReports } from "./issue-reports"; +import { ApiBillingFetch, apiBillingFetchErrors } from "./api-billing-fetch"; +import { NotchFold } from "./notch-fold"; +import { Updater } from "./updater"; +import { runJSON } from "./cli"; +import { channel, stateChannel, statusPages, panelScales, 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 shareCardWidth = 250; + +const args = process.argv.slice(1); +const desktopHelper = app.isPackaged ? join(process.resourcesPath, "desktop-keychain") : join(__dirname, "../../build/desktop-keychain"); +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(launch: Launch) { + mkdirSync(launch.data, { recursive: true }); + app.dock?.hide(); + const prefs = await Preferences.load(launch); + const http = desktopHTTP(launch, session); + const documents = new Map(), windows = new Map(); + const connectionKeys = { codex: "desktopCodexConnection", claude: "desktopClaudeIdentity", antigravity: "desktopAntigravityIdentity", grok: "desktopGrokIdentity" } as const; + const markers = { codex: prefs.values.desktopCodexConnection, claude: prefs.values.desktopClaudeIdentity, antigravity: prefs.values.desktopAntigravityIdentity, grok: prefs.values.desktopGrokIdentity }; + const connections = new DesktopConnections(launch, prefs, desktopHelper, () => { + const changed = (["codex", "claude", "antigravity", "grok"] as const).filter(p => markers[p] !== prefs.values[connectionKeys[p]]); + if (changed.length) { + for (const p of changed) { markers[p] = prefs.values[connectionKeys[p]]; coordinator.forgetQuota(p); } + coordinator.sources = sources(); + } + publish(); + }, undefined, http); + const sources = () => compose(launch, prefs, { connections, http }); + const coordinator = new Coordinator(launch, prefs, sources(), Date.now, alert => { + if (!launch.demo && !launch.candidate && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); + }, sources); + const issueReports = new IssueReports(() => coordinator.diagnostics(app.getVersion()), publish, prefs); + const apiBillingFetch = new ApiBillingFetch(launch, () => coordinator.refresh(), fetch, (provider, signal) => runCommand(desktopHelper, [`--api-${provider}`, "--allow-ui"], { signal, timeoutMs: 60000, maxBytes: 8192 })); + // Invalidate the restored quota archive while retaining this connection's activity. + for (const p of ["codex", "claude", "antigravity", "grok"] as const) if (connections.configured(p)) coordinator.forgetQuota(p, true); + const notch = { expanded: false, cardOnRight: false, selected: "codex", dragging: false }; + let notchHeight = 200, anchor: { x: number; y: number } | undefined; + let flyoutHeight = 700, settingsHeight: number | undefined; + const panelScale = () => panelScales[prefs.values.panelSize as PanelSize] ?? 1; + let panelSizeKeys = Promise.resolve(); + 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(); + const icon = nativeImage.createFromPath(join(__dirname, "../renderer/AppIcon.png")).resize({ width: 18, height: 18 }); icon.setTemplateImage(true); + const tray = new Tray(icon); + tray.setToolTip("MeterUsage"); + tray.on("right-click", () => tray.popUpContextMenu(Menu.buildFromTemplate([{ label: "Open MeterUsage", click: () => showUsage() }, { label: "Settings…", click: () => showSettings() }, { type: "separator" }, { label: "Quit MeterUsage", role: "quit" }]))); + tray.on("click", () => { const w = windows.get("flyout"); w?.isVisible() ? w.hide() : showUsage(); }); + 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 : ({ appVersion: app.getVersion(), ...(surface === "settings" ? { issueReport: issueReports.state } : {}), 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 && !launch.candidate && Notification.isSupported()) new Notification({ title: "MeterUsage update available", body: `MeterUsage ${release.version} is ready to install.`, silent: true }).show(); + }); + let hoverCheck: ReturnType | undefined; + const folding = new NotchFold(() => sharing || confirmingReset || notch.dragging, () => { notch.expanded = false; placeNotch(); publish(); }); + function stopHoverCheck() { if (hoverCheck) clearInterval(hoverCheck); hoverCheck = undefined; } + function pointerInNotch() { + const w = windows.get("notch"); if (!w || w.isDestroyed()) return false; + const b = w.getBounds(), p = screen.getCursorScreenPoint(); + return p.x >= b.x && p.x < b.x + b.width && p.y >= b.y && p.y < b.y + b.height; + } + function watchNotchHover() { + if (hoverCheck) return; + // Non-activating panels cannot blur; recover a missed Chromium pointerleave. + hoverCheck = setInterval(() => { + if (!pointerInNotch()) { stopHoverCheck(); folding.hovered = false; folding.changed(); } + }, 100); + } + 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)); + } + // Only fixed public status pages can open externally through main. + session.defaultSession.setPermissionRequestHandler((_wc, _permission, callback) => callback(false)); + session.defaultSession.setPermissionCheckHandler(() => false); + session.defaultSession.webRequest.onBeforeRequest((details, callback) => callback({ cancel: !details.url.startsWith("file://") && !details.url.startsWith("data:") })); + 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"; + if (surface === "settings") settingsHeight = undefined; + const w = new BrowserWindow({ width: shareImage ? shareCardWidth * 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); + w.webContents.on("paint", (_event, _rect, image) => { + const size = image.getSize(); + if (shareReady && shareHeight > 0 && size.width >= shareCardWidth * 2 && size.height >= shareHeight) { const ready = shareReady; shareReady = undefined; ready(image); } + }); + } + const document = pathToFileURL(join(__dirname, "../renderer/index.html")); document.searchParams.set("surface", surface); + 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()); + if (surface === "flyout" || surface === "settings") { + // A saved Chromium zoom would compound the persisted CSS panel scale. + w.webContents.on("did-finish-load", () => w.webContents.setZoomFactor(1)); + w.webContents.on("before-input-event", (event, input) => { + if (input.type !== "keyDown" || input.alt || !(input.control || input.meta) || !["+", "=", "-", "0"].includes(input.key)) return; + event.preventDefault(); + panelSizeKeys = panelSizeKeys.then(async () => { + if (quitting || w.isDestroyed()) return; + const sizes = Object.values(PanelSize), current = sizes.indexOf(prefs.values.panelSize as PanelSize); + const value = input.key === "0" ? PanelSize.Medium : sizes[Math.max(0, Math.min(sizes.length - 1, (current < 0 ? 1 : current) + (input.key === "-" ? -1 : 1)))]; + if (value !== prefs.values.panelSize) await handle({ action: "setPreference", key: "panelSize", value }, w, surface); + }).catch(() => { dialog.showErrorBox("Could not change panel size", "Your panel size was not saved. Try again."); }); + }); + } + w.on("closed", () => { documents.delete(webContentsId); if (windows.get(surface) === w) windows.delete(surface); if (panel) { stopHoverCheck(); 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(); } }); + return w; + } + 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); + sizeFlyout(); w.show(); w.focus(); void coordinator.refreshIfStale(); + } + function showUsage() { if (prefs.values.onboardingCompleted === true) showFlyout(); else showSettings(); } + 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 sizeSettings() { + const source = windows.get("settings"); if (!source || source.isDestroyed() || settingsHeight === undefined) return; + const bounds = source.getBounds(), content = source.getContentBounds(), work = screen.getDisplayMatching(bounds).workArea; + const chrome = Math.max(0, bounds.height - content.height), width = Math.min(bounds.width, work.width); + const y = bounds.y >= work.y && bounds.y + chrome + 20 <= work.y + work.height ? bounds.y : work.y; + const next = { x: Math.round(Math.min(Math.max(bounds.x, work.x), work.x + work.width - width)), y, width, height: Math.min(Math.ceil(settingsHeight) + chrome, work.y + work.height - y) }; + if (next.x !== bounds.x || next.y !== bounds.y || next.width !== bounds.width || next.height !== bounds.height) source.setBounds(next, false); + } + function showSettings() { + 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() { + const raw = String(prefs.values.sideNotchPanelCorner), nums = raw.split(",").map(Number); + if (raw && nums.length === 2 && nums.every(Number.isFinite)) { + // AppKit persisted a bottom-left-origin corner. Electron uses a + // top-left origin, relative to the primary display's coordinate space. + const primary = screen.getPrimaryDisplay().bounds; + anchor = { x: nums[0], y: primary.y + primary.height - nums[1] }; + } + } + function placeNotch() { + const w = windows.get("notch"); if (!w || w.isDestroyed() || notch.dragging) return; + const display = anchor ? screen.getDisplayNearestPoint(anchor) : screen.getPrimaryDisplay(); + const point = anchor ?? { x: display.bounds.x + display.bounds.width - 4, y: Math.max(display.workArea.y, display.bounds.y + 25) }; + notch.cardOnRight = cardOnRight(point.x, display.workArea); + w.setBounds(notchFrame(point, { width: stripWidth + (notch.expanded ? cardWidth : 0), height: notchHeight }, notch.cardOnRight, display.workArea), false); + } + function syncNotch() { + if (prefs.values.sideNotchPanelEnabled) { create("notch"); placeNotch(); windows.get("notch")?.showInactive(); } + else { finishDrag(); stopHoverCheck(); folding.hovered = false; folding.cancel(); notch.expanded = false; windows.get("notch")?.hide(); } + } + function finishDrag() { + if (!drag) return; + clearInterval(drag.timer); clearTimeout(drag.timeout); drag = undefined; notch.dragging = false; + folding.hovered = pointerInNotch(); + notch.expanded = folding.hovered; folding.changed(); + if (folding.hovered) watchNotchHover(); + if (anchor) { + const primary = screen.getPrimaryDisplay().bounds; + void prefs.set("sideNotchPanelCorner", `${anchor.x},${primary.y + primary.height - anchor.y}`).catch(() => {}); + } + placeNotch(); publish(); + } + function startDrag() { + if (drag) return; + const w = windows.get("notch"); if (!w) return; + stopHoverCheck(); folding.hovered = false; folding.cancel(); notch.expanded = false; placeNotch(); + const bounds = w.getBounds(); anchor = { x: bounds.x + stripWidth, y: bounds.y }; + notch.dragging = true; + const origin = { ...anchor }, cursor = screen.getCursorScreenPoint(); + drag = { cursor, anchor: origin, timer: setInterval(() => { + const at = screen.getCursorScreenPoint(); anchor = { x: Math.round(origin.x + at.x - cursor.x), y: Math.round(origin.y + at.y - cursor.y) }; + w.setPosition(anchor.x - stripWidth, anchor.y, false); + }, 16), timeout: setTimeout(finishDrag, 60000) }; + publish(); + } + async function handle(r: Request, source: BrowserWindow, surface: Surface): Promise { + if (surface === "tray" && !["state", "resize"].includes(r.action) || surface === "share" && !["state", "shareResize"].includes(r.action)) throw new Error("Invalid request"); + switch (r.action) { + case "state": return { ok: true, state: state(surface) }; + case "refresh": await coordinator.refresh(); break; + case "apiBillingFetch": + case "apiBillingFetchSaved": + if (surface !== "settings") throw new Error(apiBillingFetchErrors.settings); + await apiBillingFetch.run(r.provider, r.action === "apiBillingFetch" ? r.adminKey : undefined); break; + case "settings": showSettings(); break; + case "statusPage": + if (surface !== "flyout") throw new Error("Invalid request"); + await shell.openExternal(statusPages[r.provider]); break; + case "claudeUsagePage": + if (surface !== "settings" || launch.demo || launch.candidate || launch.profile || launch.codexTest || launch.claudeTest) throw new Error("Invalid request"); + await shell.openExternal("https://claude.ai/settings/usage"); break; + 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 === "grok") { + const consent = await dialog.showMessageBox(source, { type: "question", title: "Connect Grok Bot Desktop (Beta)?", message: "Allow MeterUsage to read Grok Bot Desktop allowance?", detail: "Sign in through Grok Bot Desktop first. MeterUsage reads its fixed Grok Bot Safe Storage Keychain item and active encrypted access token and team in memory, then requests allowance from api2.cursor.sh. It does not start a separate xAI sign-in, refresh tokens or change Bot files. This Beta compatibility interface and shared-pool coverage are unverified. When access expires, sign in through Bot and reconnect.", buttons: ["Cancel", "Allow access"], defaultId: 0, cancelId: 0, noLink: true }); + return consent.response === 1; + } + 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 use Claude Desktop's session cookie, decrypted through its fixed macOS Keychain item, to read your account identity, organization membership and allowance from claude.ai. It will not read chats, use refresh tokens, save credentials or write Claude Desktop files. Previous access-token consent requires this new approval. 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": case "connectionLocal": + if (surface !== "settings" && surface !== "flyout") throw new Error("Invalid request"); + if (r.action === "connectionLocal") await connections.useLocal(r.provider); + else await connections.disconnect(r.provider); + await coordinator.refresh(); break; + case "sendReport": + if (surface !== "settings" || launch.demo) throw new Error("Reporting is only available in live Settings."); + await issueReports.submit(); break; + case "copyDiagnostics": clipboard.writeText(coordinator.diagnostics(app.getVersion())); break; + case "copyJSON": clipboard.writeText(coordinator.json()); break; + case "providerOrder": + if (surface !== "settings") throw new Error("Invalid request"); + await prefs.set("providerOrder", r.order); + coordinator.publish(); break; + case "setPreference": + if (r.key === "launchAtLogin") { + 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"); + } + await prefs.set(r.key, r.value); + if (r.key === "showProviderMuse") { + coordinator.forgetQuota("muse", true); + coordinator.sources = [...coordinator.sources.filter(s => s.slot.provider !== "muse"), ...sources().filter(s => s.slot.provider === "muse")]; + } + if (r.key === "onboardingCompleted" && r.value === true && surface === "settings" && !quitting && !source.isDestroyed()) { source.hide(); showFlyout(); } + if (r.key === "panelSize") sizeFlyout(); + if (r.key === "refreshIntervalSeconds") coordinator.restartTimer(); + nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light"; + if (r.key === "updateCheckEnabled") { if (r.value) void updater.checkIfDue(); else updater.reset(); } + syncNotch(); coordinator.publish(); if (r.key.startsWith("showProvider")) await coordinator.refresh(); break; + case "accountAdd": { + if (surface !== "settings") throw new Error("Open Settings to add an account"); + 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 = sources(); await coordinator.refresh(); break; + } + case "accountPath": { + const account = prefs.accounts.find(a => a.id === r.id); + if (surface !== "settings" || !account) throw new Error("Unknown account"); + 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 = 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 = sources(); await coordinator.refresh(); break; + } + case "reset": { + if (confirmingReset) throw new Error("Reset confirmation already open"); + const intent = coordinator.prepareReset(r.key, r.creditID); + confirmingReset = true; folding.cancel(); + try { + const confirmation = await dialog.showMessageBox(source, { type: "warning", title: "Use Codex reset?", message: `Use ${intent.title} for ${intent.account}?`, detail: "This uses one earned reset credit and cannot be undone.", buttons: ["Cancel", "Use reset"], defaultId: 0, cancelId: 0, noLink: true }); + if (confirmation.response === 1) await coordinator.confirmReset(intent.token); else coordinator.cancelReset(intent.token); + } finally { confirmingReset = false; coordinator.cancelReset(intent.token); folding.changed(); } + break; + } + case "notchSelect": + if (notch.dragging) break; + if (!coordinator.snapshot().notchSlots.some(s => slotKey(s) === r.key)) throw new Error("Unknown account"); + notch.selected = r.key; notch.expanded = true; placeNotch(); watchNotchHover(); break; + case "notchHover": + if (surface !== "notch" || notch.dragging) break; + folding.hovered = r.hovering; + if (r.hovering) { notch.expanded = true; placeNotch(); watchNotchHover(); } + else stopHoverCheck(); + folding.changed(); + break; + case "notchContext": + if (surface !== "notch") throw new Error("Invalid request"); + Menu.buildFromTemplate([{ 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.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") { flyoutHeight = r.height / panelScale(); sizeFlyout(); } + else if (surface === "settings") { settingsHeight = r.height; sizeSettings(); } + break; + case "shareResize": + if (surface !== "share" || !shareReady) throw new Error("Invalid request"); + try { const size = minimumShareSize(shareCardWidth, r.height, 2); shareHeight = size.height; source.setSize(size.width, size.height, false); } + catch (e) { shareFailed?.(new Error(e instanceof Error && e.message === "Card too tall to share" ? e.message : "Could not share snapshot")); } + break; + case "dragStart": if (surface === "notch") startDrag(); break; + case "dragEnd": if (surface === "notch") finishDrag(); break; + case "share": { + if (surface !== "notch" || !notch.expanded || sharing) throw new Error("Open a provider card to share"); + sharing = true; + try { + // Render the same card at 2x zoom in an isolated hidden window. + // Upsampling a 1x screen capture would not preserve sharp text. + shareState = shareSnapshot(state(), r.key); shareHeight = 0; + const image = await new Promise((resolve, reject) => { + const timer = setTimeout(() => shareFailed?.(new Error("Share render timed out")), 5000); + shareReady = image => { clearTimeout(timer); shareReady = undefined; shareFailed = undefined; resolve(image); }; + shareFailed = error => { clearTimeout(timer); shareReady = undefined; shareFailed = undefined; reject(error); }; + create("share"); + }); + windows.get("share")?.close(); shareState = undefined; + shareDirectory = mkdtempSync(join(launch.data, "share-")); const path = join(shareDirectory, "MeterUsage.png"); writeFileSync(path, image.toPNG(), { mode: 0o600 }); + sharedDirectories.add(shareDirectory); + // ShareMenu reports menu dismissal, not completion of the service. + // Keep its file available until this app session ends. + shareMenu = new ShareMenu({ filePaths: [path] }); shareMenu.popup({ window: source, callback: () => { sharing = false; folding.changed(); shareMenu = undefined; shareDirectory = undefined; } }); + } catch (e) { shareFailed?.(new Error("Could not share snapshot")); windows.get("share")?.close(); shareState = undefined; shareReady = undefined; sharing = false; folding.changed(); if (shareDirectory) rmSync(shareDirectory, { recursive: true, force: true }); shareDirectory = undefined; throw new Error(e instanceof Error && e.message === "Card too tall to share" ? e.message : "Could not share snapshot"); } + break; + } + case "updateDismiss": await updater.dismiss(); break; + case "updateInstall": { + 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(); } + break; + } + } + publish(); return { ok: true }; + } + ipcMain.handle(channel, async (event, raw: unknown): Promise => { + if (!trustedSender(event, documents)) return { ok: false, error: "Untrusted renderer" }; + 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 && [...Object.values(apiBillingFetchErrors), "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 access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.", ...Object.values(desktopReadErrors), ...Object.values(grokBotConnectionErrors), "Could not read Claude account allowance. Try connecting again later.", "Could not verify the same Claude Desktop account after the allowance request. Try connecting again.", "Could not save the Claude Desktop connection preferences. Try connecting again.", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Could not disconnect Claude Desktop", "Open one Antigravity Desktop account and sign in, then try connecting again", "Could not disconnect Antigravity Desktop", "Could not select Grok CLI", "Could not disconnect Grok Bot Desktop", "Connections are disabled in demo mode", "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."].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(); sizeFlyout(); sizeSettings(); publish(); }); screen.on("display-removed", () => { placeNotch(); sizeFlyout(); sizeSettings(); publish(); }); + powerMonitor.on("resume", () => { void coordinator.refresh(); }); + app.on("window-all-closed", () => {}); + const clockTimer = setInterval(publish, 60000); + nativeTheme.on("updated", publish); + let quitting = false, quitReady = false; + app.on("before-quit", event => { + if (quitReady) return; + event.preventDefault(); + if (quitting) return; + quitting = true; + apiBillingFetch.stop(); + unsubscribe(); + coordinator.stop(); updater.reset(); stopHoverCheck(); 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(); + if (prefs.values.onboardingCompleted === true) create("flyout"); + // 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)) showUsage(); + if (!quitting) return () => { if (!quitting) showUsage(); }; +} diff --git a/src/main/history.ts b/src/main/history.ts new file mode 100644 index 0000000..a8110c1 --- /dev/null +++ b/src/main/history.ts @@ -0,0 +1,121 @@ +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { type Daily, type Quota, type Slot, type Tokens, totalTokens, slotKey, quota, window, providers } from "../domain/models"; + +export function atomicJSON(path: string, value: unknown) { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.${randomUUID()}.tmp`; + writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 }); + renameSync(tmp, path); +} +interface StoredDaily { dayISO: string; tokens: Tokens; estimatedCostUSD: number; sessionCount: number; peakUsedPercent?: number } +function storedRecord({ dayISO, tokens: { input, output, reasoning, cacheRead, cacheWrite }, estimatedCostUSD, sessionCount, peakUsedPercent }: StoredDaily): StoredDaily { + return { dayISO, tokens: { input, output, reasoning, cacheRead, cacheWrite }, estimatedCostUSD, sessionCount, ...(peakUsedPercent === undefined ? {} : { peakUsedPercent }) }; +} +function validRecord(v: unknown): v is StoredDaily { + if (!v || typeof v !== "object") return false; + const r = v as StoredDaily; + return typeof r.dayISO === "string" && /^\d{4}-\d{2}-\d{2}$/.test(r.dayISO) && Number.isFinite(Date.parse(r.dayISO)) && r.tokens != null && ["input", "output", "reasoning", "cacheRead", "cacheWrite"].every(k => Number.isFinite(r.tokens[k as keyof Tokens])) && Number.isFinite(r.estimatedCostUSD) && Number.isFinite(r.sessionCount) && (r.peakUsedPercent === undefined || Number.isFinite(r.peakUsedPercent)); +} +export class HistoryStore { + private data: Record = Object.create(null); + error?: "loadFailed" | "writeFailed"; + constructor(readonly path: string) { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !Object.values(parsed).every(v => Array.isArray(v) && v.every(validRecord))) throw new Error("Invalid history"); + this.data = Object.assign(Object.create(null), Object.fromEntries(Object.entries(parsed as Record).map(([key, list]) => [key, list.map(storedRecord)]))); + } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") this.error = "loadFailed"; } + } + records(key: string): Daily[] { + return (this.data[key] ?? []).map(r => ({ day: Date.parse(r.dayISO), tokens: r.tokens, estimatedCostUSD: r.estimatedCostUSD, sessionCount: r.sessionCount })); + } + record(key: string, daily: Daily[], peakUsedPercent?: number) { + if (!daily.length) return; + const byDay = new Map((this.data[key] ?? []).map(r => [r.dayISO, r])); + for (const d of daily) { + const dayISO = new Date(d.day).toISOString().slice(0, 10), prior = byDay.get(dayISO); + if (!prior || totalTokens(prior.tokens) <= totalTokens(d.tokens)) byDay.set(dayISO, storedRecord({ dayISO, tokens: d.tokens, estimatedCostUSD: d.estimatedCostUSD, sessionCount: d.sessionCount, peakUsedPercent })); + } + this.data[key] = [...byDay.values()].sort((a, b) => a.dayISO.localeCompare(b.dayISO)); + if (this.error === "loadFailed") return; + try { atomicJSON(this.path, this.data); this.error = undefined; } catch { this.error = "writeFailed"; } + } +} +const swiftEpoch = Date.UTC(2001, 0, 1); +const fromDate = (v: unknown) => v == null ? undefined : typeof v === "number" && Number.isFinite(v) && Math.abs(swiftEpoch + v * 1000) <= 8640000000000000 ? swiftEpoch + v * 1000 : (() => { throw new Error("Invalid archive date"); })(); +const toDate = (v: number | undefined) => v === undefined ? undefined : (v - swiftEpoch) / 1000; +function archiveObject(raw: unknown): Record { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid archive object"); + return raw as Record; +} +function archiveString(raw: unknown): string | undefined { + if (raw == null) return; + if (typeof raw !== "string") throw new Error("Invalid archive string"); + return raw; +} +function archiveNumber(raw: unknown): number | undefined { + if (raw == null) return; + if (typeof raw !== "number" || !Number.isFinite(raw)) throw new Error("Invalid archive number"); + return raw; +} +function archiveInteger(raw: unknown): number | undefined { + const value = archiveNumber(raw); + if (value !== undefined && !Number.isSafeInteger(value)) throw new Error("Invalid archive integer"); + return value; +} +function decodeWindow(value: unknown): import("../domain/models").QuotaWindow { + const raw = archiveObject(value); + if (typeof raw.label !== "string") throw new Error("Invalid archive window"); + const used = archiveNumber(raw.usedPercent); + if (used === undefined) throw new Error("Invalid archive window"); + const w = window(raw.label, used, fromDate(raw.resetsAt), archiveInteger(raw.windowDurationMins)); + if (raw.pacingBaseline != null) { + const b = archiveObject(raw.pacingBaseline), usedPercent = archiveNumber(b.usedPercent); + if (usedPercent === undefined) throw new Error("Invalid archive baseline"); + w.pacingBaseline = { usedPercent, capturedAt: fromDate(b.capturedAt), observedAt: fromDate(b.observedAt) }; + } + return w; +} +function encodeWindow(w: import("../domain/models").QuotaWindow) { + return { ...w, resetsAt: toDate(w.resetsAt), pacingBaseline: w.pacingBaseline && { ...w.pacingBaseline, capturedAt: toDate(w.pacingBaseline.capturedAt), observedAt: toDate(w.pacingBaseline.observedAt) } }; +} +export function readArchive(path: string): Record { + try { + const entries = JSON.parse(readFileSync(path, "utf8")); + if (!Array.isArray(entries)) return {}; + const result: Record = Object.create(null); + for (const entry of entries) { + const e = archiveObject(entry), provider = providers.find(p => p === e.provider); + if (!provider || typeof e.capturedAt !== "number" || !Number.isFinite(e.capturedAt) || !Array.isArray(e.windows)) return {}; + const slot: Slot = { provider, slotID: archiveString(e.slotID) ?? "", label: archiveString(e.label) ?? "" }; + const q = quota(provider, e.windows.map(decodeWindow), fromDate(e.capturedAt)); + if (e.groups != null) { + if (!Array.isArray(e.groups)) throw new Error("Invalid archive groups"); + q.groups = e.groups.map(value => { const g = archiveObject(value); if (typeof g.id !== "string" || typeof g.title !== "string" || !Array.isArray(g.windows)) throw new Error("Invalid archive group"); return { id: g.id, title: g.title, windows: g.windows.map(decodeWindow) }; }); + } + if (e.credits != null) { + const c = archiveObject(e.credits), units = archiveObject(c.unit), cases = Object.keys(units); + const unit = cases.length === 1 && ["credits", "dollars"].includes(cases[0]) ? cases[0] : undefined; + const balance = archiveNumber(c.balance); + if (!unit || balance === undefined || typeof c.hasCredits !== "boolean" || typeof c.unlimited !== "boolean") throw new Error("Invalid archive credit"); + archiveObject(units[unit]); + q.credits = { balance, unit: unit as "credits" | "dollars", hasCredits: c.hasCredits, unlimited: c.unlimited, usedDollars: archiveNumber(c.usedDollars), limitDollars: archiveNumber(c.limitDollars), dollarBalance: archiveNumber(c.dollarBalance) }; + } + q.resetCreditCount = archiveInteger(e.resetCreditCount); + q.planType = archiveString(e.planType); q.resetPacingSince = fromDate(e.resetPacingSince); + if (e.resetCredits != null) { + if (!Array.isArray(e.resetCredits)) throw new Error("Invalid archive resets"); + q.resetCredits = e.resetCredits.map(value => { const c = archiveObject(value); if (typeof c.id !== "string" || typeof c.title !== "string") throw new Error("Invalid archive reset"); return { id: c.id, title: c.title, status: archiveString(c.status), expiresAt: fromDate(c.expiresAt) }; }); + } + result[slotKey(slot)] = q; + } + return result; + } catch { return {}; } +} +export function saveArchive(path: string, readings: { slot: Slot; quota: Quota }[]) { + // Legacy Swift/Electron readers reject the entire file for an unknown provider. + // Muse allowance stays in memory so rollback retains every other quota. + atomicJSON(path, readings.filter(({ slot }) => slot.provider !== "muse").map(({ slot: s, quota: q }) => ({ provider: s.provider, slotID: s.slotID || undefined, label: s.label || undefined, capturedAt: toDate(q.capturedAt), windows: q.windows.map(encodeWindow), groups: q.groups.length ? q.groups.map(g => ({ ...g, windows: g.windows.map(encodeWindow) })) : undefined, credits: q.credits && { ...q.credits, unit: { [q.credits.unit]: {} } }, resetCreditCount: q.resetCreditCount, resetCredits: q.resetCredits.length ? q.resetCredits.map(c => ({ ...c, expiresAt: toDate(c.expiresAt) })) : undefined, planType: q.planType, resetPacingSince: toDate(q.resetPacingSince) }))); +} diff --git a/src/main/ipc.ts b/src/main/ipc.ts new file mode 100644 index 0000000..40698c0 --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,62 @@ +import { basename, relative } from "node:path"; +import type { ManagedAccount } from "../domain/models"; +import type { PreferenceValue } from "./preferences"; +import type { Surface, SettingsState, ViewState } from "../shared/ipc"; +import { notchEntries } from "../domain/notch"; +import { normalizeProviderOrder, validProviderOrder } from "../domain/provider-order"; +import { providers } from "../domain/models"; +import { editableBooleans, providerKey, trayKey, statusPages, panelScales, type Request } from "../shared/ipc"; +const empty = new Set(["state", "refresh", "settings", "close", "quit", "clearCache", "copyDiagnostics", "sendReport", "claudeUsagePage", "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)))); +const string = (v: unknown, max: number) => typeof v === "string" && v.length <= max && !/[\r\n\0]/.test(v); +const boolKeys = new Set([...editableBooleans, ...providers.flatMap(p => [providerKey(p), trayKey(p)])]); +export function parseRequest(raw: unknown): Request { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid request"); + const r = raw as Record, action = r.action; + let fields: string[] = [], valid = false; + if (typeof action === "string" && empty.has(action)) valid = true; + 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 === "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 "providerOrder": fields = ["order"]; valid = validProviderOrder(r.order); break; + case "accountAdd": fields = ["provider"]; valid = r.provider === "codex" || r.provider === "claude"; break; + case "apiBillingFetch": fields = ["provider", "adminKey"]; valid = Object.keys(r).length === 3 && (r.provider === "openai" || r.provider === "anthropic") && typeof r.adminKey === "string" && r.adminKey.length >= 1 && r.adminKey.length <= 8192 && !/[\s\x00-\x1f\x7f]/.test(r.adminKey); break; + case "apiBillingFetchSaved": fields = ["provider"]; valid = r.provider === "openai" || r.provider === "anthropic"; break; + case "connect": case "disconnect": case "connectionCancel": case "connectionLocal": fields = ["provider"]; valid = typeof r.provider === "string" && ["codex", "claude", "antigravity", "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; + case "reset": fields = ["key", "creditID"]; valid = key(r.key) && string(r.creditID, 200) && r.creditID !== ""; break; + 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 && (!("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; +} +// A frame URL alone is insufficient: only registered app windows, their main +// frame and the exact loaded document may invoke the bridge. +export function trustedSender(event: { sender: { id: number; mainFrame: unknown }; senderFrame?: { url: string } | null }, documents: ReadonlyMap) { + return event.senderFrame === event.sender.mainFrame && documents.get(event.sender.id) === event.senderFrame?.url; +} + +export function shareSnapshot(view: ViewState, displayedKey: string): ViewState { + if (!notchEntries(view.snapshot).some(e => e.key === displayedKey)) throw new Error("Card unavailable"); + const frozen = structuredClone(view); frozen.notch.selected = displayedKey; + return frozen; +} + +export function projectSettings(values: Record, accounts: ManagedAccount[], home: string, surface?: Surface): SettingsState { + return { + providerOrder: normalizeProviderOrder(values.providerOrder), + 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/issue-reports.ts b/src/main/issue-reports.ts new file mode 100644 index 0000000..6af11a1 --- /dev/null +++ b/src/main/issue-reports.ts @@ -0,0 +1,70 @@ +import { randomUUID } from "node:crypto"; +import type { IssueReportState } from "../shared/ipc"; +import type { Preferences } from "./preferences"; + +const endpoint = "https://meterusage-reports.fancy-queen-3301.workers.dev/report"; +const failures = { + tooLarge: "This report is too large to send. Copy diagnostics to keep the full report.", + rateLimited: "Too many reports. Try again later or copy diagnostics.", + storage: "Could not save the report cooldown. Nothing was sent. Copy diagnostics instead.", + unconfirmed: "Delivery could not be confirmed. Try again or copy diagnostics.", +}; +class ReportFailure extends Error { + constructor(code: keyof typeof failures) { super(failures[code]); } +} +interface Report { schema: 1; id: string; diagnostics: string } + +export async function sendIssueReport(report: Report, fetcher: typeof fetch = fetch): Promise { + const body = JSON.stringify(report); + if (!report.diagnostics.length || Buffer.byteLength(report.diagnostics) > 49152 || Buffer.byteLength(body) > 65536) throw new ReportFailure("tooLarge"); + const response = await fetcher(endpoint, { + method: "POST", redirect: "error", credentials: "omit", cache: "no-store", + signal: AbortSignal.timeout(20000), headers: { "Content-Type": "application/json", Accept: "application/json" }, body, + }); + if (response.status !== 201 || !response.body) { + await response.body?.cancel(); + throw new ReportFailure(response.status === 429 ? "rateLimited" : "unconfirmed"); + } + const reader = response.body.getReader(), chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 1024) throw new ReportFailure("unconfirmed"); + chunks.push(value); + } + const receipt = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks))); + if (typeof receipt?.id !== "string" || receipt.id.toLowerCase() !== report.id.toLowerCase() || typeof receipt.identifier !== "string" || !/^[A-Z][A-Z0-9]{0,15}-[0-9]{1,12}$/.test(receipt.identifier)) throw new ReportFailure("unconfirmed"); + return receipt.identifier; + } finally { + await reader.cancel().catch(() => {}); + reader.releaseLock(); + } +} + +// Keep one snapshot and UUID across retries and Settings navigation. +export class IssueReports { + private current: IssueReportState = { sending: false }; + private pending?: Report; + constructor(private diagnostics: () => string, private changed: () => void, private prefs: Pick, private send = sendIssueReport, private now = Date.now) {} + get state(): IssueReportState { + const retryAt = Number(this.prefs.values.reportNextAllowedAt ?? 0); + return { ...this.current, ...(retryAt > this.now() ? { retryAt } : {}) }; + } + async submit() { + if (this.current.sending || this.current.receipt || this.state.retryAt) return; + this.current = { sending: true }; this.changed(); + try { + this.pending ??= { schema: 1, id: randomUUID(), diagnostics: this.diagnostics() }; + // Reserve before HTTP so failures, crashes and relaunches cannot reset the cooldown. + try { await this.prefs.set("reportNextAllowedAt", this.now() + 10 * 60 * 1000); } + catch { throw new ReportFailure("storage"); } + const receipt = await this.send(this.pending); + this.current = { sending: false, receipt }; this.pending = undefined; + } catch (error) { + this.current = { sending: false, error: error instanceof ReportFailure ? error.message : failures.unconfirmed }; + } finally { this.changed(); } + } +} diff --git a/src/main/launch.ts b/src/main/launch.ts new file mode 100644 index 0000000..e5ed44e --- /dev/null +++ b/src/main/launch.ts @@ -0,0 +1,71 @@ +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; claudeTest?: boolean } +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"), claudeIndex = args.indexOf("--claude-test-profile"), codexTest = codexIndex >= 0, claudeTest = claudeIndex >= 0; + const liveTest = codexTest || claudeTest; + if (liveTest && (demo || candidateIndex >= 0) || codexTest && claudeTest) throw new Error("Provider test profile flags conflict with demo/candidate or another provider test mode"); + const index = codexTest ? codexIndex : claudeTest ? claudeIndex : candidateIndex, profile = index < 0 ? undefined : args[index + 1]; + if (index >= 0 && ((!liveTest && !demo) || !profile || !isAbsolute(profile))) throw new Error("Candidate profile requires an absolute test directory and --demo unless using --codex-test-profile or --claude-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"); + 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" : claudeTest ? ".meterusage-claude-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"); + // 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()) { + 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, liveTest ? `MeterUsage ${codexTest ? "Codex" : "Claude"} 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 = liveTest ? resolve(profile!) : demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined; + return { demo, candidate: index >= 0, ...(codexTest ? { codexTest: true } : {}), ...(claudeTest ? { claudeTest: true } : {}), home: demo ? isolated! : realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: liveTest ? { 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/native-http.ts b/src/main/native-http.ts new file mode 100644 index 0000000..d02e4fb --- /dev/null +++ b/src/main/native-http.ts @@ -0,0 +1,14 @@ +import type { session } from "electron"; +import { randomUUID } from "node:crypto"; +import type { Launch } from "./launch"; +import { httpTransport, isClaudeEndpoint, type HTTP } from "./providers/transport"; + +export function desktopHTTP(launch: Launch, sessions: Pick): HTTP { + if (launch.demo || launch.codexTest) return httpTransport(); + // A unique nonpersistent session avoids the renderer's HTTPS block and ambient credentials. + const isolated = sessions.fromPartition(`meterusage-claude-${randomUUID()}`, { cache: false }); + if (isolated.isPersistent() || isolated.storagePath !== null) throw new Error("Claude HTTP session is not isolated"); + return httpTransport((url, options) => isClaudeEndpoint(String(url)) + ? isolated.fetch(String(url), { ...options, credentials: "omit" }) + : fetch(url, options)); +} diff --git a/src/main/notch-fold.ts b/src/main/notch-fold.ts new file mode 100644 index 0000000..1f33ac7 --- /dev/null +++ b/src/main/notch-fold.ts @@ -0,0 +1,13 @@ +export class NotchFold { + hovered = false; + private timer?: ReturnType; + constructor(readonly blocked: () => boolean, readonly fold: () => void) {} + changed() { + this.cancel(); + if (!this.hovered && !this.blocked()) this.timer = setTimeout(() => { + this.timer = undefined; + if (!this.hovered && !this.blocked()) this.fold(); + }, 450); + } + cancel() { if (this.timer) clearTimeout(this.timer); this.timer = undefined; } +} diff --git a/src/main/preferences.ts b/src/main/preferences.ts new file mode 100644 index 0000000..b56caaa --- /dev/null +++ b/src/main/preferences.ts @@ -0,0 +1,123 @@ +import { validClaudeIdentity } from "./providers/claude-desktop"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { providers, type Provider, type ManagedAccount } from "../domain/models"; +import { normalizeProviderOrder, validProviderOrder } from "../domain/provider-order"; +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 readDefaultsData = `ObjC.import("Foundation"); +function run(args) { + const value = $.NSUserDefaults.alloc.initWithSuiteName(args[0]).persistentDomainForName(args[0]).objectForKey(args[1]); + return ObjC.unwrap(value.base64EncodedStringWithOptions(0)); +}`; +const suffix = (p: Provider) => p[0].toUpperCase() + p.slice(1); +export const providerPreference = (p: Provider) => `showProvider${suffix(p)}`; +export const trayPreference = (p: Provider) => `menuBarProvider${suffix(p)}`; +export type PreferenceValue = boolean | number | string | ManagedAccount[] | Provider[]; +type Kind = "boolean" | "number" | "string" | "data" | "date"; +const kinds: Record = { + refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", panelSize: "string", managedAccounts: "data", providerOrder: "data", + sideNotchPanelCorner: "string", updateLastCheckDate: "date", reportNextAllowedAt: "date", updateDismissedVersion: "string", updateAnnouncedVersion: "string", + desktopCodexConnection: "string", desktopCodexCleanup: "string", desktopClaudeIdentity: "string", desktopAntigravityIdentity: "string", desktopGrokIdentity: "string", desktopGrokSource: "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", panelSize: PanelSize.Medium, managedAccounts: [], providerOrder: [...providers], 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, + updateDismissedVersion: "", updateAnnouncedVersion: "", + desktopCodexConnection: "", desktopCodexCleanup: "", desktopClaudeIdentity: "", desktopAntigravityIdentity: "", desktopGrokIdentity: "", desktopGrokSource: "cursor", + ...Object.fromEntries(providers.flatMap(p => [[providerPreference(p), ["codex", "openCodeGo", "openRouter"].includes(p)], [trayPreference(p), true]])), +}; +const codexTestPreferences: Record = { + launchAtLogin: false, updateCheckEnabled: false, desktopClaudeIdentity: "off", desktopGrokIdentity: "off", + ...Object.fromEntries(providers.filter(p => p !== "codex").map(p => [providerPreference(p), false])), +}; +const claudeTestPreferences: Record = { + launchAtLogin: false, updateCheckEnabled: false, desktopCodexConnection: "off", desktopCodexCleanup: "", desktopGrokIdentity: "off", + ...Object.fromEntries(providers.filter(p => p !== "claude").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; +} +function validate(key: string, v: unknown): v is PreferenceValue { + const kind = kinds[key]; if (!kind) return false; + if (key === "providerOrder") return validProviderOrder(v); + if (kind === "data") return validAccounts(v); + if (kind === "date" || kind === "number") return typeof v === "number" && Number.isFinite(v); + if (kind === "boolean") return typeof v === "boolean"; + if (typeof v !== "string" || v.length > 100 || /[\r\n\0]/.test(v)) return false; + if (key === "desktopGrokSource") return v === "cursor" || v === "bot"; + 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" || validClaudeIdentity(v) || /^[a-f\d]{64}$/.test(v); + if (key === "desktopAntigravityIdentity" || key === "desktopGrokIdentity") return v === "" || v === "off" || /^[a-f\d]{64}$/.test(v); + return true; +} +export class Preferences { + values: Record = structuredClone(initialPreferences); + private writes: Promise = Promise.resolve(); + 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.codexTest) prefs.values.desktopCodexConnection = "off"; + if (launch.claudeTest) { prefs.values.desktopClaudeIdentity = "off"; prefs.values.showProviderClaude = true; } + if (launch.demo || launch.codexTest || launch.claudeTest) { + 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 (key === "providerOrder") prefs.values[key] = normalizeProviderOrder(v); else if (validate(key, v)) prefs.values[key] = v; } catch { /* Keep known defaults for a damaged candidate file. */ } } + 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 { + const type = await command("/usr/bin/defaults", ["read-type", domain, key], { timeoutMs: 5000 }); + const raw = (await command("/usr/bin/defaults", ["read", domain, key], { timeoutMs: 5000 })).trim(); + const kind = kinds[key]; let value: unknown; + if (kind === "data" && /data/i.test(type)) { + // NSData descriptions can omit bytes; read the exact domain's data instead. + const data = /^<[a-f\d\s]*>$/i.test(raw) ? Buffer.from(raw.slice(1, -1).replace(/\s/g, ""), "hex") : Buffer.from(await command("/usr/bin/osascript", ["-l", "JavaScript", "-e", readDefaultsData, domain, key], { timeoutMs: 5000 }), "base64"); + value = JSON.parse(data.toString("utf8")); + } + else if (kind === "boolean" && /boolean|integer/i.test(type)) value = raw === "1" || raw === "true" || raw === "YES"; + else if (kind === "number" && /integer|float|double/i.test(type)) value = Number(raw); + else if (kind === "date" && /date/i.test(type)) value = Date.parse(raw); + else if (kind === "string" && /string/i.test(type)) value = raw; + if (key === "providerOrder") prefs.values[key] = normalizeProviderOrder(value); else if (validate(key, value)) prefs.values[key] = value; + } catch { /* An absent or mismatched key retains its registered default. */ } + })); + } + if (launch.codexTest) Object.assign(prefs.values, codexTestPreferences); + if (launch.claudeTest) Object.assign(prefs.values, claudeTestPreferences); + 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 providerOrder() { return normalizeProviderOrder(this.values.providerOrder); } + get accounts() { return this.values.managedAccounts as ManagedAccount[]; } + enabled(p: Provider) { return (!this.launch.codexTest || p === "codex") && (!this.launch.claudeTest || p === "claude") && this.values[providerPreference(p)] === true; } + private enqueue(write: () => Promise) { + const result = this.writes.then(write); this.writes = result.catch(() => {}); return result; + } + set(key: string, value: unknown) { return this.enqueue(() => this.write(key, value)); } + updateAccounts(update: (accounts: ManagedAccount[]) => ManagedAccount[]) { + return this.enqueue(() => this.write("managedAccounts", update(structuredClone(this.accounts)))); + } + 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 (this.launch.claudeTest && key in claudeTestPreferences && value !== claudeTestPreferences[key]) throw new Error("Preference unavailable in Claude test mode"); + if (key === "refreshIntervalSeconds") value = Math.max(30, (value as number) > 0 ? value as number : 60); + if (this.launch.demo || this.launch.codexTest || this.launch.claudeTest) { + 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]; + await this.command("/usr/bin/defaults", ["write", this.domain, key, ...typed], { timeoutMs: 5000 }); + } + this.values[key] = value as PreferenceValue; + } +} diff --git a/src/main/preload.ts b/src/main/preload.ts new file mode 100644 index 0000000..0539f7d --- /dev/null +++ b/src/main/preload.ts @@ -0,0 +1,10 @@ +import { contextBridge, ipcRenderer } from "electron"; +import { channel, stateChannel, type Request, type ViewState } from "../shared/ipc"; +contextBridge.exposeInMainWorld("meterusage", { + request: (request: Request) => ipcRenderer.invoke(channel, request), + subscribe: (observer: (state: ViewState) => void) => { + const listener = (_event: unknown, state: ViewState) => observer(state); + ipcRenderer.on(stateChannel, listener); + return () => ipcRenderer.removeListener(stateChannel, listener); + }, +}); diff --git a/src/main/providers/activity.ts b/src/main/providers/activity.ts new file mode 100644 index 0000000..fce5bb4 --- /dev/null +++ b/src/main/providers/activity.ts @@ -0,0 +1,166 @@ +import { createReadStream, readFileSync, statSync, existsSync } from "node:fs"; +import { readdir, open } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { basename, join } from "node:path"; +import { type Activity, type Session, type Daily, type Tokens, type Provider, tokens, totalTokens, addTokens, utcDay, Unavailable } from "../../domain/models"; +import { telemetry } from "../../domain/telemetry"; +import { estimate } from "../../domain/pricing"; +import { atomicJSON } from "../history"; +import { selectJSON } from "../select-json"; +import { object, number, date, displayText, text } from "./parsers"; + +export function opaqueID(raw: string): string { + let hash = 0xcbf29ce484222325n; + for (const b of Buffer.from(raw, "utf8")) hash = BigInt.asUintN(64, (hash ^ BigInt(b)) * 0x100000001b3n); + return hash.toString(16).padStart(16, "0"); +} +export const projectName = (raw: string, encoded = false) => displayText(encoded ? raw.split("-").filter(Boolean).at(-1) : basename(raw)) ?? "unknown"; +export async function filesIn(root: string, suffix: string, recursive = true, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + let entries; try { entries = await readdir(root, { withFileTypes: true }); } catch { signal?.throwIfAborted(); return []; } + signal?.throwIfAborted(); + const out: string[] = []; + for (const e of entries) { + if (e.name.startsWith(".")) continue; + signal?.throwIfAborted(); + if (e.isDirectory() && recursive) out.push(...await filesIn(join(root, e.name), suffix, true, signal)); + else if (e.isFile() && e.name.endsWith(suffix)) out.push(join(root, e.name)); + } + return out; +} +const usageShape = { input_tokens: true, output_tokens: true, cache_read_input_tokens: true, cache_creation_input_tokens: true } as const; +export const claudeLineSelection = { type: true, timestamp: true, message: { model: true, usage: usageShape } } as const; +interface ClaudeParsed { tokens: Tokens; totalCostUSD: number; messageCount: number; model?: string; earliestTimestamp?: number; dailyBuckets: { dayEpoch: number; bucket: { tokens: Tokens; costUSD: number } }[] } +export function parseClaudeLine(line: string): { tokens: Tokens; model: string; at?: number } | undefined { + if (!line.includes('"usage"') || !line.includes('"assistant"')) return; + try { + const r = object(selectJSON(line, claudeLineSelection)), m = object(r.message), u = object(m.usage); + if (r.type !== "assistant" || !m.usage) return; + return { tokens: tokens({ input: number(u.input_tokens) ?? 0, output: number(u.output_tokens) ?? 0, cacheRead: number(u.cache_read_input_tokens) ?? 0, cacheWrite: number(u.cache_creation_input_tokens) ?? 0 }), model: displayText(m.model) ?? "unknown", at: date(r.timestamp) }; + } catch { return; } +} +async function parseClaudeFile(path: string, signal?: AbortSignal): Promise { + const result: ClaudeParsed = { tokens: tokens(), totalCostUSD: 0, messageCount: 0, dailyBuckets: [] }, buckets = new Map(); + const stream = createReadStream(path, { signal }), lines = createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of lines) { + const parsed = parseClaudeLine(line); if (!parsed) continue; + const cost = estimate(parsed.model, parsed.tokens).costUSD; + result.tokens = addTokens(result.tokens, parsed.tokens); result.totalCostUSD += cost; result.messageCount++; result.model = parsed.model; + if (parsed.at !== undefined) { + result.earliestTimestamp = Math.min(result.earliestTimestamp ?? Infinity, parsed.at / 1000); + const day = utcDay(parsed.at) / 1000, b = buckets.get(day) ?? { tokens: tokens(), costUSD: 0 }; + buckets.set(day, { tokens: addTokens(b.tokens, parsed.tokens), costUSD: b.costUSD + cost }); + } + } + } finally { lines.close(); stream.destroy(); } + result.dailyBuckets = [...buckets].map(([dayEpoch, bucket]) => ({ dayEpoch, bucket })); return result; +} +function cacheParsed(v: unknown): ClaudeParsed | undefined { + const r = object(v), t = object(r.tokens); + if (![t.input, t.output, t.cacheRead, t.cacheWrite, r.totalCostUSD, r.messageCount].every(n => typeof n === "number" && Number.isFinite(n)) || !Array.isArray(r.dailyBuckets)) return; + if (r.earliestTimestamp != null && (typeof r.earliestTimestamp !== "number" || !Number.isFinite(new Date(r.earliestTimestamp * 1000).getTime()))) return; + const buckets: ClaudeParsed["dailyBuckets"] = []; + for (const raw of r.dailyBuckets) { + const e = object(raw), b = object(e.bucket), tok = object(b.tokens); + if (typeof e.dayEpoch !== "number" || !Number.isFinite(new Date(e.dayEpoch * 1000).getTime()) || typeof b.costUSD !== "number" || !Number.isFinite(b.costUSD) || ![tok.input, tok.output, tok.cacheRead, tok.cacheWrite].every(n => typeof n === "number" && Number.isFinite(n))) return; + buckets.push({ dayEpoch: e.dayEpoch, bucket: { tokens: tokens({ input: tok.input as number, output: tok.output as number, reasoning: number(tok.reasoning) ?? 0, cacheRead: tok.cacheRead as number, cacheWrite: tok.cacheWrite as number }), costUSD: b.costUSD } }); + } + return { tokens: tokens({ input: t.input as number, output: t.output as number, reasoning: number(t.reasoning) ?? 0, cacheRead: t.cacheRead as number, cacheWrite: t.cacheWrite as number }), totalCostUSD: r.totalCostUSD as number, messageCount: r.messageCount as number, model: displayText(r.model), earliestTimestamp: number(r.earliestTimestamp), dailyBuckets: buckets }; +} +export class ClaudeActivitySource { + private cache: Record = Object.create(null); + private loaded = false; + constructor(readonly root: string, readonly cacheFile?: string) {} + async scan(now = Date.now(), signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + if (!existsSync(this.root)) throw new Unavailable("noData"); + if (!this.loaded && this.cacheFile) { + try { for (const [key, v] of Object.entries(object(JSON.parse(readFileSync(this.cacheFile, "utf8"))))) { const e = object(v), result = cacheParsed(e.result); if (result && number(e.mtime) !== undefined && number(e.size) !== undefined && /^[a-f0-9]{16}$/.test(key)) this.cache[key] = { mtime: number(e.mtime)!, size: number(e.size)!, result }; } } catch { /* Scan cache is disposable; durable history is not. */ } + } + this.loaded = true; + const sessions: Session[] = [], daily = new Map(); let dirty = false; + for (const path of await filesIn(this.root, ".jsonl", true, signal)) { + const relative = path.slice(this.root.length + 1).split("/"); if (relative.length !== 2) continue; + try { + const stat = statSync(path), key = opaqueID(path), previous = this.cache[key]; + signal?.throwIfAborted(); + const parsed = previous?.mtime === stat.mtimeMs / 1000 && previous.size === stat.size ? previous.result : await parseClaudeFile(path, signal); + if (previous?.result !== parsed) { this.cache[key] = { mtime: stat.mtimeMs / 1000, size: stat.size, result: parsed }; dirty = true; } + if (!parsed.messageCount) continue; + sessions.push({ id: opaqueID(basename(path, ".jsonl")), projectName: projectName(relative[0], true), model: parsed.model ?? "unknown", tokens: parsed.tokens, estimatedCostUSD: parsed.totalCostUSD, startedAt: parsed.earliestTimestamp === undefined ? Date.parse("0001-01-01T00:00:00Z") : parsed.earliestTimestamp * 1000, lastActivityAt: stat.mtimeMs, messageCount: parsed.messageCount }); + for (const { dayEpoch, bucket } of parsed.dailyBuckets) { + const d = dayEpoch * 1000, prior = daily.get(d) ?? { day: d, tokens: tokens(), estimatedCostUSD: 0, sessionCount: 0 }; + daily.set(d, { day: d, tokens: addTokens(prior.tokens, bucket.tokens), estimatedCostUSD: prior.estimatedCostUSD + bucket.costUSD, sessionCount: prior.sessionCount + 1 }); + } + } catch { signal?.throwIfAborted(); /* A failed session read does not discard its readable siblings. */ } + } + signal?.throwIfAborted(); + if (dirty && this.cacheFile) { try { atomicJSON(this.cacheFile, this.cache); } catch { /* A cache write failure does not hide live usage. */ } } + if (!sessions.length) throw new Unavailable("noData"); + return makeActivity("claude", sessions, [...daily.values()], now); + } +} +export const codexStartSelection = { timestamp: true, payload: { cwd: true, thread_source: true } } as const; +export const codexLedgerSelection = { type: true, payload: { type: true, info: { total_token_usage: { input_tokens: true, cached_input_tokens: true, output_tokens: true, reasoning_output_tokens: true } } } } as const; +export function parseCodexTokenLine(line: string): Tokens | undefined { + if (!line.includes('"total_token_usage"')) return; + try { + const r = object(selectJSON(line, codexLedgerSelection)), p = object(r.payload), u = object(object(p.info).total_token_usage); + if (r.type !== "event_msg" || p.type !== "token_count") return; + const input = number(u.input_tokens) ?? 0, output = number(u.output_tokens) ?? 0, cache = number(u.cached_input_tokens) ?? 0, reasoning = number(u.reasoning_output_tokens) ?? 0; + if (input <= 0 && output <= 0) return; + return tokens({ input: Math.max(0, input - cache), output: Math.max(0, output - reasoning), reasoning, cacheRead: cache }); + } catch { return; } +} +export function parseCodexModel(line: string): string | undefined { + if (!line.includes("turn_context")) return; + try { const r = object(selectJSON(line, { type: true, payload: { model: true } })); return r.type === "turn_context" ? displayText(object(r.payload).model) : undefined; } catch { return; } +} +async function codexLedger(path: string, signal?: AbortSignal) { + const file = await open(path, "r"); let ledger: Tokens | undefined, model: string | undefined; + try { + let remaining = (await file.stat()).size, spent = 0, modelSpent = 0, pending = Buffer.alloc(0); + while (remaining > 0 && spent < 16 * 1048576) { + signal?.throwIfAborted(); + const length = Math.min(1048576, remaining), offset = remaining - length, chunk = Buffer.alloc(length); + await file.read(chunk, 0, length, offset); remaining = offset; spent += length; + const buffer = Buffer.concat([chunk, pending]), newline = buffer.indexOf(10); + if (newline < 0) { pending = buffer; continue; } + pending = buffer.subarray(0, newline); + const lines = buffer.subarray(newline + 1).toString("utf8").split("\n").reverse(); + for (const line of lines) { + if (!ledger) { ledger = parseCodexTokenLine(line); continue; } + model = parseCodexModel(line); if (model) return { tokens: ledger, model }; + } + if (ledger) { modelSpent += length; if (modelSpent >= 4 * 1048576) break; } + } + return { tokens: ledger ?? tokens(), model: model ?? "codex" }; + } finally { await file.close(); } +} +export async function scanCodex(root: string, now = Date.now(), signal?: AbortSignal): Promise { + const paths = await filesIn(root, ".jsonl", true, signal); if (!paths.length) throw new Unavailable("noData"); + const sessions: Session[] = [], daily = new Map(); + for (const path of paths) { + signal?.throwIfAborted(); + let modified: number | undefined, first: Record = Object.create(null); + try { modified = statSync(path).mtimeMs; } catch { signal?.throwIfAborted(); } + try { + const file = await open(path, "r"); + try { const b = Buffer.alloc(65536); const { bytesRead } = await file.read(b, 0, b.length, 0); const lineEnd = b.subarray(0, bytesRead).indexOf(10); if (lineEnd >= 0) first = object(selectJSON(b.subarray(0, lineEnd).toString("utf8"), codexStartSelection)); } finally { await file.close(); } + } catch { signal?.throwIfAborted(); /* mtime is the legacy fallback. */ } + const start = date(first.timestamp) ?? modified; if (start === undefined) continue; + let ledger = { tokens: tokens(), model: "codex" }; + try { ledger = await codexLedger(path, signal); } catch { signal?.throwIfAborted(); /* Swift retains dated sessions without a readable token ledger. */ } + const payload = object(first.payload), cost = estimate(ledger.model, ledger.tokens).costUSD; + sessions.push({ id: opaqueID(path), projectName: text(payload.cwd) ? projectName(text(payload.cwd)!) : "", model: ledger.model, tokens: ledger.tokens, estimatedCostUSD: cost, startedAt: start, lastActivityAt: modified, messageCount: 0, isAutomation: payload.thread_source === "automation" }); + const day = utcDay(start), prior = daily.get(day) ?? { day, tokens: tokens(), estimatedCostUSD: 0, sessionCount: 0 }; + daily.set(day, { day, tokens: addTokens(prior.tokens, ledger.tokens), estimatedCostUSD: prior.estimatedCostUSD + cost, sessionCount: prior.sessionCount + 1 }); + } + signal?.throwIfAborted(); + if (!sessions.length) throw new Unavailable("noData"); + return makeActivity("codex", sessions, [...daily.values()], now); +} +function makeActivity(provider: Provider, sessions: Session[], daily: Daily[], now: number): Activity { + return { provider, sessions: sessions.sort((a, b) => a.startedAt - b.startedAt), daily: daily.sort((a, b) => a.day - b.day), scannedAt: now, telemetry: telemetry(sessions.map(s => ({ startedAt: s.startedAt, tokens: totalTokens(s.tokens), messageCount: s.messageCount })), daily, now) }; +} diff --git a/src/main/providers/antigravity-desktop.ts b/src/main/providers/antigravity-desktop.ts new file mode 100644 index 0000000..0cca122 --- /dev/null +++ b/src/main/providers/antigravity-desktop.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { userInfo } from "node:os"; +import { request as httpRequest, type RequestOptions, type ClientRequest, type IncomingMessage } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { Unavailable, quota, window, type QuotaWindow, type QuotaGroup } from "../../domain/models"; +import { selectJSON } from "../select-json"; +import { object, displayText } from "./parsers"; +import { runCommand, type Command } from "./transport"; + +const unavailable = () => new Unavailable("notSignedIn", "Antigravity Desktop"); +const noData = () => new Unavailable("noData"); +const methods = { GetUserStatus: { metadata: { ideName: "antigravity", extensionName: "antigravity", ideVersion: "unknown", locale: "en" } }, RetrieveUserQuotaSummary: { forceRefresh: true } }; +type Method = keyof typeof methods; +interface DesktopServer { pid: number; executable: string; csrf: string; extensionPort?: number; extensionCSRF?: string; ports: number[] } +export interface AntigravityEndpoint { pid: number; port: number; csrf: string; scheme: "https:" | "http:"; extensionPort?: number } +export type AntigravityRPC = (endpoint: AntigravityEndpoint, method: Method, signal: AbortSignal) => Promise; +const portNumber = (raw: string) => { const n = Number(raw); return /^\d+$/.test(raw) && n > 0 && n <= 65535 ? n : undefined; }; +const token = (raw: string | undefined) => raw !== undefined && /^[\x21-\x7e]{1,512}$/.test(raw) ? raw : undefined; +const executable = (path: string) => /^\/.*\/(?:Antigravity|Antigravity IDE)\.app\/Contents\/[^\r\n]*\/language_server(?:_[a-zA-Z0-9_-]+)?$/.test(path); +function flag(args: string, name: string) { + const matches = [...args.matchAll(new RegExp(`(?:^|\\s)--${name}(?:=|\\s+)([^\\s]+)`, "g"))]; + if (matches.length > 1) throw unavailable(); + return matches[0]?.[1]; +} + +// Enumerate executable names first. Only a matching, same-user PID exposes arguments. +export async function discoverAntigravity(command: Command = runCommand, signal?: AbortSignal): Promise { + const options = { signal, timeoutMs: 3000, maxBytes: 1048576 }, uid = userInfo().uid; + const processes = await command("/bin/ps", ["-axo", "pid=,uid=,comm="], options); + const candidates = processes.split("\n").flatMap(line => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/); + return match && Number(match[2]) === uid && executable(match[3]) ? [{ pid: Number(match[1]), executable: match[3] }] : []; + }); + if (candidates.length !== 1 || !Number.isSafeInteger(candidates[0].pid) || candidates[0].pid <= 0) throw unavailable(); + const candidate = candidates[0]; + const commandLine = (await command("/bin/ps", ["-ww", "-p", String(candidate.pid), "-o", "command="], { ...options, maxBytes: 16384 })).trim(); + if (!commandLine.startsWith(candidate.executable + " ")) throw unavailable(); + const args = commandLine.slice(candidate.executable.length), csrf = token(flag(args, "csrf_token")); + if (!csrf) throw unavailable(); + const declaredPort = flag(args, "extension_server_port"), extensionPort = declaredPort === undefined ? undefined : portNumber(declaredPort); + const declaredToken = flag(args, "extension_server_csrf_token"), extensionCSRF = token(declaredToken); + if (declaredPort !== undefined && extensionPort === undefined || declaredToken !== undefined && !extensionCSRF) throw unavailable(); + const listeners = await command("/usr/sbin/lsof", ["-nP", "-a", "-p", String(candidate.pid), "-iTCP", "-sTCP:LISTEN", "-Fpn"], { ...options, maxBytes: 32768 }); + let owner: number | undefined; + const ports = new Set(); + for (const line of listeners.split("\n")) { + if (line.startsWith("p")) owner = Number(line.slice(1)); + const match = line.match(/^n127\.0\.0\.1:(\d+)$/), port = match && portNumber(match[1]); + if (owner === candidate.pid && port) ports.add(port); + } + if (!ports.size || ports.size > 8) throw unavailable(); + signal?.throwIfAborted(); + return { ...candidate, csrf, extensionPort, extensionCSRF, ports: [...ports].sort((a, b) => a - b) }; +} +function endpoints(server: DesktopServer): AntigravityEndpoint[] { + const common = { pid: server.pid, extensionPort: server.extensionPort }; + const secure: AntigravityEndpoint[] = server.ports.map(port => ({ ...common, port, csrf: server.csrf, scheme: "https:" })); + if (server.extensionPort && server.ports.includes(server.extensionPort)) { + secure.push({ ...common, port: server.extensionPort, csrf: server.extensionCSRF ?? server.csrf, scheme: "http:" }); + } + return secure; +} +class UnsupportedQuotaRPC extends Error {} +type Send = (options: RequestOptions, callback: (response: IncomingMessage) => void) => ClientRequest; +// TLS relaxation belongs only to this literal loopback request, never a global agent. +export function antigravityRPC(endpoint: AntigravityEndpoint, method: Method, signal: AbortSignal, send: Send = (options, callback) => (options.protocol === "https:" ? httpsRequest : httpRequest)(options, callback)): Promise { + return new Promise((resolve, reject) => { + if (!Object.hasOwn(methods, method) || !["https:", "http:"].includes(endpoint.scheme) || !Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535 || !token(endpoint.csrf) || endpoint.scheme === "http:" && endpoint.port !== endpoint.extensionPort || signal.aborted) { reject(noData()); return; } + const body = JSON.stringify(methods[method]); + let settled = false, request: ClientRequest | undefined; + const finish = (error?: Error, raw?: string) => { + if (settled) return; settled = true; clearTimeout(timer); signal.removeEventListener("abort", abort); + if (error) { request?.destroy(); reject(error); } else resolve(raw!); + }; + const abort = () => finish(noData()); + const timer = setTimeout(abort, 3000); + signal.addEventListener("abort", abort, { once: true }); + try { + request = send({ protocol: endpoint.scheme, hostname: "127.0.0.1", port: endpoint.port, path: `/exa.language_server_pb.LanguageServerService/${method}`, method: "POST", agent: false, ...(endpoint.scheme === "https:" ? { rejectUnauthorized: false } : {}), headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body), "Connect-Protocol-Version": "1", "X-Codeium-Csrf-Token": endpoint.csrf } }, response => { + const code = response.statusCode; + if (code !== 200) { response.resume(); finish(method === "RetrieveUserQuotaSummary" && (code === 404 || code === 501) ? new UnsupportedQuotaRPC() : noData()); return; } + let bytes = 0; const chunks: Buffer[] = []; + if (Number(response.headers["content-length"]) > 1048576) { response.destroy(); finish(noData()); return; } + response.on("data", (chunk: Buffer) => { bytes += chunk.length; if (bytes > 1048576) { response.destroy(); finish(noData()); } else chunks.push(chunk); }); + response.on("error", () => finish(noData())); + response.on("aborted", () => finish(noData())); + response.on("end", () => finish(undefined, Buffer.concat(chunks).toString("utf8"))); + }); + request.on("error", () => finish(noData())); + if (settled) request.destroy(); else request.end(body); + } catch { finish(noData()); } + }); +} +const label = (raw: unknown) => { const value = displayText(raw); return value && !/[\x00-\x1f\x7f]/.test(value) ? value : undefined; }; +const bucketSelection = { bucketId: true, id: true, displayName: true, name: true, disabled: true, remainingFraction: true, remaining: { remainingFraction: true, case: true, value: true }, resetTime: true } as const; +const summarySelection = { groups: { '*': { displayName: true, name: true, buckets: { '*': bucketSelection } } } } as const; +const statusSelection = { code: true, userStatus: { email: true, userTier: { name: true }, planStatus: { planInfo: { planName: true, planDisplayName: true, displayName: true, productName: true, planShortName: true } }, cascadeModelConfigData: { clientModelConfigs: { '*': { label: true, quotaInfo: { remainingFraction: true, resetTime: true } } } } } } as const; +function success(root: Record) { + if (root.code !== undefined && ![0, "0", "ok", "OK", "success", "SUCCESS"].includes(root.code as string | number)) throw noData(); +} +export function antigravityIdentity(raw: string) { + const root = object(selectJSON(raw, statusSelection)); success(root); + const status = object(root.userStatus), email = status.email; + if (typeof email !== "string" || email.length > 320 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw unavailable(); + return createHash("sha256").update("antigravity-desktop\0" + email.toLowerCase()).digest("hex"); +} +function reading(name: string | undefined, fraction: unknown, reset: unknown, now: number): QuotaWindow[] { + if (!name || typeof fraction !== "number" || !Number.isFinite(fraction) || fraction < 0 || fraction > 1) return []; + const resetsAt = typeof reset === "string" ? Date.parse(reset) : undefined; + if (reset != null && (resetsAt === undefined || !Number.isFinite(resetsAt) || resetsAt <= now)) return []; + return [window(name, (1 - fraction) * 100, resetsAt)]; +} +export function antigravityDesktopQuota(raw: string, statusRaw: string, now: number, legacy = false) { + const statusRoot = object(selectJSON(statusRaw, statusSelection)); success(statusRoot); + const status = object(statusRoot.userStatus), plan = object(object(status.planStatus).planInfo); + let windows: QuotaWindow[]; + const quotaGroups: QuotaGroup[] = []; + if (legacy) { + const configs = object(status.cascadeModelConfigData).clientModelConfigs; + windows = Array.isArray(configs) ? configs.flatMap(config => { const c = object(config), q = object(c.quotaInfo); return reading(label(c.label), q.remainingFraction, q.resetTime, now); }) : []; + } else { + const root = object(selectJSON(raw, { code: true, ...summarySelection, response: summarySelection, summary: summarySelection })); + if ([12, "12", "unimplemented"].includes(root.code as number | string)) throw new UnsupportedQuotaRPC(); + success(root); + const payload = object(root.response ?? root.summary ?? root), groups = payload.groups; + windows = Array.isArray(groups) ? groups.flatMap(value => { + const group = object(value), rawGroupName = label(group.displayName ?? group.name); + const groupName = rawGroupName?.replace(/^Gemini Models$/i, "Gemini").replace(/^Claude and GPT models$/i, "Claude/GPT"); + const groupWindows: QuotaWindow[] = []; + const flat = Array.isArray(group.buckets) ? group.buckets.flatMap(value => { + const bucket = object(value), remaining = object(bucket.remaining); + if (!label(bucket.bucketId ?? bucket.id) || bucket.disabled != null && bucket.disabled !== false) return []; + const fraction = bucket.remainingFraction ?? remaining.remainingFraction ?? (remaining.case === "remainingFraction" ? remaining.value : undefined); + const bucketName = (label(bucket.displayName ?? bucket.name) ?? label(bucket.bucketId ?? bucket.id))?.replace(/ Limit Remaining$/i, "").replace(/^Five Hour$/i, "5-hour"); + const parsed = reading(bucketName, fraction, bucket.resetTime, now); + for (const w of parsed) { + const duration = /^(?:Weekly|Weekly Limit)$/i.test(w.label) ? 10080 : /^(?:5-hour|5-hour Limit)$/i.test(w.label) ? 300 : undefined; + if (duration !== undefined) w.windowDurationMins = duration; + groupWindows.push({ ...w, label: w.label + (/limit/i.test(w.label) ? "" : " limit") }); + } + return parsed.map(w => ({ ...w, label: groupName ? `${groupName} · ${w.label}` : w.label })); + }) : []; + if (groupName && groupWindows.length) quotaGroups.push({ id: `desktop-${quotaGroups.length}`, title: groupName, windows: groupWindows }); + return flat; + }) : []; + } + if (!windows.length) throw noData(); + return { ...quota("antigravity", windows, now), groups: quotaGroups.reduce((count, group) => count + group.windows.length, 0) === windows.length ? quotaGroups : [], planType: label(object(status.userTier).name) ?? [plan.planDisplayName, plan.displayName, plan.productName, plan.planName, plan.planShortName].map(label).find(Boolean) }; +} + +export async function readAntigravityDesktop(command: Command = runCommand, rpc: AntigravityRPC = antigravityRPC, now = Date.now(), signal?: AbortSignal, expectedIdentity?: string) { + const bounded = signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000); + try { + bounded.throwIfAborted(); + const server = await discoverAntigravity(command, bounded); + const request = async (endpoint: AntigravityEndpoint, method: Method) => { + bounded.throwIfAborted(); + const current = await discoverAntigravity(command, bounded); + if (current.executable !== server.executable || !endpoints(current).some(e => e.pid === endpoint.pid && e.port === endpoint.port && e.scheme === endpoint.scheme && e.csrf === endpoint.csrf)) throw unavailable(); + return rpc(endpoint, method, bounded); + }; + let selected: { endpoint: AntigravityEndpoint; status: string; identity: string } | undefined; + for (const endpoint of endpoints(server)) { + let status: string; + try { status = await request(endpoint, "GetUserStatus"); } catch { bounded.throwIfAborted(); continue; } + // A responding endpoint with missing/changed identity cannot select a different account. + const identity = antigravityIdentity(status); + if (expectedIdentity && identity !== expectedIdentity) throw unavailable(); + selected = { endpoint, status, identity }; break; + } + if (!selected) throw unavailable(); + let q; + try { q = antigravityDesktopQuota(await request(selected.endpoint, "RetrieveUserQuotaSummary"), selected.status, now); } + catch (error) { + if (!(error instanceof UnsupportedQuotaRPC)) throw error; + q = antigravityDesktopQuota("", selected.status, now, true); + } + if (antigravityIdentity(await request(selected.endpoint, "GetUserStatus")) !== selected.identity) throw unavailable(); + const current = await discoverAntigravity(command, bounded); + if (current.pid !== server.pid || current.csrf !== server.csrf || current.executable !== server.executable || !current.ports.includes(selected.endpoint.port)) throw unavailable(); + bounded.throwIfAborted(); + return { identity: selected.identity, quota: q }; + } catch (error) { throw error instanceof Unavailable ? error : unavailable(); } +} diff --git a/src/main/providers/antigravity.ts b/src/main/providers/antigravity.ts new file mode 100644 index 0000000..4024929 --- /dev/null +++ b/src/main/providers/antigravity.ts @@ -0,0 +1,111 @@ +import { accessSync, constants } from "node:fs"; +import { copyFile, mkdtemp, readdir, rm } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { tmpdir } from "node:os"; +import { type Usage, type Tokens, tokens, addTokens, totalTokens, localDay, weekStart, Unavailable } from "../../domain/models"; +import { telemetry } from "../../domain/telemetry"; +import { runCommand, type Command, type CommandOptions } from "./transport"; +import { parseAntigravity } from "./parsers"; +import { antigravityHistory } from "./usage"; +import { readFile } from "node:fs/promises"; + +interface Field { number: number; varint?: number; bytes?: Uint8Array } +// Unknown protobuf values are skipped as byte ranges; no text decoding occurs. +function fields(data: Uint8Array): Field[] { + let offset = 0; const out: Field[] = []; + const varint = () => { let value = 0n; for (let shift = 0n; shift < 64n && offset < data.length; shift += 7n) { const b = data[offset++]; value |= BigInt(b & 127) << shift; if (!(b & 128)) return value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(value) : undefined; } }; + while (offset < data.length) { + const tag = varint(); if (tag === undefined || tag < 8) break; + const number = Math.floor(tag / 8), wire = tag % 8; + if (wire === 0) { const v = varint(); if (v === undefined) break; out.push({ number, varint: v }); } + else if (wire === 2) { const length = varint(); if (length === undefined || length > data.length - offset) break; out.push({ number, bytes: data.subarray(offset, offset + length) }); offset += length; } + else if (wire === 1 || wire === 5) { const length = wire === 1 ? 8 : 4; if (length > data.length - offset) break; offset += length; } + else break; + } + return out; +} +const message = (fs: Field[], number: number) => { const raw = fs.find(f => f.number === number)?.bytes; return raw && fields(raw); }; +const integer = (fs: Field[], number: number) => fs.find(f => f.number === number)?.varint; +export function parseGeneration(data: Uint8Array): Tokens | undefined { + const model = message(fields(data), 1), usage = model && message(model, 4); if (!usage) return; + const t = tokens({ input: integer(usage, 2) ?? 0, output: integer(usage, 3) ?? 0, cacheRead: integer(usage, 5) ?? 0, reasoning: integer(usage, 9) ?? 0 }); return totalTokens(t) > 0 ? t : undefined; +} +export function parseStep(data: Uint8Array): { role: "user" | "model"; timestamp: number } | undefined { + const fs = fields(data), source = integer(fs, 3), created = message(fs, 1), seconds = created && integer(created, 1); + if ((source !== 4 && source !== 2) || seconds === undefined || seconds <= 1000000000 || seconds >= 4102444800) return; + return { role: source === 4 ? "user" : "model", timestamp: seconds * 1000 }; +} +export interface Conversation { generations: Uint8Array[]; steps: Uint8Array[] } +export function conversationUsage(databases: Conversation[], now: number): Usage { + const sessions = databases.flatMap(d => { + const t = d.generations.reduce((sum, raw) => addTokens(sum, parseGeneration(raw) ?? tokens()), tokens()); + const events = d.steps.flatMap(raw => { const event = parseStep(raw); return event ? [event] : []; }); + const userTurns = events.filter(e => e.role === "user").map(e => e.timestamp), lastActivity = events.length ? Math.max(...events.map(e => e.timestamp)) : undefined; + return totalTokens(t) > 0 || userTurns.length ? [{ tokens: t, userTurns, lastActivity }] : []; + }); + if (!sessions.length) throw new Unavailable("noData"); + const all = sessions.reduce((sum, s) => addTokens(sum, s.tokens), tokens()), today = localDay(now), week = weekStart(now); + const sumSince = (at: number) => sessions.filter(s => (s.lastActivity ?? -Infinity) >= at).reduce((sum, s) => addTokens(sum, s.tokens), tokens()); + const todayTokens = sumSince(today), weekTokens = sumSince(week), items = sessions.flatMap(s => { const start = s.userTurns[0] ?? s.lastActivity; return start === undefined ? [] : [{ startedAt: start, endedAt: s.lastActivity, tokens: totalTokens(s.tokens), messageCount: s.userTurns.length }]; }); + return { provider: "antigravity", sessionCount: sessions.length, messageCount: sessions.reduce((n, s) => n + s.userTurns.length, 0), tokens: totalTokens(all) > 0 ? all : undefined, todaySessionCount: sessions.filter(s => s.userTurns.length && s.userTurns[0] >= today).length, todayMessageCount: sessions.flatMap(s => s.userTurns).filter(at => at >= today).length, todayTokens: totalTokens(todayTokens) > 0 ? todayTokens : undefined, weekTokens: totalTokens(weekTokens) > 0 ? weekTokens : undefined, usageWindows: ([ ["last 24h", 1], ["last 7d", 7], ["last 30d", 30] ] as const).map(([label, days]) => { const rs = sessions.filter(s => (s.lastActivity ?? -Infinity) >= now - days * 86400000); return { label, sessionCount: rs.length, messageCount: rs.reduce((n, s) => n + s.userTurns.length, 0), tokens: rs.reduce((t, s) => addTokens(t, s.tokens), tokens()), estimatedCostUSD: 0 }; }), telemetry: telemetry(items, [], now), capturedAt: Math.max(...sessions.map(s => s.lastActivity ?? -Infinity)) === -Infinity ? now : Math.max(...sessions.map(s => s.lastActivity ?? -Infinity)) }; +} +export async function readConversations(directory: string, now: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const { DatabaseSync } = await import("node:sqlite"), databases: Conversation[] = []; + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries.filter(e => e.isFile() && e.name.endsWith(".db")).sort((a, b) => a.name.localeCompare(b.name))) { + signal?.throwIfAborted(); + const temporary = await mkdtemp(join(tmpdir(), "meterusage-agy-")), target = join(temporary, entry.name), source = join(directory, entry.name); + try { + await copyFile(source, target); + for (const suffix of ["-wal", "-shm"]) { try { await copyFile(source + suffix, target + suffix); } catch (e) { if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e; } } + // Recover only the private copy. The live DB and its WAL stay untouched. + const db = new DatabaseSync(target, { readOnly: false }); + try { const blobs = (sql: string) => db.prepare(sql).all().flatMap(r => r.blob instanceof Uint8Array ? [r.blob] : []); databases.push({ generations: blobs("SELECT data AS blob FROM gen_metadata ORDER BY idx"), steps: blobs("SELECT metadata AS blob FROM steps WHERE metadata IS NOT NULL ORDER BY idx") }); } finally { db.close(); } + } catch { signal?.throwIfAborted(); /* One corrupt conversation does not hide the rest. */ } + finally { await rm(temporary, { recursive: true, force: true }); } + } + return conversationUsage(databases, now); +} +export class AntigravityRuntime { + private recoveryFailedAt?: number; + private pending?: Promise; + constructor(readonly candidates: string[], readonly command: Command = runCommand, readonly env?: NodeJS.ProcessEnv) {} + resolve(signal?: AbortSignal, now = Date.now()): Promise { + if (this.pending) return this.pending; + this.pending = this.select(signal, now).finally(() => { this.pending = undefined; }); return this.pending; + } + private async select(signal: AbortSignal | undefined, now: number): Promise { + const available = this.candidates.filter(p => { try { accessSync(p, constants.X_OK); return true; } catch { return false; } }); + for (const p of available) if (await this.probe(p, ["info"], { signal })) return p; + if (signal?.aborted || (this.recoveryFailedAt !== undefined && now - this.recoveryFailedAt < 60000)) return; + const podman = available.find(p => basename(p) === "podman"); + if (!podman || !await this.probe(podman, ["machine", "inspect", "podman-machine-default"], { signal })) return; + if (!await this.probe(podman, ["machine", "start", "podman-machine-default"], { signal }) || !await this.probe(podman, ["info"], { signal })) { this.recoveryFailedAt = now; return; } + this.recoveryFailedAt = undefined; return podman; + } + private async probe(binary: string, args: string[], options: CommandOptions): Promise { try { await this.run(binary, args, options); return true; } catch { return false; } } + run(binary: string, args: string[], options: CommandOptions = {}) { return this.command(binary, args, { ...options, env: this.env, timeoutMs: args[0] === "info" || args.includes("inspect") ? 5000 : 30000 }); } + async quota(now: number, signal?: AbortSignal) { + const runtime = await this.resolve(signal); if (!runtime) throw new Unavailable("noData"); + for (const args of [["image", "inspect", "antigravity-cli:local"], ["volume", "inspect", "antigravity-config"], ["volume", "inspect", "antigravity-bin"]]) if (!await this.probe(runtime, args, { signal })) throw new Unavailable("noData"); + return parseAntigravity(await this.run(runtime, ["run", "--rm", "--pull=never", "-v", "antigravity-config:/root/.gemini", "-v", "antigravity-bin:/root/.local/bin", "antigravity-cli:local", "-p", "/usage"], { signal }), now); + } + async usage(root: string, now: number, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + try { return await readConversations(join(root, "conversations"), now, signal); } catch { signal?.throwIfAborted(); /* Older native history comes next. */ } + try { return antigravityHistory(await readFile(join(root, "history.jsonl"), { encoding: "utf8", signal }), now); } catch { signal?.throwIfAborted(); /* Container fallback uses existing volumes only. */ } + const runtime = await this.resolve(signal); if (!runtime) throw new Unavailable("dataNotFound", "Antigravity usage"); + for (const args of [["volume", "inspect", "antigravity-config"], ["image", "inspect", "alpine"]]) if (!await this.probe(runtime, args, { signal })) throw new Unavailable("dataNotFound", "Antigravity usage"); + const temporary = await mkdtemp(join(tmpdir(), "meterusage-agy-container-")); + try { + await this.run(runtime, ["run", "--rm", "--pull=never", "-v", "antigravity-config:/data:ro", "-v", `${temporary}:/out`, "alpine", "sh", "-c", "cp /data/antigravity-cli/conversations/*.db /data/antigravity-cli/conversations/*.db-wal /data/antigravity-cli/conversations/*.db-shm /out/ 2>/dev/null; test -n \"$(ls /out/*.db 2>/dev/null)\""], { signal }); + return await readConversations(temporary, now, signal); + } catch { signal?.throwIfAborted(); /* Older container installs keep only history. */ } + finally { await rm(temporary, { recursive: true, force: true }); } + try { return antigravityHistory(await this.run(runtime, ["run", "--rm", "--pull=never", "-v", "antigravity-config:/data:ro", "alpine", "cat", "/data/antigravity-cli/history.jsonl"], { signal }), now); } catch { throw new Unavailable("dataNotFound", "Antigravity usage"); } + } +} +export function runtimeCandidates(env: NodeJS.ProcessEnv): string[] { + return [...new Set(["/opt/homebrew/bin/docker", "/opt/homebrew/bin/podman", "/usr/local/bin/docker", "/usr/local/bin/podman", "/usr/bin/docker", ...(env.PATH ?? "").split(":").flatMap(p => [join(p, "docker"), join(p, "podman")])])]; +} diff --git a/src/main/providers/api-billing.ts b/src/main/providers/api-billing.ts new file mode 100644 index 0000000..cb36a26 --- /dev/null +++ b/src/main/providers/api-billing.ts @@ -0,0 +1,134 @@ +import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { type ApiBillingProvider, type ApiBillingReading, type ApiBillingSnapshot } from "../../domain/api-billing"; +import { selectJSON, type Selection } from "../select-json"; + +const day = 86400000, maxBytes = 1048576; +const invalid = () => new Error("API billing data unavailable"); +const record = (value: unknown): Record => { if (!value || typeof value !== "object" || Array.isArray(value)) throw invalid(); return value as Record; }; +const timestamp = (value: unknown): number => { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 8640000000000000) throw invalid(); return value; }; +const monthStart = (value: number) => { const at = new Date(value); return Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), 1); }; +const amount = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) throw invalid(); return value; }; +const directory = (home: string) => join(home, "Library/Application Support/MeterUsage/api-usage"); +export const apiBillingPath = (home: string, provider: ApiBillingProvider) => join(directory(home), `${provider}.json`); + +function privateDirectory(home: string) { + let path = home; + for (const part of ["", "Library", "Application Support", "MeterUsage", "api-usage"]) { + path = join(path, part); + const stat = lstatSync(path); + if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o022) || part === "api-usage" && (stat.mode & 0o077)) throw invalid(); + } +} + +export function parseApiBillingSnapshot(raw: string, provider: ApiBillingProvider, now = Date.now()): ApiBillingSnapshot { + if (Buffer.byteLength(raw) > maxBytes) throw invalid(); + const root = record(selectJSON(raw, { schema: true, provider: true, capturedAt: true, monthStart: true, periodEnd: true, currency: true, daily: { "*": { start: true, end: true, amountUSD: true } } }, undefined, true)); + const scalarRows = record(selectJSON(raw, { daily: { "*": true } })); + if (Array.isArray(scalarRows.daily) && scalarRows.daily.length) throw invalid(); + const capturedAt = timestamp(root.capturedAt), start = timestamp(root.monthStart), periodEnd = timestamp(root.periodEnd); + if (root.schema !== 1 || root.provider !== provider || root.currency !== "USD" || capturedAt > now + 60000 || start !== monthStart(capturedAt) || periodEnd < start || periodEnd !== Math.floor(capturedAt / 1000) * 1000 || !Array.isArray(root.daily) || root.daily.length > 31) throw invalid(); + const daily = root.daily.map(value => { + const row = record(value), from = timestamp(row.start), to = timestamp(row.end); + if (from < start || from >= periodEnd || from % day !== 0 || to <= from || to > from + day) throw invalid(); + return { start: from, end: to, amountUSD: amount(row.amountUSD) }; + }).sort((a, b) => a.start - b.start); + if (daily.some((row, i) => i > 0 && row.start < daily[i - 1].end)) throw invalid(); + amount(daily.reduce((sum, row) => sum + row.amountUSD, 0)); + return { schema: 1, provider, capturedAt, monthStart: start, periodEnd, currency: "USD", daily }; +} + +export function readApiBilling(home: string, provider: ApiBillingProvider, now = Date.now()): ApiBillingReading { + let fd: number | undefined; + try { + privateDirectory(home); + fd = openSync(apiBillingPath(home, provider), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.mode & 0o077 || stat.nlink !== 1 || stat.size > maxBytes) throw invalid(); + const buffer = Buffer.alloc(maxBytes + 1); let size = 0; + while (size < buffer.length) { const count = readSync(fd, buffer, size, buffer.length - size, null); if (!count) break; size += count; } + const after = fstatSync(fd); + if (size !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs || after.ino !== stat.ino || after.dev !== stat.dev) throw invalid(); + const value = parseApiBillingSnapshot(buffer.subarray(0, size).toString("utf8"), provider, now); + return { status: "value", value, stale: now - value.capturedAt >= day || monthStart(now) !== value.monthStart }; + } catch (error) { + return { status: "missing", code: (error as NodeJS.ErrnoException).code === "ENOENT" ? "notConfigured" : "unavailable" }; + } finally { if (fd !== undefined) closeSync(fd); } +} + +export function writeApiBilling(home: string, snapshot: ApiBillingSnapshot) { + const value = parseApiBillingSnapshot(JSON.stringify(snapshot), snapshot.provider, snapshot.capturedAt); + // Validate existing ancestors before recursive mkdir can follow a symlink. + let path = home; + for (const part of ["", "Library", "Application Support", "MeterUsage", "api-usage"]) { + path = join(path, part); + try { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o022) throw invalid(); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; mkdirSync(path, { mode: 0o700 }); } + } + privateDirectory(home); + const target = apiBillingPath(home, value.provider), temp = join(directory(home), `.${randomUUID()}.tmp`); + try { + try { const stat = lstatSync(target); if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o077 || stat.nlink !== 1) throw invalid(); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + writeFileSync(temp, JSON.stringify(value) + "\n", { mode: 0o600, flag: "wx" }); + renameSync(temp, target); + } finally { rmSync(temp, { force: true }); } +} + +const selection = { + has_more: true, next_page: true, data: { "*": { start_time: true, end_time: true, starting_at: true, ending_at: true, + results: { "*": { amount: { value: true, currency: true }, currency: true } } } }, +} as const; + +export async function collectApiBilling(provider: ApiBillingProvider, key: string, now = Date.now(), fetcher: typeof fetch = fetch): Promise { + if (!(provider === "openai" || provider === "anthropic") || !key || key.length > 8192 || /\s/.test(key)) throw invalid(); + const capturedAt = timestamp(now), start = monthStart(now), end = Math.floor(now / 1000) * 1000, daily: ApiBillingSnapshot["daily"] = [], pages = new Set(); + const signal = AbortSignal.timeout(30000); + let cursor: string | undefined; + try { + for (let page = 0; page < 32; page++) { + const url = new URL(provider === "openai" ? "https://api.openai.com/v1/organization/costs" : "https://api.anthropic.com/v1/organizations/cost_report"); + url.searchParams.set(provider === "openai" ? "start_time" : "starting_at", provider === "openai" ? String(start / 1000) : new Date(start).toISOString()); + url.searchParams.set(provider === "openai" ? "end_time" : "ending_at", provider === "openai" ? String(end / 1000) : new Date(end).toISOString()); + url.searchParams.set("bucket_width", "1d"); url.searchParams.set("limit", "31"); + if (cursor) url.searchParams.set("page", cursor); + const response = await fetcher(url, { method: "GET", redirect: "error", cache: "no-store", credentials: "omit", signal, + headers: { Accept: "application/json", "User-Agent": "MeterUsage API snapshot helper/1", ...(provider === "openai" ? { Authorization: `Bearer ${key}` } : { "x-api-key": key, "anthropic-version": "2023-06-01" }) } }); + signal.throwIfAborted(); + const length = response.headers.get("content-length"); + if (response.redirected || !response.ok || !response.body || length !== null && (!/^\d+$/.test(length) || Number(length) > maxBytes)) { + await response.body?.cancel(); + if (!response.redirected && (response.status === 401 || response.status === 403)) throw new Error("API billing authentication failed"); + throw invalid(); + } + const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0; + try { while (true) { const part = await reader.read(); signal.throwIfAborted(); if (part.done) break; size += part.value.byteLength; if (size > maxBytes) throw invalid(); chunks.push(part.value); } } + finally { await reader.cancel(); } + const raw = Buffer.concat(chunks).toString("utf8"); + // Anthropic's amount is a decimal scalar in cents; OpenAI uses a dollar object. + const fields: Selection = provider === "openai" ? selection : { ...selection, data: { "*": { ...selection.data["*"], results: { "*": { amount: true, currency: true } } } } }; + const root = record(selectJSON(raw, fields, undefined, true)); + if (!Array.isArray(root.data) || typeof root.has_more !== "boolean") throw invalid(); + const scalarBuckets = record(selectJSON(raw, { data: { "*": true } })); + const scalarResults = record(selectJSON(raw, { data: { "*": { results: { "*": true } } } })); + if ((scalarBuckets.data as unknown[]).length || (scalarResults.data as unknown[]).some(row => { const results = record(row).results; return Array.isArray(results) && results.length > 0; })) throw invalid(); + for (const value of root.data) { + const bucket = record(value), from = provider === "openai" ? timestamp(bucket.start_time) * 1000 : Date.parse(String(bucket.starting_at)), to = provider === "openai" ? timestamp(bucket.end_time) * 1000 : Date.parse(String(bucket.ending_at)); + if (!Array.isArray(bucket.results)) throw invalid(); + if (!bucket.results.length) continue; + let amountUSD = 0; + for (const result of bucket.results) { + const row = record(result); + if (provider === "openai") { const value = record(row.amount); if (value.currency !== "usd") throw invalid(); amountUSD += amount(value.value); } + else { if (row.currency !== "USD" || typeof row.amount !== "string" || !/^\d+(?:\.\d+)?$/.test(row.amount)) throw invalid(); amountUSD += amount(Number(row.amount)) / 100; } + } + daily.push({ start: from, end: to, amountUSD }); + } + if (!root.has_more) return parseApiBillingSnapshot(JSON.stringify({ schema: 1, provider, capturedAt, monthStart: start, periodEnd: end, currency: "USD", daily }), provider, now); + if (typeof root.next_page !== "string" || !root.next_page || root.next_page.length > 4096 || pages.has(root.next_page)) throw invalid(); + cursor = root.next_page; pages.add(cursor); + } + throw invalid(); + } catch (error) { throw new Error(error instanceof Error && error.message === "API billing authentication failed" ? error.message : "API billing data unavailable"); } +} diff --git a/src/main/providers/claude-desktop.ts b/src/main/providers/claude-desktop.ts new file mode 100644 index 0000000..2856d13 --- /dev/null +++ b/src/main/providers/claude-desktop.ts @@ -0,0 +1,140 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, existsSync, realpathSync } from "node:fs"; +import { join } from "node:path"; +import { createDecipheriv, createHash, pbkdf2Sync } from "node:crypto"; +import { runCommand, type Command } from "./transport"; +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 const desktopReadErrors = { + challenge: "Claude requires browser verification for account allowance. Open Claude usage to view it in your browser. Your current source is unchanged.", + configuration: "Could not read Claude Desktop's account configuration.", + organization: "Could not read Claude Desktop's active organization cookie.", + decryption: "Could not decrypt Claude Desktop's session cookies with its Keychain entry.", + request: "Could not read Claude account allowance. Try connecting again later.", + verification: "Could not verify the same Claude Desktop account and session after the allowance request. Try connecting again.", + response: "Claude did not report a supported account allowance.", + selection: "Claude Desktop's cache has no current session for the active account and organization.", + ambiguous: "Claude Desktop's cache has conflicting session or organization cookies. MeterUsage cannot safely choose one.", +} as const; +type ReadStage = (stage: keyof typeof desktopReadErrors) => void; +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 const validClaudeIdentity = (value: unknown): value is string => typeof value === "string" && /^web-v1:[a-f\d]{64}$/.test(value); +export const claudeIdentity = (account: string, organization: string) => "web-v1:" + createHash("sha256").update(account.toLowerCase() + ":" + organization.toLowerCase()).digest("hex"); +export interface DesktopCredential { account: string; organization: string; cookie: string; identity: string } +// Query only the two fixed cookies. Ciphertext and metadata remain in bounded RAM. +export const desktopCookieSQL = "SELECT host_key, name, path, is_secure, is_httponly, CAST(expires_utc AS TEXT) AS expires_utc, hex(encrypted_value) AS encrypted_value FROM cookies WHERE name IN ('sessionKey', 'lastActiveOrg') AND host_key IN ('.claude.ai', 'claude.ai') LIMIT 33"; +export const desktopSchemaSQL = "SELECT CAST(value AS TEXT) AS version FROM meta WHERE key = 'version' LIMIT 2"; +export function desktopCredential(config: string, stores: { version: string; cookies: string }[], password: string, now: number, reading?: ReadStage): DesktopCredential { + try { + reading?.("configuration"); + if (Buffer.byteLength(config) > 2 * 1048576 || !Number.isSafeInteger(now) || stores.length < 1 || stores.length > 2) throw unavailable(); + const account = text(object(selectJSON(config, { lastKnownAccountUuid: true }, undefined, true)).lastKnownAccountUuid)?.toLowerCase(); + if (!account || !uuid.test(account)) throw unavailable(); + reading?.("organization"); + const values = { sessionKey: new Set(), lastActiveOrg: new Set() }; + const expiry = (BigInt(now) + 120000n + 11644473600000n) * 1000n; + for (const store of stores) { + if (Buffer.byteLength(store.version) > 1024 || Buffer.byteLength(store.cookies) > 2 * 1048576) throw unavailable(); + const versions = JSON.parse(store.version), rows = JSON.parse(store.cookies); + if (!Array.isArray(versions) || versions.length !== 1 || versions[0].version !== "24" || !Array.isArray(rows) || rows.length > 32) throw unavailable(); + for (const raw of rows) { + const row = object(raw), name = row.name; + if (name !== "sessionKey" && name !== "lastActiveOrg" || row.host_key !== ".claude.ai" && row.host_key !== "claude.ai" || row.path !== "/" || row.is_secure !== 1 || name === "sessionKey" && row.is_httponly !== 1 || typeof row.expires_utc !== "string" || !/^[1-9]\d{0,18}$/.test(row.expires_utc) || BigInt(row.expires_utc) > 9223372036854775807n || BigInt(row.expires_utc) <= expiry || typeof row.encrypted_value !== "string" || row.encrypted_value.length > 65536 || !/^(?:[a-f\d]{2})+$/i.test(row.encrypted_value)) throw unavailable(); + reading?.("decryption"); + const plaintext = decryptDesktop(Buffer.from(row.encrypted_value, "hex"), password); + let value: string; + try { + const digest = createHash("sha256").update(row.host_key).digest(); + if (!plaintext.subarray(0, 32).equals(digest)) throw unavailable(); + value = plaintext.subarray(32).toString("utf8"); + } finally { plaintext.fill(0); } + if (name === "sessionKey" ? !/^[A-Za-z0-9._~-]{1,16384}$/.test(value) : !uuid.test(value)) throw unavailable(); + values[name].add(name === "lastActiveOrg" ? value.toLowerCase() : value); + } + } + reading?.("selection"); + if (values.sessionKey.size !== 1 || values.lastActiveOrg.size !== 1) { + if (values.sessionKey.size > 1 || values.lastActiveOrg.size > 1) reading?.("ambiguous"); + throw unavailable(); + } + const organization = [...values.lastActiveOrg][0]; + return { account, organization, cookie: "sessionKey=" + [...values.sessionKey][0], identity: claudeIdentity(account, organization) }; + } catch { throw unavailable(); } +} +function configFile(root: string) { + const fd = openSync(join(root, "config.json"), constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > 2 * 1048576) throw unavailable(); + const bytes = Buffer.alloc(2 * 1048576 + 1); + let length = 0, count: number; + do { count = readSync(fd, bytes, length, bytes.length - length, null); length += count; } while (count && length < bytes.length); + if (length > 2 * 1048576) throw unavailable(); + return bytes.subarray(0, length).toString("utf8"); + } finally { closeSync(fd); } +} +export async function readDesktopCredential(home: string, password: string, now: number, reading?: ReadStage, command: Command = runCommand, signal?: AbortSignal): Promise { + try { + signal?.throwIfAborted(); reading?.("configuration"); + const root = realpathSync(join(home, "Library/Application Support/Claude")); + const config = configFile(root), stores: { version: string; cookies: string }[] = []; + // Canonical paths keep the deny rule on the actual store, including WAL/SHM. + const sandbox = `(version 1)(allow default)(deny file-write* (subpath ${JSON.stringify(root)}))`; + reading?.("organization"); + for (const relative of ["Cookies", "Network/Cookies"]) { + const path = join(root, relative); + if (!existsSync(path)) continue; + if (realpathSync(path) !== path || !lstatSync(path).isFile()) throw unavailable(); + const read = async (sql: string, maxBytes: number) => { + signal?.throwIfAborted(); + const raw = await command("/usr/bin/sandbox-exec", ["-p", sandbox, "/usr/bin/sqlite3", "-init", "/dev/null", "-batch", "-readonly", "-json", path, sql], { signal, timeoutMs: 5000, maxBytes }); + signal?.throwIfAborted(); return raw; + }; + stores.push({ version: await read(desktopSchemaSQL, 1024), cookies: await read(desktopCookieSQL, 2 * 1048576) }); + } + signal?.throwIfAborted(); + const result = desktopCredential(config, stores, password, now, reading); + const after = object(selectJSON(configFile(root), { lastKnownAccountUuid: true }, undefined, true)).lastKnownAccountUuid; + if (typeof after !== "string" || after.toLowerCase() !== result.account) throw unavailable(); + return result; + } catch { throw unavailable(); } +} +export function verifyDesktopAccount(raw: string, credential: DesktopCredential) { + try { + if (Buffer.byteLength(raw) > 1048576) throw unavailable(); + const account = object(object(selectJSON(raw, { account: { uuid: true, memberships: { "*": { organization: { uuid: true } } } } }, undefined, true)).account); + if (typeof account.uuid !== "string" || !uuid.test(account.uuid) || account.uuid.toLowerCase() !== credential.account || !Array.isArray(account.memberships)) throw unavailable(); + let matches = 0; + for (const membership of account.memberships) { + const id = object(object(membership).organization).uuid; + if (typeof id !== "string" || !uuid.test(id)) throw unavailable(); + if (id.toLowerCase() === credential.organization) matches++; + } + if (matches !== 1) 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/codex-account-usage.ts b/src/main/providers/codex-account-usage.ts new file mode 100644 index 0000000..6cfe205 --- /dev/null +++ b/src/main/providers/codex-account-usage.ts @@ -0,0 +1,24 @@ +import { type AccountActivity, Unavailable, failed } from "../../domain/models"; + +export function parseCodexAccountUsage(raw: string, capturedAt: number): AccountActivity { + try { + // Keep integer text until validation so JSON number rounding cannot hide fractions. + const tokenText = new WeakMap(); + const root = JSON.parse(raw, function (key: string, value: unknown, context?: { source: string }) { + if (key === "tokens" && context) tokenText.set(this, context.source); + return value; + }); + if (root.error) throw root.error.code === -32600 ? new Unavailable("notSignedIn", "Codex") : failed("codex"); + const buckets = root.result?.dailyUsageBuckets; + if (buckets === null || buckets === undefined) return { dailyUsageBuckets: null, capturedAt }; + if (!Array.isArray(buckets)) throw failed("codex"); + const dates = new Set(); + const dailyUsageBuckets = buckets.map(bucket => { + const { startDate, tokens } = bucket ?? {}; + if (typeof startDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(startDate) || !Number.isFinite(Date.parse(startDate)) || new Date(startDate).toISOString().slice(0, 10) !== startDate || dates.has(startDate) || !Number.isSafeInteger(tokens) || tokens < 0 || !/^(0|[1-9]\d*)$/.test(tokenText.get(bucket) ?? "")) throw failed("codex"); + dates.add(startDate); + return { startDate, tokens }; + }); + return { dailyUsageBuckets, capturedAt }; + } catch (error) { throw error instanceof Unavailable ? error : failed("codex"); } +} diff --git a/src/main/providers/cursor-grok.ts b/src/main/providers/cursor-grok.ts new file mode 100644 index 0000000..50e722c --- /dev/null +++ b/src/main/providers/cursor-grok.ts @@ -0,0 +1,95 @@ +import { lstatSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { pathToFileURL } from "node:url"; +import { createHash } from "node:crypto"; +import { selectJSON } from "../select-json"; +import { date, object } from "./parsers"; +import { runCommand, type Command } from "./transport"; +import { Unavailable, quota, window } from "../../domain/models"; + +const unavailable = () => new Unavailable("notSignedIn", "Grok Bot via Cursor"); +const subject = (value: unknown) => { + if (typeof value !== "string" || value.length > 512) throw unavailable(); + const id = value.split("|").at(-1); + if (!id || !/^[A-Za-z0-9._-]+$/.test(id)) throw unavailable(); + return id; +}; +const identity = (id: string) => createHash("sha256").update(`cursor-grok:${id}`).digest("hex"); +export function cursorCredential(token: string, now: number) { + try { + if (token.length > 16384 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) throw unavailable(); + const payload = object(selectJSON(Buffer.from(token.split(".")[1], "base64url").toString("utf8"), { sub: true, exp: true })); + const id = subject(payload.sub); + if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp * 1000 <= now + 60000) throw unavailable(); + return { cookie: `WorkosCursorSessionToken=${id}%3A%3A${token}`, identity: identity(id), expiresAt: payload.exp * 1000 }; + } catch { throw unavailable(); } +} +export function cursorIdentity(raw: string, credential: ReturnType) { + try { + if (identity(subject(object(selectJSON(raw, { sub: true })).sub)) !== credential.identity) throw unavailable(); + } catch { throw unavailable(); } +} +async function closedCursor(command: Command, signal?: AbortSignal) { + // comm excludes process arguments, which can contain private data. + const raw = await command("/bin/ps", ["-axo", "pid=,comm="], { signal, timeoutMs: 5000, maxBytes: 2 * 1048576 }); + const lines = raw.trim().split("\n"); + if (!raw.trim() || lines.some(line => !/^\s*\d+\s+\S.*$/.test(line))) throw unavailable(); + if (lines.some(line => { + const executable = line.replace(/^\s*\d+\s+/, ""); + // Apple's system cursor UI service is unrelated to the Cursor editor. + return /cursor/i.test(executable) && !/^\/System\/Library\/.*\/CursorUIViewService$/.test(executable); + })) throw unavailable(); +} +function storeSnapshot(path: string) { + const directories: string[] = []; + for (let dir = dirname(path); ; dir = dirname(dir)) { directories.push(dir); if (dirname(dir) === dir) break; } + return JSON.stringify([...directories, path, path + "-wal", path + "-shm", path + "-journal"].map((file, index) => { + let stat; + try { stat = lstatSync(file, { bigint: true }); } + catch (error) { if (index > directories.length && (error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; } + if (index < directories.length ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n) throw unavailable(); + if (file === path && (stat.size < 100n || stat.size > 268435456n) || (file === path + "-wal" || file === path + "-journal") && stat.size !== 0n) throw unavailable(); + // Access times may change on reads. Identity, content-change and sidecar metadata must stay fixed. + return (index > 0 && index < directories.length ? [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid] : [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid, stat.nlink, stat.size, stat.mtimeNs, stat.ctimeNs]).map(String); + })); +} +export async function readCursorCredential(home: string, now: number, command: Command = runCommand, signal?: AbortSignal) { + try { + signal?.throwIfAborted(); + await closedCursor(command, signal); + const path = join(home, "Library/Application Support/Cursor/User/globalStorage/state.vscdb"), before = storeSnapshot(path); + const { DatabaseSync } = await import("node:sqlite"); + signal?.throwIfAborted(); + const uri = pathToFileURL(path); uri.searchParams.set("immutable", "1"); uri.searchParams.set("mode", "ro"); + // immutable disables SQLite writes, locks and sidecar bookkeeping even if another process races us. + const db = new DatabaseSync(uri.href, { readOnly: true, allowExtension: false }); + let token: string; + try { + const rows = db.prepare("SELECT CASE WHEN length(value) <= 32768 THEN value END AS value FROM ItemTable WHERE key = 'cursorAuth/accessToken' LIMIT 2").all(); + if (rows.length !== 1) throw unavailable(); + const value = rows[0].value; + if (typeof value === "string") token = value; + else if (value instanceof Uint8Array && value.length <= 32768) { + const bytes = Buffer.from(value); + const utf16 = bytes.length % 2 === 0 && bytes.every((b, i) => i % 2 ? b === 0 : b > 0 && b < 128); + token = bytes.toString(utf16 ? "utf16le" : "utf8"); + } else throw unavailable(); + } finally { db.close(); } + if (storeSnapshot(path) !== before) throw unavailable(); + await closedCursor(command, signal); + if (storeSnapshot(path) !== before) throw unavailable(); + signal?.throwIfAborted(); + return { ...cursorCredential(token, now), store: createHash("sha256").update(before).digest("hex") }; + } catch { throw unavailable(); } +} +export function cursorGrokQuota(raw: string, now: number) { + try { + const value = object(selectJSON(raw, { usagePercent: true, nextResetTimestampUtc: true, includedLimitZero: true, hasNonZeroIncludedLimit: true, sandTrialExpiresAt: true })); + for (const key of ["includedLimitZero", "hasNonZeroIncludedLimit"]) if (value[key] != null && typeof value[key] !== "boolean") throw new Unavailable("noData"); + const paid = typeof value.includedLimitZero === "boolean" ? !value.includedLimitZero : value.hasNonZeroIncludedLimit === true; + const trial = !paid && typeof value.sandTrialExpiresAt === "string" && (date(value.sandTrialExpiresAt) ?? 0) > now; + const percent = value.usagePercent, reset = paid && typeof value.nextResetTimestampUtc === "string" ? date(value.nextResetTimestampUtc) : undefined; + if ((!paid && !trial) || typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 || paid && value.nextResetTimestampUtc != null && reset === undefined) throw new Unavailable("noData"); + return quota("grok", [window(trial ? "Grok Bot via Cursor · Trial" : "Grok Bot via Cursor", percent, reset)], now); + } catch { throw new Unavailable("noData"); } +} diff --git a/src/main/providers/grok-bot-desktop.ts b/src/main/providers/grok-bot-desktop.ts new file mode 100644 index 0000000..c60f6ef --- /dev/null +++ b/src/main/providers/grok-bot-desktop.ts @@ -0,0 +1,137 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { selectJSON } from "../select-json"; +import { object } from "./parsers"; +import { decryptDesktop } from "./claude-desktop"; +import { type Command } from "./transport"; +import { Unavailable, failed, quota, window } from "../../domain/models"; + +export const grokBotConnectionErrors = { + store: "Could not connect Grok Bot Desktop: could not read a stable sign-in store.", + keychain: "Could not connect Grok Bot Desktop: could not access its macOS Keychain item.", + credentials: "Could not connect Grok Bot Desktop: could not validate the active account and access token.", + decryption: "Could not connect Grok Bot Desktop: could not decrypt the sign-in cache with its Keychain item.", + request: "Could not connect Grok Bot Desktop: the allowance request failed.", + verification: "Could not connect Grok Bot Desktop: the account or sign-in store could not be verified after the request.", + quota: "Could not connect Grok Bot Desktop: the provider did not report a supported allowance.", + preferences: "Could not connect Grok Bot Desktop: could not save connection preferences.", +} as const; +export class GrokBotFailure extends Unavailable { + constructor(readonly stage: keyof typeof grokBotConnectionErrors, code: Unavailable["code"] = "notSignedIn") { + super(code, "Grok Bot Desktop"); + } +} +const unavailable = () => new Unavailable("notSignedIn", "Grok Bot Desktop"); +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); +const scopePattern = /^[a-f0-9]{64}$/; +const selected = (raw: string, shape: Parameters[1], onScalar?: (raw: string) => void) => object(selectJSON(raw, shape, onScalar, true)); +function envelope(raw: unknown, scope: string, password: string, allowLegacy = false) { + if (typeof raw !== "string" || raw.length > 65536) throw unavailable(); + const match = /^scoped:v1:([a-f0-9]{64}):([A-Za-z0-9+/]+={0,2})$/.exec(raw); + if (match ? match[1] !== scope : !allowLegacy) throw unavailable(); + // Bot also persists legacy encrypted values; the full JWT subject still binds the account. + const encoded = match ? match[2] : raw; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw unavailable(); + const bytes = Buffer.from(encoded, "base64"); + if (bytes.toString("base64") !== encoded || bytes.subarray(0, 3).toString() !== "v10") throw unavailable(); + let plaintext: Buffer; + try { plaintext = decryptDesktop(bytes, password); } + catch { throw new GrokBotFailure("decryption"); } + try { return plaintext.toString("utf8"); } finally { plaintext.fill(0); } +} +// Only the active access-token/team values are decoded. Refresh tokens stay skipped. +export function grokBotCredential(raw: string, password: string, now: number, onScalar?: (raw: string) => void) { + try { + if (Buffer.byteLength(raw) > 4 * 1048576) throw unavailable(); + const root = selected(raw, { version: true, "cursor-accounts": true }, onScalar); + if (root.version !== 1 || typeof root["cursor-accounts"] !== "string") throw unavailable(); + const accounts = root["cursor-accounts"], active = selected(accounts, { active: true }, onScalar).active; + if (typeof active !== "string" || !scopePattern.test(active)) throw unavailable(); + const values = object(object(selected(accounts, { accounts: { [active]: { "cursor-access-token": true, "cursor-selected-team-id": true } } }, onScalar).accounts)[active]); + const token = envelope(values["cursor-access-token"], active, password, true); + if (token.length > 16384 || !/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token)) throw unavailable(); + const payload = selected(Buffer.from(token.split(".")[1], "base64url").toString("utf8"), { sub: true, exp: true }, onScalar); + if (typeof payload.sub !== "string" || payload.sub.length < 1 || payload.sub.length > 512 || /[\x00-\x20\x7f]/.test(payload.sub) || hash(payload.sub) !== active || typeof payload.exp !== "number" || !Number.isFinite(payload.exp * 1000) || payload.exp * 1000 <= now + 60000) throw unavailable(); + let team: string | undefined; + if (values["cursor-selected-team-id"] !== undefined) { + team = envelope(values["cursor-selected-team-id"], active, password); + if (!/^(?:0|[1-9]\d*)$/.test(team) || !Number.isSafeInteger(Number(team))) throw unavailable(); + } + return { token, team, identity: hash(`grok-bot-desktop\0${active}\0${team ?? "personal"}`), expiresAt: payload.exp * 1000 }; + } catch (error) { throw error instanceof GrokBotFailure ? error : new GrokBotFailure("credentials"); } +} +function snapshot(path: string) { + const dirs: string[] = []; + for (let dir = dirname(path); ; dir = dirname(dir)) { dirs.push(dir); if (dirname(dir) === dir) break; } + return JSON.stringify([...dirs, path].map((file, index) => { + const s = lstatSync(file, { bigint: true }); + if (index < dirs.length ? !s.isDirectory() : !s.isFile() || s.nlink !== 1n || s.size <= 0 || s.size > 4 * 1048576) throw unavailable(); + return (index < dirs.length ? [s.dev, s.ino, s.mode, s.uid, s.gid] : [s.dev, s.ino, s.mode, s.uid, s.gid, s.nlink, s.size, s.mtimeNs, s.ctimeNs]).map(String); + })); +} +export async function readGrokBotCredential(home: string, helper: string, command: Command, now: number, allowUI: boolean, signal: AbortSignal, guard: () => void) { + const check = () => { guard(); signal.throwIfAborted(); }; + let stage: keyof typeof grokBotConnectionErrors = "store"; + try { + check(); + const path = join(home, "Library/Application Support/Grok Bot/sand-secrets.json"), before = snapshot(path); + check(); const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); + let raw: string; + try { + const stat = fstatSync(fd, { bigint: true }); + const descriptor = () => { const s = fstatSync(fd, { bigint: true }); return [s.dev, s.ino, s.mode, s.uid, s.gid, s.nlink, s.size, s.mtimeNs, s.ctimeNs].map(String); }; + const expected = JSON.parse(before).at(-1); + if (!stat.isFile() || stat.nlink !== 1n || stat.size > 4 * 1048576 || JSON.stringify(descriptor()) !== JSON.stringify(expected)) throw unavailable(); + check(); const bytes = Buffer.alloc(4 * 1048576 + 1); + let length = 0, count: number; + do { check(); count = readSync(fd, bytes, length, bytes.length - length, null); length += count; } while (count && length < bytes.length); + if (length > 4 * 1048576) throw unavailable(); + raw = bytes.subarray(0, length).toString("utf8"); bytes.fill(0); + check(); if (JSON.stringify(descriptor()) !== JSON.stringify(expected)) throw unavailable(); + } finally { closeSync(fd); } + check(); if (snapshot(path) !== before) throw unavailable(); + check(); + stage = "keychain"; + const password = await command(helper, allowUI ? ["--grok-bot", "--allow-ui"] : ["--grok-bot"], { signal, timeoutMs: allowUI ? 60000 : 5000, maxBytes: 4096 }); + check(); stage = "credentials"; const credential = grokBotCredential(raw, password, now); + stage = "store"; check(); if (snapshot(path) !== before) throw unavailable(); + return { ...credential, store: hash(before + "\0" + raw) }; + } catch (error) { throw error instanceof GrokBotFailure ? error : new GrokBotFailure(stage); } +} +export function grokBotQuota(raw: string, now: number) { + try { + const v = selected(raw, { usagePercent: true, nextResetTimestampUtc: true, includedLimitZero: true, hasNonZeroIncludedLimit: true, usesPooledEnterpriseAllowance: true, sandTrialExpiresAt: true }); + for (const key of ["includedLimitZero", "hasNonZeroIncludedLimit", "usesPooledEnterpriseAllowance"]) if (v[key] !== undefined && typeof v[key] !== "boolean") throw new Error(); + if (v.usesPooledEnterpriseAllowance === true || v.includedLimitZero === true && v.hasNonZeroIncludedLimit === true || v.includedLimitZero === false && v.hasNonZeroIncludedLimit === false) throw new Error(); + const paid = v.includedLimitZero === false || v.hasNonZeroIncludedLimit === true; + const timestamp = (value: unknown) => { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value)) return NaN; + const n = Date.parse(value); + return Number.isFinite(n) && new Date(n).toISOString().slice(0, 19) === value.slice(0, 19) ? n : NaN; + }; + const trial = !paid && timestamp(v.sandTrialExpiresAt) > now; + const reset = paid && v.nextResetTimestampUtc !== undefined ? timestamp(v.nextResetTimestampUtc) : undefined; + if ((!paid && !trial) || typeof v.usagePercent !== "number" || !Number.isFinite(v.usagePercent) || v.usagePercent < 0 || v.usagePercent > 100 || reset !== undefined && (!Number.isFinite(reset) || reset <= now)) throw new Error(); + return quota("grok", [window(trial ? "Grok Bot Desktop · Trial" : "Grok Bot Desktop", v.usagePercent, reset)], now); + } catch { throw new GrokBotFailure("quota", "noData"); } +} +export type GrokBotRPC = (token: string, team: string | undefined, signal: AbortSignal) => Promise; +export function grokBotTransport(fetcher: typeof fetch = fetch): GrokBotRPC { + return async (token, team, signal) => { + const bounded = AbortSignal.any([signal, AbortSignal.timeout(30000)]); + try { + bounded.throwIfAborted(); + if (!/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token) || token.length > 16384 || team !== undefined && (!/^(?:0|[1-9]\d*)$/.test(team) || !Number.isSafeInteger(Number(team)))) throw unavailable(); + const response = await fetcher("https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus", { method: "POST", body: "{}", redirect: "error", cache: "no-store", signal: bounded, headers: { "Content-Type": "application/json", "Connect-Protocol-Version": "1", "User-Agent": "MeterUsage/0.2.41", Authorization: `Bearer ${token}`, ...(team === undefined ? {} : { "x-cursor-team-id": team }) } }); + if (response.status !== 200 || response.redirected) { await response.body?.cancel(); if ([401, 403].includes(response.status)) throw unavailable(); throw failed("grok"); } + if (Number(response.headers.get("content-length")) > 1048576) { await response.body?.cancel(); throw failed("grok"); } + const reader = response.body?.getReader(); if (!reader) throw failed("grok"); + const chunks: Buffer[] = []; let bytes = 0; + try { + while (true) { bounded.throwIfAborted(); const { done, value } = await reader.read(); if (done) break; bytes += value.length; if (bytes > 1048576) throw failed("grok"); chunks.push(Buffer.from(value)); } + bounded.throwIfAborted(); return Buffer.concat(chunks).toString("utf8"); + } finally { await reader.cancel(); } + } catch (error) { throw error instanceof Unavailable ? error : failed("grok"); } + }; +} diff --git a/src/main/providers/muse-code.ts b/src/main/providers/muse-code.ts new file mode 100644 index 0000000..af77b73 --- /dev/null +++ b/src/main/providers/muse-code.ts @@ -0,0 +1,57 @@ +import { Unavailable, quota, type QuotaWindow } from "../../domain/models"; +import { selectJSON } from "../select-json"; +import { object } from "./parsers"; +import { boundedChild, type CommandOptions } from "./transport"; + +const unavailable = () => new Unavailable("noData", "Muse Code"); +const usageShape = { observedAtMs: true, tier: true, window: { usedPercent: true, resetsAtMs: true, windowDurationMins: true }, weekly: { usedPercent: true, resetsAtMs: true } } as const; +const integer = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; + +// MSP's percentages can exceed 100. Preserve the reported value; display rings +// clamp their geometry independently of the numeric reading. +export function museCodeQuota(raw: string, now: number) { + try { + const response = object(selectJSON(raw, { result: { usage: usageShape }, error: { code: true } }, undefined, true)); + if (response.error) throw unavailable(); + const usage = object(object(response.result).usage); + if (!integer(usage.observedAtMs) || usage.observedAtMs === 0 || usage.observedAtMs > now) throw unavailable(); + const readWindow = (raw: unknown, label: string, duration?: number): QuotaWindow => { + const value = object(raw), mins = duration ?? value.windowDurationMins; + if (!integer(value.usedPercent) || !integer(value.resetsAtMs) || value.resetsAtMs <= now || value.resetsAtMs > 8640000000000000 || !integer(mins) || mins === 0) throw unavailable(); + return { label, usedPercent: value.usedPercent, resetsAt: value.resetsAtMs, windowDurationMins: mins }; + }; + const current = readWindow(usage.window, "Session"), weekly = readWindow(usage.weekly, "Weekly", 10080); + const result = quota("muse", [current, weekly], usage.observedAtMs); + if (typeof usage.tier === "string" && /^[A-Za-z0-9_-]{1,64}$/.test(usage.tier)) result.planType = usage.tier; + return result; + } catch { throw unavailable(); } +} + +// Read only the host's observation. No session, model turn, login or logout is +// started, and authentication remains inside the installed official CLI. +export function museCodeRPC(binary: string, options: CommandOptions): Promise { + return boundedChild(binary, ["serve"], { ...options, timeoutMs: 20000, maxBytes: 1048576 }, (child, finish) => { + const write = (message: unknown) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...message as object }) + "\n"); + let pending = "", initialized = false; + 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); + if (!line.trim()) continue; + try { + const message = object(selectJSON(line, { jsonrpc: true, id: true, error: { code: true }, result: { experimentalApi: true } }, undefined, true)); + if (message.jsonrpc !== "2.0") throw unavailable(); + if (message.id === 1 && !initialized) { + if (message.error || object(message.result).experimentalApi !== true) throw unavailable(); + initialized = true; + write({ method: "initialized", params: {} }); + write({ id: 2, method: "usage/read", params: {} }); + } else if (message.id === 2 && initialized) { finish(line); return; } + } catch { finish(unavailable()); return; } + } + }); + write({ id: 1, method: "initialize", params: { clientInfo: { name: "meterusage", version: "0.2.41" }, capabilities: { experimentalApi: true, userInputDialogs: false } } }); + }); +} diff --git a/src/main/providers/parsers.ts b/src/main/providers/parsers.ts new file mode 100644 index 0000000..66e1254 --- /dev/null +++ b/src/main/providers/parsers.ts @@ -0,0 +1,145 @@ +import { type Quota, type Provider, type QuotaWindow, type Credits, type Severity, quota, window, Unavailable, failed, type ServiceStatus } from "../../domain/models"; +import { selectJSON } from "../select-json"; + +export const object = (v: unknown): Record => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record : Object.create(null); +export const number = (v: unknown): number | undefined => typeof v === "number" && Number.isFinite(v) ? v : typeof v === "string" && v.trim() && Number.isFinite(Number(v)) ? Number(v) : undefined; +export const text = (v: unknown): string | undefined => typeof v === "string" && v.trim() ? v.trim() : undefined; +export function displayText(v: unknown): string | undefined { + const s = text(v); + return s && s.length <= 100 && !/[@\\\r\n]|(?:sk-|ghp_|gho_|Bearer\s)|eyJ[A-Za-z0-9_-]{12}/.test(s) && !s.startsWith("/") ? s : undefined; +} +export function date(v: unknown): number | undefined { + const n = number(v); + const d = n === undefined ? typeof v === "string" ? Date.parse(v) : NaN : n > 100000000000 ? n : n * 1000; + return Number.isFinite(new Date(d).getTime()) ? d : undefined; +} +export const json = (s: string): Record => object(JSON.parse(s)); +const array = (v: unknown): unknown[] => Array.isArray(v) ? v : []; + +export function parseCodex(s: string, now = Date.now()): Quota { + const root = json(s), error = object(root.error); + if (root.error) { + const message = (text(error.message) ?? "").toLowerCase(); + if (/not logged in|not authenticated|sign in/.test(message)) throw new Unavailable("notSignedIn", "Codex"); + if (error.code === -32603 && /reach|backend|network|connect/.test(message)) throw new Unavailable("offline"); + throw failed("codex"); + } + const result = object(root.result), limits = object(result.rateLimits); + if (!result.rateLimits) throw failed("codex"); + function windows(payload: Record) { + return [payload.primary, payload.secondary].flatMap(v => { + if (v === null || v === undefined) return []; + const w = object(v), used = number(w.usedPercent), duration = number(w.windowDurationMins); + if (used === undefined || duration === undefined) throw failed("codex"); + return [window(duration >= 1440 ? "Weekly" : "5-hour", used, date(w.resetsAt), duration)]; + }); + } + const q = quota("codex", windows(limits), now), baseID = displayText(limits.limitId) ?? "codex"; + q.planType = displayText(limits.planType); + if (q.windows.length) q.groups.push({ id: baseID, title: "General usage limits", windows: q.windows }); + try { + const extras = Object.entries(object(result.rateLimitsByLimitId)).sort(([a], [b]) => a.localeCompare(b)).flatMap(([key, payload]) => { + const extra = object(payload), id = displayText(extra.limitId) ?? displayText(key); + if (!id || id === baseID || key === baseID) return []; + const ws = windows(extra); return ws.length ? [{ id, title: displayText(extra.limitName) ?? id, windows: ws }] : []; + }); + q.groups.push(...extras); + } catch { /* Swift discards malformed optional additional limits, retaining base quota. */ } + const c = object(limits.credits), balance = number(c.balance); + if (balance !== undefined && typeof c.hasCredits === "boolean" && typeof c.unlimited === "boolean") q.credits = { balance, hasCredits: c.hasCredits, unlimited: c.unlimited, unit: "credits", dollarBalance: balance / 25 }; + const reset = object(result.rateLimitResetCredits), count = number(reset.availableCount); + if (count !== undefined) { + q.resetCreditCount = count; + q.resetCredits = array(reset.credits).flatMap(v => { + const c = object(v), id = text(c.id); return id ? [{ id, title: displayText(c.title) ?? "Full reset", status: displayText(c.status), expiresAt: date(c.expiresAt) }] : []; + }); + } + return q; +} +export function parseClaudeQuota(s: string, now = Date.now()): Quota { + const r = json(s), ws: QuotaWindow[] = []; + const rank: Record = { session: 0, weekly_all: 1, weekly_scoped: 2 }; + for (const raw of array(r.limits).map(object).sort((a, b) => (rank[text(a.kind) ?? ""] ?? 3) - (rank[text(b.kind) ?? ""] ?? 3))) { + const used = number(raw.percent); if (used === undefined) continue; + const kind = text(raw.kind), name = displayText(object(object(raw.scope).model).display_name), group = displayText(raw.group); + const label = kind === "session" ? "5-hour" : kind === "weekly_all" ? "Weekly · All models" : kind === "weekly_scoped" ? `Weekly${name ? ` · ${name}` : ""}` : name ? `${group ? group[0].toUpperCase() + group.slice(1) + " · " : ""}${name}` : undefined; + if (label) ws.push(window(label, used, date(raw.resets_at))); + } + if (!ws.length) { + const add = (v: unknown, label: string) => { const o = object(v), used = number(o.used_percentage); if (used !== undefined) ws.push(window(label, used, date(o.resets_at))); }; + add(r.five_hour, "5-hour"); + if (array(r.weekly).length) for (const e of array(r.weekly)) add(e, displayText(object(e).label) ?? "Weekly"); + else add(r.seven_day, "7-day"); + for (const key of Object.keys(r).filter(k => k.startsWith("seven_day_")).sort()) { const name = displayText(key.slice(10)); if (name) add(r[key], `7-day (${name[0].toUpperCase() + name.slice(1)})`); } + } + const q = quota("claude", ws, date(r.updated_at) ?? now), e = object(r.extra_usage), used = number(e.used_credits), limit = number(e.monthly_limit); + if (e.is_enabled === true && used !== undefined && limit !== undefined) q.credits = { balance: limit <= 0 ? 0 : (limit - used) / 100, hasCredits: true, unlimited: limit <= 0, unit: "dollars", usedDollars: used / 100, ...(limit > 0 ? { limitDollars: limit / 100 } : {}) }; + return q; +} +export const claudeTierSelection = { oauthAccount: { organizationRateLimitTier: true, seatTier: true, userRateLimitTier: true } } as const; +export function parseClaudePlan(s: string): string { + const r = object(selectJSON(s, claudeTierSelection)), a = object(r.oauthAccount); + const labels: Record = { max_20x: "Max 20×", max20x: "Max 20×", max_5x: "Max 5×", max5x: "Max 5×", pro: "Pro", free: "Free", team: "Team", enterprise: "Enterprise" }; + for (const tier of [a.organizationRateLimitTier, a.seatTier, a.userRateLimitTier].map(text)) { + const key = tier?.toLowerCase().replace(/^default_claude_/, ""), label = key && Object.hasOwn(labels, key) ? labels[key] : undefined; + if (label) return label; + } + throw new Unavailable("noData"); +} +export function parseOpenRouter(s: string, creditsData?: string, now = Date.now()): Quota { + const r = object(json(s).data), usedRaw = number(r.usage), used = usedRaw === undefined ? undefined : Math.max(0, usedRaw), limit = number(r.limit); + const remainder = number(r.limit_remaining), remaining = remainder === undefined ? limit === undefined ? undefined : Math.max(0, limit - (used ?? 0)) : Math.max(0, remainder); + if (used === undefined && limit === undefined && remaining === undefined) throw new Unavailable("noData"); + const reset = text(r.limit_reset)?.toLowerCase(), labels: Record = { daily: "Daily", weekly: "Weekly", monthly: "Monthly" }; + const q = quota("openRouter", used !== undefined && limit !== undefined && limit > 0 ? [window(labels[reset ?? ""] ?? "Spending limit", used / limit * 100)] : [], now); + q.credits = { balance: remaining ?? 0, hasCredits: remaining === undefined ? (used ?? 0) > 0 : remaining > 0, unlimited: limit === undefined, unit: "dollars", usedDollars: used, limitDollars: limit }; + if (creditsData) { + try { const c = object(json(creditsData).data), total = number(c.total_credits), spend = number(c.total_usage); if (total !== undefined) { const balance = Math.max(0, Math.max(0, total) - Math.max(0, spend ?? 0)); q.credits = { balance, hasCredits: balance > 0, unlimited: false, unit: "dollars", usedDollars: spend === undefined ? undefined : Math.max(0, spend), limitDollars: Math.max(0, total) }; } } catch { /* Optional balance never hides valid key usage. */ } + } + return q; +} +export function parseOpenCode(s: string, now = Date.now()): Quota { + const r = json(s), e = text(object(r.error).type)?.toLowerCase(); + if (e) throw e === "autherror" || e === "invalid_api_key" ? new Unavailable("notSignedIn", "OpenCode Go") : failed("openCodeGo"); + const u = object(r.usage), ws = ([ ["rolling", "Rolling", 1440], ["weekly", "Weekly", 10080], ["monthly", "Monthly", 43200] ] as const).map(([key, label, mins]) => { + const w = object(u[key]), percent = number(w.percent); + if (percent === undefined || !text(w.status)) throw failed("openCodeGo"); + const reset = date(w.resetsAt); if (w.resetsAt != null && reset === undefined) throw failed("openCodeGo"); + return window(label, percent, reset, mins); + }); + return { ...quota("openCodeGo", ws, now), planType: "go" }; +} +export function parseGrok(s: string, now = Date.now()): Quota { + const r = json(s); + if (r.error) { const e = (text(r.error) ?? text(object(r.error).type) ?? "").toLowerCase(); throw /invalid|expired|unauth|not signed|autherror/.test(e) ? new Unavailable("notSignedIn", "Grok") : failed("grok"); } + const c = object(r.config), period = object(c.currentPeriod), reset = date(c.billingPeriodEnd) ?? date(period.end), used = number(c.creditUsagePercent); + if (reset === undefined || used === undefined) throw new Unavailable("noData"); + const kind = text(period.type)?.toUpperCase(), label = kind === "USAGE_PERIOD_TYPE_WEEKLY" ? "Weekly" : kind === "USAGE_PERIOD_TYPE_MONTHLY" ? "Monthly" : "Usage"; + return { ...quota("grok", [window(label, used, reset)], now), planType: displayText(r.subscriptionTier ?? r.subscription_tier) }; +} +export function parseLocalQuota(p: "cursor" | "copilot" | "gemini", s: string, now = Date.now()): Quota { + const r = json(s), key = p === "cursor" ? "fast_requests" : p === "copilot" ? "premium_requests" : "requests", nested = object(r[key]); + const n = number(nested.used_percent), root = number(r.used_percent), used = n ?? root; + const label = n === undefined ? p === "gemini" ? "Daily" : "Monthly" : p === "cursor" ? "Fast requests" : p === "copilot" ? "Premium requests" : "Daily"; + const q = quota(p, used === undefined ? [] : [window(label, used, n === undefined ? undefined : date(nested.resets_at), p === "gemini" ? 1440 : 43200)], now); + q.planType = displayText(r.plan) ?? (p === "cursor" ? "Pro" : p === "copilot" ? "Individual" : "Developer"); return q; +} +export function parseAntigravity(s: string, now = Date.now()): Quota { + const rows = s.split(/\r?\n/).flatMap(line => { const parts = line.split("\t"); if (parts.length !== 4 || !parts[2].trim().endsWith("%")) return []; const remaining = number(parts[2].trim().slice(0, -1)), group = displayText(parts[0]), label = displayText(parts[1].replace(" Limit Remaining", "")); return remaining === undefined || !group || !label ? [] : [{ remaining, group, label, reset: date(parts[3]) }]; }); + if (!rows.length) throw new Unavailable("noData"); + const short = (s: string) => s === "Five Hour" ? "5-hour" : s; + const duration = (s: string) => /week/i.test(s) ? 10080 : /five|5|hour/i.test(s) ? 300 : undefined; + const q = quota("antigravity", rows.map(r => window(`${/claude|gpt/i.test(r.group) ? "Claude/GPT" : /gemini/i.test(r.group) ? "Gemini" : r.group} ${short(r.label)}`, 100 - r.remaining, r.reset, duration(r.label))), now); + for (const group of new Set(rows.map(r => r.group))) q.groups.push({ id: group.toLowerCase(), title: group, windows: rows.filter(r => r.group === group).map(r => window(short(r.label) + (/limit/i.test(short(r.label)) ? "" : " limit"), 100 - r.remaining, r.reset, duration(r.label))) }); + return q; +} +export function parseStatus(p: Provider, s: string, now = Date.now()): ServiceStatus { + const fragments = p === "codex" ? ["codex", "cli", "login"] : p === "claude" ? ["api", "claude", "code", "console"] : ["api"]; + const components = array(json(s).components).map(object).filter(c => c.group !== true), relevant = components.filter(c => fragments.some(f => (text(c.name) ?? "").toLowerCase().includes(f))); + const pool = relevant.length ? relevant : components; + const map: Record = { operational: "operational", degraded_performance: "degraded", under_maintenance: "degraded", partial_outage: "partialOutage", major_outage: "majorOutage", full_outage: "majorOutage" }; + const rank: Severity[] = ["operational", "unknown", "degraded", "partialOutage", "majorOutage"]; + let severity: Severity = pool.length ? "operational" : "unknown", culprit: string | undefined; + for (const c of pool) { const v = map[(text(c.status) ?? "").toLowerCase()] ?? "unknown"; if (rank.indexOf(v) > rank.indexOf(severity)) { severity = v; culprit = displayText(c.name); } } + return { provider: p, severity, description: severity === "operational" ? "All systems operational" : culprit ? `${culprit}: ${severity === "partialOutage" ? "partial outage" : severity === "majorOutage" ? "major outage" : severity}` : "Status unavailable", checkedAt: now }; +} diff --git a/src/main/providers/transport.ts b/src/main/providers/transport.ts new file mode 100644 index 0000000..b570448 --- /dev/null +++ b/src/main/providers/transport.ts @@ -0,0 +1,142 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { constants, accessSync } from "node:fs"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { Unavailable, failed, type Provider } from "../../domain/models"; +import { selectJSON } from "../select-json"; +import { object } from "./parsers"; + +export interface CommandOptions { env?: NodeJS.ProcessEnv; signal?: AbortSignal; timeoutMs?: number; maxBytes?: number } +export type Command = (binary: string, args: string[], options?: CommandOptions) => Promise; +export function cliPath(name: string, home: string, env: NodeJS.ProcessEnv): string { + const paths = ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), ...(name === "opencode" ? [join(home, ".bun/bin")] : []), join(home, ".local/bin"), ...(env.PATH ?? "/usr/bin:/bin").split(":")]; + for (const dir of paths) { const path = join(dir, name); try { accessSync(path, constants.X_OK); return path; } catch { /* Probe common GUI install paths. */ } } + throw new Unavailable("cliNotFound", name); +} +export function cliEnvironment(home: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return { ...env, PATH: ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), join(home, ".local/bin"), env.PATH ?? "/usr/bin:/bin"].join(":") }; +} +// Provider stderr may contain credentials or paths. Drain it, but never retain +// or propagate it. All failures crossing this boundary use fixed messages. +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 }); + let outcome: string | Error | undefined, killTimer: ReturnType | undefined, bytes = 0; + const stop = () => { if (child.exitCode === null) { child.kill("SIGTERM"); killTimer ??= setTimeout(() => child.kill("SIGKILL"), 250); } }; + const finish = (result: string | Error) => { if (outcome !== undefined) return; outcome = result; stop(); }; + const timer = setTimeout(() => finish(new Error("Provider request timed out")), options.timeoutMs ?? 20000); + const abort = () => finish(new Error("Provider request cancelled")); + options.signal?.addEventListener("abort", abort, { once: true }); + child.stderr.resume(); + child.stdout.on("data", (data: Buffer) => { bytes += data.length; if (bytes > (options.maxBytes ?? 16 * 1048576)) finish(new Error("Provider output exceeded limit")); }); + child.stdin.on("error", () => finish(new Error("Provider input closed"))); + child.on("error", () => finish(new Error("Provider process unavailable"))); + child.on("close", () => { + clearTimeout(timer); if (killTimer) clearTimeout(killTimer); options.signal?.removeEventListener("abort", abort); + const result = outcome ?? new Error("Provider process exited"); + if (result instanceof Error) reject(result); else resolve(result); + }); + exchange(child, finish); + }); +} +export const runCommand: Command = (binary, args, options = {}) => boundedChild(binary, args, options, (child, finish) => { + const chunks: Buffer[] = []; + let ended = false, exitCode: number | null | undefined; + const complete = () => { if (ended && exitCode !== undefined) finish(exitCode === 0 ? Buffer.concat(chunks).toString("utf8") : new Error("Provider command failed")); }; + let capturedBytes = 0; + child.stdout.on("data", (chunk: Buffer) => { capturedBytes += chunk.length; if (capturedBytes <= (options.maxBytes ?? 16 * 1048576)) chunks.push(chunk); }); + child.stdout.on("end", () => { ended = true; complete(); }); + child.on("exit", code => { exitCode = code; complete(); }); + child.stdin.end(); +}); +export async function codexRPC(binary: string, options: CommandOptions, creditID?: string, configuration: string[] = [], operation: "read" | "logout" | "usage" = "read"): Promise { + 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" : operation === "usage" ? "account/usage/read" : 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) => { + pending += chunk; + let newline: number; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); pending = pending.slice(newline + 1); + 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: {} }); + 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); + } + }); + write({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "meterusage", version: "0.2.41" }, capabilities: { experimentalApi: true } } }); + }); +} +export const endpoints = { + openRouterKey: "https://openrouter.ai/api/v1/key", + openRouterCredits: "https://openrouter.ai/api/v1/credits", + 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", + claudeBootstrap: "https://claude.ai/api/bootstrap", + cursorIdentity: "https://cursor.com/api/auth/me", + cursorGrok: "https://cursor.com/api/dashboard/get-sand-usage-status", + codexStatus: "https://status.openai.com/api/v2/components.json", + claudeStatus: "https://status.claude.com/api/v2/components.json", +} as const; +const claudeUsagePattern = /^https:\/\/claude\.ai\/api\/organizations\/[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}\/usage$/; +export const isClaudeEndpoint = (url: string): boolean => url === endpoints.claudeBootstrap || claudeUsagePattern.test(url); +export function claudeUsageURL(organization: string): `https://claude.ai/api/organizations/${string}/usage` { + const url = `https://claude.ai/api/organizations/${organization}/usage` as const; + if (!claudeUsagePattern.test(url)) throw new Unavailable("notSignedIn", "Claude Desktop"); + return url; +} +export type Endpoint = typeof endpoints[keyof typeof endpoints] | ReturnType; +export type HTTP = (url: Endpoint, provider: Provider, key?: string, signal?: AbortSignal) => Promise; +export class ClaudeBrowserVerification extends Unavailable { + constructor() { super("notSignedIn", "Claude Desktop"); } +} +export function httpTransport(fetcher: typeof fetch = fetch): HTTP { + return async (url, provider, key, signal) => { + if (!(Object.values(endpoints) as string[]).includes(url) && !claudeUsagePattern.test(url)) throw failed(provider); + const controller = new AbortController(); + const boundedSignal = AbortSignal.any([controller.signal, ...(signal ? [signal] : []), AbortSignal.timeout(30000)]); + let response: Response | undefined; + let reader: ReadableStreamDefaultReader | undefined; + try { + boundedSignal.throwIfAborted(); + const claude = isClaudeEndpoint(url); + if (claude && (provider !== "claude" || !key || !/^sessionKey=[A-Za-z0-9._~-]{1,16384}$/.test(key)) || !claude && key?.startsWith("sessionKey=")) throw new Unavailable("notSignedIn", "Claude Desktop"); + const cursor = url === endpoints.cursorIdentity || url === endpoints.cursorGrok; + if (cursor && (provider !== "grok" || !key || key.length > 17000 || !/^WorkosCursorSessionToken=[A-Za-z0-9._-]+%3A%3A[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(key))) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + response = await fetcher(url, { signal: boundedSignal, redirect: "error", cache: "no-store", ...(url === endpoints.cursorGrok ? { method: "POST", body: "{}" } : {}), headers: { Accept: "application/json", "User-Agent": "meterusage/0.2.41", ...(url === endpoints.cursorGrok ? { "Content-Type": "application/json", Origin: "https://cursor.com" } : {}), ...(key ? cursor || claude ? { Cookie: key } : { Authorization: `Bearer ${key}` } : {}) } }); + boundedSignal.throwIfAborted(); + if (claude && response.status === 403 && response.headers.get("cf-mitigated") === "challenge") throw new ClaudeBrowserVerification(); + if (cursor && response.status === 403) throw new Unavailable("notSignedIn", "Grok Bot via Cursor"); + if (response.status === 401 || response.status === 403 && claude) 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); + reader = response.body.getReader(); + const chunks: Uint8Array[] = []; let size = 0; + while (true) { const part = await reader.read(); if (part.done) break; size += part.value.length; if (size > (cursor || claude ? 1048576 : 16 * 1048576)) throw failed(provider); chunks.push(part.value); } + boundedSignal.throwIfAborted(); + return Buffer.concat(chunks).toString("utf8"); + } catch (error) { + controller.abort(); + if (error instanceof Unavailable) throw error; + const code = object(object(error).cause).code; + if (["ENOTFOUND", "ENETUNREACH", "ECONNRESET", "EHOSTUNREACH"].includes(String(code))) throw new Unavailable("offline"); + throw failed(provider); + } finally { + if (reader) await reader.cancel().catch(() => {}); + else if (response?.body && !response.body.locked) await response.body.cancel().catch(() => {}); + } + }; +} diff --git a/src/main/providers/usage.ts b/src/main/providers/usage.ts new file mode 100644 index 0000000..52bbb50 --- /dev/null +++ b/src/main/providers/usage.ts @@ -0,0 +1,69 @@ +import { type Usage, type Tokens, type Daily, tokens, addTokens, totalTokens, localDay, utcDay, weekStart, Unavailable, failed } from "../../domain/models"; +import { telemetry } from "../../domain/telemetry"; +import { selectJSON } from "../select-json"; +import { object, number, date, text } from "./parsers"; +import { projectName, filesIn } from "./activity"; +import { readFile } from "node:fs/promises"; +export const openCodeQuery = `SELECT tokens_input AS input, tokens_output AS output, tokens_reasoning AS reasoning, + tokens_cache_read AS cache_read, tokens_cache_write AS cache_write, cost, + (SELECT COUNT(*) FROM message WHERE message.session_id = session.id) AS messages, + time_created AS created, time_updated AS updated, directory + FROM session WHERE json_extract(model, '$.providerID') = 'opencode-go'`; +export interface UsageRecord { tokens: Tokens; cost: number; messages: number; created: number; updated: number; project: string } +const count = (v: unknown) => Math.trunc(number(v) ?? 0); +export function parseOpenCodeRows(s: string): UsageRecord[] { + const rows = selectJSON(s, { '*': { input: true, output: true, reasoning: true, cache_read: true, cache_write: true, cost: true, messages: true, created: true, updated: true, directory: true } }); + if (!Array.isArray(rows)) throw failed("openCodeGo"); + return rows.flatMap(raw => { const r = object(raw), created = date(r.created), updated = date(r.updated); return created === undefined || updated === undefined ? [] : [{ tokens: tokens({ input: count(r.input), output: count(r.output), reasoning: count(r.reasoning), cacheRead: count(r.cache_read), cacheWrite: count(r.cache_write) }), cost: number(r.cost) ?? 0, messages: count(r.messages), created, updated, project: text(r.directory) ? projectName(text(r.directory)!) : "" }]; }); +} +export function openCodeUsage(records: UsageRecord[], now: number): Usage { + if (!records.length) throw new Unavailable("dataNotFound", "OpenCode Go usage"); + const sum = (rs: UsageRecord[]) => rs.reduce((t, r) => addTokens(t, r.tokens), tokens()); + const cost = (rs: UsageRecord[]) => rs.reduce((n, r) => n + r.cost, 0); + const messages = (rs: UsageRecord[]) => rs.reduce((n, r) => n + r.messages, 0); + const todayCreated = records.filter(r => r.created >= localDay(now)), todayTouched = records.filter(r => r.updated >= localDay(now)), weekTouched = records.filter(r => r.updated >= weekStart(now)); + const todayTokens = sum(todayTouched), weekTokens = sum(weekTouched), projects = new Map(); + for (const r of weekTouched) projects.set(r.project, addTokens(projects.get(r.project) ?? tokens(), r.tokens)); + const projectBreakdown = [...projects].filter(([, t]) => totalTokens(t) > 0).map(([project, tokens]) => ({ project, tokens })).sort((a, b) => totalTokens(b.tokens) - totalTokens(a.tokens)); + return { provider: "openCodeGo", sessionCount: records.length, messageCount: messages(records), tokens: sum(records), estimatedCostUSD: cost(records), todaySessionCount: todayCreated.length, todayMessageCount: messages(todayCreated), ...(totalTokens(todayTokens) > 0 ? { todayTokens } : {}), ...(totalTokens(weekTokens) > 0 ? { weekTokens } : {}), ...(projectBreakdown.length ? { projectBreakdown } : {}), ...(cost(todayTouched) > 0 ? { todayCostUSD: cost(todayTouched) } : {}), usageWindows: ([ ["last 24h", 1], ["last 7d", 7], ["last 30d", 30] ] as const).map(([label, days]) => { const rs = records.filter(r => r.updated >= now - days * 86400000); return { label, sessionCount: rs.length, messageCount: messages(rs), tokens: sum(rs), estimatedCostUSD: cost(rs) }; }), telemetry: telemetry(records.map(r => ({ startedAt: r.created, endedAt: r.updated, tokens: totalTokens(r.tokens), messageCount: r.messages })), [], now), capturedAt: Math.max(...records.map(r => r.updated)) }; +} +const grokSelection = { created_at: true, createdAt: true, last_active_at: true, updated_at: true, updatedAt: true, num_chat_messages: true, num_messages: true, message_count: true, messageCount: true } as const; +export function grokUsage(summaries: string[], now: number): Usage { + const records = summaries.flatMap(s => { + try { const r = object(selectJSON(s, grokSelection)), created = date(r.created_at) ?? date(r.createdAt), endedAt = date(r.last_active_at) ?? date(r.updated_at) ?? date(r.updatedAt), messages = number(r.num_chat_messages) ?? number(r.num_messages) ?? number(r.message_count) ?? number(r.messageCount); return created === undefined || messages === undefined || messages <= 0 ? [] : [{ startedAt: created, endedAt, tokens: 0, messageCount: Math.trunc(messages) }]; } catch { return []; } + }); + if (!records.length) throw new Unavailable("noData"); + const today = records.filter(r => r.startedAt >= localDay(now)); + return { provider: "grok", sessionCount: records.length, messageCount: records.reduce((n, r) => n + r.messageCount, 0), todaySessionCount: today.length, todayMessageCount: today.reduce((n, r) => n + r.messageCount, 0), telemetry: telemetry(records, [], now), capturedAt: Math.max(...records.map(r => r.startedAt)) }; +} +export async function scanGrok(root: string, now: number, signal?: AbortSignal): Promise { + const paths = (await filesIn(root, "summary.json", true, signal)).filter(p => p.endsWith("/summary.json")); + if (!paths.length) throw new Unavailable("dataNotFound", "Grok session history"); + const summaries = await Promise.all(paths.map(p => readFile(p, { encoding: "utf8", signal }).catch(e => { signal?.throwIfAborted(); return ""; }))); return grokUsage(summaries, now); +} +export function antigravityHistory(s: string, now: number): Usage { + const byID = new Map(); + for (const line of s.split("\n")) { + try { const r = object(selectJSON(line, { conversationId: true, timestamp: true })), id = text(r.conversationId); if (!id || r.timestamp === undefined) continue; const record = byID.get(id) ?? { messages: 0 }; record.messages++; const at = date(r.timestamp); if (at !== undefined) record.start = Math.min(record.start ?? Infinity, at); byID.set(id, record); } catch { /* Bad history rows are skipped. */ } + } + const records = [...byID.values()].filter(r => r.start !== undefined); if (!records.length) throw new Unavailable("noData"); + const today = records.filter(r => r.start! >= localDay(now)); + return { provider: "antigravity", sessionCount: records.length, messageCount: records.reduce((n, r) => n + r.messages, 0), todaySessionCount: today.length, todayMessageCount: today.reduce((n, r) => n + r.messages, 0), capturedAt: now }; +} +export function openRouterUsage(s: string, now: number): Usage { + const root = object(selectJSON(s, { data: { '*': { date: true, prompt_tokens: true, completion_tokens: true, reasoning_tokens: true, requests: true, usage: true } } })); + if (!Array.isArray(root.data)) throw failed("openRouter"); + const map = new Map(); + for (const raw of root.data) { + const r = object(raw), day = typeof r.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(r.date) ? Date.parse(r.date + "T00:00:00Z") : NaN; + if (!Number.isFinite(day)) continue; + const prior = map.get(day) ?? { day, tokens: tokens(), sessionCount: 0, estimatedCostUSD: 0 }; + const t = tokens({ input: Math.max(0, count(r.prompt_tokens)), output: Math.max(0, count(r.completion_tokens)), reasoning: Math.max(0, count(r.reasoning_tokens)) }); + map.set(day, { day, tokens: addTokens(prior.tokens, t), sessionCount: prior.sessionCount + Math.max(0, count(r.requests)), estimatedCostUSD: prior.estimatedCostUSD + Math.max(0, number(r.usage) ?? 0) }); + } + const daily = [...map.values()].sort((a, b) => a.day - b.day), total = daily.reduce((a, d) => ({ tokens: addTokens(a.tokens, d.tokens), requests: a.requests + d.sessionCount, cost: a.cost + d.estimatedCostUSD }), { tokens: tokens(), requests: 0, cost: 0 }); + const today = map.get(utcDay(now)) ?? { tokens: tokens(), sessionCount: 0, estimatedCostUSD: 0 }, week = daily.filter(d => d.day >= utcDay(now) - 6 * 86400000).reduce((t, d) => addTokens(t, d.tokens), tokens()); + const calculated = telemetry([], daily, now, true); + const measured = { ...calculated, lifetimeTokens: calculated.lifetimeTokens ?? 0, peakDailyTokens: calculated.peakDailyTokens ?? 0, todayTokens: calculated.todayTokens ?? 0, last30DaysTokens: calculated.last30DaysTokens ?? 0 }; + return { provider: "openRouter", sessionCount: total.requests, messageCount: total.requests, tokens: total.tokens, estimatedCostUSD: total.cost > 0 ? total.cost : undefined, todaySessionCount: today.sessionCount, todayMessageCount: today.sessionCount, todayTokens: totalTokens(today.tokens) > 0 ? today.tokens : undefined, weekTokens: totalTokens(week) > 0 ? week : undefined, todayCostUSD: today.estimatedCostUSD > 0 ? today.estimatedCostUSD : undefined, usageWindows: [{ label: "last 24h", sessionCount: today.sessionCount, messageCount: today.sessionCount, tokens: today.tokens, estimatedCostUSD: today.estimatedCostUSD }, { label: "last 30d", sessionCount: total.requests, messageCount: total.requests, tokens: total.tokens, estimatedCostUSD: total.cost }], telemetry: measured, capturedAt: now }; +} diff --git a/src/main/quota-activity.ts b/src/main/quota-activity.ts new file mode 100644 index 0000000..cbc57e0 --- /dev/null +++ b/src/main/quota-activity.ts @@ -0,0 +1,101 @@ +import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { type Quota, type QuotaWindow, localDay } from "../domain/models"; +import { atomicJSON } from "./history"; + +// Builds each provider's heatmap from its own quota readings. Desktop and web +// clients leave no local transcripts, but quota readings are account-wide, so +// the rise in a provider's tracking window covers every device. Each slot keeps +// its own series; providers never share one. Only labels, percentages, and +// dates are stored. The first reading spreads the window's existing usage over +// its elapsed days as an estimate; later rises land on the day observed, and a +// rise seen after the Mac was offline lands on the day of the next reading. + +export interface QuotaDay { day: number; percent: number; estimated?: boolean } +interface Series { label: string; lastPercent: number; lastResetsAt?: number; days: QuotaDay[]; seeded?: boolean } + +const retentionDays = 200; +const weekMins = 10080; + +function durationMins(w: QuotaWindow) { + if (w.windowDurationMins && w.windowDurationMins > 0) return w.windowDurationMins; + const l = w.label.toLowerCase(); + if (l.includes("weekly") || l.includes("7-day") || l.includes("7 day")) return weekMins; + if (l.includes("month")) return 43200; + if (l.includes("daily") || l.includes("24-hour")) return 1440; + if (l.includes("5-hour") || l.includes("session")) return 300; + return 0; +} + +/** The weekly all-models window when present, else the longest reported window. */ +export function trackingWindow(q: Quota): QuotaWindow | undefined { + const windows = q.windows.length ? q.windows : q.groups.flatMap(g => g.windows); + const weekly = windows.filter(w => durationMins(w) === weekMins); + return weekly.find(w => w.label.toLowerCase().includes("all")) ?? weekly[0] ?? [...windows].sort((a, b) => durationMins(b) - durationMins(a))[0]; +} + +/** Spreads the usage already in the current window evenly over its elapsed + * local days, marked estimated: when it happened inside the window is unknown. */ +function seed(series: Series, w: QuotaWindow, at: number) { + series.seeded = true; + if (!(w.usedPercent > 0)) return; + const mins = durationMins(w), start = w.resetsAt !== undefined && mins ? Math.max(w.resetsAt - mins * 60_000, at - retentionDays * 86_400_000) : at; + const days: number[] = []; + for (let d = localDay(Math.min(start, at)); d <= localDay(at); d = localDay(d + 36 * 3_600_000)) days.push(d); + const share = w.usedPercent / days.length; + for (const day of days) { + const found = series.days.find(d => d.day === day); + if (found) { found.percent += share; found.estimated = true; } else series.days.push({ day, percent: share, estimated: true }); + } + series.days.sort((a, b) => a.day - b.day); +} + +function validSeries(v: unknown): v is Series { + const s = v as Series; + return !!s && (s.seeded === undefined || typeof s.seeded === "boolean") && typeof s.label === "string" && Number.isFinite(s.lastPercent) && (s.lastResetsAt === undefined || Number.isFinite(s.lastResetsAt)) && Array.isArray(s.days) && s.days.every(d => Number.isFinite(d?.day) && Number.isFinite(d?.percent)); +} + +export class QuotaActivityStore { + private data: Record = Object.create(null); + constructor(readonly path: string) { + try { + const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) for (const [key, s] of Object.entries(parsed)) if (validSeries(s)) this.data[key] = s; + } catch { /* A missing or unreadable file starts a fresh history. */ } + } + days(key: string): QuotaDay[] { return (this.data[key]?.days ?? []).map(d => ({ ...d })); } + retire(key: string) { + if (!this.data[key]) return; + // Preserve old observations without attributing them to the replacement account. + this.data[`${key}#retired-${randomUUID()}`] = this.data[key]; + delete this.data[key]; this.save(); + } + record(key: string, q: Quota, at: number) { + const w = trackingWindow(q); if (!w) return; + const prior = this.data[key], current = w.usedPercent; + if (!prior || prior.label !== w.label) { + const series: Series = { label: w.label, lastPercent: current, ...(w.resetsAt === undefined ? {} : { lastResetsAt: w.resetsAt }), days: prior?.days ?? [] }; + this.data[key] = series; seed(series, w, at); + return this.save(); + } + // Series baselined before seeding existed get the current window once. + if (!prior.seeded) { + // Rises already recorded inside the window are not counted twice. + const resetsAt = prior.lastResetsAt ?? w.resetsAt, mins = durationMins(w), windowStart = resetsAt !== undefined && mins ? localDay(resetsAt - mins * 60_000) : localDay(at); + const recorded = prior.days.filter(d => d.day >= windowStart).reduce((n, d) => n + d.percent, 0); + seed(prior, { ...w, usedPercent: Math.max(0, prior.lastPercent - recorded), resetsAt }, at); + } + const rolled = (prior.lastResetsAt !== undefined && w.resetsAt !== undefined && Math.abs(w.resetsAt - prior.lastResetsAt) > 3_600_000) || current + 0.5 < prior.lastPercent; + const delta = rolled ? current : Math.max(0, current - prior.lastPercent); + prior.lastPercent = current; + if (w.resetsAt === undefined) delete prior.lastResetsAt; else prior.lastResetsAt = w.resetsAt; + if (delta > 0) { + const day = localDay(at), found = prior.days.find(d => d.day === day); + if (found) found.percent += delta; else prior.days.push({ day, percent: delta }); + const cutoff = localDay(at - retentionDays * 86_400_000); + prior.days = prior.days.filter(d => d.day >= cutoff).sort((a, b) => a.day - b.day); + } + this.save(); + } + private save() { try { atomicJSON(this.path, this.data); } catch { /* History is best effort. */ } } +} diff --git a/src/main/select-json.ts b/src/main/select-json.ts new file mode 100644 index 0000000..871c697 --- /dev/null +++ b/src/main/select-json.ts @@ -0,0 +1,67 @@ +export type Selection = true | { [key: string]: Selection }; + +// Walk syntax without decoding values outside the declared selection. In +// particular, Claude identity fields and transcript content are never passed +// to JSON.parse or materialized as JavaScript values. +export function selectJSON(source: string, selection: Selection, onScalar?: (raw: string) => void, rejectDuplicateKeys = false): unknown { + let i = 0; + const scalar = /(?:null|true|false|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/y; + const bad = (): never => { throw new Error("Invalid selected JSON"); }; + const ws = () => { while (i < source.length && /[\t\r\n ]/.test(source[i])) i++; }; + function stringEnd(): number { + if (source[i++] !== '"') return bad(); + while (i < source.length) { + const c = source[i++]; + if (c === '"') return i; + if (c.charCodeAt(0) < 32) return bad(); + if (c === "\\") { + const e = source[i++]; + if (e === "u") { if (!/^[0-9a-fA-F]{4}$/.test(source.slice(i, i + 4))) return bad(); i += 4; } + else if (!e || !'"\\/bfnrt'.includes(e)) return bad(); + } + } + return bad(); + } + function value(shape: Selection | undefined, depth: number): unknown { + if (depth > 256) return bad(); + ws(); + const start = i, c = source[i]; + if (c === "{" || c === "[") { + i++; + const object = c === "{", end = object ? "}" : "]"; + const selected = typeof shape === "object"; + const result: Record | unknown[] | undefined = selected ? object ? Object.create(null) : [] : undefined; + const keys = object && rejectDuplicateKeys ? new Set() : undefined; + ws(); if (source[i] === end) { i++; return result; } + while (true) { + ws(); let key: string | undefined; + if (object) { + const keyStart = i; stringEnd(); + // Object keys describe the selection; unknown values are skipped. + key = selected || keys ? JSON.parse(source.slice(keyStart, i)) as string : undefined; + if (keys?.has(key!)) return bad(); + keys?.add(key!); + ws(); if (source[i++] !== ":") return bad(); + } + const child = selected ? object ? Object.hasOwn(shape, key!) ? shape[key!] : shape["*"] : shape["*"] : undefined; + const v = value(child, depth + 1); + if (result && child !== undefined && v !== undefined) { + if (Array.isArray(result)) result.push(v); else result[key!] = v; + } + ws(); const next = source[i++]; + if (next === end) return result; + if (next !== ",") return bad(); + } + } + if (c === '"') stringEnd(); + else { + scalar.lastIndex = i; + const m = scalar.exec(source); + if (!m) return bad(); i += m[0].length; + } + if (shape !== true) return; + const raw = source.slice(start, i); onScalar?.(raw); + return JSON.parse(raw) as unknown; + } + const result = value(selection, 0); ws(); if (i !== source.length) return bad(); return result; +} diff --git a/src/main/update-archive.ts b/src/main/update-archive.ts new file mode 100644 index 0000000..55d5f4b --- /dev/null +++ b/src/main/update-archive.ts @@ -0,0 +1,45 @@ +import { fromBuffer, type Entry } from "yauzl"; +import { posix } from "node:path"; + +// Inspect central-directory entries and link payloads before any extraction. +// No archive entry may write through another entry's symlink. +export function inspectUpdateArchive(data: Buffer): Promise { + return new Promise((resolve, reject) => fromBuffer(data, { lazyEntries: true, strictFileNames: true }, (error, zip) => { + if (error || !zip) { reject(new Error("Invalid update archive")); return; } + const names = new Set(), links = new Set(); let size = 0, finished = false; + const fail = () => { if (!finished) { finished = true; zip.close(); reject(new Error("Invalid update archive")); } }; + zip.on("error", fail); + zip.on("entry", (entry: Entry) => { + if (finished) return; + const path = entry.fileName.replace(/\/$/, ""), parts = path.split("/"), type = (entry.externalFileAttributes >>> 16) & 0xf000; + size += entry.uncompressedSize; + if (!path.startsWith("MeterUsage.app/") && path !== "MeterUsage.app" || parts.some(p => !p || p === "." || p === "..") || /[\\\x00-\x1f\x7f]/.test(path) || names.has(path) || entry.isEncrypted() || size > 2 * 1024 ** 3 || names.size >= 100000 || ![0, 0x4000, 0x8000, 0xa000].includes(type)) { fail(); return; } + names.add(path); + if (type !== 0xa000) { zip.readEntry(); return; } + if (entry.uncompressedSize > 4096) { fail(); return; } + links.add(path); + zip.openReadStream(entry, (error, stream) => { + if (error || !stream) { fail(); return; } + const chunks: Buffer[] = []; let length = 0; + stream.on("error", fail); + stream.on("data", (chunk: Buffer) => { length += chunk.length; if (length > 4096) { stream.destroy(); fail(); } else chunks.push(chunk); }); + stream.on("end", () => { + if (finished) return; + const target = Buffer.concat(chunks).toString("utf8"), resolved = posix.normalize(posix.join(posix.dirname(path), target)); + if (!target || posix.isAbsolute(target) || /[\\\x00-\x1f\x7f]/.test(target) || resolved !== "MeterUsage.app" && !resolved.startsWith("MeterUsage.app/")) { fail(); return; } + zip.readEntry(); + }); + }); + }); + zip.on("end", () => { + if (finished) return; + if (!names.has("MeterUsage.app/Contents/Info.plist") || !names.has("MeterUsage.app/Contents/MacOS/meterusage")) { fail(); return; } + for (const name of names) { + let parent = posix.dirname(name); + while (parent !== ".") { if (links.has(parent)) { fail(); return; } parent = posix.dirname(parent); } + } + finished = true; resolve(); + }); + zip.readEntry(); + })); +} diff --git a/src/main/updater.ts b/src/main/updater.ts new file mode 100644 index 0000000..3f6b6cc --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,147 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, writeFile, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative, basename, dirname } from "node:path"; +import { spawn } from "node:child_process"; +import { inspectUpdateArchive } from "./update-archive"; +import { selectJSON } from "./select-json"; +import { Preferences } from "./preferences"; +import { runCommand, type Command } from "./providers/transport"; +export interface Release { version: string; tag: string; asset: { url: string; digest: string } } +export type InstallState = "idle" | "downloading" | "installing" | "failed"; +export const releaseEndpoint = "https://api.github.com/repos/pekth/meterusage/releases/latest"; +/** Beta builds read the release list: GitHub's "latest" never includes prereleases. */ +export const releasesEndpoint = "https://api.github.com/repos/pekth/meterusage/releases?per_page=20"; +const versionPattern = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/i; +export const isPrerelease = (version: string) => /-beta\.\d+$/i.test(version); +/** Semantic order for x.y.z and x.y.z-beta.n; a final release outranks its betas. */ +export function newer(candidate: string, current: string) { + const parse = (v: string) => { const m = v.trim().match(versionPattern); return m ? [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? Infinity : Number(m[4])] : undefined; }; + const a = parse(candidate), b = parse(current); + if (!a || !b) return false; + for (let i = 0; i < 4; i++) if (a[i] !== b[i]) return a[i] > b[i]; + return false; +} +type RawRelease = { tag_name?: unknown; draft?: unknown; assets?: { name?: unknown; browser_download_url?: unknown; digest?: unknown }[] }; +const releaseShape = { tag_name: true, draft: true, assets: { '*': { name: true, browser_download_url: true, digest: true } } } as const; +function releaseFrom(data: RawRelease): Release { + if (typeof data.tag_name !== "string" || !versionPattern.test(data.tag_name) || data.draft === true) throw new Error("Invalid release"); + const version = data.tag_name.replace(/^v/i, ""); + // Stable Swift assets are MeterUsage-.zip; Electron betas add -mac-arm64. + for (const expected of [`MeterUsage-${version}.zip`, `MeterUsage-${version}-mac-arm64.zip`]) { + const asset = data.assets?.find(a => a.name === expected), url = `https://github.com/pekth/meterusage/releases/download/${data.tag_name}/${expected}`; + if (asset && asset.browser_download_url === url && typeof asset.digest === "string" && /^sha256:[a-f\d]{64}$/i.test(asset.digest)) return { version, tag: data.tag_name, asset: { url, digest: asset.digest.toLowerCase() } }; + } + throw new Error("Invalid update asset"); +} +export function parseRelease(raw: string): Release { + return releaseFrom(selectJSON(raw, releaseShape) as RawRelease); +} +/** The newest valid, non-draft release in a GitHub release list. */ +export function parseReleases(raw: string): Release { + const list = selectJSON(raw, { '*': releaseShape }) as RawRelease[] | undefined; + if (!Array.isArray(list)) throw new Error("Invalid release list"); + let best: Release | undefined; + for (const data of list) { try { const release = releaseFrom(data); if (!best || newer(release.version, best.version)) best = release; } catch { /* Skip drafts and unusable releases. */ } } + if (!best) throw new Error("No usable release"); + return best; +} +export function verifyDigest(data: Uint8Array, expected: string) { + if (!/^sha256:[a-f\d]{64}$/i.test(expected) || createHash("sha256").update(data).digest("hex") !== expected.slice(7).toLowerCase()) throw new Error("Invalid update digest"); +} +export async function fetchUpdate(url: string, maxBytes: number, signal?: AbortSignal): Promise { + const allowed = (u: URL) => u.protocol === "https:" && !u.username && !u.password && !u.port && ["api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"].includes(u.hostname); + let target = new URL(url); const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 120000); + const abort = () => controller.abort(); signal?.addEventListener("abort", abort, { once: true }); if (signal?.aborted) abort(); + try { + for (let redirects = 0; redirects <= 3; redirects++) { + if (!allowed(target)) throw new Error("Invalid update host"); + const response = await fetch(target, { redirect: "manual", signal: controller.signal, headers: { Accept: "application/json", "User-Agent": "meterusage/0.2.41" } }); + if ([301, 302, 303, 307, 308].includes(response.status)) { + await response.body?.cancel(); const next = response.headers.get("location"); if (!next) throw new Error("Invalid redirect"); target = new URL(next, target); continue; + } + if (response.status !== 200 || !response.body || Number(response.headers.get("content-length") ?? 0) > maxBytes) { await response.body?.cancel(); throw new Error("Update download failed"); } + const chunks: Uint8Array[] = []; let size = 0; + for await (const chunk of response.body) { size += chunk.byteLength; if (size > maxBytes) { controller.abort(); throw new Error("Update too large"); } chunks.push(chunk); } + return Buffer.concat(chunks, size); + } + throw new Error("Too many update redirects"); + } finally { clearTimeout(timer); signal?.removeEventListener("abort", abort); } +} +export async function stageUpdate(release: Release, data: Uint8Array, command: Command = runCommand, signal?: AbortSignal): Promise { + verifyDigest(data, release.asset.digest); + await inspectUpdateArchive(Buffer.from(data)); signal?.throwIfAborted(); + const run: Command = (binary, args, options) => command(binary, args, { ...options, signal }); + const directory = await mkdtemp(join(tmpdir(), "MeterUsageUpdate-")), zip = join(directory, `MeterUsage-${release.version}.zip`), bundle = join(directory, "MeterUsage.app"); + try { + await writeFile(zip, data, { mode: 0o600 }); + await run("/usr/bin/ditto", ["-x", "-k", zip, directory], { timeoutMs: 30000 }); + async function checkLinks(root: string) { + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isSymbolicLink()) { const resolved = relative(bundle, await realpath(path)); if (resolved === ".." || resolved.startsWith("../")) throw new Error("Invalid bundle link"); } + else if (entry.isDirectory()) await checkLinks(path); + } + } + await checkLinks(bundle); + const plist = join(bundle, "Contents/Info.plist"); + for (const [key, expected] of [["CFBundleIdentifier", "dev.meterusage.app"], ["CFBundleExecutable", "meterusage"], ["CFBundleShortVersionString", release.version]]) { + const actual = await run("/usr/libexec/PlistBuddy", ["-c", `Print :${key}`, plist], { timeoutMs: 5000 }); if (actual.trim() !== expected) throw new Error("Invalid update bundle"); + } + await run("/usr/bin/codesign", ["--verify", "--deep", "--strict", bundle], { timeoutMs: 30000 }); + signal?.throwIfAborted(); return bundle; + } catch (e) { await rm(directory, { recursive: true, force: true }); throw e; } +} +const quote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; +export function installScript(current: string, staged: string, pid: number): string { + // Copy on the destination filesystem before moving the current bundle. + // An incomplete copy never removes the user's working application. + if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error("Invalid process"); + return `#!/bin/zsh\nset -eu\ncurrent=${quote(current)}\nstaged=${quote(staged)}\nreplacement="$current.replacement.$$"\nbackup="$current.backup.$$"\nstage="${'${staged:h}'}"\nhelper="${'${0:h}'}"\ntrap '/bin/rm -rf "$replacement" "$stage" "$helper"' EXIT\nfor attempt in {1..30}; do\n if ! /bin/kill -0 ${pid} 2>/dev/null; then break; fi\n /bin/sleep 1\ndone\nif /bin/kill -0 ${pid} 2>/dev/null; then exit 1; fi\n/usr/bin/ditto "$staged" "$replacement"\n/usr/bin/codesign --verify --deep --strict "$replacement"\n/bin/mv "$current" "$backup"\nif ! /bin/mv "$replacement" "$current"; then\n /bin/mv "$backup" "$current"\n exit 1\nfi\n/usr/bin/open "$current"\n/bin/rm -rf "$backup"\n`; +} +export class Updater { + available?: Release; + installState: InstallState = "idle"; + private check?: Promise; + private generation = 0; + 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.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.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); + if (generation !== this.generation || this.prefs.values.updateCheckEnabled !== true) return; + const beta = isPrerelease(this.currentVersion); + const raw = (await this.download(beta ? releasesEndpoint : releaseEndpoint, 1048576, controller.signal)).toString("utf8"); + const release = beta ? parseReleases(raw) : parseRelease(raw); + if (generation !== this.generation || this.prefs.values.updateCheckEnabled !== true || !newer(release.version, this.currentVersion)) return; + this.available = release; + if (this.prefs.values.updateAnnouncedVersion !== release.version) { await this.prefs.set("updateAnnouncedVersion", release.version); if (generation !== this.generation || this.prefs.values.updateCheckEnabled !== true) return; this.announce(release); } + this.changed(); + })().catch(() => { /* A release lookup failure stays separate from usage. */ }).finally(() => { if (this.check === pending) { this.check = undefined; this.checkController = undefined; } }); + this.check = pending; return pending; + } + 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.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(); + let stage: string | undefined, helper: string | undefined; + try { + const bytes = await this.download(release.asset.url, 512 * 1048576, controller.signal); guard(); + const staged = await stageUpdate(release, bytes, command, controller.signal); + stage = dirname(staged); guard(); helper = await mkdtemp(join(tmpdir(), "MeterUsageUpdateInstall-")); + const script = join(helper, "install.zsh"); await writeFile(script, installScript(current, staged, process.pid), { mode: 0o700 }); + guard(); const child = spawn("/bin/zsh", [script], { detached: true, stdio: "ignore" }); + await new Promise((resolve, reject) => { child.once("spawn", resolve); child.once("error", reject); }); + try { guard(); } catch (error) { child.kill(); throw error; } child.unref(); + this.installState = "installing"; this.changed(); + } catch { if (stage) await rm(stage, { recursive: true, force: true }); if (helper) await rm(helper, { recursive: true, force: true }); this.installState = "failed"; this.changed(); throw new Error("Could not install update"); } + finally { if (this.installController === controller) this.installController = undefined; } + } +} diff --git a/src/renderer/api-billing-setup.tsx b/src/renderer/api-billing-setup.tsx new file mode 100644 index 0000000..a8b7a3f --- /dev/null +++ b/src/renderer/api-billing-setup.tsx @@ -0,0 +1,24 @@ +import { useRef, useState } from "react"; +import { apiBillingNames, type ApiBillingProvider } from "../domain/api-billing"; +import type { Request } from "../shared/ipc"; + +export function ApiBillingSetup({ provider, disabled, action }: { provider: ApiBillingProvider; disabled: boolean; action: (request: Request) => Promise }) { + const input = useRef(null), pending = useRef(false); + const [busy, setBusy] = useState(false); + const fetchBilling = async (saved: boolean) => { + if (disabled || pending.current) return; + const adminKey = input.current?.value.trim(); + if (input.current) input.current.value = ""; + if (!saved && !adminKey) return; + pending.current = true; setBusy(true); + try { await action(saved ? { action: "apiBillingFetchSaved", provider } : { action: "apiBillingFetch", provider, adminKey: adminKey! }); } + finally { pending.current = false; setBusy(false); } + }; + return
{ event.preventDefault(); await fetchBilling(false); }}> + +

Reuse this provider's Admin key saved by the previous MeterUsage. Approve macOS Keychain access if asked.

+

{provider === "openai" ? "OpenAI Platform → Organization settings → Admin keys." : "Anthropic Console → Settings → Admin keys."}

+ + +
; +} diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx new file mode 100644 index 0000000..859a18a --- /dev/null +++ b/src/renderer/app.tsx @@ -0,0 +1,377 @@ +import { apiBillingProviders, apiBillingNames, type ApiBillingReading } from "../domain/api-billing"; +import { normalizeProviderOrder } from "../domain/provider-order"; +import { ProviderOrder } from "./provider-order"; +import { ApiBillingSetup } from "./api-billing-setup"; +import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { type Bridge, type ViewState, type Request, type Surface, type ConnectionState, PanelSize, panelScales, providerKey, trayKey, statusPages } from "../shared/ipc"; +import { type AccountActivity, type Slot, type Provider, type QuotaWindow, type Telemetry, type Daily, type Loaded, type Session, type UsageWindow, clamp, providerNames, primary, slotKey, slotName, headlineWindow, lastBurn, totalTokens, localDay, shiftDay, utcDay, tokens, resetCreditAvailable, severityNames } from "../domain/models"; +import { pace, measuredBurn, durationMins, burnActive, etaSeconds, formatETA, paceText } from "../domain/pacing"; +import { attributionSessions, stripTotals, failoverNudge, heatmapCells, quotaHeatmapCells, type HeatmapMode } from "../domain/overview"; +import { effectiveWindows, notchEntries } from "../domain/notch"; +import { burnBreakdown } from "../domain/telemetry"; +import { pricingSnapshot } from "../domain/pricing"; +const accents: Record = { blue: "#0a84ff", violet: "#a78bfa", teal: "#2dd4bf", amber: "#fbbf24", rose: "#fb7185", graphite: "#aeaeb2" }; +const colours: Record = { codex: "var(--accent)", claude: "var(--claude)", antigravity: "var(--antigravity)", grok: "var(--grok)", openCodeGo: "var(--opencode)", openRouter: "var(--openrouter)", cursor: "#1ab3e6", copilot: "#6699ff", gemini: "#4d80f2", muse: "#588cf5" }; +const logos: Partial> = { codex: "codex-logo.png", grok: "grok-logo.png", antigravity: "antigravity-logo.png", openCodeGo: "opencode-logo.png" }; +const n = (x: number) => new Intl.NumberFormat("en", { notation: x >= 1000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(x); +const money = (x: number) => new Intl.NumberFormat("en", { style: "currency", currency: "USD", maximumFractionDigits: 2 }).format(x); +const extract = (s?: Loaded) => s?.status === "value" ? s.value : undefined; +const reason = (s?: Loaded) => s?.status === "missing" ? s.reason : "Not checked yet"; +const headroom = (used: number) => used >= 95 ? "var(--alert)" : used >= 80 ? "var(--warn)" : "var(--good)"; +function Mark({ slot, state }: { slot: Slot; state: ViewState }) { + const id = useId(); + const status = extract(state.snapshot.statuses[slot.provider])?.severity; + const tint = status === undefined || status === "operational" ? colours[slot.provider] : status === "degraded" ? "var(--warn)" : status === "unknown" ? "var(--muted)" : "var(--alert)"; + if (logos[slot.provider]) return