From 7cb5db7402d78464b8f2a88ae8ca102c83a3d307 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:56:09 -0400 Subject: [PATCH 1/3] feat: add Windows desktop candidate --- .github/workflows/windows.yml | 41 +++++ CHANGELOG.md | 10 + CONTRIBUTING.md | 17 +- README.md | 7 + Scripts/electron/after-pack.cjs | 3 +- Scripts/electron/assets/copilot-symbol.png | Bin 0 -> 609 bytes Scripts/electron/assets/cursor-symbol.png | Bin 0 -> 728 bytes Scripts/electron/assets/gemini-symbol.png | Bin 0 -> 529 bytes Scripts/electron/assets/openRouter-symbol.png | Bin 0 -> 598 bytes Scripts/electron/export-portable-assets.cjs | 29 +++ Scripts/electron/smoke-windows.cjs | 40 ++++ docs/BETA.md | 5 +- docs/KB.md | 20 +- docs/PRIVACY.md | 17 +- docs/adr/0033-windows-desktop-candidate.md | 54 ++++++ docs/adr/README.md | 3 +- docs/electron/README.md | 34 +++- docs/plans/feature-and-windows-pipeline.md | 24 ++- electron-builder.yml | 19 +- package.json | 5 +- src/main/api-billing-fetch.ts | 2 +- src/main/connections.ts | 17 +- src/main/coordinator.ts | 4 +- src/main/electron.ts | 31 ++-- src/main/launch.ts | 11 +- src/main/platform.ts | 18 ++ src/main/preferences.ts | 12 +- src/main/providers/antigravity.ts | 3 +- src/main/providers/api-billing.ts | 60 +++--- src/main/providers/transport.ts | 90 +++++++-- src/renderer/api-billing-setup.tsx | 8 +- src/renderer/app.tsx | 20 +- src/shared/ipc.ts | 1 + tests/api-billing-fetch.test.ts | 36 +++- tests/api-billing-integration.test.ts | 12 +- tests/api-billing.test.ts | 60 +++++- tests/windows-api-storage.test.ts | 62 +++++++ tests/windows-packaging.test.ts | 51 +++++ tests/windows-renderer.test.ts | 44 +++++ tests/windows-runtime.test.ts | 66 +++++++ tests/windows-transport-native.test.ts | 43 +++++ tests/windows-transport.test.ts | 174 ++++++++++++++++++ 42 files changed, 1019 insertions(+), 134 deletions(-) create mode 100644 .github/workflows/windows.yml create mode 100644 Scripts/electron/assets/copilot-symbol.png create mode 100644 Scripts/electron/assets/cursor-symbol.png create mode 100644 Scripts/electron/assets/gemini-symbol.png create mode 100644 Scripts/electron/assets/openRouter-symbol.png create mode 100644 Scripts/electron/export-portable-assets.cjs create mode 100644 Scripts/electron/smoke-windows.cjs create mode 100644 docs/adr/0033-windows-desktop-candidate.md create mode 100644 src/main/platform.ts create mode 100644 tests/windows-api-storage.test.ts create mode 100644 tests/windows-packaging.test.ts create mode 100644 tests/windows-renderer.test.ts create mode 100644 tests/windows-runtime.test.ts create mode 100644 tests/windows-transport-native.test.ts create mode 100644 tests/windows-transport.test.ts diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 0000000..38a0ab1 --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,41 @@ +name: Windows + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + package: + runs-on: windows-2022 + timeout-minutes: 25 + env: + CSC_IDENTITY_AUTO_DISCOVERY: "false" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24.13.1 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - name: Test Windows contracts and portable calculations + run: pnpm test tests/windows tests/overview.test.ts tests/domain.test.ts + - name: Build unsigned NSIS and portable executables + run: pnpm package:win + - name: Smoke isolated packaged demo and single-instance relaunch + run: pnpm smoke:win + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: MeterUsage-Windows-x64-unsigned + path: release/*.exe + if-no-files-found: error diff --git a/CHANGELOG.md b/CHANGELOG.md index 826c6c9..fba7803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ follows [Semantic Versioning](https://semver.org/). - Optically center the Grok mark in provider rings, menu items, cards and Settings. Preserve the original provider artwork and existing icon sizes. +### Added + +- Add a Windows x64 Electron source candidate with tray and side-notch usage, + local CLI activity, Codex browser sign-in through the installed CLI, + OpenRouter usage and one-time OpenAI or Anthropic API billing keys. Windows + desktop Claude, Antigravity and Grok Bot connections remain unavailable. + Packaging and a Windows CI workflow are configured; workflow success, native + acceptance, installer acceptance and signing remain unverified. This is not + a Windows release. + ## [0.3.0-beta.3] - 2026-10-17 ### Release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5c05f6..fad6ff4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -136,8 +136,8 @@ See [`docs/DEMO.md`](docs/DEMO.md) for the data and privacy rules. ## TypeScript Electron candidate -The source manifest retains 0.2.41. The public Electron beta is 0.3.0-beta.1, -build 54, with the version explicitly set during packaging. See the +The source manifest retains 0.2.41. The public Electron beta is 0.3.0-beta.3, +build 61, with the version explicitly set during packaging. See the [beta guide](docs/BETA.md) for the signed artifact, known cosmetic footer issue and incomplete native acceptance, and the [Electron guide](docs/electron/README.md) for source choices and parity gaps. @@ -145,8 +145,13 @@ for source choices and parity gaps. Use the Node and pnpm versions declared in `package.json`. Provider accounts are optional; synthetic tests and demo mode require no login. Shared checks -can run on Linux. Packaging, system symbol export and native acceptance require -macOS with the existing Swift and signing tools. +can run on Linux. macOS packaging, system symbol export and native acceptance +require macOS with the existing Swift and signing tools. The Windows source +candidate uses `pnpm package:win` to produce unsigned x64 NSIS and portable +executables. Its workflow is configured for typecheck, focused tests, packaging +and an isolated packaged-demo/single-instance smoke check; no completed +workflow receipt is claimed here. Native Windows UI/provider checks, installer +acceptance and signing remain open. See [ADR 0033](docs/adr/0033-windows-desktop-candidate.md). `pnpm package:mac` also compiles the fixed Claude Desktop Keychain helper and bundles it as an app resource. `pnpm build:desktop-helper` targets arm64 and @@ -182,7 +187,9 @@ pnpm package:mac packages an Apple silicon ZIP with publication disabled. It does not install or replace an app. Locally packaged output is not the verified public beta artifact. Keep the stable release and beta download routes separate. -Windows packaging, CI, native testing and release remain paused. +Windows packaging and a CI workflow are implemented in the candidate source. +They do not establish successful CI, native acceptance, signing or a release. +See the [platform pipeline](docs/plans/feature-and-windows-pipeline.md). electron-builder resolves `pnpm` from PATH when collecting production dependencies. If an existing launcher incorrectly loads a standalone pnpm diff --git a/README.md b/README.md index dc80e8f..3cd3ba1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ for rollback. Both apps share their identity and saved data, so do not run them together against a normal profile. See [beta installation and limits](docs/BETA.md) and the [version strategy](docs/VERSIONS.md). +Windows has an unsigned x64 source candidate, not a public download. The +candidate includes the tray, side notch, heatmaps, local CLI activity, Codex +browser sign-in through the installed CLI, OpenRouter and one-time OpenAI or +Anthropic API billing keys. Windows desktop Claude, Antigravity and Grok Bot +connections are unavailable. Native Windows acceptance, installer review and +signing remain open. See the [Windows candidate notes](docs/adr/0033-windows-desktop-candidate.md). + ## Current beta UI

diff --git a/Scripts/electron/after-pack.cjs b/Scripts/electron/after-pack.cjs index deeca49..a41a489 100644 --- a/Scripts/electron/after-pack.cjs +++ b/Scripts/electron/after-pack.cjs @@ -9,7 +9,8 @@ function renameExecutable(bundle, command = execFileSync) { 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"); + if (context.electronPlatformName === "win32") return; + if (context.electronPlatformName !== "darwin") throw new Error("Unsupported packaging platform"); renameExecutable(join(context.appOutDir, "MeterUsage.app")); }; module.exports.renameExecutable = renameExecutable; diff --git a/Scripts/electron/assets/copilot-symbol.png b/Scripts/electron/assets/copilot-symbol.png new file mode 100644 index 0000000000000000000000000000000000000000..bf64fa69b89b6a7cbdb61f49c8fd1b1feb8c11b2 GIT binary patch literal 609 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>g0%imoB-!uha1yBax~Gd{NCfBGu(R5(fdch* zO5BU4uxg$jf-pEKXqiAbMExVQ2OMn-bQ= zTo0_H8Do;U%PkDmw>k&*9W-0Klh=A>=2R&|^^HpZ8^0}6eEjn1$)`Id7~2{WCM{zW zd(3)OBUIjnJ{cw!yJLWONs(MZ@N#Y&1*_#d=PuC{Dg4;KM%9b95;)k-Dw}i z=U!pB|C`l-PlnUby~sCbZPbPxSNlxLf|&PAx~;oN>Dupxx>}j*)vtGkoL<5fqoQiT z9^0s6_g8z`MpuN@4e4oCm$%-_j1DB19MrPFZdd}zIf9OUXNK5 zT!J50XWnl){^67J;r-`)D%hMj>=TxWWgj`kpd{b$GhHO=bM-};=vb!37nU=ecV5Bv i!&0Bg)<^}F_8ANzht^L7MYX4^pUXO@geCx$G4ebB literal 0 HcmV?d00001 diff --git a/Scripts/electron/assets/cursor-symbol.png b/Scripts/electron/assets/cursor-symbol.png new file mode 100644 index 0000000000000000000000000000000000000000..2bf94cd97edce7957fa14f0dcc7e4718546c2f89 GIT binary patch literal 728 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz#^NA%Cx&(BWL^R}7O4@QX}-P; zT0k}j17mw80}DtA5K93u0|V0nCb)>g0%imoB-!uha1yA*&(p;*B!ctpwA0>VffC2u z?VV#)g1UnQMD!I{V?DHl1Viq0H+7daatCIJZqdyU+#+yjX}!ai!rMw69OVz44vJ}B zJ>=vX;CN8)pWIw?|K*QO?w@Jj@v#KeT28&2 zR-InIfp?ch^9BCwSk=F7D;|lc+qKLz^jxAk%X8+_mBODbQhgTPu3Z!VAYjq;jT`&l zssDHp5&G`$apMQ`lAf>p%vQ4fnbN1{Ex+C;R8LRHKTyVc%OdK+=YZX>^mP_)6wP1z zZT-0>fi1_Mn#&n2x@`8BO?UcMsXbhBhiY%v23x)5|HZ}Q$jov6;oZac=JOwYx$KML z-S>0MOp8`Ehc8_HyFq4--j}lltXy7JJn5Iq^#G0I&aa&m2{+{G>aJ*H8X< z<-$P^vmINlU*ydXW1ZW`6w;9wu=dX+$LXE>ZmyUfc>mhHJNa9G{XKn}+g|8ytUysk z!zWG~_P_6=Iat;#tmZFu&31f#h~?t9v|T;3_U2rd{atmZ{_)Qgv3#M-|4hGDUu^n( zvDJxnU&E*ENq=+OS5}k=FKU@}>B9X9Mz8AMOKxHIbYGLSEYWkp)HRNm4%Nk#)Xevl z4)Lg0%imoB-!uha1yAv-P6S}B!ctpwEcRnjsnN@ zxk5yCw0bGF1gU>y`Pb62`h$Romj4q`fqX?5)rCO{f?OZJo1dJSQncxuZFSi<6Q571 z&(58D_xTu5%Etm?^55TWzuBgQ92d>f(h> zNAB#3tPi~|b%kO5s(l^uzW21ZWcRg+O^tosf5x(KgNDgQx4cPGnMp;5{1aqYa_6t9 z<6VA4`QWM@Peh)IJ6xN}y^mAtZAYNK;HF~lec>l_*lb-))WT&ol_s4GcG)q}T=1cv zoKT^Y(HAS7oqwFy)jpiEWd8Q@MLLJ}isf?fJ-Tcy#HlO1u1K^8%iC2ll(pKfphG;gUQ(o$yg0%imoB-!uha1yBajHioZNCfBGDYhD}i4w>4 zyQaM7nUWGPsnbXzCr7Q@_0*vxh1qvqHc3nkWm~*RMRWEVJ0YVJ2V8%a{j@Ee_wMq} z=N8ZJJigcX`^x_B|Nou;{MGLH9`%Vo-iJMrI>7Q<@B^zI@4W}B6C_z8dHK8-2u|7Y z;Y6KC43qzDx2ccfBEI*W5Q&}`WM`@J-XYB+YUW4I8n=A^-K<748?GzHU0hw)_3i`b z9jEDqZCmwZ_Ac8XsCJOkFYc~)L23s}?GKgOqMr5+|1+~6sQ+lbDSs%taX#@{ad59@9IdePH6^x0~BGCGAo1$ZILsSf#N1 z`V;MfY~7r9jdt_R_^Tfj^&amL^H};hXnHvFZ{r27Y;&JqdeV0>UG}bCX_;_)x$v6z zKe*!9+8WJNgNy&)nNWBANlR1#SKXU8Pn&buEVpNA-C@jhy0_MBGsF4WX9GRCPtRiV ztz7M=`1A5jwzAE->#pl${b6L>u9k86gNMm&wj~`V3)6OHExcyIJ7+eRc$@p8La7P% z^P_@1ubBEV>o1$})QxXhp%lkCxkHODI3zN)avqs^pnYfRG-=E2TPAmdKI;Vst0AyJA82|tP literal 0 HcmV?d00001 diff --git a/Scripts/electron/export-portable-assets.cjs b/Scripts/electron/export-portable-assets.cjs new file mode 100644 index 0000000..63853ce --- /dev/null +++ b/Scripts/electron/export-portable-assets.cjs @@ -0,0 +1,29 @@ +const { copyFileSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs"); +const { join, resolve } = require("node:path"); + +function exportAssets(root) { + const resources = join(root, "Resources"); + for (const provider of ["openRouter", "cursor", "copilot", "gemini"]) { + copyFileSync(join(__dirname, "assets", `${provider}-symbol.png`), join(resources, `${provider}-symbol.png`)); + } + // Reuse the original icon's 256px PNG without image conversion tools. + const icns = readFileSync(join(resources, "AppIcon.icns")); + if (icns.toString("ascii", 0, 4) !== "icns" || icns.readUInt32BE(4) !== icns.length) throw new Error("Invalid app icon"); + let png; + for (let offset = 8; offset < icns.length;) { + const size = icns.readUInt32BE(offset + 4); + if (size < 8 || offset + size > icns.length) throw new Error("Invalid app icon entry"); + if (icns.toString("ascii", offset, offset + 4) === "ic08") png = icns.subarray(offset + 8, offset + size); + offset += size; + } + if (!png || png.readUInt32BE(16) !== 256 || png.readUInt32BE(20) !== 256) throw new Error("Missing 256px app icon"); + const header = Buffer.alloc(22); + header.writeUInt16LE(1, 2); header.writeUInt16LE(1, 4); + header.writeUInt16LE(1, 10); header.writeUInt16LE(32, 12); + header.writeUInt32LE(png.length, 14); header.writeUInt32LE(22, 18); + mkdirSync(join(root, "build"), { recursive: true }); + writeFileSync(join(root, "build", "AppIcon.ico"), Buffer.concat([header, png])); +} + +if (require.main === module) exportAssets(resolve(__dirname, "../..")); +module.exports = { exportAssets }; diff --git a/Scripts/electron/smoke-windows.cjs b/Scripts/electron/smoke-windows.cjs new file mode 100644 index 0000000..dc41e4f --- /dev/null +++ b/Scripts/electron/smoke-windows.cjs @@ -0,0 +1,40 @@ +const { spawn, spawnSync } = require("node:child_process"); +const { existsSync, mkdtempSync, realpathSync, rmSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { dirname, join, resolve } = require("node:path"); +const { setTimeout: delay } = require("node:timers/promises"); + +async function smoke(executable) { + if (process.platform !== "win32") throw new Error("Windows smoke requires Windows"); + if (existsSync(join(dirname(executable), "resources", "desktop-keychain"))) throw new Error("Windows package includes the macOS Keychain helper"); + const root = mkdtempSync(join(realpathSync(tmpdir()), "meterusage-windows-smoke-")); + const args = ["--demo", "--candidate-profile", join(root, "profile")]; + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + let first, second; + const alive = child => child.pid && child.exitCode === null && child.signalCode === null; + const launch = () => { + const child = spawn(executable, args, { env, stdio: "ignore" }); + child.on("error", () => {}); + return child; + }; + try { + first = launch(); + await delay(10000); + if (!alive(first) || !existsSync(join(root, "profile", "electron"))) throw new Error("Packaged demo did not remain running in its isolated profile"); + second = launch(); + for (let attempt = 0; attempt < 50 && alive(second); attempt++) await delay(100); + if (second.exitCode !== 0 || !alive(first)) throw new Error("Packaged single-instance relaunch failed"); + console.log("Windows packaged demo stayed alive; second invocation exited successfully."); + } finally { + for (const child of [second, first]) { + if (child && alive(child)) { + const result = spawnSync("taskkill.exe", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }); + if (result.status !== 0) throw new Error("Could not stop packaged smoke process tree"); + } + } + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } +} + +if (require.main === module) smoke(resolve(process.argv[2] || "release/win-unpacked/MeterUsage.exe")).catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/docs/BETA.md b/docs/BETA.md index b6a737c..61a3d87 100644 --- a/docs/BETA.md +++ b/docs/BETA.md @@ -81,7 +81,10 @@ See [source choices](electron/README.md#source-choices), - Live Codex refresh was observed. Claude paid-account allowance was not live-tested. Optional support-report delivery and deployed relay limits were not verified end to end. -- Windows support is paused. Intel Mac binaries are not provided by this beta. +- This beta remains macOS-only. A separate Windows x64 source candidate exists, + with native and installer acceptance, signing and public + release still open. See [ADR 0033](adr/0033-windows-desktop-candidate.md). + Intel Mac binaries are not provided by this beta. Use Settings > Diagnostics for sanitized diagnostics and **Send report**. The app limits report attempts to one every ten minutes, including relaunches. diff --git a/docs/KB.md b/docs/KB.md index e591734..1eb01b4 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -1,6 +1,6 @@ # Project knowledge -Release documentation refresh: 2026-10-17; build 61 executable inputs are unchanged. +Documentation refresh: 2026-10-18; the public Mac build 61 executable inputs are unchanged. ## Edition documentation @@ -188,7 +188,18 @@ Release documentation refresh: 2026-10-17; build 61 executable inputs are unchan - 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. - ADR 0006 replaces fixed alternate cases with a managed account list for Codex and Claude. Generated app IDs, provider, user label, directory and enabled state persist as JSON in UserDefaults Data. Primary keys retain their old form; additional history/archive keys use provider plus generated ID, and schema-1 reports add `account` labels. Rename preserves identity; remove stops metering without deleting provider files. No provider account identity is read for labels. - ADR 0010 selects macOS TypeScript/Electron/React with pnpm, Vite+, Tailwind, Vitest and electron-builder. Main owns provider files/keys, native actions and persistence; a separate sandbox-compatible preload exposes sender-validated IPC to React. The candidate keeps JSON/history/archive and known preference formats. Swift remains the oracle and rollback path. No server or remote control layer is added. -- Windows implementation, installers, CI, testing and releases are paused until a test machine is available. The former RN/Tauri Windows-first plan is superseded; shared portable source does not establish Windows support. +- A Windows x64 Electron source candidate now covers tray and side-notch usage, + local CLI activity, Codex browser sign-in through the installed CLI, + OpenRouter and one-time OpenAI or Anthropic API billing keys. Windows desktop + Claude, Antigravity and Grok Bot account connections are unavailable. The + candidate stores preferences, history and sanitized billing snapshots under + `%APPDATA%\MeterUsage` (normally the user's `AppData\Roaming\MeterUsage`); + Windows does not read the macOS defaults or Keychain stores, and one-time API + keys are memory-only. `package:win` configures unsigned x64 NSIS and portable + executables, and a Windows workflow defines build and isolated demo smoke + steps. CI success, native GUI/provider behavior, installer acceptance and + signing are unverified. No Windows release is available. The former RN/Tauri + Windows-first plan is superseded. See [ADR 0033](adr/0033-windows-desktop-candidate.md). - Candidate checks use synthetic fixtures. Demo/candidate selection isolates preferences, history, caches and Electron state before composition. Live provider/key discovery, updater installation and login-item changes are disabled in demo. `pnpm package:mac` creates a separate candidate with publication disabled, using existing macOS tools. - Candidate account visibility rechecks directory presence. Sharing freezes the displayed account key. Pacing-off hides forecasts; notch details reuse window-scoped burn metrics and have a bounded scroller. These contracts have synthetic/static-markup proof; native UI acceptance remains open. - The browser-demo notch uses viewport height when no native work-area height is supplied. The card and strip scroll independently; the 36-pixel charcoal strip has 22-pixel rings containing provider marks, percentages below. Settings is available in each expanded card footer, with no strip button or reserved slot. The 220-pixel detail card plus strip use 256 pixels total. Synthetic browser checks cover scrolling, two card heights and both CSS card sides, but do not verify native window placement or display changes. @@ -257,9 +268,10 @@ Release documentation refresh: 2026-10-17; build 61 executable inputs are unchan - [`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/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/adr/0010-macos-typescript-electron.md`](adr/0010-macos-typescript-electron.md): macOS migration and the historical Windows hold, amended by ADR 0033 for source implementation. - [`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): superseded platform plan, current macOS direction and Windows hold. +- [`docs/adr/0033-windows-desktop-candidate.md`](adr/0033-windows-desktop-candidate.md): Windows candidate scope and remaining proof. +- [`docs/plans/feature-and-windows-pipeline.md`](plans/feature-and-windows-pipeline.md): current macOS direction, Windows candidate and open gates. - [`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. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index 7944170..f361a9a 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -11,6 +11,15 @@ The published Swift app retains the existing local/CLI boundaries. The TypeScript/Electron candidate adds the opt-in boundaries described below. The candidate has source/fixture checks, but native behavior and cutover acceptance remain pending. See [ADR 0010](adr/0010-macos-typescript-electron.md). +The Windows candidate stores preferences, provider history and sanitized API +billing snapshots under `%APPDATA%\MeterUsage`, normally the user's +`AppData\Roaming\MeterUsage`. It follows the redirected `%APPDATA%` location +when set. Windows uses the user's AppData access controls; it does not read +macOS UserDefaults or Keychain items. OpenAI and Anthropic billing accept a +one-time key in Settings, held in memory for that request. Windows has no saved +Keychain-key import. The compact tray is fixed on Windows, PNG sharing uses the +Save dialog, and automatic update installation is unavailable. + ## Provider inputs and requests | Provider | Read or request | Retained data | @@ -213,7 +222,8 @@ compatibility and packaged acceptance. See [setup](DESKTOP-CONNECTIONS.md). ## Optional API billing snapshots OpenAI API and Anthropic API billing use separate normalized snapshots under -`Library/Application Support/MeterUsage/api-usage`. The optional bundled helper +`Library/Application Support/MeterUsage/api-usage` on macOS and +`%APPDATA%\MeterUsage\api-usage` on Windows. The optional bundled helper reads the selected organization Admin key from its environment for one fetch, removes that key from its own environment and calls only the documented OpenAI `/v1/organization/costs` or Anthropic `/v1/organizations/cost_report` @@ -234,8 +244,9 @@ reject redirects, cap response size and pagination, and never start inference. The helper writes only schema/provider, capture/period timestamps, currency and daily USD totals. Identifiers, headers and raw responses/errors are discarded. -Files and their final directory are owner-only; replacement is atomic and a -failed request preserves the previous file. The app validates the fixed files +On macOS, files and their final directory are owner-only. On Windows, files +inherit the user's AppData access controls. Replacement is atomic and a failed +request preserves the previous file. The app validates the fixed files and publishes only projected fields. Demo and isolated provider tests never read live API billing files. Demo, candidate and isolated profiles cannot use the Settings fetch action. Polling rereads snapshots and never fetches billing. diff --git a/docs/adr/0033-windows-desktop-candidate.md b/docs/adr/0033-windows-desktop-candidate.md new file mode 100644 index 0000000..9590364 --- /dev/null +++ b/docs/adr/0033-windows-desktop-candidate.md @@ -0,0 +1,54 @@ +# ADR 0033: Windows desktop source candidate + +- Status: Accepted candidate scope; Windows validation and release remain open +- Date: 2026-10-18 +- Amends: [ADR 0010](0010-macos-typescript-electron.md) only for the Windows implementation hold + +## Context + +ADR 0010 selected macOS Electron development and deferred Windows work until a +test machine was available. The shared Electron application now has a Windows +x64 source candidate with platform-specific persistence, CLI discovery, +packaging and a CI workflow definition. This changes the implementation hold, +not the public release status or ADR 0010's macOS migration requirements. + +## Decision + +Keep the Windows work as an unsigned source candidate. The candidate includes +the tray, side notch, shared usage views and heatmaps, local CLI sources, +OpenRouter, Codex browser sign-in through the installed CLI, and one-time +OpenAI or Anthropic API billing keys. Windows does not offer Claude Desktop, +Antigravity Desktop or Grok Bot Desktop account connections. It does not import +saved macOS Admin keys. The tray stays compact, sharing opens a PNG Save dialog, +and the macOS updater is unavailable. + +Windows preferences, history and sanitized billing snapshots use +`%APPDATA%\MeterUsage`; the default resolves under the user's +`AppData\Roaming\MeterUsage`. The candidate does not read macOS UserDefaults +or Keychain data. One-time API keys stay in memory for the requested fetch. + +`pnpm package:win` configures unsigned x64 NSIS and portable executables. The +Windows workflow defines typecheck, focused tests, package creation and an +isolated demo/single-instance smoke check. Its definition is not evidence that +CI passed. No public Windows build or release is authorized by this ADR. + +## Consequences and remaining proof + +The Windows hold in ADR 0010 is lifted for source implementation only. Before +any Windows release claim, record a successful workflow run for the exact +candidate revision, then complete native Windows checks for startup, settings, +provider and offline paths, storage and relaunch, tray/notch behavior, PNG +sharing, and installer install/uninstall. Signing and artifact publication +require separate review and authorization. Until those receipts exist, describe +Windows as a source candidate and leave native, installer, signing and release +status open. + +The public macOS beta remains 0.3.0-beta.3, build 61. The published stable +Swift release remains 0.2.41. This decision does not change either artifact, +the macOS migration gates, data migration or application identity. + +## References + +- [Windows packaging plan](../plans/feature-and-windows-pipeline.md) +- [Electron development guide](../electron/README.md) +- [Privacy and storage boundaries](../PRIVACY.md) diff --git a/docs/adr/README.md b/docs/adr/README.md index 061ddac..92b3a1a 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -21,7 +21,7 @@ compatibility and acceptance requirements remain in force. The | [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 | +| [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; Windows implementation hold amended by [0033](0033-windows-desktop-candidate.md); native acceptance pending | | [0011](0011-desktop-account-connections.md) | Add opt-in Codex browser sign-in and Claude Desktop account allowance; separate account allowance from local activity. | Accepted implementation direction; native and provider acceptance pending | | [0012](0012-codex-live-test-profile.md) | Run isolated live Codex validation in the existing UI while blocking other providers and candidate system effects. | Accepted implementation direction; reset prohibition superseded by [0017](0017-connected-codex-menu-reset.md); native UI acceptance pending | | [0013](0013-candidate-single-instance-startup.md) | Validate candidate Electron singleton metadata before locking; preserve other symlink rejection and safe activation during startup and Quit. | Accepted implementation direction; native acceptance pending | @@ -44,3 +44,4 @@ compatibility and acceptance requirements remain in force. The | [0031](0031-grok-quota-burn.md) | Measure Grok Bot quota burn from recent same-account percentages without inventing token counts or cycle duration. | Source implementation; native and live rate acceptance separate | | [0032](0032-one-time-api-setup.md) | Separate API setup from account connections and accept a one-time Admin key for explicit billing fetches, without saving credentials. | Source implementation; real billing acceptance pending | +| [0033](0033-windows-desktop-candidate.md) | Record the Windows x64 Electron candidate scope, storage boundary and evidence still required before release. | Source candidate; CI, native acceptance, installer acceptance and signing unverified | diff --git a/docs/electron/README.md b/docs/electron/README.md index 93e4466..9caba61 100644 --- a/docs/electron/README.md +++ b/docs/electron/README.md @@ -10,6 +10,15 @@ ID signed and notarized. This is an opt-in manual download; Swift stays stable. Source manifests retain `0.2.41`; packaging explicitly sets the beta version. Settings shows that bundle version. Full native acceptance remains open. +Windows has a separate unsigned x64 source candidate, not a public build. It +includes shared tray/notch usage views and heatmaps, local CLI sources, Codex +browser sign-in through the installed CLI, OpenRouter and one-time OpenAI or +Anthropic API billing. Claude Desktop, Antigravity Desktop and Grok Bot Desktop +account connections are unavailable. Preferences, history and sanitized +billing snapshots use `%APPDATA%\MeterUsage`, defaulting to the user's +`AppData\Roaming\MeterUsage`. Windows CI success, native behavior, installer +acceptance and signing are unverified. See [ADR 0033](../adr/0033-windows-desktop-candidate.md). + See the [current beta screenshots](../SCREENSHOTS.md) for Usage, notch and Settings. ## Source choices @@ -21,8 +30,8 @@ Usage. See [ADR 0023](../adr/0023-onboarding-source-choice.md). | Provider and method | Available source behavior | Coverage limit | | --- | --- | --- | -| Codex browser sign-in | Existing official app helper, with command-install compatibility, signs in through the provider page using a separate keyring-only profile. Reads allowance and provider-reported daily account tokens. | Account history is separate from CLI tokens. No local sessions, category split, cost or burn attribution comes from this method. Universal device/product coverage is unverified. | -| Codex CLI | Existing authenticated CLI supplies quota/reset metadata; local rollouts supply tokens, sessions and estimated cost. | Activity on this Mac only. | +| Codex browser sign-in | Existing official app helper, with command-install compatibility, signs in through the provider page using a separate keyring-only profile on macOS. On Windows it uses the installed Codex CLI. Reads allowance and provider-reported daily account tokens. | Account history is separate from CLI tokens. No local sessions, category split, cost or burn attribution comes from this method. Universal device/product coverage is unverified. | +| Codex CLI | Existing authenticated CLI supplies quota/reset metadata; local rollouts supply tokens, sessions and estimated cost. | Activity on this Mac or PC only. | | Claude Desktop | Explicit consent permits a fixed Keychain helper and read-only Desktop cache/cookie access for current account allowance. | Allowance only. No Desktop chat/token history, local cost, tier or extra-usage credit projection. Compatibility can fail when the active account has no allowance token; paid-account live acceptance remains open. | | Claude Code CLI | Local transcript counters, tier metadata and optional companion quota files. | Local activity; allowance requires the [companion](../COMPANION.md). | | Antigravity Desktop | Explicit consent reads allowance from one running, signed-in desktop server. | Account allowance only. Local CLI activity stays separate. The owner confirmed a live connection; automated quota comparison and broad compatibility remain unverified. | @@ -39,6 +48,14 @@ to local collection. See [desktop connections](../DESKTOP-CONNECTIONS.md) and ### Existing provider inputs +Windows keeps the shared CLI/local sources and OpenRouter. Desktop account +connections are available only for Codex there; Claude Desktop, Antigravity +Desktop and Grok Bot Desktop require macOS integration. Windows does not import +saved macOS Admin keys. Its billing setup accepts one-time OpenAI and Anthropic +Admin keys in Settings. The tray is compact, PNG sharing uses a Save dialog, +and automatic updates are unavailable. The [Windows candidate ADR](../adr/0033-windows-desktop-candidate.md) +records the remaining acceptance gates. + All nine adapters remain wired in [composition](../../src/main/composition.ts). The current source adds an OpenCode Go local setup action and Antigravity Desktop connection. Both are included in build 61. @@ -54,10 +71,11 @@ Desktop connection. Both are included in build 61. A quota key alone cannot supply local history. The OpenCode-only CLI fallback checks executable `~/.bun/bin/opencode` installs as well as the existing paths. - OpenRouter uses existing keys for account quota/credit and aggregate activity. -- Separate OpenAI API and Anthropic API cards read owner-only billing snapshots +- Separate OpenAI API and Anthropic API cards read normalized billing snapshots from **Settings → API connections → Fetch billing** or the bundled one-run - helper. Settings can reuse the previous app's saved Keychain Admin key or - accept a masked, one-time key. Neither path saves new credentials. See [API billing](../API_USAGE_BETA.md). + helper. macOS can reuse the previous app's saved Keychain Admin key; both + platforms accept a masked, one-time key. Neither path saves new credentials. + Windows supports the one-time key path only. See [API billing](../API_USAGE_BETA.md). That activity is provider account scope, not a local chat count. - Cursor, Copilot CLI and Gemini CLI use optional existing local quota snapshots. Presence alone does not provide cloud quota or a token ledger. @@ -214,8 +232,10 @@ Private installation receipts do not close native acceptance. Remaining proof includes native setup/account/offline journeys, tray/focus/accessibility, notifications, login items, sharing, updater replacement/relaunch, drag/display persistence and two different-height notch captures. Reuse matching source, -packaging and installation receipts only for their recorded layer. Windows -implementation, packaging, CI, testing and release remain paused. +packaging and installation receipts only for their recorded layer. Windows has +a source candidate and configured CI workflow, but workflow success, native +acceptance, installer acceptance, signing and release remain unverified. See +[ADR 0033](../adr/0033-windows-desktop-candidate.md). [ADR 0010](../adr/0010-macos-typescript-electron.md) keeps Swift as the behavior reference and rollback path. No automatic cutover, data migration, release or diff --git a/docs/plans/feature-and-windows-pipeline.md b/docs/plans/feature-and-windows-pipeline.md index 0162824..a775a0f 100644 --- a/docs/plans/feature-and-windows-pipeline.md +++ b/docs/plans/feature-and-windows-pipeline.md @@ -1,4 +1,4 @@ -# Platform pipeline: macOS Electron, Windows paused +# Platform pipeline: macOS Electron and Windows candidate The earlier React Native/Expo spike, Tauri fallback and Windows-first rollout are superseded by [ADR 0010](../adr/0010-macos-typescript-electron.md), accepted @@ -13,9 +13,20 @@ 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. +Windows has an unsigned x64 source candidate. Its `package:win` command +configures NSIS and portable executables, and `.github/workflows/windows.yml` +defines typecheck, focused tests, packaging and isolated demo/single-instance +smoke steps. This workflow has not yet been confirmed green. The candidate +supports local CLI data, Codex browser sign-in through the installed CLI, +OpenRouter, one-time OpenAI/Anthropic API billing keys, the tray and side notch. +Claude Desktop, Antigravity Desktop and Grok Bot Desktop account connections are +unavailable. Preferences, history and sanitized billing snapshots use +`%APPDATA%\MeterUsage`, defaulting to the user's `AppData\Roaming` location. + +Do not claim Windows release support from source or workflow configuration. +Native GUI/provider behavior, installer acceptance and signing remain unproven. +See [ADR 0033](../adr/0033-windows-desktop-candidate.md) for the scope and +release evidence required. ## Migration gates @@ -28,6 +39,11 @@ schedule. Portable shared source does not establish Windows behavior. 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. +4. Windows candidate: confirm the workflow on the exact candidate revision, + then record native Windows startup, settings, provider/offline, persistence, + tray/notch and PNG-sharing checks, plus installer install/uninstall evidence. + Signing and publication are separate gates. An unsigned package or passing + workflow alone does not close this stage. The original [ambient ETA](../mockups/01-time-to-empty.md), [burn attribution](../mockups/02-burn-attribution.md) and diff --git a/electron-builder.yml b/electron-builder.yml index 8f244a2..a3f3175 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -13,9 +13,10 @@ files: extraResources: - from: dist/api-helper/api-usage-helper.cjs to: api-usage-helper.cjs - - from: build/desktop-keychain - to: desktop-keychain mac: + extraResources: + - from: build/desktop-keychain + to: desktop-keychain target: - target: zip arch: [arm64] @@ -30,3 +31,17 @@ mac: LSUIElement: true NSAppTransportSecurity: NSAllowsArbitraryLoads: false +win: + target: + - target: nsis + arch: [x64] + - target: portable + arch: [x64] + icon: build/AppIcon.ico +nsis: + artifactName: MeterUsage-${version}-${arch}-setup.${ext} + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true +portable: + artifactName: MeterUsage-${version}-${arch}-portable.${ext} diff --git a/package.json b/package.json index b1dc41c..77dcc27 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "json": "node dist/main/cli.cjs json", "package:mac": "pnpm export:marks && pnpm build:desktop-helper && pnpm build && electron-builder --mac --publish never", "build:desktop-helper": "mkdir -p build && swiftc -target arm64-apple-macosx13.0 Scripts/electron/desktop-keychain.swift -o build/desktop-keychain", - "export:marks": "swift Scripts/electron/export-symbols.swift Resources" + "export:marks": "swift Scripts/electron/export-symbols.swift Resources", + "package:win": "pnpm export:marks:portable && pnpm build && electron-builder --win --x64 --publish never", + "export:marks:portable": "node Scripts/electron/export-portable-assets.cjs", + "smoke:win": "node Scripts/electron/smoke-windows.cjs" }, "dependencies": { "react": "19.2.6", diff --git a/src/main/api-billing-fetch.ts b/src/main/api-billing-fetch.ts index e65ded9..7bfcb1d 100644 --- a/src/main/api-billing-fetch.ts +++ b/src/main/api-billing-fetch.ts @@ -32,7 +32,7 @@ export class ApiBillingFetch { if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); const snapshot = await collectApiBilling(provider, adminKey, Date.now(), (url, init) => this.fetcher(url, { ...init, signal: AbortSignal.any([controller.signal, ...(init?.signal ? [init.signal] : [])]) })); if (this.stopped) throw new Error(apiBillingFetchErrors.unavailable); - writeApiBilling(this.launch.home, snapshot); + writeApiBilling(this.launch.home, snapshot, this.launch.data, this.launch.platform); await this.refresh(); } catch (error) { throw new Error(!this.stopped && error instanceof Error && [apiBillingFetchErrors.auth, apiBillingFetchErrors.savedMissing, apiBillingFetchErrors.savedAccess].some(message => message === error.message) ? error.message : apiBillingFetchErrors.unavailable); diff --git a/src/main/connections.ts b/src/main/connections.ts index f41894c..f18a7dd 100644 --- a/src/main/connections.ts +++ b/src/main/connections.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { randomUUID } from "node:crypto"; import type { Launch } from "./launch"; import { Preferences } from "./preferences"; +import { accountConnectionAvailable } from "./platform"; import { boundedChild, cliEnvironment, cliPath, codexRPC, endpoints, httpTransport, runCommand, type Command, type HTTP } from "./providers/transport"; import { object, parseCodex } from "./providers/parsers"; import { parseCodexAccountUsage } from "./providers/codex-account-usage"; @@ -72,22 +73,24 @@ export class DesktopConnections { private grokController = new AbortController(); constructor(readonly launch: Launch, readonly prefs: Preferences, readonly helper: string, readonly changed: () => void, readonly command: Command = runCommand, readonly http: HTTP = httpTransport(), readonly now = Date.now, readonly antigravity: AntigravityRPC = antigravityRPC, readonly grokBot: GrokBotRPC = grokBotTransport()) {} get state(): ConnectionState[] { - return (["codex", "claude", "antigravity", "grok"] as const).map(provider => ({ provider, status: this.launch.codexTest && provider !== "codex" || this.launch.claudeTest && provider !== "claude" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected", source: this.configured(provider) ? "account" : "cli", ...(provider === "grok" && /^[a-f\d]{64}$/.test(String(this.prefs.values.desktopGrokIdentity)) ? { accountMethod: this.prefs.values.desktopGrokSource === "bot" ? "bot" as const : "cursor" as const } : {}), localAvailable: !this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest })); + return (["codex", "claude", "antigravity", "grok"] as const).map(provider => ({ provider, status: !accountConnectionAvailable(provider, this.launch.platform ?? process.platform) || this.launch.codexTest && provider !== "codex" || this.launch.claudeTest && provider !== "claude" ? "unsupported" : this.pending.has(provider) ? "connecting" : this.enabled(provider) ? "connected" : this.errors.has(provider) ? "failed" : "disconnected", source: this.configured(provider) ? "account" : "cli", ...(provider === "grok" && /^[a-f\d]{64}$/.test(String(this.prefs.values.desktopGrokIdentity)) ? { accountMethod: this.prefs.values.desktopGrokSource === "bot" ? "bot" as const : "cursor" as const } : {}), localAvailable: !this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest })); } - enabled(provider: ConnectionProvider) { const value = this.prefs.values[this.key(provider)]; return (!this.launch.codexTest || provider === "codex") && (!this.launch.claudeTest || provider === "claude") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; } + enabled(provider: ConnectionProvider) { const value = this.prefs.values[this.key(provider)]; return accountConnectionAvailable(provider, this.launch.platform ?? process.platform) && (!this.launch.codexTest || provider === "codex") && (!this.launch.claudeTest || provider === "claude") && !this.stopped && !this.pending.has(provider) && !(provider === "codex" && this.prefs.values.desktopCodexCleanup !== "") && typeof value === "string" && value !== "" && value !== "off"; } configured(provider: ConnectionProvider) { return this.prefs.values[this.key(provider)] !== ""; } private key(provider: ConnectionProvider) { return provider === "codex" ? "desktopCodexConnection" : provider === "claude" ? "desktopClaudeIdentity" : provider === "antigravity" ? "desktopAntigravityIdentity" : "desktopGrokIdentity"; } private binary() { - for (const root of ["/Applications", join(this.launch.home, "Applications")]) { + for (const root of (this.launch.platform ?? process.platform) === "win32" ? [] : ["/Applications", join(this.launch.home, "Applications")]) { const path = join(root, "Codex.app/Contents/Resources/codex"); try { accessSync(path, constants.X_OK); return path; } catch { /* Existing command installations remain supported. */ } } - return cliPath("codex", this.launch.home, this.launch.env); + return cliPath("codex", this.launch.home, this.launch.env, this.launch.platform ?? process.platform); } private env(id: string) { const home = join(this.launch.data, "connections", "codex", id); mkdirSync(home, { recursive: true, mode: 0o700 }); - return { HOME: this.launch.home, PATH: cliEnvironment(this.launch.home, { PATH: this.launch.env.PATH }).PATH, CODEX_HOME: home, TMPDIR: this.launch.env.TMPDIR }; + const windows = (this.launch.platform ?? process.platform) === "win32"; + const env = windows ? Object.fromEntries(Object.entries(this.launch.env).filter(([key]) => ["path", "systemroot", "comspec", "userprofile", "localappdata", "appdata", "pathext", "temp", "tmp"].includes(key.toLowerCase()))) : { PATH: this.launch.env.PATH }; + return { ...cliEnvironment(this.launch.home, env, this.launch.platform ?? process.platform), HOME: this.launch.home, CODEX_HOME: home, TMPDIR: this.launch.env.TMPDIR }; } private async claude(allowUI: boolean, signal?: AbortSignal, readingCache?: (stage: keyof typeof desktopReadErrors) => void) { try { @@ -203,6 +206,7 @@ export class DesktopConnections { } finally { this.resetController = undefined; } } async connect(provider: ConnectionProvider, open: (url: string) => Promise, consent: () => Promise = async () => provider === "codex" || provider === "antigravity") { + if (!accountConnectionAvailable(provider, this.launch.platform ?? process.platform)) throw new Error("Desktop connection is unavailable on Windows. Choose CLI instead."); if (this.stopped) throw new Error("Connection unavailable"); if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode"); @@ -282,7 +286,7 @@ export class DesktopConnections { try { await this.prefs.set("desktopGrokSource", previousGrokSource); } catch { this.prefs.values.desktopGrokSource = previousGrokSource; } } - this.errors.add(provider); throw new Error(provider === "codex" ? "Open Codex on this Mac, then try connecting again" : provider === "antigravity" ? "Open one Antigravity Desktop account and sign in, then try connecting again" : provider === "grok" ? grokError : controller.signal.aborted ? "Open Claude Desktop and sign in, then try connecting again" : claudeError); + this.errors.add(provider); throw new Error(provider === "codex" ? (this.launch.platform ?? process.platform) === "win32" ? "Install Codex CLI on this PC, then try signing in again" : "Open Codex on this Mac, then try connecting again" : provider === "antigravity" ? "Open one Antigravity Desktop account and sign in, then try connecting again" : provider === "grok" ? grokError : controller.signal.aborted ? "Open Claude Desktop and sign in, then try connecting again" : claudeError); } finally { finish(); } } @@ -318,6 +322,7 @@ export class DesktopConnections { await this.clearConnection(provider, true); } private async clearConnection(provider: ConnectionProvider, local: boolean) { + if (!local && !accountConnectionAvailable(provider, this.launch.platform ?? process.platform)) throw new Error("Desktop connection is unavailable on Windows. Choose CLI instead."); if (this.stopped) throw new Error("Connection unavailable"); if (this.launch.demo) throw new Error("Connections are disabled in demo mode"); if (this.launch.codexTest && provider !== "codex") throw new Error("Connection unavailable in Codex test mode"); diff --git a/src/main/coordinator.ts b/src/main/coordinator.ts index ec8b828..405121f 100644 --- a/src/main/coordinator.ts +++ b/src/main/coordinator.ts @@ -81,7 +81,7 @@ export class Coordinator { const quotas = Object.fromEntries(Object.entries(this.quotas).map(([key, state]) => [key, state.status === "value" ? value({ ...state.value, canConsumeReset: resettable.has(key) }) : state])); const statuses = { ...this.statuses }; for (const source of this.sources) if (source.status && !statuses[source.slot.provider]) statuses[source.slot.provider] = { status: "idle" }; - return structuredClone({ ...(this.apiBilling ? { apiBilling: this.apiBilling } : {}), demo: this.launch.demo, refreshing: !!this.inflight, clock: this.now(), lastRefreshedAt: this.lastRefreshedAt, slots, traySlots: tray.filter(s => s.provider !== "openRouter"), notchSlots: tray, quotas, archived: this.archived, accountActivities: Object.fromEntries(this.sources.filter(s => s.accountActivity).map(s => [slotKey(s.slot), this.accountActivities[slotKey(s.slot)] ?? { status: "idle" }])), activities: this.activities, usages: this.usages, plans: this.plans, statuses, appearance: { theme: p.appearanceTheme as string, accent: p.accentTheme as string, heatmap: p.showHeatmap === true, claudeHeatmap: p.showClaudeHeatmap === true, codexHeatmap: p.showCodexHeatmap === true, pacing: p.showPacingBurnRate === true, telemetry: p.showActivityTelemetry === true, chart: p.showDailyActivityChart === true, resetButton: p.showSideNotchResetButton === true, compactTray: p.menuBarCompactEnabled === true, notch: p.sideNotchPanelEnabled === true, pinned: false, onboarding: p.onboardingCompleted === true }, historyError: this.history.error, archiveWriteFailed: this.archiveWriteFailed, clearingCache: this.clearingCache }); + return structuredClone({ ...(this.apiBilling ? { apiBilling: this.apiBilling } : {}), demo: this.launch.demo, refreshing: !!this.inflight, clock: this.now(), lastRefreshedAt: this.lastRefreshedAt, slots, traySlots: tray.filter(s => s.provider !== "openRouter"), notchSlots: tray, quotas, archived: this.archived, accountActivities: Object.fromEntries(this.sources.filter(s => s.accountActivity).map(s => [slotKey(s.slot), this.accountActivities[slotKey(s.slot)] ?? { status: "idle" }])), activities: this.activities, usages: this.usages, plans: this.plans, statuses, appearance: { theme: p.appearanceTheme as string, accent: p.accentTheme as string, heatmap: p.showHeatmap === true, claudeHeatmap: p.showClaudeHeatmap === true, codexHeatmap: p.showCodexHeatmap === true, pacing: p.showPacingBurnRate === true, telemetry: p.showActivityTelemetry === true, chart: p.showDailyActivityChart === true, resetButton: p.showSideNotchResetButton === true, compactTray: (this.launch.platform ?? process.platform) === "win32" || p.menuBarCompactEnabled === true, notch: p.sideNotchPanelEnabled === true, pinned: false, onboarding: p.onboardingCompleted === true }, historyError: this.history.error, archiveWriteFailed: this.archiveWriteFailed, clearingCache: this.clearingCache }); } subscribe(observer: (state: Snapshot) => void) { this.observers.add(observer); return () => this.observers.delete(observer); } publish() { const state = this.snapshot(); for (const observe of this.observers) observe(state); } @@ -104,7 +104,7 @@ export class Coordinator { } private async sweep(force: boolean, onlyKey?: string) { if (!this.launch.demo && !this.launch.codexTest && !this.launch.claudeTest) { - this.apiBilling = Object.fromEntries(apiBillingProviders.map(provider => [provider, readApiBilling(this.launch.home, provider, this.now())])); + this.apiBilling = Object.fromEntries(apiBillingProviders.map(provider => [provider, readApiBilling(this.launch.home, provider, this.now(), this.launch.data, this.launch.platform)])); } const tasks: Promise[] = []; for (const source of this.sources) { diff --git a/src/main/electron.ts b/src/main/electron.ts index cf3850c..5fd0240 100644 --- a/src/main/electron.ts +++ b/src/main/electron.ts @@ -5,6 +5,7 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { launchConfiguration, type Launch } from "./launch"; import { Preferences } from "./preferences"; +import { windowsFlyoutY } from "./platform"; import { compose } from "./composition"; import { DesktopConnections } from "./connections"; import { desktopReadErrors } from "./providers/claude-desktop"; @@ -33,10 +34,11 @@ async function main() { // Validate isolation before Electron creates a session or contacts a singleton socket. if (launch.profile) { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); } app.setName("MeterUsage"); + if (process.platform === "win32") { app.setPath("userData", join(launch.data, "electron")); app.setPath("sessionData", join(launch.data, "electron")); app.setAppUserModelId("dev.meterusage.app"); } if (json) { await runJSON(args, process.env, desktopHelper).then(json => { process.stdout.write(json + "\n"); app.exit(0); }, () => { process.stderr.write("meterusage: could not read report\n"); app.exit(1); }); - } else if (process.platform !== "darwin") { - process.stderr.write("MeterUsage desktop candidates currently support macOS only.\n"); app.exit(1); + } else if (process.platform !== "darwin" && process.platform !== "win32") { + process.stderr.write("MeterUsage desktop supports macOS and Windows.\n"); app.exit(1); } else { let reveal: (() => void) | undefined, requested = false; const activate = () => { if (reveal) reveal(); else requested = true; }; @@ -67,7 +69,7 @@ async function start(launch: Launch) { if (!launch.demo && !launch.candidate && Notification.isSupported()) new Notification({ title: alert.title, body: alert.body, silent: !alert.sound }).show(); }, sources); const issueReports = new IssueReports(() => coordinator.diagnostics(app.getVersion()), publish, prefs); - const apiBillingFetch = new ApiBillingFetch(launch, () => coordinator.refresh(), fetch, (provider, signal) => runCommand(desktopHelper, [`--api-${provider}`, "--allow-ui"], { signal, timeoutMs: 60000, maxBytes: 8192 })); + const apiBillingFetch = new ApiBillingFetch(launch, () => coordinator.refresh(), fetch, process.platform === "darwin" ? (provider, signal) => runCommand(desktopHelper, [`--api-${provider}`, "--allow-ui"], { signal, timeoutMs: 60000, maxBytes: 8192 }) : undefined); // Do not restore a legacy archive under a newly connected account. for (const p of ["codex", "claude", "antigravity", "grok"] as const) if (connections.configured(p)) coordinator.forgetQuota(p); const notch = { expanded: false, cardOnRight: false, selected: "codex", dragging: false }; @@ -78,13 +80,13 @@ async function start(launch: Launch) { 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 icon = nativeImage.createFromPath(join(__dirname, "../renderer/AppIcon.png")).resize({ width: 18, height: 18 }); icon.setTemplateImage(process.platform === "darwin"); const tray = new Tray(icon); tray.setToolTip("MeterUsage"); tray.on("right-click", () => tray.popUpContextMenu(Menu.buildFromTemplate([{ label: "Open MeterUsage", click: () => showUsage() }, { label: "Settings…", click: () => showSettings() }, { type: "separator" }, { label: "Quit MeterUsage", role: "quit" }]))); tray.on("click", () => { const w = windows.get("flyout"); w?.isVisible() ? w.hide() : showUsage(); }); let shareState: ViewState | undefined, shareHeight = 0, shareReady: ((image: Electron.NativeImage) => void) | undefined, shareFailed: ((error: Error) => void) | undefined; - const state = (surface?: Surface): ViewState => surface === "share" && shareState ? shareState : ({ appVersion: app.getVersion(), ...(surface === "settings" ? { issueReport: issueReports.state } : {}), connections: connections.state, snapshot: coordinator.snapshot(), settings: projectSettings(prefs.values, prefs.accounts, launch.home, surface), systemDark: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI, notch: { ...notch, maxHeight: Math.floor((anchor ? screen.getDisplayNearestPoint(anchor) : screen.getPrimaryDisplay()).workArea.height) }, update: updater.visible ? { version: updater.visible.version, state: updater.installState } : undefined }); + const state = (surface?: Surface): ViewState => surface === "share" && shareState ? shareState : ({ platform: process.platform === "win32" ? "win32" : "darwin", appVersion: app.getVersion(), ...(surface === "settings" ? { issueReport: issueReports.state } : {}), connections: connections.state, snapshot: coordinator.snapshot(), settings: projectSettings(prefs.values, prefs.accounts, launch.home, surface), systemDark: nativeTheme.shouldUseDarkColorsForSystemIntegratedUI, notch: { ...notch, maxHeight: Math.floor((anchor ? screen.getDisplayNearestPoint(anchor) : screen.getPrimaryDisplay()).workArea.height) }, update: updater.visible ? { version: updater.visible.version, state: updater.installState } : undefined }); const updater = new Updater(prefs, app.getVersion(), publish, undefined, release => { if (!launch.demo && !launch.candidate && Notification.isSupported()) new Notification({ title: "MeterUsage update available", body: `MeterUsage ${release.version} is ready to install.`, silent: true }).show(); }); @@ -117,7 +119,7 @@ async function start(launch: Launch) { const existing = windows.get(surface); if (existing && !existing.isDestroyed()) return existing; const panel = surface === "notch", trayImage = surface === "tray", shareImage = surface === "share"; if (surface === "settings") settingsHeight = undefined; - const w = new BrowserWindow({ width: shareImage ? shareCardWidth * 2 : trayImage ? 1000 : panel ? stripWidth : surface === "settings" ? 620 : Math.round(420 * panelScale()), height: trayImage ? 22 : panel ? notchHeight : surface === "settings" ? 680 : Math.ceil(flyoutHeight * panelScale()), show: false, frame: surface === "settings", transparent: panel || trayImage || shareImage, resizable: surface === "settings", minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, ...(panel ? { type: "panel", focusable: false, hasShadow: false } : {}), title: surface === "settings" ? "MeterUsage Settings" : "MeterUsage", backgroundColor: panel || trayImage || shareImage ? "#00000000" : "#242428", webPreferences: { preload: join(__dirname, "../preload/preload.cjs"), offscreen: trayImage || shareImage, zoomFactor: shareImage ? 2 : 1, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true, spellcheck: false } }); + const w = new BrowserWindow({ width: shareImage ? shareCardWidth * 2 : trayImage ? 1000 : panel ? stripWidth : surface === "settings" ? 620 : Math.round(420 * panelScale()), height: trayImage ? 22 : panel ? notchHeight : surface === "settings" ? 680 : Math.ceil(flyoutHeight * panelScale()), show: false, frame: surface === "settings", transparent: panel || trayImage || shareImage, resizable: surface === "settings", minimizable: false, maximizable: false, fullscreenable: false, skipTaskbar: true, ...(panel ? { ...(process.platform === "darwin" ? { type: "panel" as const } : {}), 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); @@ -147,7 +149,7 @@ async function start(launch: Launch) { } w.on("closed", () => { documents.delete(webContentsId); if (windows.get(surface) === w) windows.delete(surface); if (panel) { stopHoverCheck(); finishDrag(); } }); if (surface === "flyout") w.on("blur", () => { if (!sharing) w.hide(); }); - if (panel) { w.setAlwaysOnTop(true, "status"); w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } + if (panel) { w.setAlwaysOnTop(true, process.platform === "darwin" ? "status" : "normal"); if (process.platform === "darwin") w.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); } void w.loadURL(document.href).then(() => { publish(); if (panel) { placeNotch(); w.showInactive(); } }); return w; } @@ -161,7 +163,7 @@ async function start(launch: Launch) { const w = windows.get("flyout"); if (!w || w.isDestroyed()) return; const bounds = w.getBounds(), work = screen.getDisplayMatching(bounds).workArea; const width = Math.min(Math.round(420 * panelScale()), work.width), height = Math.min(Math.ceil(flyoutHeight * panelScale()), work.height); - w.setBounds({ x: Math.round(Math.min(Math.max(bounds.x, work.x), work.x + work.width - width)), y: Math.round(Math.min(Math.max(bounds.y, work.y), work.y + work.height - height)), width, height }, false); + w.setBounds({ x: Math.round(Math.min(Math.max(bounds.x, work.x), work.x + work.width - width)), y: Math.round(process.platform === "win32" ? windowsFlyoutY(tray.getBounds(), work, height) : Math.min(Math.max(bounds.y, work.y), work.y + work.height - height)), width, height }, false); } function sizeSettings() { const source = windows.get("settings"); if (!source || source.isDestroyed() || settingsHeight === undefined) return; @@ -278,7 +280,7 @@ async function start(launch: Launch) { if (r.key === "panelSize") sizeFlyout(); if (r.key === "refreshIntervalSeconds") coordinator.restartTimer(); nativeTheme.themeSource = prefs.values.appearanceTheme as "system" | "dark" | "light"; - if (r.key === "updateCheckEnabled") { if (r.value) void updater.checkIfDue(); else updater.reset(); } + if (r.key === "updateCheckEnabled") { if (r.value && process.platform === "darwin") 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"); @@ -352,6 +354,11 @@ async function start(launch: Launch) { create("share"); }); windows.get("share")?.close(); shareState = undefined; + if (process.platform === "win32") { + const saved = await dialog.showSaveDialog(source, { title: "Save usage card", defaultPath: "MeterUsage.png", filters: [{ name: "PNG image", extensions: ["png"] }] }); + if (!saved.canceled && saved.filePath) writeFileSync(saved.filePath, image.toPNG()); + sharing = false; folding.changed(); break; + } 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. @@ -362,7 +369,7 @@ async function start(launch: Launch) { } case "updateDismiss": await updater.dismiss(); break; case "updateInstall": { - if (launch.demo || launch.candidate || !app.isPackaged || !updater.visible) throw new Error("Update installation unavailable"); + if (process.platform !== "darwin" || launch.demo || launch.candidate || !app.isPackaged || !updater.visible) throw new Error("Update installation unavailable"); const confirmedRelease = structuredClone(updater.visible); const accepted = await dialog.showMessageBox(source, { type: "question", title: "Install MeterUsage update?", message: `Install MeterUsage ${confirmedRelease.version}?`, detail: "The app will quit, verify the downloaded bundle, replace this app and relaunch.", buttons: ["Cancel", "Install update"], defaultId: 0, cancelId: 0 }); if (accepted.response === 1) { await updater.install(resolve(app.getPath("exe"), "../../.."), confirmedRelease); app.quit(); } @@ -376,9 +383,9 @@ async function start(launch: Launch) { const source = BrowserWindow.fromWebContents(event.sender), surface = [...windows].find(([, w]) => w === source)?.[0]; if (!source || !surface) return { ok: false, error: "Unknown window" }; try { return await handle(parseRequest(raw), source, surface); } - catch (e) { return { ok: false, error: e instanceof Error && [...Object.values(apiBillingFetchErrors), "Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Open Claude Desktop and sign in, then try connecting again", "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.", ...Object.values(desktopReadErrors), ...Object.values(grokBotConnectionErrors), "Could not read Claude account allowance. Try connecting again later.", "Could not verify the same Claude Desktop account after the allowance request. Try connecting again.", "Could not save the Claude Desktop connection preferences. Try connecting again.", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Could not disconnect Claude Desktop", "Open one Antigravity Desktop account and sign in, then try connecting again", "Could not disconnect Antigravity Desktop", "Could not select Grok CLI", "Could not disconnect Grok Bot Desktop", "Connections are disabled in demo mode", "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."].includes(e.message) ? e.message : "Could not complete action" }; } + catch (e) { return { ok: false, error: e instanceof Error && [...Object.values(apiBillingFetchErrors), "Invalid request", "Unknown account", "Reset credit unavailable", "Reset confirmation expired", "Couldn't redeem Codex reset", "Login items require an installed app", "Could not change login item", "Could not share snapshot", "Card too tall to share", "Open Codex on this Mac, then try connecting again", "Install Codex CLI on this PC, then try signing in again", "Desktop connection is unavailable on Windows. Choose CLI instead.", "Open Claude Desktop and sign in, then try connecting again", "Could not access Claude Desktop through macOS Keychain. Check the access prompt, then try connecting again.", ...Object.values(desktopReadErrors), ...Object.values(grokBotConnectionErrors), "Could not read Claude account allowance. Try connecting again later.", "Could not verify the same Claude Desktop account after the allowance request. Try connecting again.", "Could not save the Claude Desktop connection preferences. Try connecting again.", "Could not clear the cancelled Codex connection", "Could not disconnect Codex", "Could not disconnect Claude Desktop", "Open one Antigravity Desktop account and sign in, then try connecting again", "Could not disconnect Antigravity Desktop", "Could not select Grok CLI", "Could not disconnect Grok Bot Desktop", "Connections are disabled in demo mode", "Could not connect Grok Bot Desktop. Sign in to Grok Bot, check allowance and Keychain access, then reconnect."].includes(e.message) ? e.message : "Could not complete action" }; } }); - const unsubscribe = coordinator.subscribe(() => { syncNotch(); publish(); if (!coordinator.snapshot().refreshing) void updater.checkIfDue(); }); + const unsubscribe = coordinator.subscribe(() => { syncNotch(); publish(); if (process.platform === "darwin" && !coordinator.snapshot().refreshing) void updater.checkIfDue(); }); screen.on("display-metrics-changed", () => { placeNotch(); sizeFlyout(); sizeSettings(); publish(); }); screen.on("display-removed", () => { placeNotch(); sizeFlyout(); sizeSettings(); publish(); }); powerMonitor.on("resume", () => { void coordinator.refresh(); }); app.on("window-all-closed", () => {}); diff --git a/src/main/launch.ts b/src/main/launch.ts index e5ed44e..9e9b43b 100644 --- a/src/main/launch.ts +++ b/src/main/launch.ts @@ -1,7 +1,8 @@ import { userInfo, tmpdir, hostname } from "node:os"; import { mkdtempSync, mkdirSync, realpathSync, existsSync, readdirSync, writeFileSync, lstatSync, readlinkSync } from "node:fs"; -import { isAbsolute, join, resolve, relative, dirname } from "node:path"; -export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string; codexTest?: boolean; claudeTest?: boolean } +import { isAbsolute, join, resolve, relative, dirname, parse, sep } from "node:path"; +import { applicationData } from "./platform"; +export interface Launch { demo: boolean; candidate: boolean; home: string; data: string; env: NodeJS.ProcessEnv; profile?: string; codexTest?: boolean; claudeTest?: boolean; platform?: NodeJS.Platform } export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = process.env, realHome = userInfo().homedir, electronSingletons = false): Launch { const demo = args.includes("--demo") || env.METERUSAGE_DEMO === "1", candidateIndex = args.indexOf("--candidate-profile"), codexIndex = args.indexOf("--codex-test-profile"), claudeIndex = args.indexOf("--claude-test-profile"), codexTest = codexIndex >= 0, claudeTest = claudeIndex >= 0; const liveTest = codexTest || claudeTest; @@ -9,9 +10,9 @@ export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = pro const index = codexTest ? codexIndex : claudeTest ? claudeIndex : candidateIndex, profile = index < 0 ? undefined : args[index + 1]; if (index >= 0 && ((!liveTest && !demo) || !profile || !isAbsolute(profile))) throw new Error("Candidate profile requires an absolute test directory and --demo unless using --codex-test-profile or --claude-test-profile"); if (profile) { - const normalized = resolve(profile), liveData = join(realHome, "Library/Application Support/MeterUsage"); + const normalized = resolve(profile), liveData = applicationData(realHome, process.platform, env); const inside = relative(liveData, normalized); - if (normalized === "/" || normalized === realHome || inside === "" || (inside !== ".." && !inside.startsWith("../"))) throw new Error("Candidate profile cannot use installed-app storage"); + if (normalized === parse(normalized).root || normalized === realHome || inside === "" || (!isAbsolute(inside) && inside !== ".." && !inside.startsWith(`..${sep}`))) throw new Error("Candidate profile cannot use installed-app storage"); let ancestor = normalized; while (!existsSync(ancestor)) ancestor = dirname(ancestor); if (realpathSync(ancestor) !== ancestor) throw new Error("Candidate profile cannot follow a symlink"); @@ -40,7 +41,7 @@ export function launchConfiguration(args: string[], env: NodeJS.ProcessEnv = pro // Demo always isolates preferences, provider sources, caches and Electron's // userData before any provider or coordinator is constructed. const isolated = liveTest ? resolve(profile!) : demo ? profile ?? mkdtempSync(join(tmpdir(), "meterusage-demo-")) : undefined; - return { demo, candidate: index >= 0, ...(codexTest ? { codexTest: true } : {}), ...(claudeTest ? { claudeTest: true } : {}), home: demo ? isolated! : realHome, data: isolated ?? join(realHome, "Library/Application Support/MeterUsage"), profile: isolated, env: liveTest ? { PATH: env.PATH, TMPDIR: env.TMPDIR } : demo ? {} : { ...env } }; + return { demo, candidate: index >= 0, ...(process.platform === "win32" ? { platform: "win32" as const } : {}), ...(codexTest ? { codexTest: true } : {}), ...(claudeTest ? { claudeTest: true } : {}), home: demo ? isolated! : realHome, data: isolated ?? applicationData(realHome, process.platform, env), profile: isolated, env: liveTest ? { PATH: env.PATH, TMPDIR: env.TMPDIR, ...(process.platform === "win32" ? Object.fromEntries(Object.entries(env).filter(([key]) => ["systemroot", "comspec", "userprofile", "localappdata", "appdata", "pathext", "temp", "tmp"].includes(key.toLowerCase()))) : {}) } : demo ? {} : { ...env } }; } function validateElectronSingletons([socketLink, cookieLink, lockLink]: string[]) { diff --git a/src/main/platform.ts b/src/main/platform.ts new file mode 100644 index 0000000..ff5d881 --- /dev/null +++ b/src/main/platform.ts @@ -0,0 +1,18 @@ +import { join, win32 } from "node:path"; +import type { ConnectionProvider } from "../shared/ipc"; +import type { Rect } from "../domain/notch"; + +export function applicationData(home: string, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): string { + if (platform !== "win32") return join(home, "Library/Application Support/MeterUsage"); + const roaming = Object.entries(env).find(([key]) => key.toLowerCase() === "appdata")?.[1]; + if (roaming && (!win32.isAbsolute(roaming) || win32.parse(roaming).root.length < 2 || /[\r\n\0]/.test(roaming))) throw new Error("APPDATA must be an absolute directory"); + return win32.join(roaming || win32.join(home, "AppData", "Roaming"), "MeterUsage"); +} + +export function windowsFlyoutY(tray: Rect, work: Rect, height: number): number { + return tray.y >= work.y + work.height / 2 ? work.y + work.height - height : work.y; +} + +export function accountConnectionAvailable(provider: ConnectionProvider, platform: NodeJS.Platform = process.platform): boolean { + return platform !== "win32" || provider === "codex"; +} diff --git a/src/main/preferences.ts b/src/main/preferences.ts index 1701348..727adf4 100644 --- a/src/main/preferences.ts +++ b/src/main/preferences.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, lstatSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { providers, type Provider, type ManagedAccount } from "../domain/models"; import { normalizeProviderOrder, validProviderOrder } from "../domain/provider-order"; @@ -62,9 +62,9 @@ export class Preferences { const prefs = new Preferences(launch, command, domain); if (launch.codexTest) prefs.values.desktopCodexConnection = "off"; if (launch.claudeTest) { prefs.values.desktopClaudeIdentity = "off"; prefs.values.showProviderClaude = true; } - if (launch.demo || launch.codexTest || launch.claudeTest) { + if (launch.demo || launch.codexTest || launch.claudeTest || (launch.platform ?? process.platform) === "win32") { const path = join(launch.data, "preferences.json"); - if (existsSync(path)) { try { const raw = JSON.parse(readFileSync(path, "utf8")); for (const [key, v] of Object.entries(raw)) if (key === "providerOrder") prefs.values[key] = normalizeProviderOrder(v); else if (validate(key, v)) prefs.values[key] = v; } catch { /* Keep known defaults for a damaged candidate file. */ } } + if (existsSync(path)) { try { const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1048576) throw new Error("Invalid preferences file"); const raw = JSON.parse(readFileSync(path, "utf8")); for (const [key, v] of Object.entries(raw)) if (key === "providerOrder") prefs.values[key] = normalizeProviderOrder(v); else if (validate(key, v)) prefs.values[key] = v; } catch { if ((launch.platform ?? process.platform) === "win32" && !launch.demo) throw new Error("Could not read preferences"); /* Keep known defaults for a damaged candidate file. */ } } else if (launch.demo) for (const p of providers) prefs.values[providerPreference(p)] = true; } else { await Promise.all(Object.keys(kinds).map(async key => { @@ -101,8 +101,10 @@ export class Preferences { if (this.launch.codexTest && key in codexTestPreferences && value !== codexTestPreferences[key]) throw new Error("Preference unavailable in Codex test mode"); if (this.launch.claudeTest && key in claudeTestPreferences && value !== claudeTestPreferences[key]) throw new Error("Preference unavailable in Claude test mode"); if (key === "refreshIntervalSeconds") value = Math.max(30, (value as number) > 0 ? value as number : 60); - if (this.launch.demo || this.launch.codexTest || this.launch.claudeTest) { - const next = { ...this.values, [key]: value as PreferenceValue }; atomicJSON(join(this.launch.data, "preferences.json"), next); + if (this.launch.demo || this.launch.codexTest || this.launch.claudeTest || (this.launch.platform ?? process.platform) === "win32") { + const path = join(this.launch.data, "preferences.json"); + if (existsSync(path) && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) throw new Error("Invalid preferences file"); + const next = { ...this.values, [key]: value as PreferenceValue }; atomicJSON(path, 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 }); diff --git a/src/main/providers/antigravity.ts b/src/main/providers/antigravity.ts index 4024929..a2dcc14 100644 --- a/src/main/providers/antigravity.ts +++ b/src/main/providers/antigravity.ts @@ -4,7 +4,7 @@ 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 { cliPath, runCommand, type Command, type CommandOptions } from "./transport"; import { parseAntigravity } from "./parsers"; import { antigravityHistory } from "./usage"; import { readFile } from "node:fs/promises"; @@ -107,5 +107,6 @@ export class AntigravityRuntime { } } export function runtimeCandidates(env: NodeJS.ProcessEnv): string[] { + if (process.platform === "win32") return ["docker", "podman"].flatMap(name => { try { return [cliPath(name, env.USERPROFILE ?? env.HOME ?? "", env)]; } catch { return []; } }); return [...new Set(["/opt/homebrew/bin/docker", "/opt/homebrew/bin/podman", "/usr/local/bin/docker", "/usr/local/bin/podman", "/usr/bin/docker", ...(env.PATH ?? "").split(":").flatMap(p => [join(p, "docker"), join(p, "podman")])])]; } diff --git a/src/main/providers/api-billing.ts b/src/main/providers/api-billing.ts index cb36a26..461234f 100644 --- a/src/main/providers/api-billing.ts +++ b/src/main/providers/api-billing.ts @@ -1,8 +1,9 @@ -import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, realpathSync, renameSync, rmSync, writeFileSync, type Stats } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { randomUUID } from "node:crypto"; import { type ApiBillingProvider, type ApiBillingReading, type ApiBillingSnapshot } from "../../domain/api-billing"; import { selectJSON, type Selection } from "../select-json"; +import { applicationData } from "../platform"; const day = 86400000, maxBytes = 1048576; const invalid = () => new Error("API billing data unavailable"); @@ -10,15 +11,26 @@ const record = (value: unknown): Record => { if (!value || type const timestamp = (value: unknown): number => { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > 8640000000000000) throw invalid(); return value; }; const monthStart = (value: number) => { const at = new Date(value); return Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), 1); }; const amount = (value: unknown): number => { if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > Number.MAX_SAFE_INTEGER) throw invalid(); return value; }; -const directory = (home: string) => join(home, "Library/Application Support/MeterUsage/api-usage"); -export const apiBillingPath = (home: string, provider: ApiBillingProvider) => join(directory(home), `${provider}.json`); +const directory = (data: string) => join(data, "api-usage"); +export const apiBillingPath = (home: string, provider: ApiBillingProvider, data = applicationData(home)) => join(directory(data), `${provider}.json`); +const within = (base: string, path: string) => { const part = relative(base, path); return part === "" || part !== ".." && !part.startsWith(`..${sep}`) && !isAbsolute(part); }; +const redirected = (path: string) => realpathSync(path).toLowerCase() !== resolve(path).toLowerCase(); +const privateFile = (stat: Stats, platform: NodeJS.Platform) => { + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || platform !== "win32" && (stat.uid !== process.getuid?.() || stat.mode & 0o077)) throw invalid(); +}; -function privateDirectory(home: string) { - let path = home; - for (const part of ["", "Library", "Application Support", "MeterUsage", "api-usage"]) { - path = join(path, part); - const stat = lstatSync(path); - if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o022) || part === "api-usage" && (stat.mode & 0o077)) throw invalid(); +function privateDirectory(home: string, data: string, platform: NodeJS.Platform, create = false) { + const target = resolve(directory(data)), boundary = within(resolve(home), target) ? resolve(home) : resolve(data), paths: string[] = []; + for (let path = target; ; path = dirname(path)) { paths.unshift(path); if (dirname(path) === path || platform !== "win32" && path === boundary) break; } + for (const path of paths) { + let stat: Stats; + try { stat = lstatSync(path); } + catch (error) { + if (!create || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + mkdirSync(path, { mode: 0o700 }); stat = lstatSync(path); + } + // Windows snapshots inherit the user's AppData ACL; POSIX ownership checks stay unchanged. + if (!stat.isDirectory() || stat.isSymbolicLink() || platform === "win32" && redirected(path) || platform !== "win32" && within(boundary, path) && (stat.uid !== process.getuid?.() || stat.mode & 0o022 || path === target && stat.mode & 0o077)) throw invalid(); } } @@ -39,13 +51,17 @@ export function parseApiBillingSnapshot(raw: string, provider: ApiBillingProvide return { schema: 1, provider, capturedAt, monthStart: start, periodEnd, currency: "USD", daily }; } -export function readApiBilling(home: string, provider: ApiBillingProvider, now = Date.now()): ApiBillingReading { +export function readApiBilling(home: string, provider: ApiBillingProvider, now = Date.now(), data = applicationData(home), platform: NodeJS.Platform = process.platform): ApiBillingReading { let fd: number | undefined; try { - privateDirectory(home); - fd = openSync(apiBillingPath(home, provider), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + privateDirectory(home, data, platform); + const path = apiBillingPath(home, provider, data), before = lstatSync(path); + privateFile(before, platform); + if (before.size > maxBytes || platform === "win32" && redirected(path)) throw invalid(); + fd = openSync(path, constants.O_RDONLY | (platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK)); const stat = fstatSync(fd); - if (!stat.isFile() || stat.uid !== process.getuid?.() || stat.mode & 0o077 || stat.nlink !== 1 || stat.size > maxBytes) throw invalid(); + privateFile(stat, platform); + if (stat.size > maxBytes || stat.ino !== before.ino || stat.dev !== before.dev || stat.size !== before.size || stat.mtimeMs !== before.mtimeMs || stat.ctimeMs !== before.ctimeMs) throw invalid(); const buffer = Buffer.alloc(maxBytes + 1); let size = 0; while (size < buffer.length) { const count = readSync(fd, buffer, size, buffer.length - size, null); if (!count) break; size += count; } const after = fstatSync(fd); @@ -57,19 +73,13 @@ export function readApiBilling(home: string, provider: ApiBillingProvider, now = } finally { if (fd !== undefined) closeSync(fd); } } -export function writeApiBilling(home: string, snapshot: ApiBillingSnapshot) { +export function writeApiBilling(home: string, snapshot: ApiBillingSnapshot, data = applicationData(home), platform: NodeJS.Platform = process.platform) { const value = parseApiBillingSnapshot(JSON.stringify(snapshot), snapshot.provider, snapshot.capturedAt); - // Validate existing ancestors before recursive mkdir can follow a symlink. - let path = home; - for (const part of ["", "Library", "Application Support", "MeterUsage", "api-usage"]) { - path = join(path, part); - try { const stat = lstatSync(path); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o022) throw invalid(); } - catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; mkdirSync(path, { mode: 0o700 }); } - } - privateDirectory(home); - const target = apiBillingPath(home, value.provider), temp = join(directory(home), `.${randomUUID()}.tmp`); + // Validate every existing ancestor before creating the next directory. + privateDirectory(home, data, platform, true); + const target = apiBillingPath(home, value.provider, data), temp = join(directory(data), `.${randomUUID()}.tmp`); try { - try { const stat = lstatSync(target); if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o077 || stat.nlink !== 1) throw invalid(); } + try { privateFile(lstatSync(target), platform); if (platform === "win32" && redirected(target)) throw invalid(); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } writeFileSync(temp, JSON.stringify(value) + "\n", { mode: 0o600, flag: "wx" }); renameSync(temp, target); diff --git a/src/main/providers/transport.ts b/src/main/providers/transport.ts index 5fbb37d..f9f211a 100644 --- a/src/main/providers/transport.ts +++ b/src/main/providers/transport.ts @@ -1,6 +1,6 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { constants, accessSync } from "node:fs"; -import { join } from "node:path"; +import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { constants, accessSync, statSync } from "node:fs"; +import { join, win32 } from "node:path"; import { randomUUID } from "node:crypto"; import { Unavailable, failed, type Provider } from "../../domain/models"; import { selectJSON } from "../select-json"; @@ -8,22 +8,92 @@ 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 { +function windowsEnv(env: NodeJS.ProcessEnv, name: string): string | undefined { + const key = Object.keys(env).find(key => key.toLowerCase() === name.toLowerCase()); + return key === undefined ? undefined : env[key]; +} +function windowsCLIPaths(home: string, env: NodeJS.ProcessEnv, name?: string): string[] { + const local = windowsEnv(env, "LOCALAPPDATA") ?? win32.join(home, "AppData", "Local"); + return [...new Set([ + win32.join(home, ".local", "bin"), win32.join(home, ".bun", "bin"), win32.join(home, ".cargo", "bin"), + win32.join(windowsEnv(env, "APPDATA") ?? win32.join(home, "AppData", "Roaming"), "npm"), + win32.join(local, "Microsoft", "WinGet", "Links"), + ...(name && ["codex", "claude", "opencode"].includes(name) ? [win32.join(local, "Programs", name)] : []), + ...(windowsEnv(env, "PATH") ?? "").split(";").map(path => path.replace(/^"(.*)"$/, "$1")), + ].filter(path => win32.isAbsolute(path) && win32.parse(path).root.length > 1))]; +} +export function cliPath(name: string, home: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): string { + if (platform === "win32") { + const extensions = win32.extname(name) ? [""] : (windowsEnv(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD").split(";").map(ext => ext.toLowerCase()).filter(ext => [".exe", ".com", ".cmd", ".bat"].includes(ext)); + for (const dir of windowsCLIPaths(home, env, name)) for (const ext of extensions) { + const path = win32.join(dir, name + ext); + try { accessSync(path, constants.R_OK); if (statSync(path).isFile()) return path; } catch { /* Probe existing Windows CLI installations. */ } + } + throw new Unavailable("cliNotFound", name); + } const paths = ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), ...(name === "opencode" ? [join(home, ".bun/bin")] : []), join(home, ".local/bin"), ...(env.PATH ?? "/usr/bin:/bin").split(":")]; for (const dir of paths) { const path = join(dir, name); try { accessSync(path, constants.X_OK); return path; } catch { /* Probe common GUI install paths. */ } } throw new Unavailable("cliNotFound", name); } -export function cliEnvironment(home: string, env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function cliEnvironment(home: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { + if (platform === "win32") return { ...Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== "path")), PATH: windowsCLIPaths(home, env).join(";") }; return { ...env, PATH: ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), join(home, ".local/bin"), env.PATH ?? "/usr/bin:/bin"].join(":") }; } +function commandInvocation(binary: string, args: string[], env: NodeJS.ProcessEnv): [string, string[]] { + if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(binary)) return [binary, args]; + // Bypass known npm shims entirely so provider arguments never reach cmd.exe. + const scripts: Record = { codex: "@openai/codex/bin/codex.js", claude: "@anthropic-ai/claude-code/cli.js", opencode: "opencode-ai/bin/opencode" }; + const script = scripts[win32.basename(binary, win32.extname(binary)).toLowerCase()]; + if (!script) throw new Error("Provider command wrapper unsupported"); + try { + const dir = win32.dirname(binary), target = win32.join(dir, "node_modules", script); + accessSync(target, constants.R_OK); if (!statSync(target).isFile()) throw new Error(); + let node = win32.join(dir, "node.exe"); + try { accessSync(node, constants.R_OK); if (!statSync(node).isFile()) throw new Error(); } + catch { node = cliPath("node.exe", windowsEnv(env, "USERPROFILE") ?? windowsEnv(env, "HOME") ?? "", env); } + return [node, [target, ...args]]; + } catch { throw new Error("Provider command wrapper unavailable"); } +} // Provider stderr may contain credentials or paths. Drain it, but never retain // or propagate it. All failures crossing this boundary use fixed messages. export function boundedChild(binary: string, args: string[], options: CommandOptions, exchange: (child: ChildProcessWithoutNullStreams, finish: (result: string | Error) => void) => void): Promise { return new Promise((resolve, reject) => { if (options.signal?.aborted) { reject(new Error("Provider request cancelled")); return; } - const child = spawn(binary, args, { env: options.env, stdio: "pipe", shell: false }); + let invocation: [string, string[]]; + try { invocation = commandInvocation(binary, args, options.env ?? process.env); } + catch (error) { reject(error); return; } + const windows = process.platform === "win32"; + const child = spawn(invocation[0], invocation[1], { env: options.env, stdio: "pipe", shell: false, ...(windows ? { windowsHide: true } : {}) }); 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); } }; + let treeKill: ChildProcess | undefined, closed = false, cleanupDone = true, settled = false; + const settle = () => { + if (settled || !closed || !cleanupDone) return; + settled = true; 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); + }; + const cleanupFailed = () => { + if (settled) return; + outcome = new Error("Provider process cleanup failed"); + try { treeKill?.kill("SIGKILL"); child.kill("SIGKILL"); } catch { /* Cleanup failures use the fixed error above. */ } + child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); + closed = cleanupDone = true; settle(); + }; + const stop = () => { + if (windows) { + killTimer = setTimeout(cleanupFailed, 1500); + if (child.exitCode !== null || child.pid === undefined) return; + // Node launchers cannot forward signals after Windows force-terminates them. + cleanupDone = false; + try { + treeKill = spawn(win32.join(windowsEnv(process.env, "SystemRoot") ?? "C:\\Windows", "System32", "taskkill.exe"), ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: false, windowsHide: true }); + treeKill.on("error", cleanupFailed); + treeKill.on("close", code => { if (code !== 0) cleanupFailed(); else { cleanupDone = true; settle(); } }); + } catch { cleanupFailed(); } + } else 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")); @@ -32,11 +102,7 @@ export function boundedChild(binary: string, args: string[], options: CommandOpt 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); - }); + child.on("close", () => { closed = true; settle(); }); exchange(child, finish); }); } diff --git a/src/renderer/api-billing-setup.tsx b/src/renderer/api-billing-setup.tsx index a8b7a3f..1a339e0 100644 --- a/src/renderer/api-billing-setup.tsx +++ b/src/renderer/api-billing-setup.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from "react"; import { apiBillingNames, type ApiBillingProvider } from "../domain/api-billing"; import type { Request } from "../shared/ipc"; -export function ApiBillingSetup({ provider, disabled, action }: { provider: ApiBillingProvider; disabled: boolean; action: (request: Request) => Promise }) { +export function ApiBillingSetup({ provider, disabled, action, savedKeyAvailable = true }: { provider: ApiBillingProvider; disabled: boolean; savedKeyAvailable?: boolean; action: (request: Request) => Promise }) { const input = useRef(null), pending = useRef(false); const [busy, setBusy] = useState(false); const fetchBilling = async (saved: boolean) => { @@ -15,10 +15,10 @@ export function ApiBillingSetup({ provider, disabled, action }: { provider: ApiB finally { pending.current = false; setBusy(false); } }; return

{ event.preventDefault(); await fetchBilling(false); }}> - -

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

+ {savedKeyAvailable && } + {savedKeyAvailable &&

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

}

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

- +
; } diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 32dd194..25040d8 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -77,7 +77,7 @@ export function ActivityHeatmap({ title, scope, period, label, fullHistory, unav function HeatmapCell({ label, readout, amount, peak, first = false, staticExport = false }: { label: string; readout: string; amount?: number; peak: number; first?: boolean; staticExport?: boolean }) { return ; } -function TelemetryView({ provider, t, stats, chart, heatmap, daily, now, staticExport }: { provider: Provider; t: Telemetry; stats: boolean; chart: boolean; heatmap: boolean; daily?: Daily[]; now: number; staticExport: boolean }) { +function TelemetryView({ provider, t, stats, chart, heatmap, daily, now, staticExport, platform }: { platform?: ViewState["platform"]; provider: Provider; t: Telemetry; stats: boolean; chart: boolean; heatmap: boolean; daily?: Daily[]; now: number; staticExport: boolean }) { const [mode, setMode] = useState("daily"); const today = provider === "openRouter" ? utcDay(now) : localDay(now), offset = 25 * 7 + new Date(today).getUTCDay(); const history = new Map(t.dailyHistory.map(d => [d.day, d])); @@ -92,7 +92,7 @@ function TelemetryView({ provider, t, stats, chart, heatmap, daily, now, staticE const metricRows = (rows: typeof availableMetrics) =>
{rows.map(([k, v]) =>
{k}
{typeof v === "number" ? n(v) : v}
)}
; const peak = Math.max(1, ...t.dailyHistory.map(amount)), cellPeak = Math.max(1, ...cells.flatMap(d => d ? [amount(d)] : [])); const exact = new Intl.NumberFormat(), date = (day: number) => new Date(day).toLocaleDateString([], { year: "numeric", month: "short", day: "numeric", ...(provider === "openRouter" ? { timeZone: "UTC" } : {}) }); - return
{stats && <>

Recorded activity

{metricRows(staticExport ? availableMetrics : availableMetrics.slice(0, 2))}{!staticExport && availableMetrics.length > 2 &&
More activity{metricRows(availableMetrics.slice(2))}
}}{chart && `${i * 300 / 29},${48 - amount(d) / peak * 45}`).join(" ")} fill="none" stroke="var(--accent)" strokeWidth="2" />}{heatmap && d?.available === false)} controls={daily && !staticExport && }>{cells.map((d, i) => { + return
{stats && <>

Recorded activity

{metricRows(staticExport ? availableMetrics : availableMetrics.slice(0, 2))}{!staticExport && availableMetrics.length > 2 &&
More activity{metricRows(availableMetrics.slice(2))}
}}{chart && `${i * 300 / 29},${48 - amount(d) / peak * 45}`).join(" ")} fill="none" stroke="var(--accent)" strokeWidth="2" />}{heatmap && d?.available === false)} controls={daily && !staticExport && }>{cells.map((d, i) => { if (!d) return ; const scope = !daily || mode === "daily" ? date(d.day) : mode === "weekly" ? `Week of ${date(shiftDay(d.day, -new Date(d.day).getDay()))}` : `Through ${date(d.day)}`; const label = `${scope}: ${d.available === false ? "Unavailable" : measured ? `${exact.format(d.tokens)} tokens, ${exact.format(d.sessionCount)} sessions` : `${exact.format(d.sessionCount)} sessions, token totals unavailable`}`; @@ -117,7 +117,7 @@ export function ProviderCard({ slot, state, action, detail = false, staticExport const t = activity?.telemetry ?? usage?.telemetry, at = activity && lastBurn(activity.sessions), status = extract(s.statuses[slot.provider]); const activeWindow = detail && q && activity ? headlineWindow(slot.provider, effectiveWindows(q)) : undefined; const heatmap = s.appearance.heatmap && (slot.provider !== "claude" || s.appearance.claudeHeatmap) && (slot.provider !== "codex" || s.appearance.codexHeatmap); - return

{slotName(slot)}{slot.provider === "grok" && <> Beta}

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

{status.stale ? `Last known ${status.severity === "operational" ? "Operational" : status.description} · ${formatETA((s.clock - status.checkedAt) / 1000, true)} ago · ${status.refreshError}` : status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

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

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

{g.title}

{g.windows.map((w, index) => c.provider === "grok" && c.source === "account" && c.accountMethod === "bot")} />)}
) : (detail ? effectiveWindows(q) : q.windows).map((w, index) => c.provider === "grok" && c.source === "account" && c.accountMethod === "bot")} />)}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

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

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

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

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

{q.resetCreditCount} earned reset credits

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

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

)}
} :

{reason(s.quotas[key])}

}{usage &&

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

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

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

{usage.usageWindows && (staticExport ? :
Usage by period
)}
}{accountActivity && heatmap && }{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && (staticExport ? :
Active window breakdown
)}{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

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

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

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

}{detail && !staticExport &&
}
; + return

{slotName(slot)}{slot.provider === "grok" && <> Beta}

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

{status.stale ? `Last known ${status.severity === "operational" ? "Operational" : status.description} · ${formatETA((s.clock - status.checkedAt) / 1000, true)} ago · ${status.refreshError}` : status.severity === "operational" ? "Operational" : status.description}

}{q ? <>{!fresh &&

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

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

{g.title}

{g.windows.map((w, index) => c.provider === "grok" && c.source === "account" && c.accountMethod === "bot")} />)}
) : (detail ? effectiveWindows(q) : q.windows).map((w, index) => c.provider === "grok" && c.source === "account" && c.accountMethod === "bot")} />)}{q.credits &&
{q.credits.unit === "dollars" ? "Balance" : "Credits"}{q.credits.unlimited ? "Unlimited" : q.credits.unit === "dollars" ? money(q.credits.balance) : n(q.credits.balance)}
{q.credits.dollarBalance !== undefined &&

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

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

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

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

{q.resetCreditCount} earned reset credits

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

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

)}
} :

{reason(s.quotas[key])}

}{usage &&

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

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

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

{usage.usageWindows && (staticExport ? :
Usage by period
)}
}{accountActivity && heatmap && }{t && (s.appearance.telemetry || s.appearance.chart || heatmap) && }{activeWindow && activity && (staticExport ? :
Active window breakdown
)}{activity && activity.sessions.length > 0 &&
Recent sessions ({activity.sessions.length}){activity.sessions.slice(-8).reverse().map(session =>
{session.projectName}{n(totalTokens(session.tokens))}

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

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

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

}{detail && !staticExport &&
}
; } function RollingUsage({ windows, provider }: { windows: UsageWindow[]; provider: Provider }) { const reference = windows.find(w => w.label === "last 30d"), cost = (reference?.estimatedCostUSD ?? 0) > 0; @@ -143,7 +143,7 @@ function UnifiedStrip({ state }: { state: ViewState }) { const s = state.snapshot, localSlots = s.slots.filter(slot => slot.provider !== "openRouter"), activities = localSlots.flatMap(slot => { const a = extract(s.activities[slotKey(slot)]); return a ? [a] : []; }), usages = localSlots.flatMap(slot => { const u = extract(s.usages[slotKey(slot)]); return u ? [u] : []; }); const totals = stripTotals(activities, usages, s.clock), nudge = failoverNudge(s.slots, s.quotas, s.activities, s.clock); if (totals.todayTokens === undefined && !nudge) return null; - return

AI activity on this Mac

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

{nudge}

}
; + return

AI activity on this {state.platform === "win32" ? "PC" : "Mac"}

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

{nudge}

}
; } const labels: Record = { launchAtLogin: "Launch at login", quotaAlertsEnabled: "Quota alerts", updateCheckEnabled: "Check for updates", sideNotchPanelEnabled: "Show side notch", menuBarCompactEnabled: "Compact menu bar", showHeatmap: "Daily heatmaps", showClaudeHeatmap: "Claude heatmap", showCodexHeatmap: "Codex heatmap", showPacingBurnRate: "Pacing and burn rate", showActivityTelemetry: "Activity statistics", showDailyActivityChart: "Daily activity chart", showSideNotchResetButton: "Codex limit resets in the side notch" }; function ConnectionRow({ connection: c, state, action }: { connection: ConnectionState; state: ViewState; action: (r: Request) => Promise }) { @@ -161,7 +161,7 @@ function ConnectionRow({ connection: c, state, action }: { connection: Connectio : c.provider === "claude" ? "Uses the account signed in to Claude Desktop. You approve access in macOS; allowance depends on your plan." : c.provider === "antigravity" ? "Uses one running, signed-in Antigravity Desktop app for account allowance. Local CLI activity stays separate." : c.provider === "grok" ? "Sign in through Grok Bot Desktop first, then allow read-only Keychain and allowance access. No CLI or separate MeterUsage xAI sign-in is required. Beta compatibility and shared-pool coverage remain unverified." - : "Sign in through your browser for account allowance and provider-reported token history. Requires an existing Codex app or CLI."; + : state.platform === "win32" ? "Sign in through your browser for account allowance and provider-reported token history. Install Codex CLI on this PC first." : "Sign in through your browser for account allowance and provider-reported token history. Requires an existing Codex app or CLI."; const reading = state.snapshot.quotas[c.provider]; const status = busy ? "Updating connection…" : c.source === "cli" ? localActive ? "CLI selected. Data appears when available." : "CLI selected. Collection is off." : c.status === "connected" ? reading?.status === "value" ? "Connected · Account allowance" : "Connection saved. Account allowance is unavailable." @@ -174,7 +174,7 @@ function ConnectionRow({ connection: c, state, action }: { connection: Connectio -

{help}

+

{state.platform === "win32" && c.status === "unsupported" ? "Desktop account access is available on macOS. Choose CLI for local data on this PC." : state.platform === "win32" ? help.replaceAll("this Mac", "this PC") : help}

{method === "cli" && c.source === "account" &&

Use CLI disconnects this MeterUsage account connection. Your other apps stay signed in.

} {!c.localAvailable && method === "cli" && !state.snapshot.demo &&

CLI selection is unavailable in this isolated test.

}
@@ -199,9 +199,9 @@ export function ApiBillingCards({ state }: { state: ViewState }) { })}; } export function ApiBillingConnections({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { - return

API connections Beta

View organization API spend. Use an Admin key, not a project key or ChatGPT or Claude subscription sign-in.

Use a saved key from the previous MeterUsage, or paste a one-time key. Pasted keys are cleared on submit and never saved.

{state.snapshot.demo &&

API access is disabled in this profile.

}
{apiBillingProviders.map(provider => { + return

API connections Beta

View organization API spend. Use an Admin key, not a project key or ChatGPT or Claude subscription sign-in.

{state.platform === "win32" ? "Paste a one-time Admin key. Pasted keys are cleared on submit and never saved." : "Use a saved key from the previous MeterUsage, or paste a one-time key. Pasted keys are cleared on submit and never saved."}

{state.snapshot.demo &&

API access is disabled in this profile.

}
{apiBillingProviders.map(provider => { const reading = state.snapshot.apiBilling?.[provider]; - return
{apiBillingNames[provider]}{reading?.status === "value" ? :

{reading?.code === "unavailable" ? "Billing snapshot unavailable. Fetch again to replace it." : "No billing snapshot yet."}

}{provider === "anthropic" &&

Priority Tier costs are excluded.

}
; + return
{apiBillingNames[provider]}{reading?.status === "value" ? :

{reading?.code === "unavailable" ? "Billing snapshot unavailable. Fetch again to replace it." : "No billing snapshot yet."}

}{provider === "anthropic" &&

Priority Tier costs are excluded.

}
; })}

Fetch billing updates the snapshot automatically. Refresh rereads saved snapshots. OpenRouter keeps its existing connection.

; } export function Connections({ state, action }: { state: ViewState; action: (r: Request) => Promise }) { @@ -244,7 +244,7 @@ export function Settings({ state, action, saveOrder }: { state: ViewState; actio
Accent color{Object.entries(accents).map(([name, color]) => )}
- +

MeterUsage{state.appVersion ? ` ${state.appVersion}` : ""} · Cost figures are local estimates.

diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index d5c447f..58350bd 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -14,6 +14,7 @@ export interface SettingsState { } export interface IssueReportState { sending: boolean; receipt?: string; error?: string; retryAt?: number } export interface ViewState { + platform?: "darwin" | "win32"; appVersion?: string; issueReport?: IssueReportState; connections?: ConnectionState[]; diff --git a/tests/api-billing-fetch.test.ts b/tests/api-billing-fetch.test.ts index aae38d1..a398f6a 100644 --- a/tests/api-billing-fetch.test.ts +++ b/tests/api-billing-fetch.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vite-plus/test"; -import { chmodSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { chmodSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { ApiBillingFetch, apiBillingFetchErrors } from "../src/main/api-billing-fetch"; @@ -21,7 +21,7 @@ const files = (root: string): string[] => readdirSync(root, { withFileTypes: tru afterEach(() => { vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); async function setup(overrides: Partial = {}) { vi.spyOn(Date, "now").mockReturnValue(now); - const home = mkdtempSync(join(tmpdir(), "meterusage-api-fetch-")); roots.push(home); + const home = realpathSync(mkdtempSync(join(tmpdir(), "meterusage-api-fetch-"))); roots.push(home); const isolated: Launch = { home, data: home, env: {}, demo: true, candidate: true }; const prefs = await Preferences.load(isolated); await prefs.set("appearanceTheme", "dark"); @@ -71,27 +71,27 @@ it("refuses an in-flight duplicate, permits the other provider, and releases the expect(fetcher).toHaveBeenCalledTimes(3); }); -it.each([401, 403, 500, "transport", "write"])("preserves the old snapshot, masks failure and releases the lock (%s)", async failure => { +it.each([401, 403, 500, "transport", ...(process.platform === "win32" ? [] : ["write"])])("preserves the old snapshot, masks failure and releases the lock (%s)", async failure => { const { launch, home, coordinator, refresh } = await setup(); - writeApiBilling(home, snapshot("openai")); await coordinator.refresh(); - const oldFile = readFileSync(apiBillingPath(home, "openai"), "utf8"), oldState = coordinator.snapshot().apiBilling; + writeApiBilling(home, snapshot("openai"), home); await coordinator.refresh(); + const oldFile = readFileSync(apiBillingPath(home, "openai", home), "utf8"), oldState = coordinator.snapshot().apiBilling; const fetcher = vi.fn(async () => { if (failure === "transport") throw new Error(keys.openai); return typeof failure === "number" ? new Response(keys.openai, { status: failure }) : response("openai"); }); // A non-private existing snapshot must be refused by the atomic writer. - if (failure === "write") chmodSync(apiBillingPath(home, "openai"), 0o644); + if (failure === "write") chmodSync(apiBillingPath(home, "openai", home), 0o644); const billing = new ApiBillingFetch(launch, refresh, fetcher); await expect(billing.run("openai", keys.openai)).rejects.toThrow(failure === 401 || failure === 403 ? apiBillingFetchErrors.auth : apiBillingFetchErrors.unavailable); - expect(readFileSync(apiBillingPath(home, "openai"), "utf8")).toBe(oldFile); expect(coordinator.snapshot().apiBilling).toEqual(oldState); expect(refresh).not.toHaveBeenCalled(); - if (failure === "write") chmodSync(apiBillingPath(home, "openai"), 0o600); + expect(readFileSync(apiBillingPath(home, "openai", home), "utf8")).toBe(oldFile); expect(coordinator.snapshot().apiBilling).toEqual(oldState); expect(refresh).not.toHaveBeenCalled(); + if (failure === "write") chmodSync(apiBillingPath(home, "openai", home), 0o600); fetcher.mockResolvedValueOnce(response("openai")); await billing.run("openai", keys.openai); expect(refresh).toHaveBeenCalledOnce(); }); it("aborts on quit and rejects a late success without writing or refreshing", async () => { const { launch, home, refresh } = await setup(); - writeApiBilling(home, snapshot("openai")); + writeApiBilling(home, snapshot("openai"), home); const before = files(home); let finish!: (value: Response) => void; const fetcher = vi.fn(() => new Promise(resolve => { finish = resolve; })); @@ -129,7 +129,7 @@ it("blocks saved-key access in isolated profiles and after quit", async () => { it.each(["missing", "denied", "cancelled"])("preserves snapshots and hides saved-key errors (%s)", async failure => { const { launch, refresh, home } = await setup(); - writeApiBilling(home, snapshot("openai")); const before = files(home); + writeApiBilling(home, snapshot("openai"), home); const before = files(home); let finish!: (key: string) => void; const saved = vi.fn(async (_provider: ApiBillingProvider, _signal: AbortSignal) => { if (failure === "missing") return ""; @@ -145,3 +145,19 @@ it.each(["missing", "denied", "cancelled"])("preserves snapshots and hides saved await expect(pending).rejects.toThrow(failure === "cancelled" ? apiBillingFetchErrors.unavailable : failure === "missing" ? apiBillingFetchErrors.savedMissing : apiBillingFetchErrors.savedAccess); expect(fetcher).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled(); expect(files(home)).toEqual(before); }); + +it("reports a missing saved-key reader without a Keychain error or a request", async () => { + const { launch, refresh, home } = await setup(), fetcher = vi.fn(); + const before = files(home); + await expect(new ApiBillingFetch(launch, refresh, fetcher).run("openai")).rejects.toThrow(apiBillingFetchErrors.savedMissing); + expect(fetcher).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled(); expect(files(home)).toEqual(before); +}); + +it("writes billing into the configured app-data directory", async () => { + const { launch, refresh, home } = await setup(); + launch.data = join(home, "configured-app-data"); + await new ApiBillingFetch(launch, refresh, vi.fn(async () => response("openai"))).run("openai", keys.openai); + const raw = readFileSync(apiBillingPath(home, "openai", launch.data), "utf8"); + expect(JSON.parse(raw)).toMatchObject({ provider: "openai", daily: [{ amountUSD: 2 }] }); + expect(raw).not.toContain(keys.openai); expect(raw).not.toContain("PRIVATE_RESPONSE"); +}); diff --git a/tests/api-billing-integration.test.ts b/tests/api-billing-integration.test.ts index 93ff79c..929d134 100644 --- a/tests/api-billing-integration.test.ts +++ b/tests/api-billing-integration.test.ts @@ -1,7 +1,7 @@ import { it, expect, afterEach, vi } from "vite-plus/test"; import { createElement, useState } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { Coordinator } from "../src/main/coordinator"; @@ -24,7 +24,7 @@ const snapshot = (provider: ApiBillingProvider, capturedAt = now): ApiBillingSna }; afterEach(() => { vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); async function setup(mode: "live" | "demo" | "codex" | "claude" = "live") { - const home = mkdtempSync(join(tmpdir(), "meterusage-api-ui-")); roots.push(home); + const home = realpathSync(mkdtempSync(join(tmpdir(), "meterusage-api-ui-"))); roots.push(home); const isolated = launchConfiguration(["--demo", "--candidate-profile", home], {}); const preferences = await Preferences.load(isolated); const launch = { ...isolated, demo: mode === "demo", ...(mode === "codex" ? { codexTest: true } : {}), ...(mode === "claude" ? { claudeTest: true } : {}) }; @@ -34,10 +34,10 @@ async function setup(mode: "live" | "demo" | "codex" | "claude" = "live") { } it("refresh projects billing independently and removes a value after a file becomes unavailable", async () => { const { c, home, view } = await setup(); - billing.writeApiBilling(home, snapshot("openai")); - billing.writeApiBilling(home, snapshot("anthropic")); + billing.writeApiBilling(home, snapshot("openai"), home); + billing.writeApiBilling(home, snapshot("anthropic"), home); // Extra private fields never cross the snapshot projection. - const path = billing.apiBillingPath(home, "openai"); + const path = billing.apiBillingPath(home, "openai", home); writeFileSync(path, JSON.stringify({ ...JSON.parse(readFileSync(path, "utf8")), privateField: "OMIT_PRIVATE" })); const before = c.json(); await c.refresh(); @@ -56,7 +56,7 @@ it("refresh projects billing independently and removes a value after a file beco }); it.each(["demo", "codex", "claude"] as const)("does not discover snapshots in the %s profile", async mode => { const { c, home } = await setup(mode); - billing.writeApiBilling(home, snapshot("openai")); + billing.writeApiBilling(home, snapshot("openai"), home); const read = vi.spyOn(billing, "readApiBilling"); await c.refresh(); expect(read).not.toHaveBeenCalled(); expect(c.snapshot().apiBilling).toBeUndefined(); diff --git a/tests/api-billing.test.ts b/tests/api-billing.test.ts index ad318cb..bf9e16e 100644 --- a/tests/api-billing.test.ts +++ b/tests/api-billing.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { appendFileSync, chmodSync, lstatSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, fstatSync, linkSync, lstatSync, mkdtempSync, readFileSync, readdirSync, readSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { type ApiBillingProvider, type ApiBillingSnapshot } from "../src/domain/api-billing"; @@ -8,7 +8,7 @@ import { runApiUsageHelper } from "../src/main/api-usage-helper"; const now = Date.parse("2026-10-16T12:00:00Z"), start = Date.parse("2026-10-01T00:00:00Z"), day = 86400000; const roots: string[] = []; -vi.mock("node:fs", async original => { const fs = await original(); return { ...fs, readSync: vi.fn(fs.readSync) }; }); +vi.mock("node:fs", async original => { const fs = await original(); return { ...fs, readSync: vi.fn(fs.readSync), fstatSync: vi.fn(fs.fstatSync) }; }); const temp = () => { const root = realpathSync(mkdtempSync(join(tmpdir(), "meterusage-api-fixture-"))); roots.push(root); return root; }; afterEach(() => { vi.useRealTimers(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); const snapshot = (provider: ApiBillingProvider = "openai"): ApiBillingSnapshot => ({ schema: 1, provider, capturedAt: now, monthStart: start, periodEnd: now, currency: "USD", daily: [{ start, end: start + day, amountUSD: 1.25 }] }); @@ -23,7 +23,8 @@ describe("API billing snapshot privacy and filesystem boundary", () => { const home = temp(), value = snapshot(provider); writeApiBilling(home, { ...value, organization_id: "PRIVATE_ORG", daily: [{ ...value.daily[0], workspace_id: "PRIVATE_WORKSPACE" }] } as unknown as ApiBillingSnapshot); const file = apiBillingPath(home, provider), raw = readFileSync(file, "utf8"); - expect(raw).not.toContain("PRIVATE"); expect(lstatSync(file).mode & 0o777).toBe(0o600); expect(lstatSync(dirname(file)).mode & 0o777).toBe(0o700); + expect(raw).not.toContain("PRIVATE"); + if (process.platform !== "win32") { expect(lstatSync(file).mode & 0o777).toBe(0o600); expect(lstatSync(dirname(file)).mode & 0o777).toBe(0o700); } expect(readApiBilling(home, provider, now + 60000)).toEqual({ status: "value", value, stale: false }); expect(readApiBilling(home, provider, now + day)).toEqual({ status: "value", value, stale: true }); expect(readdirSync(dirname(file))).toEqual([`${provider}.json`]); @@ -38,12 +39,12 @@ describe("API billing snapshot privacy and filesystem boundary", () => { writeApiBilling(home, snapshot()); writeFileSync(apiBillingPath(home, "openai"), "invalid"); expect(readApiBilling(home, "openai", now)).toEqual({ status: "missing", code: "unavailable" }); }); - it.each([0o644, 0o666])("rejects public snapshot permissions %s", mode => { + it.skipIf(process.platform === "win32").each([0o644, 0o666])("rejects public snapshot permissions %s", mode => { const home = temp(); writeApiBilling(home, snapshot()); chmodSync(apiBillingPath(home, "openai"), mode); expect(readApiBilling(home, "openai", now).status).toBe("missing"); expect(() => writeApiBilling(home, snapshot())).toThrow(); }); - it("rejects symlink files and directories without changing their targets", () => { + it.skipIf(process.platform === "win32")("rejects symlink files and directories without changing their targets", () => { const home = temp(), outside = temp(), secret = join(outside, "private.json"); writeFileSync(secret, "PRIVATE_DATA", { mode: 0o600 }); writeApiBilling(home, snapshot()); rmSync(apiBillingPath(home, "openai")); symlinkSync(secret, apiBillingPath(home, "openai")); expect(readApiBilling(home, "openai", now).status).toBe("missing"); expect(() => writeApiBilling(home, snapshot())).toThrow(); @@ -51,7 +52,7 @@ describe("API billing snapshot privacy and filesystem boundary", () => { expect(() => writeApiBilling(home, snapshot())).toThrow(); expect(readApiBilling(home, "openai", now).status).toBe("missing"); expect(readFileSync(secret, "utf8")).toBe("PRIVATE_DATA"); expect(readdirSync(outside)).toEqual(["private.json"]); }); - it("rejects public directories and oversized files", () => { + it.skipIf(process.platform === "win32")("rejects public directories and oversized files", () => { const home = temp(); writeApiBilling(home, snapshot()); const path = apiBillingPath(home, "openai"); chmodSync(dirname(path), 0o755); expect(readApiBilling(home, "openai", now).status).toBe("missing"); chmodSync(dirname(path), 0o700); writeFileSync(path, " ".repeat(1048577)); expect(readApiBilling(home, "openai", now).status).toBe("missing"); @@ -81,6 +82,53 @@ describe("API billing snapshot privacy and filesystem boundary", () => { expect(() => writeApiBilling(home, { ...snapshot(), currency: "EUR" } as unknown as ApiBillingSnapshot)).toThrow(); expect(readFileSync(apiBillingPath(home, "openai"), "utf8")).toBe(old); }); + it.each(process.platform === "win32" ? ["win32"] as const : ["darwin", "win32"] as const)("rejects hard links and oversized reads under %s storage policy", platform => { + const home = temp(), data = join(home, "configured-data"); + writeApiBilling(home, snapshot(), data, platform); + const path = apiBillingPath(home, "openai", data), linked = join(home, "linked.json"); + linkSync(path, linked); + expect(readApiBilling(home, "openai", now, data, platform)).toEqual({ status: "missing", code: "unavailable" }); + expect(() => writeApiBilling(home, snapshot(), data, platform)).toThrow(); + rmSync(linked); writeFileSync(path, " ".repeat(1048577)); + expect(readApiBilling(home, "openai", now, data, platform)).toEqual({ status: "missing", code: "unavailable" }); + }); + it("uses Windows inherited permissions while retaining projection and atomic replacement", () => { + const home = temp(), data = join(home, "AppData", "Roaming", "MeterUsage"); + const value = { ...snapshot(), apiKey: "PRIVATE_KEY", response: "PRIVATE_RESPONSE" } as unknown as ApiBillingSnapshot; + writeApiBilling(home, value, data, "win32"); + const file = apiBillingPath(home, "openai", data); + chmodSync(file, 0o644); chmodSync(dirname(file), 0o755); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "value", value: snapshot(), stale: false }); + const next = { ...snapshot(), daily: [{ ...snapshot().daily[0], amountUSD: 2 }] }; + writeApiBilling(home, next, data, "win32"); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "value", value: next, stale: false }); + expect(readFileSync(file, "utf8")).not.toContain("PRIVATE"); expect(readdirSync(dirname(file))).toEqual(["openai.json"]); + if (process.platform !== "win32") expect(readApiBilling(home, "openai", now, data, "darwin").status).toBe("missing"); + }); + it("rejects Windows junction/symlink ancestors before creating or reading snapshot files", () => { + const home = temp(), outside = temp(), parent = join(home, "AppData"); + symlinkSync(outside, parent, process.platform === "win32" ? "junction" : "dir"); + const data = join(parent, "Roaming", "MeterUsage"); + expect(() => writeApiBilling(home, snapshot(), data, "win32")).toThrow(); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "missing", code: "unavailable" }); + expect(readdirSync(outside)).toEqual([]); + }); + it.skipIf(process.platform === "win32")("rejects a Windows snapshot symlink without opening or replacing its target", () => { + const home = temp(), data = join(home, "app-data"), outside = join(home, "private.json"); + writeApiBilling(home, snapshot(), data, "win32"); + const file = apiBillingPath(home, "openai", data); + writeFileSync(outside, "PRIVATE_DATA"); rmSync(file); symlinkSync(outside, file); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "missing", code: "unavailable" }); + expect(() => writeApiBilling(home, snapshot(), data, "win32")).toThrow(); + expect(readFileSync(outside, "utf8")).toBe("PRIVATE_DATA"); + }); + it("rejects a changed Windows file descriptor identity", async () => { + const home = temp(), data = join(home, "app-data"); + writeApiBilling(home, snapshot(), data, "win32"); + const fs = await vi.importActual("node:fs"); + vi.mocked(fstatSync).mockImplementationOnce(fd => { const stat = fs.fstatSync(fd); stat.ino++; return stat; }); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "missing", code: "unavailable" }); + }); }); describe("official API cost contracts", () => { diff --git a/tests/windows-api-storage.test.ts b/tests/windows-api-storage.test.ts new file mode 100644 index 0000000..a2600c6 --- /dev/null +++ b/tests/windows-api-storage.test.ts @@ -0,0 +1,62 @@ +import { afterEach, expect, it, vi } from "vite-plus/test"; +import { chmodSync, fstatSync, linkSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { apiBillingPath, readApiBilling, writeApiBilling } from "../src/main/providers/api-billing"; +import { ApiBillingFetch, apiBillingFetchErrors } from "../src/main/api-billing-fetch"; +import type { ApiBillingSnapshot } from "../src/domain/api-billing"; +import type { Launch } from "../src/main/launch"; + +vi.mock("node:fs", async original => { const fs = await original(); return { ...fs, fstatSync: vi.fn(fs.fstatSync) }; }); +const roots: string[] = [], now = Date.parse("2026-10-17T12:00:00Z"), start = Date.UTC(2026, 9, 1), day = 86400000; +const temp = () => { const home = realpathSync(mkdtempSync(join(tmpdir(), "meterusage-windows-api-"))); roots.push(home); return home; }; +const snapshot = (amountUSD = 1): ApiBillingSnapshot => ({ schema: 1, provider: "openai", capturedAt: now, monthStart: start, periodEnd: now, currency: "USD", daily: [{ start, end: start + day, amountUSD }] }); +afterEach(() => { vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +it("stores only numeric billing in configured Windows app data and replaces atomically", () => { + const home = temp(), data = join(home, "AppData", "Roaming", "MeterUsage"), path = apiBillingPath(home, "openai", data); + writeApiBilling(home, { ...snapshot(), apiKey: "PRIVATE_KEY", account: "PRIVATE_ACCOUNT" } as ApiBillingSnapshot, data, "win32"); + chmodSync(path, 0o644); chmodSync(dirname(path), 0o755); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "value", stale: false, value: snapshot() }); + writeApiBilling(home, snapshot(2), data, "win32"); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "value", stale: false, value: snapshot(2) }); + expect(readFileSync(path, "utf8")).not.toContain("PRIVATE"); expect(readdirSync(dirname(path))).toEqual(["openai.json"]); +}); + +it("rejects Windows junction ancestors before following them", () => { + const home = temp(), outside = temp(), parent = join(home, "AppData"), data = join(parent, "Roaming", "MeterUsage"); + symlinkSync(outside, parent, process.platform === "win32" ? "junction" : "dir"); + expect(() => writeApiBilling(home, snapshot(), data, "win32")).toThrow(); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "missing", code: "unavailable" }); + expect(readdirSync(outside)).toEqual([]); +}); + +it.each(["hardlink", "directory", "oversized"])("rejects an unsafe Windows snapshot: %s", failure => { + const home = temp(), data = join(home, "app-data"), path = apiBillingPath(home, "openai", data); + writeApiBilling(home, snapshot(), data, "win32"); + if (failure === "hardlink") linkSync(path, join(home, "linked.json")); + else if (failure === "directory") { rmSync(path); mkdirSync(path); } + else writeFileSync(path, " ".repeat(1048577)); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "missing", code: "unavailable" }); + if (failure !== "oversized") expect(() => writeApiBilling(home, snapshot(), data, "win32")).toThrow(); +}); + +it("rejects a changed Windows descriptor identity", async () => { + const home = temp(), data = join(home, "app-data"), fs = await vi.importActual("node:fs"); + writeApiBilling(home, snapshot(), data, "win32"); + vi.mocked(fstatSync).mockImplementationOnce(fd => { const stat = fs.fstatSync(fd); stat.ino++; return stat; }); + expect(readApiBilling(home, "openai", now, data, "win32")).toEqual({ status: "missing", code: "unavailable" }); +}); + +it("requires a pasted Windows key, never saves it and honors configured app data", async () => { + vi.spyOn(Date, "now").mockReturnValue(now); + const home = temp(), data = join(home, "app-data"), launch: Launch = { home, data, env: {}, demo: false, candidate: false, platform: "win32" }; + const refresh = vi.fn(async () => {}), fetcher = vi.fn(async () => Response.json({ has_more: false, private: "PRIVATE_RESPONSE", data: [{ start_time: start / 1000, end_time: (start + day) / 1000, results: [{ amount: { value: 2, currency: "usd" } }] }] })); + const billing = new ApiBillingFetch(launch, refresh, fetcher); + await expect(billing.run("openai")).rejects.toThrow(apiBillingFetchErrors.savedMissing); + expect(fetcher).not.toHaveBeenCalled(); expect(refresh).not.toHaveBeenCalled(); expect(readdirSync(home)).toEqual([]); + await billing.run("openai", "synthetic-admin-key"); + const raw = readFileSync(apiBillingPath(home, "openai", data), "utf8"); + expect(JSON.parse(raw)).toMatchObject({ provider: "openai", daily: [{ amountUSD: 2 }] }); + expect(raw).not.toMatch(/synthetic|PRIVATE/); expect(refresh).toHaveBeenCalledOnce(); +}); diff --git a/tests/windows-packaging.test.ts b/tests/windows-packaging.test.ts new file mode 100644 index 0000000..26ba3bd --- /dev/null +++ b/tests/windows-packaging.test.ts @@ -0,0 +1,51 @@ +import { createRequire } from "node:module"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, expect, it } from "vite-plus/test"; + +const require = createRequire(import.meta.url); +const afterPack = require("../Scripts/electron/after-pack.cjs"); +const { exportAssets } = require("../Scripts/electron/export-portable-assets.cjs"); +const roots: string[] = []; +const temp = () => { const root = mkdtempSync(join(tmpdir(), "meterusage-packaging-")); roots.push(root); return root; }; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +it("skips all macOS executable and plist operations for Windows", async () => { + const root = temp(); + await expect(afterPack({ electronPlatformName: "win32", appOutDir: root })).resolves.toBeUndefined(); + expect(existsSync(join(root, "MeterUsage.app"))).toBe(false); + await expect(afterPack({ electronPlatformName: "linux", appOutDir: root })).rejects.toThrow("Unsupported"); +}); + +it("keeps the macOS executable rename and plist update", () => { + const bundle = temp(), macos = join(bundle, "Contents", "MacOS"); + mkdirSync(macos, { recursive: true }); writeFileSync(join(macos, "MeterUsage"), "fixture"); + const commands: unknown[][] = []; + afterPack.renameExecutable(bundle, (...args: unknown[]) => commands.push(args)); + expect(readFileSync(join(macos, "meterusage"), "utf8")).toBe("fixture"); + expect(commands).toEqual([["/usr/libexec/PlistBuddy", ["-c", "Set :CFBundleExecutable meterusage", join(bundle, "Contents/Info.plist")]]]); +}); + +it("copies the original provider marks and wraps the original 256px PNG as ICO", () => { + const root = temp(), resources = join(root, "Resources"); mkdirSync(resources); + copyFileSync(resolve("Resources/AppIcon.icns"), join(resources, "AppIcon.icns")); + exportAssets(root); + for (const provider of ["openRouter", "cursor", "copilot", "gemini"]) { + expect(readFileSync(join(resources, `${provider}-symbol.png`))).toEqual(readFileSync(resolve("Scripts/electron/assets", `${provider}-symbol.png`))); + } + const ico = readFileSync(join(root, "build/AppIcon.ico")); + expect(ico.readUInt16LE(2)).toBe(1); expect(ico.readUInt16LE(4)).toBe(1); + expect(ico.readUInt32LE(14)).toBe(ico.length - 22); expect(ico.readUInt32LE(18)).toBe(22); + expect(ico.subarray(22, 30)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(ico.readUInt32BE(38)).toBe(256); expect(ico.readUInt32BE(42)).toBe(256); +}); + +it("keeps Swift and the Keychain helper confined to macOS packaging", () => { + const manifest = JSON.parse(readFileSync(resolve("package.json"), "utf8")); + expect(manifest.scripts["package:win"]).toBe("pnpm export:marks:portable && pnpm build && electron-builder --win --x64 --publish never"); + const config = readFileSync(resolve("electron-builder.yml"), "utf8"); + expect(config.split("mac:\n")[0]).not.toContain("desktop-keychain"); + expect(config.split("win:\n")[1]).not.toContain("desktop-keychain"); + expect(config).toContain("mac:\n extraResources:\n - from: build/desktop-keychain\n to: desktop-keychain"); +}); diff --git a/tests/windows-renderer.test.ts b/tests/windows-renderer.test.ts new file mode 100644 index 0000000..5b078d9 --- /dev/null +++ b/tests/windows-renderer.test.ts @@ -0,0 +1,44 @@ +import { expect, it } from "vite-plus/test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { ApiBillingConnections, Connections, Settings, UsagePanel } from "../src/renderer/app"; +import { primary, value } from "../src/domain/models"; +import type { ViewState } from "../src/shared/ipc"; + +const state = (): ViewState => ({ + platform: "win32", systemDark: false, settings: { values: { refreshIntervalSeconds: 60, panelSize: "medium" }, accounts: [] }, + notch: { expanded: false, cardOnRight: false, selected: "codex", dragging: false }, + connections: ["codex", "claude", "antigravity", "grok"].map(provider => ({ provider: provider as "codex" | "claude" | "antigravity" | "grok", source: "cli", localAvailable: true, status: provider === "codex" ? "disconnected" : "unsupported" })), + snapshot: { + demo: false, refreshing: false, clock: 1791763200000, slots: [], traySlots: [], notchSlots: [], quotas: {}, archived: {}, activities: {}, usages: {}, plans: {}, statuses: {}, archiveWriteFailed: false, clearingCache: false, + appearance: { theme: "system", accent: "blue", heatmap: true, claudeHeatmap: true, codexHeatmap: true, pacing: true, telemetry: true, chart: true, resetButton: true, compactTray: true, notch: false, pinned: false, onboarding: true }, + }, +}); +const action = async () => {}; + +it("offers Windows CLI collection while disabling unavailable desktop account choices", () => { + const markup = renderToStaticMarkup(createElement(Connections, { state: state(), action })); + expect(markup).toContain("Desktop account access is available on macOS. Choose CLI for local data on this PC."); + for (const name of ["Claude Desktop", "Antigravity Desktop", "Grok Bot Desktop (Beta)"]) { + expect(markup).toContain(``); + } + expect(markup).toContain(''); + expect(markup).toContain("Use CLI"); + expect(markup).not.toContain("Connect Claude Desktop"); +}); + +it("hides macOS saved-key and updater controls while keeping one-time billing and local usage", () => { + const view = state(); + const billing = renderToStaticMarkup(createElement(ApiBillingConnections, { state: view, action })); + expect(billing).toContain("OpenAI API Admin key"); + expect(billing).toContain("Anthropic API Admin key"); + expect(billing).not.toContain("Use saved key"); + expect(billing).not.toContain("Use a saved key"); + expect(billing).not.toContain("Keychain"); + const settings = renderToStaticMarkup(createElement(Settings, { state: view, action, saveOrder: async () => true })); + expect(settings).not.toContain("Check for updates"); + expect(settings).not.toContain("Compact menu bar"); + view.snapshot.slots = [primary("codex")]; + view.snapshot.usages.codex = value({ provider: "codex", sessionCount: 1, messageCount: 1, todaySessionCount: 1, todayMessageCount: 1, capturedAt: view.snapshot.clock }); + expect(renderToStaticMarkup(createElement(UsagePanel, { state: view, action }))).toContain("Activity on this PC"); +}); diff --git a/tests/windows-runtime.test.ts b/tests/windows-runtime.test.ts new file mode 100644 index 0000000..b5ab9a0 --- /dev/null +++ b/tests/windows-runtime.test.ts @@ -0,0 +1,66 @@ +import { expect, it, vi } from "vite-plus/test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { applicationData, accountConnectionAvailable, windowsFlyoutY } from "../src/main/platform"; +import { Preferences } from "../src/main/preferences"; +import { DesktopConnections } from "../src/main/connections"; +import type { Launch } from "../src/main/launch"; + +it("keeps Windows app data separate from the existing macOS location", () => { + expect(applicationData("C:\\Users\\Example", "win32", {})).toBe("C:\\Users\\Example\\AppData\\Roaming\\MeterUsage"); + expect(applicationData("C:\\Users\\Example", "win32", { AppData: "D:\\Profiles\\Roaming" })).toBe("D:\\Profiles\\Roaming\\MeterUsage"); + expect(() => applicationData("C:\\Users\\Example", "win32", { APPDATA: "relative" })).toThrow("APPDATA must be an absolute directory"); + expect(applicationData("/Users/example", "darwin")).toBe("/Users/example/Library/Application Support/MeterUsage"); +}); + +it("offers only implemented Windows account connections", () => { + expect(accountConnectionAvailable("codex", "win32")).toBe(true); + for (const provider of ["claude", "antigravity", "grok"] as const) { + expect(accountConnectionAvailable(provider, "win32")).toBe(false); + expect(accountConnectionAvailable(provider, "darwin")).toBe(true); + } +}); + +it("keeps the flyout beside a top or bottom taskbar as its content resizes", () => { + const work = { x: -1200, y: -900, width: 1200, height: 860 }; + expect(windowsFlyoutY({ x: -30, y: -40, width: 30, height: 40 }, work, 300)).toBe(-340); + expect(windowsFlyoutY({ x: -30, y: -40, width: 30, height: 40 }, work, 600)).toBe(-640); + expect(windowsFlyoutY({ x: -30, y: -930, width: 30, height: 30 }, work, 300)).toBe(-900); +}); + +it("persists normal Windows settings and cooldowns without invoking macOS defaults", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage-windows-preferences-")); + const launch: Launch = { home: root, data: root, env: {}, demo: false, candidate: false, platform: "win32" }; + const command = vi.fn(async () => { throw new Error("No platform command should run"); }); + try { + const prefs = await Preferences.load(launch, command); + await Promise.all([prefs.set("panelSize", "small"), prefs.set("reportNextAllowedAt", 1900000000000)]); + const reloaded = await Preferences.load(launch, command); + expect(reloaded.values.panelSize).toBe("small"); + expect(reloaded.values.reportNextAllowedAt).toBe(1900000000000); + expect(JSON.parse(readFileSync(join(root, "preferences.json"), "utf8"))).toMatchObject({ panelSize: "small", reportNextAllowedAt: 1900000000000 }); + expect(command).not.toHaveBeenCalled(); + const connections = new DesktopConnections(launch, reloaded, "must-not-run", () => {}, command); + for (const provider of ["claude", "antigravity", "grok"] as const) { + expect(connections.state.find(c => c.provider === provider)).toMatchObject({ status: "unsupported", localAvailable: true }); + await expect(connections.connect(provider, async () => {})).rejects.toThrow("Desktop connection is unavailable on Windows"); + expect(connections.enabled(provider)).toBe(false); + await connections.useLocal(provider); + expect(connections.state.find(c => c.provider === provider)).toMatchObject({ source: "cli", status: "unsupported" }); + expect(reloaded.enabled(provider)).toBe(true); + } + expect(command).not.toHaveBeenCalled(); + await connections.stop(); + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +it("preserves a damaged normal Windows preference file instead of replacing it", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage-windows-damaged-")); + const file = join(root, "preferences.json"); + try { + writeFileSync(file, "{damaged"); + await expect(Preferences.load({ home: root, data: root, env: {}, demo: false, candidate: false, platform: "win32" })).rejects.toThrow("Could not read preferences"); + expect(readFileSync(file, "utf8")).toBe("{damaged"); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/tests/windows-transport-native.test.ts b/tests/windows-transport-native.test.ts new file mode 100644 index 0000000..5a374fc --- /dev/null +++ b/tests/windows-transport-native.test.ts @@ -0,0 +1,43 @@ +import { afterEach, expect, it } from "vite-plus/test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { boundedChild, runCommand } from "../src/main/providers/transport"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +it.skipIf(process.platform !== "win32")("runs an installed npm target with literal arguments on Windows without executing its batch wrapper", async () => { + const root = mkdtempSync(join(tmpdir(), "meterusage cli fixture ")); roots.push(root); + const script = join(root, "node_modules", "@openai", "codex", "bin", "codex.js"); + mkdirSync(dirname(script), { recursive: true }); + writeFileSync(script, "process.stdout.write(JSON.stringify(process.argv.slice(2)))"); + const binary = join(root, "codex.cmd"); writeFileSync(binary, "@echo PRIVATE_WRAPPER_MUST_NOT_RUN\r\n@exit /b 99\r\n"); + const args = ["space value", 'quote"value', "&echo PRIVATE", "|more", "%PATH%", "!value!", "^", "\r\n"]; + const result = await runCommand(binary, args, { env: { SystemRoot: process.env.SystemRoot, USERPROFILE: root, PATH: dirname(process.execPath), PATHEXT: ".EXE" } }); + expect(JSON.parse(result)).toEqual(args); +}); +it.skipIf(process.platform !== "win32").each(["success", "cancel"])("settles %s and reaps a launcher and grandchild retaining inherited pipes", async outcome => { + const root = mkdtempSync(join(tmpdir(), "meterusage process tree ")); roots.push(root); + const launcher = join(root, "launcher.cjs"), launcherPID = join(root, "launcher.pid"), grandchildPID = join(root, "grandchild.pid"); + const grandchild = "require('node:fs').writeFileSync(process.argv[1], String(process.pid));process.stdout.write('READY\\n');setInterval(()=>{},1000)"; + writeFileSync(launcher, `const {spawn}=require('node:child_process');require('node:fs').writeFileSync(process.argv[2],String(process.pid));const child=spawn(process.execPath,['-e',${JSON.stringify(grandchild)},process.argv[3]],{stdio:'inherit'});process.on('SIGTERM',()=>child.kill('SIGTERM'));child.on('exit',()=>process.exit(0));`); + const controller = new AbortController(); + let watchdog: ReturnType | undefined; + const pids = () => [launcherPID, grandchildPID].flatMap(path => { try { return [Number(readFileSync(path, "utf8"))]; } catch { return []; } }); + const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } }; + try { + const pending = boundedChild(process.execPath, [launcher, launcherPID, grandchildPID], { signal: controller.signal, timeoutMs: 3000 }, (child, finish) => { + let text = ""; + child.stdout.on("data", chunk => { text += String(chunk); if (text.includes("READY\n")) { if (outcome === "cancel") controller.abort(); else finish("synthetic result"); } }); + }); + const bounded = Promise.race([pending, new Promise((_resolve, reject) => { watchdog = setTimeout(() => reject(new Error("Fixture process cleanup did not settle")), 6000); })]); + if (outcome === "cancel") await expect(bounded).rejects.toThrow("Provider request cancelled"); + else await expect(bounded).resolves.toBe("synthetic result"); + expect(pids()).toHaveLength(2); + await expect.poll(() => pids().some(alive), { timeout: 2000 }).toBe(false); + } finally { + if (watchdog) clearTimeout(watchdog); + for (const pid of pids()) if (alive(pid)) spawnSync(join(process.env.SystemRoot ?? "C:\\Windows", "System32", "taskkill.exe"), ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", shell: false, windowsHide: true, timeout: 1500 }); + } +}); diff --git a/tests/windows-transport.test.ts b/tests/windows-transport.test.ts new file mode 100644 index 0000000..605d76b --- /dev/null +++ b/tests/windows-transport.test.ts @@ -0,0 +1,174 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { win32 } from "node:path"; +import { boundedChild, cliEnvironment, cliPath, codexRPC, runCommand } from "../src/main/providers/transport"; + +const files = vi.hoisted(() => new Map()); +vi.mock("node:fs", async original => ({ + ...await original(), + accessSync: vi.fn((path: string) => { if (!files.has(path.toLowerCase())) throw new Error("Synthetic path missing"); }), + statSync: vi.fn((path: string) => ({ isFile: () => files.get(path.toLowerCase()) === true })), +})); +vi.mock("node:child_process", async original => { + const actual = await original(); + return { ...actual, spawn: vi.fn(actual.spawn) }; +}); +afterEach(() => { files.clear(); vi.clearAllMocks(); vi.mocked(spawn).mockReset(); vi.useRealTimers(); }); +const home = "C:\\Users\\testuser", npm = win32.join(home, "AppData", "Roaming", "npm"); +function file(path: string) { files.set(path.toLowerCase(), true); return path; } +function onWindows(run: () => T): T { + const platform = Object.getOwnPropertyDescriptor(process, "platform")!; + Object.defineProperty(process, "platform", { value: "win32" }); + try { return run(); } finally { Object.defineProperty(process, "platform", platform); } +} +function fakeChild(cleanup: "success" | "error" | "timeout" = "success") { + const child = Object.assign(new EventEmitter(), { pid: 43210, stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), exitCode: null as number | null, + kill: vi.fn(() => { queueMicrotask(() => close(0)); return true; }), + }); + let closed = false; + const close = (code: number) => { + if (closed) return; closed = true; child.exitCode = code; + child.stdout.once("end", () => child.emit("close", code)); + child.stdout.end(); child.stderr.end(); child.emit("exit", code); + }; + const treeKill = Object.assign(new EventEmitter(), { kill: vi.fn(() => true) }); + vi.mocked(spawn).mockImplementationOnce(() => child as unknown as ChildProcessWithoutNullStreams); + vi.mocked(spawn).mockImplementationOnce(() => { + queueMicrotask(() => { + if (cleanup === "success") { close(0); treeKill.emit("close", 0); } + if (cleanup === "error") treeKill.emit("error", new Error("PRIVATE taskkill failure")); + }); + return treeKill as unknown as ChildProcessWithoutNullStreams; + }); + return { child, close, treeKill }; +} + +describe("Windows CLI discovery", () => { + it.each(["codex", "claude", "opencode"])("finds a native %s install outside PATH", name => { + const binary = file(win32.join(home, ".local", "bin", name + ".exe")); + expect(cliPath(name, home, {}, "win32")).toBe(binary); + }); + it("honors case-insensitive Path/PATHEXT, quoted directories and spaces", () => { + const binary = file("D:\\CLI Tools\\codex.cmd"); + expect(cliPath("codex", home, { pAtH: '"D:\\CLI Tools"', pAtHeXt: ".pS1;.cMd" }, "win32")).toBe(binary); + file("D:\\CLI Tools\\claude.ps1"); + expect(() => cliPath("claude", home, { Path: "D:\\CLI Tools", PATHEXT: ".PS1" }, "win32")).toThrow("not found"); + }); + it.each([ + ["codex", "AppData\\Roaming\\npm"], ["claude", ".bun\\bin"], ["opencode", ".cargo\\bin"], + ["claude", "AppData\\Local\\Programs\\claude"], ["codex", "AppData\\Local\\Microsoft\\WinGet\\Links"], + ])("finds %s in the per-user %s installation", (name, dir) => { + const binary = file(win32.join(home, dir, name + ".exe")); + expect(cliPath(name, home, {}, "win32")).toBe(binary); + }); + it("uses redirected AppData locations and skips directories and current-directory PATH entries", () => { + files.set(win32.join(home, ".local", "bin", "codex.exe").toLowerCase(), false); + const binary = file("D:\\Roaming\\npm\\codex.cmd"); + expect(cliPath("codex", home, { AppData: "D:\\Roaming", Path: ";.;relative" }, "win32")).toBe(binary); + expect(cliEnvironment(home, { Path: ';.;relative;"D:\\CLI Tools"', SystemRoot: "C:\\Windows" }, "win32")).toMatchObject({ SystemRoot: "C:\\Windows" }); + expect(cliEnvironment(home, { Path: ';.;relative;"D:\\CLI Tools"' }, "win32").PATH?.split(";")).toContain("D:\\CLI Tools"); + expect(cliEnvironment(home, { Path: ";.;relative" }, "win32").PATH?.split(";")).not.toContain("."); + }); + it("emits one PATH key and preserves the rest of the provider environment", () => { + const env = cliEnvironment(home, { Path: "D:\\CLI Tools", USERPROFILE: home, LOCALAPPDATA: "D:\\Local", CODEX_HOME: "D:\\Profile" }, "win32"); + expect(Object.keys(env).filter(key => key.toLowerCase() === "path")).toEqual(["PATH"]); + expect(env.PATH).toContain("D:\\CLI Tools"); expect(env.PATH).toContain("D:\\Local\\Microsoft\\WinGet\\Links"); + expect(env.USERPROFILE).toBe(home); expect(env.CODEX_HOME).toBe("D:\\Profile"); + }); + it("rejects relative user directories and drive-relative PATH entries", () => { + const env = cliEnvironment("", { APPDATA: "relative", LOCALAPPDATA: "relative", Path: "\\root-relative;D:drive-relative;\\\\server\\share\\tools" }, "win32"); + expect(env.PATH?.split(";")).toEqual(["\\\\server\\share\\tools"]); + file(".local\\bin\\codex.exe"); + expect(() => cliPath("codex", "", {}, "win32")).toThrow("not found"); + }); + it("preserves the POSIX environment augmentation", () => { + expect(cliEnvironment("/synthetic/home", { PATH: "/synthetic/bin", KEEP: "value" }, "linux")).toEqual({ KEEP: "value", PATH: "/opt/homebrew/bin:/usr/local/bin:/synthetic/home/.cargo/bin:/synthetic/home/.local/bin:/synthetic/bin" }); + }); +}); + +describe("Windows provider process boundary", () => { + it.each([ + ["codex.CMD", "@openai/codex/bin/codex.js"], ["claude.bat", "@anthropic-ai/claude-code/cli.js"], ["opencode.cmd", "opencode-ai/bin/opencode"], + ])("bypasses %s using the installed Node script and literal argv", async (name, script) => { + const binary = file(win32.join(npm, name)), target = file(win32.join(npm, "node_modules", script)), node = file(win32.join(npm, "node.exe")); + const { child, close } = fakeChild(), args = ["space value", 'quote"value', "&whoami", "|more", "%PATH%", "!value!", "^", "\r\n"]; + const pending = onWindows(() => runCommand(binary, args, { env: { Path: "D:\\Node" } })); + expect(spawn).toHaveBeenCalledWith(node, [target, ...args], { env: { Path: "D:\\Node" }, stdio: "pipe", shell: false, windowsHide: true }); + child.stdout.write("synthetic result"); close(0); expect(await pending).toBe("synthetic result"); + }); + it("finds node.exe on Path without using the Electron executable", async () => { + const binary = file(win32.join(npm, "codex.cmd")), target = file(win32.join(npm, "node_modules", "@openai/codex/bin/codex.js")), node = file("D:\\Node Tools\\node.exe"); + const { close } = fakeChild(); + const pending = onWindows(() => runCommand(binary, ["--version"], { env: { pAtH: "D:\\Node Tools", PATHEXT: ".CMD;.COM", USERPROFILE: home } })); + expect(spawn).toHaveBeenCalledWith(node, [target, "--version"], expect.objectContaining({ shell: false })); close(0); await pending; + }); + it("passes native executable arguments directly without a shell", async () => { + const { close } = fakeChild(), args = ["a & b", '"quoted"', "%PATH%"]; + const pending = onWindows(() => runCommand("D:\\CLI Tools\\claude.exe", args)); + expect(spawn).toHaveBeenCalledWith("D:\\CLI Tools\\claude.exe", args, expect.objectContaining({ shell: false, windowsHide: true })); close(0); await pending; + }); + it("rejects unknown wrappers, absent package targets and missing Node without leaking command details", async () => { + await expect(onWindows(() => runCommand("D:\\PRIVATE\\custom.cmd", ["PRIVATE_ARG"]))).rejects.toThrow("Provider command wrapper unsupported"); + const missingTarget = onWindows(() => runCommand(win32.join(npm, "codex.cmd"), ["PRIVATE_ARG"])); + await expect(missingTarget).rejects.toThrow("Provider command wrapper unavailable"); + file(win32.join(npm, "node_modules", "@openai/codex/bin/codex.js")); + await expect(onWindows(() => runCommand(win32.join(npm, "codex.cmd"), ["PRIVATE_ARG"], { env: { USERPROFILE: home } }))).rejects.toThrow("Provider command wrapper unavailable"); + expect(spawn).not.toHaveBeenCalled(); + }); + it("does not substitute node.com or a relative node.exe for Node", async () => { + file(win32.join(npm, "node_modules", "@openai/codex/bin/codex.js")); + file("D:\\Node Tools\\node.com"); file(".local\\bin\\node.exe"); + await expect(onWindows(() => runCommand(win32.join(npm, "codex.cmd"), [], { env: { PATH: "D:\\Node Tools" } }))).rejects.toThrow("Provider command wrapper unavailable"); + expect(spawn).not.toHaveBeenCalled(); + }); + it("retains JSON-RPC initialization and closes/reaps the shim-free child", async () => { + const binary = file(win32.join(npm, "codex.cmd")); file(win32.join(npm, "node.exe")); file(win32.join(npm, "node_modules", "@openai/codex/bin/codex.js")); + const { child } = fakeChild(), requests: Record[] = []; + child.stdin.on("data", chunk => { + const request = JSON.parse(String(chunk)); requests.push(request); + if (request.id === 1) queueMicrotask(() => child.stdout.write('{"id":1,"result":{}}\n')); + if (request.id === 2) queueMicrotask(() => child.stdout.write('{"id":2,"result":{"ok":true}}\n')); + }); + expect(await onWindows(() => codexRPC(binary, {}))).toBe('{"id":2,"result":{"ok":true}}'); + expect(requests.map(request => request.method)).toEqual(["initialize", "initialized", "account/rateLimits/read"]); + expect(spawn).toHaveBeenLastCalledWith(expect.stringMatching(/System32\\taskkill\.exe$/), ["/PID", "43210", "/T", "/F"], { stdio: "ignore", shell: false, windowsHide: true }); + expect(child.kill).not.toHaveBeenCalled(); expect(child.exitCode).toBe(0); + }); + it("cancels and reaps an npm script process", async () => { + const binary = file(win32.join(npm, "codex.cmd")); file(win32.join(npm, "node.exe")); file(win32.join(npm, "node_modules", "@openai/codex/bin/codex.js")); + const { child } = fakeChild(), controller = new AbortController(); + const pending = onWindows(() => runCommand(binary, [], { signal: controller.signal })); controller.abort(); + await expect(pending).rejects.toThrow("Provider request cancelled"); expect(child.kill).not.toHaveBeenCalled(); expect(child.exitCode).toBe(0); + }); + it("waits for tree termination after the launcher's pipes close", async () => { + const { close, treeKill } = fakeChild("timeout"); + const pending = onWindows(() => boundedChild("D:\\CLI\\codex.exe", [], {}, (_child, finish) => finish("synthetic result"))); + let settled = false; void pending.then(() => { settled = true; }); + close(0); await Promise.resolve(); expect(settled).toBe(false); + treeKill.emit("close", 0); await expect(pending).resolves.toBe("synthetic result"); + }); + it.each(["error", "timeout", "pipes", "exited-launcher"] as const)("bounds tree cleanup %s and rejects without exposing stderr", async cleanup => { + vi.useFakeTimers(); + const { child, treeKill } = fakeChild(cleanup === "error" ? "error" : "timeout"); + if (cleanup === "exited-launcher") child.exitCode = 0; + const pending = onWindows(() => boundedChild("D:\\CLI\\codex.exe", [], {}, (_child, finish) => finish("synthetic result"))); + if (cleanup === "pipes") treeKill.emit("close", 0); + const rejected = expect(pending).rejects.toThrow("Provider process cleanup failed"); + await vi.advanceTimersByTimeAsync(1500); await rejected; + if (cleanup !== "exited-launcher") expect(treeKill.kill).toHaveBeenCalledWith("SIGKILL"); + expect(child.kill).toHaveBeenCalledWith("SIGKILL"); + expect(child.stdout.destroyed).toBe(true); expect(child.stderr.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + it("executes portable literal argv and enforces timeout, cancellation, output limits and stderr secrecy", async () => { + const args = ["space value", 'quote"value', "&whoami", "%PATH%", "|more"]; + expect(JSON.parse(await runCommand(process.execPath, ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", ...args]))).toEqual(args); + await expect(runCommand(process.execPath, ["-e", "process.stderr.write('PRIVATE');setInterval(()=>{},1000)"], { timeoutMs: 50 })).rejects.toThrow("Provider request timed out"); + const controller = new AbortController(), pending = runCommand(process.execPath, ["-e", "setInterval(()=>{},1000)"], { signal: controller.signal }); controller.abort(); + await expect(pending).rejects.toThrow("Provider request cancelled"); + await expect(runCommand(process.execPath, ["-e", "process.stdout.write('x'.repeat(1000))"], { maxBytes: 10 })).rejects.toThrow("Provider output exceeded limit"); + await expect(runCommand(process.execPath, ["-e", "process.stderr.write('PRIVATE');process.exit(1)"])).rejects.toThrow("Provider command failed"); + }); +}); From 82b7ca2952bc725576879647747ff4fa4018d60b Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:16:36 -0400 Subject: [PATCH 2/3] fix: handle Windows path and process exit races --- CHANGELOG.md | 2 ++ src/main/platform.ts | 4 ++-- src/main/providers/transport.ts | 16 +++++++++++----- tests/windows-packaging.test.ts | 2 +- tests/windows-transport.test.ts | 29 ++++++++++++++++++++++++++--- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fba7803..fafb7f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ follows [Semantic Versioning](https://semver.org/). - Optically center the Grok mark in provider rings, menu items, cards and Settings. Preserve the original provider artwork and existing icon sizes. +- Preserve a finished provider command's result when Windows process cleanup + races its natural exit. ### Added diff --git a/src/main/platform.ts b/src/main/platform.ts index ff5d881..e6a4f6f 100644 --- a/src/main/platform.ts +++ b/src/main/platform.ts @@ -1,9 +1,9 @@ -import { join, win32 } from "node:path"; +import { posix, win32 } from "node:path"; import type { ConnectionProvider } from "../shared/ipc"; import type { Rect } from "../domain/notch"; export function applicationData(home: string, platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): string { - if (platform !== "win32") return join(home, "Library/Application Support/MeterUsage"); + if (platform !== "win32") return posix.join(home, "Library/Application Support/MeterUsage"); const roaming = Object.entries(env).find(([key]) => key.toLowerCase() === "appdata")?.[1]; if (roaming && (!win32.isAbsolute(roaming) || win32.parse(roaming).root.length < 2 || /[\r\n\0]/.test(roaming))) throw new Error("APPDATA must be an absolute directory"); return win32.join(roaming || win32.join(home, "AppData", "Roaming"), "MeterUsage"); diff --git a/src/main/providers/transport.ts b/src/main/providers/transport.ts index f9f211a..64b6910 100644 --- a/src/main/providers/transport.ts +++ b/src/main/providers/transport.ts @@ -1,6 +1,6 @@ import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { constants, accessSync, statSync } from "node:fs"; -import { join, win32 } from "node:path"; +import { posix, win32 } from "node:path"; import { randomUUID } from "node:crypto"; import { Unavailable, failed, type Provider } from "../../domain/models"; import { selectJSON } from "../select-json"; @@ -31,13 +31,13 @@ export function cliPath(name: string, home: string, env: NodeJS.ProcessEnv, plat } throw new Unavailable("cliNotFound", name); } - const paths = ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), ...(name === "opencode" ? [join(home, ".bun/bin")] : []), join(home, ".local/bin"), ...(env.PATH ?? "/usr/bin:/bin").split(":")]; - for (const dir of paths) { const path = join(dir, name); try { accessSync(path, constants.X_OK); return path; } catch { /* Probe common GUI install paths. */ } } + const paths = ["/opt/homebrew/bin", "/usr/local/bin", posix.join(home, ".cargo/bin"), ...(name === "opencode" ? [posix.join(home, ".bun/bin")] : []), posix.join(home, ".local/bin"), ...(env.PATH ?? "/usr/bin:/bin").split(":")]; + for (const dir of paths) { const path = posix.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, platform: NodeJS.Platform = process.platform): NodeJS.ProcessEnv { if (platform === "win32") return { ...Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== "path")), PATH: windowsCLIPaths(home, env).join(";") }; - return { ...env, PATH: ["/opt/homebrew/bin", "/usr/local/bin", join(home, ".cargo/bin"), join(home, ".local/bin"), env.PATH ?? "/usr/bin:/bin"].join(":") }; + return { ...env, PATH: ["/opt/homebrew/bin", "/usr/local/bin", posix.join(home, ".cargo/bin"), posix.join(home, ".local/bin"), env.PATH ?? "/usr/bin:/bin"].join(":") }; } function commandInvocation(binary: string, args: string[], env: NodeJS.ProcessEnv): [string, string[]] { if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(binary)) return [binary, args]; @@ -66,7 +66,10 @@ export function boundedChild(binary: string, args: string[], options: CommandOpt const child = spawn(invocation[0], invocation[1], { env: options.env, stdio: "pipe", shell: false, ...(windows ? { windowsHide: true } : {}) }); let outcome: string | Error | undefined, killTimer: ReturnType | undefined, bytes = 0; let treeKill: ChildProcess | undefined, closed = false, cleanupDone = true, settled = false; + let cleanupExitCode: number | null | undefined; const settle = () => { + // taskkill reports 128 when the provider exits before termination reaches it. + if (cleanupExitCode === 128 && child.exitCode !== null && closed) cleanupDone = true; if (settled || !closed || !cleanupDone) return; settled = true; clearTimeout(timer); if (killTimer) clearTimeout(killTimer); options.signal?.removeEventListener("abort", abort); const result = outcome ?? new Error("Provider process exited"); @@ -88,7 +91,10 @@ export function boundedChild(binary: string, args: string[], options: CommandOpt try { treeKill = spawn(win32.join(windowsEnv(process.env, "SystemRoot") ?? "C:\\Windows", "System32", "taskkill.exe"), ["/PID", String(child.pid), "/T", "/F"], { stdio: "ignore", shell: false, windowsHide: true }); treeKill.on("error", cleanupFailed); - treeKill.on("close", code => { if (code !== 0) cleanupFailed(); else { cleanupDone = true; settle(); } }); + treeKill.on("close", code => { + cleanupExitCode = code; + if (code !== 0 && code !== 128) cleanupFailed(); else { cleanupDone = code === 0; settle(); } + }); } catch { cleanupFailed(); } } else if (child.exitCode === null) { child.kill("SIGTERM"); killTimer = setTimeout(() => child.kill("SIGKILL"), 250); diff --git a/tests/windows-packaging.test.ts b/tests/windows-packaging.test.ts index 26ba3bd..b294b02 100644 --- a/tests/windows-packaging.test.ts +++ b/tests/windows-packaging.test.ts @@ -44,7 +44,7 @@ it("copies the original provider marks and wraps the original 256px PNG as ICO", it("keeps Swift and the Keychain helper confined to macOS packaging", () => { const manifest = JSON.parse(readFileSync(resolve("package.json"), "utf8")); expect(manifest.scripts["package:win"]).toBe("pnpm export:marks:portable && pnpm build && electron-builder --win --x64 --publish never"); - const config = readFileSync(resolve("electron-builder.yml"), "utf8"); + const config = readFileSync(resolve("electron-builder.yml"), "utf8").replace(/\r\n/g, "\n"); expect(config.split("mac:\n")[0]).not.toContain("desktop-keychain"); expect(config.split("win:\n")[1]).not.toContain("desktop-keychain"); expect(config).toContain("mac:\n extraResources:\n - from: build/desktop-keychain\n to: desktop-keychain"); diff --git a/tests/windows-transport.test.ts b/tests/windows-transport.test.ts index 605d76b..e7813a7 100644 --- a/tests/windows-transport.test.ts +++ b/tests/windows-transport.test.ts @@ -6,6 +6,10 @@ import { win32 } from "node:path"; import { boundedChild, cliEnvironment, cliPath, codexRPC, runCommand } from "../src/main/providers/transport"; const files = vi.hoisted(() => new Map()); +vi.mock("node:path", async original => { + const actual = await original(); + return { ...actual, join: actual.win32.join }; +}); vi.mock("node:fs", async original => ({ ...await original(), accessSync: vi.fn((path: string) => { if (!files.has(path.toLowerCase())) throw new Error("Synthetic path missing"); }), @@ -83,8 +87,10 @@ describe("Windows CLI discovery", () => { file(".local\\bin\\codex.exe"); expect(() => cliPath("codex", "", {}, "win32")).toThrow("not found"); }); - it("preserves the POSIX environment augmentation", () => { - expect(cliEnvironment("/synthetic/home", { PATH: "/synthetic/bin", KEEP: "value" }, "linux")).toEqual({ KEEP: "value", PATH: "/opt/homebrew/bin:/usr/local/bin:/synthetic/home/.cargo/bin:/synthetic/home/.local/bin:/synthetic/bin" }); + it.each(["linux", "darwin"] as const)("preserves %s paths when the host uses Windows joins", platform => { + expect(cliEnvironment("/synthetic/home", { PATH: "/synthetic/bin", KEEP: "value" }, platform)).toEqual({ KEEP: "value", PATH: "/opt/homebrew/bin:/usr/local/bin:/synthetic/home/.cargo/bin:/synthetic/home/.local/bin:/synthetic/bin" }); + file("/synthetic/home/.bun/bin/opencode"); + expect(cliPath("opencode", "/synthetic/home", {}, platform)).toBe("/synthetic/home/.bun/bin/opencode"); }); }); @@ -149,12 +155,29 @@ describe("Windows provider process boundary", () => { close(0); await Promise.resolve(); expect(settled).toBe(false); treeKill.emit("close", 0); await expect(pending).resolves.toBe("synthetic result"); }); - it.each(["error", "timeout", "pipes", "exited-launcher"] as const)("bounds tree cleanup %s and rejects without exposing stderr", async cleanup => { + it.each(["helper-first", "child-first"])("preserves the output limit after process-not-found cleanup (%s)", async order => { + const { child, close, treeKill } = fakeChild("timeout"); + const pending = onWindows(() => runCommand("D:\\CLI\\codex.exe", [], { maxBytes: 10 })); + const rejected = expect(pending).rejects.toThrow("Provider output exceeded limit"); + child.stdout.write("x".repeat(1000)); + if (order === "helper-first") treeKill.emit("close", 128); + close(0); await Promise.resolve(); + if (order === "child-first") treeKill.emit("close", 128); + await rejected; expect(child.kill).not.toHaveBeenCalled(); + }); + it("does not accept access failure after the provider closes", async () => { + const { close, treeKill } = fakeChild("timeout"); + const pending = onWindows(() => boundedChild("D:\\CLI\\codex.exe", [], {}, (_child, finish) => finish("synthetic result"))); + const rejected = expect(pending).rejects.toThrow("Provider process cleanup failed"); + close(0); await Promise.resolve(); treeKill.emit("close", 1); await rejected; + }); + it.each(["error", "timeout", "pipes", "exited-launcher", "missing-with-open-pipes"] as const)("bounds tree cleanup %s and rejects without exposing stderr", async cleanup => { vi.useFakeTimers(); const { child, treeKill } = fakeChild(cleanup === "error" ? "error" : "timeout"); if (cleanup === "exited-launcher") child.exitCode = 0; const pending = onWindows(() => boundedChild("D:\\CLI\\codex.exe", [], {}, (_child, finish) => finish("synthetic result"))); if (cleanup === "pipes") treeKill.emit("close", 0); + if (cleanup === "missing-with-open-pipes") { child.exitCode = 0; treeKill.emit("close", 128); } const rejected = expect(pending).rejects.toThrow("Provider process cleanup failed"); await vi.advanceTimersByTimeAsync(1500); await rejected; if (cleanup !== "exited-launcher") expect(treeKill.kill).toHaveBeenCalledWith("SIGKILL"); From 2772e8bbb86845efe5d7928362dab43dc023d8a8 Mon Sep 17 00:00:00 2001 From: pekth <5535435+pekth@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:38:36 -0400 Subject: [PATCH 3/3] docs: record verified Windows CI candidate --- CHANGELOG.md | 3 ++- CONTRIBUTING.md | 9 +++++---- docs/KB.md | 8 +++++--- docs/adr/0033-windows-desktop-candidate.md | 22 +++++++++++++++++++++- docs/adr/README.md | 2 +- docs/electron/README.md | 12 +++++++----- docs/plans/feature-and-windows-pipeline.md | 7 ++++--- 7 files changed, 45 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fafb7f2..de0e147 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ follows [Semantic Versioning](https://semver.org/). local CLI activity, Codex browser sign-in through the installed CLI, OpenRouter usage and one-time OpenAI or Anthropic API billing keys. Windows desktop Claude, Antigravity and Grok Bot connections remain unavailable. - Packaging and a Windows CI workflow are configured; workflow success, native + Windows CI passed focused tests, unsigned NSIS/portable packaging and an + isolated packaged-demo/single-instance smoke check. Interactive Windows acceptance, installer acceptance and signing remain unverified. This is not a Windows release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fad6ff4..75ffbbd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,10 +148,11 @@ are optional; synthetic tests and demo mode require no login. Shared checks can run on Linux. macOS packaging, system symbol export and native acceptance require macOS with the existing Swift and signing tools. The Windows source candidate uses `pnpm package:win` to produce unsigned x64 NSIS and portable -executables. Its workflow is configured for typecheck, focused tests, packaging -and an isolated packaged-demo/single-instance smoke check; no completed -workflow receipt is claimed here. Native Windows UI/provider checks, installer -acceptance and signing remain open. See [ADR 0033](docs/adr/0033-windows-desktop-candidate.md). +executables. Its workflow passed typecheck, focused tests, packaging and an +isolated packaged-demo/single-instance smoke check at the revision recorded in +[ADR 0033](docs/adr/0033-windows-desktop-candidate.md#ci-validation). +Interactive Windows UI/provider checks, installer acceptance and signing +remain open. `pnpm package:mac` also compiles the fixed Claude Desktop Keychain helper and bundles it as an app resource. `pnpm build:desktop-helper` targets arm64 and diff --git a/docs/KB.md b/docs/KB.md index 1eb01b4..fd925f7 100644 --- a/docs/KB.md +++ b/docs/KB.md @@ -196,9 +196,11 @@ Documentation refresh: 2026-10-18; the public Mac build 61 executable inputs are `%APPDATA%\MeterUsage` (normally the user's `AppData\Roaming\MeterUsage`); Windows does not read the macOS defaults or Keychain stores, and one-time API keys are memory-only. `package:win` configures unsigned x64 NSIS and portable - executables, and a Windows workflow defines build and isolated demo smoke - steps. CI success, native GUI/provider behavior, installer acceptance and - signing are unverified. No Windows release is available. The former RN/Tauri + executables. Windows CI passed 78 focused tests, packaging and the isolated + packaged-demo/single-instance smoke check for `82b7ca2`; see the + [CI receipt](adr/0033-windows-desktop-candidate.md#ci-validation). + Interactive GUI/provider behavior, installer acceptance and signing remain + unverified. No Windows release is available. The former RN/Tauri Windows-first plan is superseded. See [ADR 0033](adr/0033-windows-desktop-candidate.md). - 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. diff --git a/docs/adr/0033-windows-desktop-candidate.md b/docs/adr/0033-windows-desktop-candidate.md index 9590364..85ee33c 100644 --- a/docs/adr/0033-windows-desktop-candidate.md +++ b/docs/adr/0033-windows-desktop-candidate.md @@ -1,6 +1,6 @@ # ADR 0033: Windows desktop source candidate -- Status: Accepted candidate scope; Windows validation and release remain open +- Status: Accepted candidate scope; interactive Windows validation and release remain open - Date: 2026-10-18 - Amends: [ADR 0010](0010-macos-typescript-electron.md) only for the Windows implementation hold @@ -32,6 +32,26 @@ Windows workflow defines typecheck, focused tests, package creation and an isolated demo/single-instance smoke check. Its definition is not evidence that CI passed. No public Windows build or release is authorized by this ADR. +## CI validation + +On 2026-10-18, [Windows run 39107127936](https://github.com/pekth/meterusage/actions/runs/39107127936) +passed for candidate head `82b7ca2952bc725576879647747ff4fa4018d60b`, using +PR merge checkout `58f48c2b20ca572b9480e8bfa2c0941b73a08d20`: + +- Typecheck and 78 tests passed, including three native Windows npm-wrapper + and process-tree tests. +- Unsigned x64 NSIS and portable executables were built. +- The unpacked packaged demo stayed running in an isolated profile, and a + second invocation exited successfully while the first remained running. +- Both executables were uploaded as the + [MeterUsage-Windows-x64-unsigned CI artifact](https://github.com/pekth/meterusage/actions/runs/39107127936/artifacts/12128544404). + +The [macOS CI run](https://github.com/pekth/meterusage/actions/runs/39107127916) +also built the Swift package and passed 394 tests using the same PR merge checkout. +These receipts establish CI behavior for the recorded inputs. They do not +establish interactive Windows behavior, installer install/uninstall acceptance, +signing or a public release. + ## Consequences and remaining proof The Windows hold in ADR 0010 is lifted for source implementation only. Before diff --git a/docs/adr/README.md b/docs/adr/README.md index 92b3a1a..5e5caeb 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -44,4 +44,4 @@ compatibility and acceptance requirements remain in force. The | [0031](0031-grok-quota-burn.md) | Measure Grok Bot quota burn from recent same-account percentages without inventing token counts or cycle duration. | Source implementation; native and live rate acceptance separate | | [0032](0032-one-time-api-setup.md) | Separate API setup from account connections and accept a one-time Admin key for explicit billing fetches, without saving credentials. | Source implementation; real billing acceptance pending | -| [0033](0033-windows-desktop-candidate.md) | Record the Windows x64 Electron candidate scope, storage boundary and evidence still required before release. | Source candidate; CI, native acceptance, installer acceptance and signing unverified | +| [0033](0033-windows-desktop-candidate.md) | Record the Windows x64 Electron candidate scope, storage boundary and evidence still required before release. | CI verified; interactive acceptance, installer acceptance and signing unverified | diff --git a/docs/electron/README.md b/docs/electron/README.md index 9caba61..a459d6e 100644 --- a/docs/electron/README.md +++ b/docs/electron/README.md @@ -16,8 +16,10 @@ browser sign-in through the installed CLI, OpenRouter and one-time OpenAI or Anthropic API billing. Claude Desktop, Antigravity Desktop and Grok Bot Desktop account connections are unavailable. Preferences, history and sanitized billing snapshots use `%APPDATA%\MeterUsage`, defaulting to the user's -`AppData\Roaming\MeterUsage`. Windows CI success, native behavior, installer -acceptance and signing are unverified. See [ADR 0033](../adr/0033-windows-desktop-candidate.md). +`AppData\Roaming\MeterUsage`. Windows CI passed focused tests, packaging and +isolated packaged startup/single-instance checks. Interactive Windows behavior, +installer acceptance and signing remain unverified. See the +[CI receipt and open gates](../adr/0033-windows-desktop-candidate.md#ci-validation). See the [current beta screenshots](../SCREENSHOTS.md) for Usage, notch and Settings. @@ -233,9 +235,9 @@ includes native setup/account/offline journeys, tray/focus/accessibility, notifications, login items, sharing, updater replacement/relaunch, drag/display persistence and two different-height notch captures. Reuse matching source, packaging and installation receipts only for their recorded layer. Windows has -a source candidate and configured CI workflow, but workflow success, native -acceptance, installer acceptance, signing and release remain unverified. See -[ADR 0033](../adr/0033-windows-desktop-candidate.md). +a source candidate with passing focused tests, packaging and isolated startup +checks. Interactive acceptance, installer acceptance, signing and release +remain open. See [ADR 0033](../adr/0033-windows-desktop-candidate.md). [ADR 0010](../adr/0010-macos-typescript-electron.md) keeps Swift as the behavior reference and rollback path. No automatic cutover, data migration, release or diff --git a/docs/plans/feature-and-windows-pipeline.md b/docs/plans/feature-and-windows-pipeline.md index a775a0f..dd90542 100644 --- a/docs/plans/feature-and-windows-pipeline.md +++ b/docs/plans/feature-and-windows-pipeline.md @@ -16,7 +16,8 @@ oracle and rollback path. Windows has an unsigned x64 source candidate. Its `package:win` command configures NSIS and portable executables, and `.github/workflows/windows.yml` defines typecheck, focused tests, packaging and isolated demo/single-instance -smoke steps. This workflow has not yet been confirmed green. The candidate +smoke steps. These checks passed for `82b7ca2`, as recorded in the +[CI receipt](../adr/0033-windows-desktop-candidate.md#ci-validation). The candidate supports local CLI data, Codex browser sign-in through the installed CLI, OpenRouter, one-time OpenAI/Anthropic API billing keys, the tray and side notch. Claude Desktop, Antigravity Desktop and Grok Bot Desktop account connections are @@ -39,8 +40,8 @@ release evidence required. 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. -4. Windows candidate: confirm the workflow on the exact candidate revision, - then record native Windows startup, settings, provider/offline, persistence, +4. Windows candidate: reuse the recorded workflow receipt only for matching + inputs. Record interactive Windows startup, settings, provider/offline, persistence, tray/notch and PNG-sharing checks, plus installer install/uninstall evidence. Signing and publication are separate gates. An unsigned package or passing workflow alone does not close this stage.