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 df556bf..6df4dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,67 @@ follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- 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 + +- Upgrade the Electron candidate's TypeScript checker from 6.0.3 to the native + 7.0.2 compiler. Keep the existing typecheck, test and build commands. +- 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 +- 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea09333..44b172e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,9 +12,9 @@ pull request. ## 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: +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 @@ -63,7 +63,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 @@ -116,6 +117,65 @@ 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 + +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. + +```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. Do not upload this candidate as the current public release. +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 - `Sources/MeterUsage/Services/` contains provider readers, pricing, and diff --git a/README.md b/README.md index c22d205..5cf8614 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

Platform: macOS 13+ - Privacy: 100% local + Privacy: no telemetry License: MIT

@@ -25,7 +25,7 @@ * **Ambient Time-to-Empty**: Live depletion velocity against reset deadlines (`~47m left at current pace` / `Paced to last until reset`) directly in the menu bar and side notch. * **Burn Attribution & Context Waste**: Token breakdown by project, model, and turns over the last 7 days across every token-bearing provider, plus cache-hit efficiency % and long-chat flags. * **Durable Daily History**: Local summary store surviving CLI transcript purges and session cleanup. -* **100% Private & Zero Setup**: No accounts to connect, no passwords entered. Reads already-authenticated local CLI sessions and local SQLite/JSON logs on your machine. +* **Existing provider access**: No accounts to connect or passwords to enter. Uses existing provider CLIs, local SQLite/JSON stores and aggregate usage endpoints. * **Agent Budget API**: Machine-readable JSON CLI (`meterusage json`) exporting burn rates, pacing, and time-to-empty for autonomous AI agents. [Download MeterUsage 0.2.41 for Apple silicon](https://github.com/pekth/meterusage/releases/download/v0.2.41/MeterUsage-0.2.41.zip), @@ -36,6 +36,27 @@ See [installation](#download-and-installation) for first-launch steps. --- +## macOS Electron migration candidate + +The macOS app and schema-1 JSON CLI are being ported to TypeScript, Electron +and React. The candidate uses pnpm, Vite+, Tailwind, Vitest and electron-builder. +The downloadable 0.2.41 app above remains the Swift release. The Electron +candidate has not passed native macOS acceptance and is not a published release. + +Provider readers and credentials stay in the main process. The sandboxed +renderer receives validated snapshots through a small IPC bridge. Existing +history/archive JSON, preference keys, managed account IDs and provider marks +remain compatibility targets. Windows implementation, installers, testing and +release are paused until a test machine is available. + +See [the migration decision](docs/adr/0010-macos-typescript-electron.md), +[contributor commands](CONTRIBUTING.md#typescript-electron-candidate), and +[isolated demo testing](docs/DEMO.md#electron-candidate). Native tray, settings, +notifications, login items, sharing, updater and two different-height notch +captures are required before cutover. + +--- + ## ๐Ÿ“ธ Showcase ### Ambient Time-To-Empty & Side Notch HUD @@ -87,9 +108,9 @@ Toggle providers on or off, choose refresh cadence, and switch themes and the ac | **OpenRouter** | โœ… | โœ… | โœ… | โ€” | Public account API & `/api/v1/activity` telemetry | | **Grok** | โœ… | โœ… | โœ… | โ€” | CLI auth bearer token & session summaries | | **OpenCode Go** | โœ… | โœ… | โœ… | โ€” | Local `opencode db` read-only queries | -| **Cursor** | โœ… | โœ… | โœ… | โ€” | Local sqlite state & token usage cache | -| **Copilot CLI** | โœ… | โœ… | โœ… | โ€” | Local GitHub CLI auth token & token telemetry | -| **Gemini CLI** | โœ… | โœ… | โœ… | โ€” | Local Gemini CLI session state | +| **Cursor** | Local snapshot | โ€” | Snapshot-dependent | โ€” | Existing local quota/usage JSON or presence check | +| **Copilot CLI** | Local snapshot | โ€” | Snapshot-dependent | โ€” | Existing local quota/usage JSON or presence check | +| **Gemini CLI** | Local snapshot | โ€” | Snapshot-dependent | โ€” | Existing local quota/usage JSON or presence check | *Claude publishes no public quota API; meterusage reads an on-disk JSON snapshot if a local companion writes one (see [docs/COMPANION.md](docs/COMPANION.md) and [`Scripts/claude-companion.sh`](Scripts/claude-companion.sh)). @@ -126,7 +147,7 @@ appear once the directory exists. Removing a row stops metering that account; nothing in the directory is deleted. Slots are named by your own label ("Codex ยท Work", a digit beside the mark in -the tray and notch); no account identifier is ever read or displayed. Each +the tray and notch); no provider account identifier is read or displayed. Each account stays under its own keys in the Agent Budget API, which reports the label in an additive `account` field. @@ -136,9 +157,9 @@ label in an additive `account` field. meterusage is built from the ground up to respect developer privacy: * **No Network Man-in-the-Middle**: Reuses the authenticated CLI sessions already on your Mac. -* **No Prompts or Code Read**: Reads only numeric session metadata, token tallies, and timestamps. Project identifiers are strictly directory basenames. Never opens prompt contents, tool payloads, or file diffs. +* **Selective local reads**: Selects numeric session metadata, token tallies, model names and timestamps. Project directories become basenames. Message content, tool payloads and file diffs are skipped. * **No Telemetry / Analytics**: Zero outgoing telemetry calls. -* **Sandboxed & Inspectable**: Full privacy architecture documented in [docs/PRIVACY.md](docs/PRIVACY.md). +* **Inspectable data boundary**: Full privacy architecture documented in [docs/PRIVACY.md](docs/PRIVACY.md). --- 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/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/DEMO.md b/docs/DEMO.md index e5e3fa6..000c6e2 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -68,4 +68,31 @@ Percentages span calm green and amber warning bands so screenshots demonstrate c - **Strict Activation**: The environment variable must be exactly `1`. Values like `true`, `yes`, or `0` remain off. - **Whole-App Integrity**: Evaluated once at composition root โ€” the dashboard is never partially real and partially fake. - **Prominent Badge**: A **Demo** badge is displayed in the header to prevent any confusion with live telemetry. -- **Zero Disk & Network Access**: Demo sources perform no network requests, touch no external disk files, and invoke no CLI commands. +- **Synthetic sources**: Demo provider sources perform no network requests and invoke no provider CLI commands. The Electron candidate writes its own isolated preferences, history and cache files. + +## Electron candidate + +After `pnpm build`, run the candidate JSON path without a tray or window: + +```sh +meterusage_demo_root="$(node -p 'require("node:fs").realpathSync(require("node:os").tmpdir())')" +node dist/main/cli.cjs json --demo --candidate-profile "$meterusage_demo_root/meterusage-cli-demo" +``` + +On macOS, launch the separate packaged candidate directly: + +```sh +./release/mac-arm64/MeterUsage.app/Contents/MacOS/meterusage --demo --candidate-profile /private/tmp/meterusage-native-demo +``` + +The explicit profile must be empty on first use and use a canonical path. +Candidate mode rejects symlink aliases, including macOS `/tmp` and `/var`. +Demo mode selects synthetic +providers before composition and isolates all preferences, caches, history and +Electron state. Relaunch with the same profile to test persistence. Live +provider discovery, login-item changes and update installation are disabled. +Demo reset confirmation consumes only synthetic fixture credits. The original +logo PNGs are reused; SF Symbol stand-ins are exported on macOS during packaging. + +Native candidate UI and two different-height notch captures remain required +acceptance evidence. Swift screenshots above do not prove Electron rendering. diff --git a/docs/KB.md b/docs/KB.md index 3cffa77..6d2467f 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -1,19 +1,19 @@ # Project knowledge -Last verified: 2026-09-29 +Last source review: 2026-10-06 ## 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 - 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. +- 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 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. @@ -30,10 +30,33 @@ Last verified: 2026-09-29 - 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. +- `package.json` selects TypeScript `~7.0.2`, and `pnpm-lock.yaml` resolves the native compiler and its platform packages to 7.0.2. `pnpm typecheck` still runs `tsc --noEmit`; Vite+ builds the application. The compiler upgrade does not establish native migration acceptance or release readiness. +- Windows implementation, installers, CI, testing and releases are paused until a test machine is available. The former RN/Tauri Windows-first plan is superseded; shared portable source does not establish Windows support. +- Candidate checks use synthetic fixtures. Demo/candidate selection isolates preferences, history, caches and Electron state before composition. Live provider/key discovery, updater installation and login-item changes are disabled in demo. `pnpm package:mac` creates a separate candidate with publication disabled, using existing macOS tools. +- Candidate account visibility rechecks directory presence. Sharing freezes the displayed account key. Pacing-off hides forecasts; notch details reuse window-scoped burn metrics and have a bounded scroller. These contracts have synthetic/static-markup proof; native UI acceptance remains open. +- Current source checks have matching passing execution proof for 128 TypeScript fixture tests, TypeScript checking and production bundling. The rebuilt bundled headless demo preserves stable schema-1 fields against the Swift demo oracle. This is source/fixture evidence only, not native parity or a published Electron release. +- The 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. Its status buttons ask main to open only the existing fixed public provider pages. Notch ambient ETA uses raw window pace, including dated archive readings, without requiring recent burn; disabling pacing hides it. 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. + ## Verification gaps +- The Electron candidate still needs native GUI launch/relaunch, preferences/account/offline journeys, tray/focus/accessibility, notification/login/share/updater paths, drag/display persistence and two different-height notch captures. Verified packaging and headless JSON cannot close migration or cutover. - 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. @@ -47,9 +70,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. @@ -61,4 +85,4 @@ 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. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 011453c..27d5fc8 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -1,132 +1,146 @@ -# Privacy & security design - -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**: Zero analytics, telemetry, crash reporting, or remote tracking servers. Only connects directly to documented usage endpoints or public status feeds. -* ๐Ÿงช **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 telemetry, analytics, crash reporting, or update ping; it has no server. -- **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. - -## 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. - -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. +# Privacy and security design + +MeterUsage reads quota and usage from existing local provider stores, provider +CLIs and aggregate endpoints. It has no sign-in form, telemetry service, +analytics or crash-upload endpoint. It does not ask for credentials or send +prompts, code or tool payloads to a provider. + +The published Swift app and TypeScript/Electron candidate share these data +boundaries. The candidate has source/fixture checks, but native behavior and +cutover acceptance remain pending. See [ADR 0010](adr/0010-macos-typescript-electron.md). + +## Provider inputs and requests + +| Provider | Read or request | Retained data | +| --- | --- | --- | +| Codex | `codex app-server --stdio` initializes the CLI and calls `account/rateLimits/read`. The CLI uses its own existing authentication. Local rollout headers, model context and cumulative token-ledger events are selected from `sessions/**/*.jsonl`. | Quota windows/groups, plan, balances, reset credits, timestamps, numeric tokens and model. Session paths become opaque IDs; workspace directories become basenames. | +| 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. | + +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. + +MeterUsage does not open Claude `.credentials.json` or macOS Keychain items. +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. + +## 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. + +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. An explicit candidate profile 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. + +## 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. 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/README.md b/docs/adr/README.md index 2283401..a51429e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,3 +14,4 @@ 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 | 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..50c0761 --- /dev/null +++ b/docs/plans/macos-electron-migration-plan.md @@ -0,0 +1,193 @@ +# 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. + +## 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. + +### 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. + +### Current execution state + +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/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..245e521 --- /dev/null +++ b/electron-builder.yml @@ -0,0 +1,27 @@ +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 +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..2df9c3f --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "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 && electron-builder --mac --publish never", + "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..4e1f3df --- /dev/null +++ b/src/domain/alerts.ts @@ -0,0 +1,35 @@ +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(); + 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/models.ts b/src/domain/models.ts new file mode 100644 index 0000000..589a143 --- /dev/null +++ b/src/domain/models.ts @@ -0,0 +1,83 @@ +export const providers = ["codex", "antigravity", "grok", "openCodeGo", "openRouter", "claude", "cursor", "copilot", "gemini"] 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", +}; +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 { + 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"); + 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 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 } +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..9b6ddc0 --- /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 = 44, cardWidth = 250; +// 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..61d8dcf --- /dev/null +++ b/src/domain/overview.ts @@ -0,0 +1,72 @@ +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") parts.push(severityNames[status.value.severity]); + 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(); +} diff --git a/src/domain/pacing.ts b/src/domain/pacing.ts new file mode 100644 index 0000000..dfca979 --- /dev/null +++ b/src/domain/pacing.ts @@ -0,0 +1,65 @@ +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"; +} +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/report.ts b/src/domain/report.ts new file mode 100644 index 0000000..c05d667 --- /dev/null +++ b/src/domain/report.ts @@ -0,0 +1,37 @@ +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; 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 }), + 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/cli.ts b/src/main/cli.ts new file mode 100644 index 0000000..2f88cbb --- /dev/null +++ b/src/main/cli.ts @@ -0,0 +1,28 @@ +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"; +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): Promise { + const launch = launchConfiguration(args, env), preferences = await Preferences.load(launch); + return canonicalJSON(await jsonReport(compose(launch, preferences))); +} +// 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..e3551e5 --- /dev/null +++ b/src/main/composition.ts @@ -0,0 +1,108 @@ +import { readFileSync, existsSync, statSync } from "node:fs"; +import { join, isAbsolute, resolve } from "node:path"; +import { type Quota, 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"; +export interface Source { + 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 } = {}): Source[] { + const now = options.now ?? Date.now; + if (launch.demo) return demoSources(now, prefs.accounts); + const command = options.command ?? runCommand, http = options.http ?? httpTransport(), env = cliEnvironment(launch.home, launch.env); + 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"); + let capturedAt = now(); try { capturedAt = statSync(path!).mtimeMs; } catch { /* Swift falls back to polling time only when file metadata is unavailable. */ } + try { return parseClaudeQuota(raw, capturedAt); } 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 = makeCodex(primary("codex")), claude = makeClaude(primary("claude"), join(launch.home, ".claude")); + codex.status = async signal => parseStatus("codex", await http(endpoints.codexStatus, "codex", undefined, signal), now()); + claude.status = async signal => parseStatus("claude", await http(endpoints.claudeStatus, "claude", undefined, signal), now()); + const sources: Source[] = [codex, { + 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()); + }, + }, { + slot: primary("antigravity"), quota: signal => runtime.quota(now(), signal), usage: signal => runtime.usage(join(launch.home, ".gemini/antigravity-cli"), now(), signal), + }, { + 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/coordinator.ts b/src/main/coordinator.ts new file mode 100644 index 0000000..217dddb --- /dev/null +++ b/src/main/coordinator.ts @@ -0,0 +1,199 @@ +import { join } from "node:path"; +import { readdir, unlink } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { type Loaded, type Quota, type Activity, type Usage, type ServiceStatus, type Slot, type Provider, providers, primary, slotKey, lastBurn, totalTokens, utcDay, value, missing, Unavailable, resetCreditAvailable } from "../domain/models"; +import { AlertEvaluator, type Alert } from "../domain/alerts"; +import { telemetry } from "../domain/telemetry"; +import { 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 { limitsReport, canonicalJSON } from "../domain/report"; +type Kind = "quota" | "activity" | "usage" | "plan" | "status"; +export const backoffDelay = (failures: number) => Math.min(2 ** (Math.min(Math.max(failures, 1), 6) - 1), 30) * 60000; +export class Coordinator { + 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; + 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 Set(); + private confirmations = new Map(); + private resets = new Set(); + private currentSources: readonly Source[]; + constructor(readonly launch: Launch, readonly preferences: Preferences, sources: Source[], readonly now: () => number = Date.now, readonly notify: (alert: Alert) => void = () => {}) { + this.currentSources = sources; + this.history = new HistoryStore(join(launch.data, "durable-daily-history.json")); this.archived = readArchive(join(launch.data, "quota-archive.json")); + } + 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 readings of [this.quotas, this.activities, this.usages, this.plans]) delete readings[key]; + for (const kind of ["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 [...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))!); + } + get burns() { return Object.fromEntries(Object.entries(this.activities).flatMap(([key, state]) => { const at = state.status === "value" ? lastBurn(state.value.sessions) : undefined; return at === undefined ? [] : [[key, at]]; })); } + snapshot(): Snapshot { + const p = this.preferences.values, slots = this.slots, tray = slots.filter(s => p[trayPreference(s.provider)] === true); + return structuredClone({ demo: this.launch.demo, refreshing: !!this.inflight, clock: this.now(), lastRefreshedAt: this.lastRefreshedAt, slots, traySlots: tray.filter(s => s.provider !== "openRouter"), notchSlots: tray, quotas: this.quotas, archived: this.archived, activities: this.activities, usages: this.usages, plans: this.plans, statuses: this.statuses, appearance: { theme: p.appearanceTheme as string, accent: p.accentTheme as string, heatmap: p.showHeatmap === true, claudeHeatmap: p.showClaudeHeatmap === true, codexHeatmap: p.showCodexHeatmap === true, pacing: p.showPacingBurnRate === true, telemetry: p.showActivityTelemetry === true, chart: p.showDailyActivityChart === true, resetButton: p.showSideNotchResetButton === true, compactTray: p.menuBarCompactEnabled === true, notch: p.sideNotchPanelEnabled === true, pinned: p.sideNotchPanelPinned === true, onboarding: p.onboardingCompleted === true }, historyError: this.history.error, archiveWriteFailed: this.archiveWriteFailed, clearingCache: this.clearingCache }); + } + 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) 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) { + 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.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] = q.value; + 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, slot: slotMap.get(key) ?? { provider: quota.provider, slotID: key.includes("#") ? key.slice(key.indexOf("#") + 1) : "", label: "" } }))); + this.archiveWriteFailed = false; + } catch { this.archiveWriteFailed = true; } + } + 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.add(controller); + 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" && 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); + } + } + target[key] = result; + 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"); + this.resets.add(intent.key); + try { + if (!await source.consumeReset(intent.creditID)) 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 = compose(this.launch, this.preferences); this.backoffs.clear(); await this.refresh(); + } finally { this.clearingCache = false; this.publish(); } + } + diagnostics(): string { + const describe = (s: Loaded | undefined) => !s || s.status === "idle" ? "not checked yet" : s.status === "value" ? "ok" : `unavailable (${s.code})`; + const slots = this.slots, lines = ["MeterUsage 0.2.41", `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: ${describe(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..f19538a --- /dev/null +++ b/src/main/demo.ts @@ -0,0 +1,41 @@ +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 === "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"] 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..20d6e18 --- /dev/null +++ b/src/main/electron.ts @@ -0,0 +1,271 @@ +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 } from "./launch"; +import { Preferences } from "./preferences"; +import { compose } from "./composition"; +import { Coordinator } from "./coordinator"; +import { NotchFold } from "./notch-fold"; +import { Updater } from "./updater"; +import { runJSON } from "./cli"; +import { channel, stateChannel, statusPages, type ViewState, type Surface, type Request, type Reply } from "../shared/ipc"; +import { parseRequest, trustedSender, projectSettings, shareSnapshot } from "./ipc"; +import { providers, slotKey } from "../domain/models"; +import { notchFrame, cardOnRight, stripWidth, cardWidth, minimumShareSize } from "../domain/notch"; +import { trayTooltip } from "../domain/overview"; + +const args = process.argv.slice(1), launch = launchConfiguration(args); +// Isolation is selected before Electron creates a session or any source reads. +if (launch.demo) { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); } +app.setName("MeterUsage"); +if (args.includes("json") || args.includes("--json")) { + runJSON(args).then(json => { process.stdout.write(json + "\n"); app.exit(0); }, () => { process.stderr.write("meterusage: could not read report\n"); app.exit(1); }); +} else if (process.platform !== "darwin") { + process.stderr.write("MeterUsage desktop candidates currently support macOS only.\n"); app.exit(1); +} else { + void app.whenReady().then(start).catch(() => { process.stderr.write("MeterUsage could not start.\n"); app.exit(1); }); +} +async function start() { + mkdirSync(launch.data, { recursive: true }); + app.dock?.hide(); + if (!app.requestSingleInstanceLock({ candidate: launch.demo })) { app.quit(); return; } + const prefs = await Preferences.load(launch); + const documents = new Map(), windows = new Map(); + const coordinator = new Coordinator(launch, prefs, compose(launch, prefs), Date.now, alert => { + if (!launch.demo && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); + }); + const notch = { expanded: prefs.values.sideNotchPanelPinned === true, cardOnRight: false, selected: "codex", dragging: false }; + let notchHeight = 200, anchor: { x: number; y: number } | undefined; + let drag: { cursor: Electron.Point; anchor: Electron.Point; timer: ReturnType; timeout: ReturnType } | undefined; + 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: () => showFlyout() }, { label: "Settingsโ€ฆ", click: () => showSettings() }, { type: "separator" }, { label: "Quit MeterUsage", role: "quit" }]))); + tray.on("click", () => { const w = windows.get("flyout"); w?.isVisible() ? w.hide() : showFlyout(); }); + let shareState: ViewState | undefined, shareHeight = 0, shareReady: ((image: Electron.NativeImage) => void) | undefined, shareFailed: ((error: Error) => void) | undefined; + const state = (surface?: Surface): ViewState => surface === "share" && shareState ? shareState : ({ snapshot: coordinator.snapshot(), settings: projectSettings(prefs.values, prefs.accounts, launch.home, surface), systemDark: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI, notch: { ...notch, maxHeight: Math.floor((anchor ? screen.getDisplayNearestPoint(anchor) : screen.getPrimaryDisplay()).workArea.height) }, update: updater.visible ? { version: updater.visible.version, state: updater.installState } : undefined }); + const updater = new Updater(prefs, app.getVersion(), publish, undefined, release => { + if (!launch.demo && Notification.isSupported()) new Notification({ title: "MeterUsage update available", body: `MeterUsage ${release.version} is ready to install.`, silent: true }).show(); + }); + const folding = new NotchFold(() => prefs.values.sideNotchPanelPinned === true || sharing || confirmingReset || notch.dragging, () => { notch.expanded = false; placeNotch(); publish(); }); + function publish() { + 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"; + const w = new BrowserWindow({ width: shareImage ? cardWidth * 2 : trayImage ? 1000 : panel ? stripWidth : surface === "settings" ? 620 : 420, height: trayImage ? 22 : panel ? notchHeight : surface === "settings" ? 680 : 700, show: false, frame: surface === "settings", transparent: panel || trayImage || shareImage, resizable: surface === "settings", minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, ...(panel ? { type: "panel", focusable: false, hasShadow: false } : {}), title: surface === "settings" ? "MeterUsage Settings" : "MeterUsage", backgroundColor: panel || trayImage || shareImage ? "#00000000" : "#242428", webPreferences: { preload: join(__dirname, "../preload/preload.cjs"), offscreen: trayImage || shareImage, zoomFactor: shareImage ? 2 : 1, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, spellcheck: false } }); + 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 >= cardWidth * 2 && size.height >= shareHeight) { const ready = shareReady; shareReady = undefined; ready(image); } + }); + } + const document = pathToFileURL(join(__dirname, "../renderer/index.html")); document.searchParams.set("surface", surface); + documents.set(w.webContents.id, document.href); windows.set(surface, w); + w.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + w.webContents.on("will-navigate", e => e.preventDefault()); w.webContents.on("will-attach-webview", e => e.preventDefault()); + w.on("closed", () => { documents.delete(w.webContents.id); windows.delete(surface); if (panel) finishDrag(); }); + 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); + w.show(); w.focus(); void coordinator.refreshIfStale(); + } + function showSettings() { + if (!launch.demo && 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(); windows.get("notch")?.hide(); } + } + function finishDrag() { + if (!drag) return; + clearInterval(drag.timer); clearTimeout(drag.timeout); drag = undefined; notch.dragging = false; + notch.expanded = prefs.values.sideNotchPanelPinned === true || folding.hovered; folding.changed(); + 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; + 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 "settings": showSettings(); break; + case "statusPage": + if (surface !== "flyout") throw new Error("Invalid request"); + await shell.openExternal(statusPages[r.provider]); break; + case "close": source.hide(); break; + case "quit": app.quit(); break; + case "clearCache": await coordinator.clearCache(); break; + case "copyDiagnostics": clipboard.writeText(coordinator.diagnostics()); break; + case "copyJSON": clipboard.writeText(coordinator.json()); break; + case "setPreference": + if (r.key === "launchAtLogin") { + if (launch.demo || !app.isPackaged) throw new Error("Login items require an installed app"); + 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 === "refreshIntervalSeconds") coordinator.restartTimer(); + if (r.key === "sideNotchPanelPinned") { notch.expanded = r.value === true || folding.hovered; folding.changed(); } + nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light"; + 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 = compose(launch, prefs); 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 = compose(launch, prefs); await coordinator.refresh(); break; + } + case "accountUpdate": case "accountRemove": { + if (surface !== "settings" || !prefs.accounts.some(a => a.id === r.id)) throw new Error("Unknown account"); + await prefs.updateAccounts(accounts => r.action === "accountRemove" ? accounts.filter(a => a.id !== r.id) : accounts.map(a => a.id === r.id ? { ...a, ...(r.label === undefined ? {} : { label: r.label }), ...(r.enabled === undefined ? {} : { enabled: r.enabled }) } : a)); + coordinator.sources = compose(launch, prefs); await coordinator.refresh(); break; + } + 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(); break; + case "notchHover": + if (surface !== "notch" || notch.dragging) break; + folding.hovered = r.hovering; + if (r.hovering) { notch.expanded = true; placeNotch(); } + folding.changed(); + break; + case "notchContext": + if (surface !== "notch") throw new Error("Invalid request"); + Menu.buildFromTemplate([{ label: "Keep open", type: "checkbox", checked: prefs.values.sideNotchPanelPinned === true, click: () => { void handle({ action: "setPreference", key: "sideNotchPanelPinned", value: prefs.values.sideNotchPanelPinned !== true }, source, surface); } }, { label: "Refresh now", click: () => { void coordinator.refresh(); } }, { type: "separator" }, { label: "Hide panel", click: () => { void handle({ action: "setPreference", key: "sideNotchPanelEnabled", value: false }, source, surface); } }]).popup({ window: source }); + break; + case "resize": + if (surface === "notch") { notchHeight = Math.ceil(r.height); placeNotch(); } + else if (surface === "tray" && r.width !== undefined) { trayWidth = Math.ceil(r.width); source.setSize(trayWidth, 22, false); } + else if (surface === "flyout") source.setSize(420, Math.min(Math.ceil(r.height), screen.getDisplayMatching(source.getBounds()).workArea.height)); + break; + case "shareResize": + if (surface !== "share" || !shareReady) throw new Error("Invalid request"); + try { const size = minimumShareSize(cardWidth, 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 || !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 && ["Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share"].includes(e.message) ? e.message : "Could not complete action" }; } + }); + coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); }); + screen.on("display-metrics-changed", () => { placeNotch(); publish(); }); screen.on("display-removed", () => { placeNotch(); publish(); }); + powerMonitor.on("resume", () => { void coordinator.refresh(); }); + app.on("second-instance", () => showFlyout()); + app.on("window-all-closed", () => {}); + const clockTimer = setInterval(publish, 60000); + nativeTheme.on("updated", publish); + app.on("before-quit", () => { coordinator.stop(); updater.reset(); clearInterval(clockTimer); if (drag) { clearInterval(drag.timer); clearTimeout(drag.timeout); } folding.cancel(); if (shareDirectory) rmSync(shareDirectory, { recursive: true, force: true }); for (const directory of sharedDirectories) rmSync(directory, { recursive: true, force: true }); tray.destroy(); }); + nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light"; + create("tray"); restoreAnchor(); syncNotch(); await coordinator.start(); + if (launch.candidate || prefs.values.onboardingCompleted !== true) showFlyout(); +} diff --git a/src/main/history.ts b/src/main/history.ts new file mode 100644 index 0000000..176d88c --- /dev/null +++ b/src/main/history.ts @@ -0,0 +1,119 @@ +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 }[]) { + atomicJSON(path, readings.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..0d6072f --- /dev/null +++ b/src/main/ipc.ts @@ -0,0 +1,56 @@ +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 { providers } from "../domain/models"; +import { editableBooleans, providerKey, trayKey, statusPages, type Request } from "../shared/ipc"; +const empty = new Set(["state", "refresh", "settings", "close", "quit", "clearCache", "copyDiagnostics", "copyJSON", "dragStart", "dragEnd", "notchContext", "updateInstall", "updateDismiss"]); +const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9-]{1,80}$/.test(v); +const key = (v: unknown) => typeof v === "string" && providers.some(p => v === p || (v.startsWith(p + "#") && id(v.slice(p.length + 1)))); +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 === "accentTheme" && typeof r.value === "string" && ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(r.value)); + break; + case "accountAdd": fields = ["provider"]; valid = r.provider === "codex" || r.provider === "claude"; break; + case "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 && r.height <= 2000 && (!("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 { + values: Object.fromEntries(["refreshIntervalSeconds", "appearanceTheme", "accentTheme", ...editableBooleans, ...providers.flatMap(p => [providerKey(p), trayKey(p)])].map(k => [k, values[k] as boolean | number | string])), + accounts: accounts.map(a => { + const sub = relative(home, a.path), pathLabel = a.path.startsWith("~") ? a.path : !sub.startsWith("..") ? `~/${sub}` : basename(a.path); + return { id: a.id, provider: a.provider, label: a.label, enabled: a.enabled, ...(surface === "settings" ? { pathLabel } : {}) }; + }), + }; +} diff --git a/src/main/launch.ts b/src/main/launch.ts new file mode 100644 index 0000000..46cc8a5 --- /dev/null +++ b/src/main/launch.ts @@ -0,0 +1,22 @@ +import { userInfo, tmpdir } from "node:os"; +import { mkdtempSync, mkdirSync, realpathSync, existsSync, readdirSync, writeFileSync } from "node:fs"; +import { isAbsolute, join, resolve, relative } from "node:path"; +export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string } +export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = process.env, realHome = userInfo().homedir): Launch { + const demo = args.includes("--demo") || env.METERUSAGE_DEMO === "1", index = args.indexOf("--candidate-profile"), profile = index < 0 ? undefined : args[index + 1]; + if (index >= 0 && (!demo || !profile || !isAbsolute(profile))) throw new Error("Candidate profile requires --demo and an absolute test directory"); + if (profile) { + const normalized = resolve(profile), liveData = join(realHome, "Library/Application Support/MeterUsage"); + const inside = relative(liveData, normalized); + if (normalized === "/" || normalized === realHome || inside === "" || (inside !== ".." && !inside.startsWith("../"))) throw new Error("Candidate profile cannot use installed-app storage"); + mkdirSync(normalized, { recursive: true }); + if (realpathSync(normalized) !== normalized) throw new Error("Candidate profile cannot follow a symlink"); + const marker = join(normalized, ".meterusage-candidate"); + if (!existsSync(marker) && readdirSync(normalized).length) throw new Error("Candidate profile must be an empty test directory"); + if (!existsSync(marker)) writeFileSync(marker, "Synthetic MeterUsage candidate profile\n", { mode: 0o600 }); + } + // Demo always isolates preferences, provider sources, caches and Electron's + // userData before any provider or coordinator is constructed. + const isolated = demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined; + return { demo, candidate: index >= 0, home: isolated ?? realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: demo ? {} : { ...env } }; +} 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..b5b89e6 --- /dev/null +++ b/src/main/preferences.ts @@ -0,0 +1,88 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { providers, type Provider, type ManagedAccount } from "../domain/models"; +import { atomicJSON } from "./history"; +import { runCommand, type Command } from "./providers/transport"; +import { type Launch } from "./launch"; +export const defaultsDomain = "dev.meterusage.app"; +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[]; +type Kind = "boolean" | "number" | "string" | "data" | "date"; +const kinds: Record = { + refreshIntervalSeconds: "number", appearanceTheme: "string", accentTheme: "string", managedAccounts: "data", + sideNotchPanelCorner: "string", updateLastCheckDate: "date", updateDismissedVersion: "string", updateAnnouncedVersion: "string", + ...Object.fromEntries(["launchAtLogin", "showHeatmap", "showClaudeHeatmap", "showCodexHeatmap", "quotaAlertsEnabled", "sideNotchPanelEnabled", "sideNotchPanelPinned", "menuBarCompactEnabled", "onboardingCompleted", "updateCheckEnabled", "showPacingBurnRate", "showActivityTelemetry", "showDailyActivityChart", "showSideNotchResetButton", ...providers.flatMap(p => [providerPreference(p), trayPreference(p)])].map(k => [k, "boolean" as const])), +}; +export const initialPreferences: Record = { + refreshIntervalSeconds: 60, appearanceTheme: "system", accentTheme: "blue", managedAccounts: [], sideNotchPanelCorner: "", + 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: "", + ...Object.fromEntries(providers.flatMap(p => [[providerPreference(p), ["codex", "openCodeGo", "openRouter"].includes(p)], [trayPreference(p), true]])), +}; +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 (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 === "appearanceTheme") return ["system", "light", "dark"].includes(v); + if (key === "accentTheme") return ["blue", "violet", "teal", "amber", "rose", "graphite"].includes(v); + if (key === "sideNotchPanelCorner") return v === "" || /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(v); + 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.demo) { + const path = join(launch.data, "preferences.json"); + if (existsSync(path)) { try { const raw = JSON.parse(readFileSync(path, "utf8")); for (const [key, v] of Object.entries(raw)) if (validate(key, v)) prefs.values[key] = v; } catch { /* Keep known defaults for a damaged candidate file. */ } } + else for (const p of providers) prefs.values[providerPreference(p)] = true; + } else { + 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) && /^<[a-f\d\s]*>$/i.test(raw)) value = JSON.parse(Buffer.from(raw.slice(1, -1).replace(/\s/g, ""), "hex").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 (validate(key, value)) prefs.values[key] = value; + } catch { /* An absent or mismatched key retains its registered default. */ } + })); + } + prefs.values.refreshIntervalSeconds = prefs.refreshInterval; return prefs; + } + get refreshInterval() { const stored = this.values.refreshIntervalSeconds as number; return Math.max(30, stored > 0 ? stored : 60); } + get accounts() { return this.values.managedAccounts as ManagedAccount[]; } + enabled(p: Provider) { return this.values[providerPreference(p)] === true; } + 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 (key === "refreshIntervalSeconds") value = Math.max(30, (value as number) > 0 ? value as number : 60); + if (this.launch.demo) { + 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.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/parsers.ts b/src/main/providers/parsers.ts new file mode 100644 index 0000000..d2f77bb --- /dev/null +++ b/src/main/providers/parsers.ts @@ -0,0 +1,143 @@ +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 tier = text(a.organizationRateLimitTier) ?? text(a.seatTier) ?? text(a.userRateLimitTier); + if (!tier) throw new Unavailable("noData"); + const v = tier.toLowerCase(); + return /max_?20x/.test(v) ? "Max 20ร—" : /max_?5x/.test(v) ? "Max 5ร—" : v.includes("enterprise") ? "Enterprise" : v.includes("team") ? "Team" : v.includes("pro") ? "Pro" : v.includes("free") ? "Free" : displayText(tier) ?? "Unknown"; +} +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..a35c53a --- /dev/null +++ b/src/main/providers/transport.ts @@ -0,0 +1,105 @@ +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"), 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. +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")); }; + child.stdout.on("data", (chunk: Buffer) => 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): Promise { + return boundedChild(binary, ["app-server", "--stdio"], options, (child, finish) => { + const write = (v: unknown) => child.stdin.write(JSON.stringify(v) + "\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); + let id: unknown; try { id = object(selectJSON(line, { id: true })).id; } catch { continue; } + if (id === 1 && !initialized) { + initialized = true; + write({ jsonrpc: "2.0", method: "initialized", params: {} }); + write({ jsonrpc: "2.0", id: 2, method: creditID === undefined ? "account/rateLimits/read" : "account/rateLimitResetCredit/consume", params: creditID === undefined ? {} : { creditId: creditID, idempotencyKey: randomUUID() } }); + } 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", + codexStatus: "https://status.openai.com/api/v2/components.json", + claudeStatus: "https://status.claude.com/api/v2/components.json", +} as const; +export type Endpoint = typeof endpoints[keyof typeof endpoints]; +export type HTTP = (url: Endpoint, provider: Provider, key?: string, signal?: AbortSignal) => Promise; +export function httpTransport(fetcher: typeof fetch = fetch): HTTP { + return async (url, provider, key, signal) => { + if (!(Object.values(endpoints) as string[]).includes(url)) throw failed(provider); + const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000); + try { + const response = await fetcher(url, { signal: boundedSignal, redirect: "error", cache: "no-store", headers: { Accept: "application/json", "User-Agent": "meterusage/0.2.41", ...(key ? { Authorization: `Bearer ${key}` } : {}) } }); + if (response.status === 401) throw new Unavailable("notSignedIn", provider === "openCodeGo" ? "OpenCode Go" : provider === "openRouter" ? "OpenRouter" : "Grok"); + if (response.status === 403 && url === endpoints.openRouterActivity) throw new Unavailable("dataNotFound", "OpenRouter Management Key"); + if (!response.ok || !response.body) throw failed(provider); + const reader = response.body.getReader(), chunks: Uint8Array[] = []; let size = 0; + try { + while (true) { const part = await reader.read(); if (part.done) break; size += part.value.length; if (size > 16 * 1048576) throw failed(provider); chunks.push(part.value); } + } finally { await reader.cancel(); } + return Buffer.concat(chunks).toString("utf8"); + } catch (error) { + 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); + } + }; +} 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/select-json.ts b/src/main/select-json.ts new file mode 100644 index 0000000..827edaa --- /dev/null +++ b/src/main/select-json.ts @@ -0,0 +1,64 @@ +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): 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; + 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 ? JSON.parse(source.slice(keyStart, i)) as string : undefined; + 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..1e3fe5e --- /dev/null +++ b/src/main/updater.ts @@ -0,0 +1,123 @@ +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"; +export function newer(candidate: string, current: string) { + const parts = (v: string) => v.replace(/^v/i, "").split(".").map(s => Number(s.match(/^\d+/)?.[0] ?? 0)); + const a = parts(candidate), b = parts(current); + for (let i = 0; i < Math.max(a.length, b.length); i++) if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0); + return false; +} +export function parseRelease(raw: string): Release { + const data = selectJSON(raw, { tag_name: true, assets: { '*': { name: true, browser_download_url: true, digest: true } } }) as { tag_name?: unknown; assets?: { name?: unknown; browser_download_url?: unknown; digest?: unknown }[] }; + if (typeof data.tag_name !== "string" || !/^v?\d+\.\d+\.\d+$/i.test(data.tag_name)) throw new Error("Invalid release"); + const version = data.tag_name.replace(/^v/i, ""), expected = `MeterUsage-${version}.zip`, asset = data.assets?.find(a => a.name === expected); + const 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)) throw new Error("Invalid update asset"); + return { version, tag: data.tag_name, asset: { url, digest: asset.digest.toLowerCase() } }; +} +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.values.updateCheckEnabled === true && this.available?.version !== this.prefs.values.updateDismissedVersion ? this.available : undefined; } + checkIfDue(now = Date.now()) { + if (this.prefs.launch.demo || this.prefs.values.updateCheckEnabled !== true || this.check || now - Number(this.prefs.values.updateLastCheckDate ?? 0) < 3600000) return; + 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 release = parseRelease((await this.download(releaseEndpoint, 1048576, controller.signal)).toString("utf8")); + 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.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/app.tsx b/src/renderer/app.tsx new file mode 100644 index 0000000..4182962 --- /dev/null +++ b/src/renderer/app.tsx @@ -0,0 +1,155 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { type Bridge, type ViewState, type Request, type Surface, providerKey, trayKey, statusPages } from "../shared/ipc"; +import { type Slot, type Provider, type QuotaWindow, type Telemetry, type Daily, type Loaded, type Session, type UsageWindow, clamp, providers, providerNames, primary, slotKey, slotName, headlineWindow, lastBurn, totalTokens, localDay, shiftDay, tokens, resetCreditAvailable, severityNames } from "../domain/models"; +import { pace, burnActive, etaSeconds, formatETA, paceText } from "../domain/pacing"; +import { attributionSessions, stripTotals, failoverNudge, heatmapCells, type HeatmapMode } from "../domain/overview"; +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" }; +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 >= 10000 ? "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 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