diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50cc26a6..2c0efa69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,11 @@ permissions: jobs: check: name: Lint, Type-check, Build, Test - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 @@ -18,6 +22,15 @@ jobs: with: bun-version: latest + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Install dependencies (Ubuntu only) + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - run: bun install - name: Type-check and build @@ -25,3 +38,9 @@ jobs: - name: Run tests run: bun run test + + - name: Build Tauri (Windows only) + if: matrix.os == 'windows-latest' + run: bun run tauri build + env: + CI: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 500e84b8..f9a13252 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,6 +24,8 @@ jobs: args: "--target aarch64-apple-darwin" - platform: macos-latest args: "--target x86_64-apple-darwin" + - platform: windows-latest + args: "--target x86_64-pc-windows-msvc" runs-on: ${{ matrix.platform }} env: RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.tag }} @@ -34,7 +36,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: - targets: aarch64-apple-darwin,x86_64-apple-darwin + targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || 'x86_64-pc-windows-msvc' }} - uses: swatinem/rust-cache@v2 with: workspaces: "./src-tauri -> target" @@ -46,14 +48,20 @@ jobs: - name: Bundle plugins run: bun run bundle:plugins - name: Verify bundled plugins + shell: bash run: | - COUNT=$(find src-tauri/resources/bundled_plugins -maxdepth 2 -name plugin.json | wc -l | tr -d ' ') + if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then + COUNT=$(find src-tauri/resources/bundled_plugins -maxdepth 2 -name plugin.json | wc -l | tr -d ' \r') + else + COUNT=$(find src-tauri/resources/bundled_plugins -maxdepth 2 -name plugin.json | wc -l | tr -d ' ') + fi if [[ "$COUNT" -lt 1 ]]; then echo "No bundled plugins found under src-tauri/resources/bundled_plugins." exit 1 fi - name: Validate release tag + shell: bash run: | if [[ -z "$RELEASE_TAG" ]]; then echo "Missing RELEASE_TAG (push a v* tag, or provide workflow_dispatch input 'tag')." @@ -65,6 +73,7 @@ jobs: fi - name: Validate app version matches tag + shell: bash run: | TAG_VERSION="${RELEASE_TAG#v}" @@ -86,6 +95,7 @@ jobs: fi - name: Import Apple Developer Certificate + if: matrix.platform == 'macos-latest' env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} @@ -100,6 +110,38 @@ jobs: security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain rm certificate.p12 + - name: Validate Windows signing secrets + if: matrix.platform == 'windows-latest' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE)) { + Write-Error "Missing WINDOWS_CERTIFICATE secret for Windows release signing." + exit 1 + } + if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERTIFICATE_PASSWORD)) { + Write-Error "Missing WINDOWS_CERTIFICATE_PASSWORD secret for Windows release signing." + exit 1 + } + + - name: Import Windows signing certificate + if: matrix.platform == 'windows-latest' + shell: pwsh + env: + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + run: | + $certDir = Join-Path $env:RUNNER_TEMP "windows-cert" + New-Item -ItemType Directory -Path $certDir | Out-Null + $certBase64 = Join-Path $certDir "cert.base64" + $certPfx = Join-Path $certDir "cert.pfx" + Set-Content -Path $certBase64 -Value $env:WINDOWS_CERTIFICATE -NoNewline + certutil -decode $certBase64 $certPfx | Out-Null + $securePassword = ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force + Import-PfxCertificate -FilePath $certPfx -CertStoreLocation Cert:\CurrentUser\My -Password $securePassword | Out-Null + - uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -114,6 +156,9 @@ jobs: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} with: tagName: ${{ env.RELEASE_TAG }} releaseName: ${{ env.RELEASE_TAG }} @@ -123,6 +168,7 @@ jobs: args: ${{ matrix.args }} - name: Verify updater assets uploaded + shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | diff --git a/OWNER_GUIDE.md b/OWNER_GUIDE.md new file mode 100644 index 00000000..789b63e1 --- /dev/null +++ b/OWNER_GUIDE.md @@ -0,0 +1,134 @@ +# Owner Guide: Windows Release Setup + +Generate Windows certificate from your Mac, add GitHub secrets, push a tag. + +## 1. Generate Windows Certificate + +Install OpenSSL if not present: + +```bash +brew install openssl +``` + +Create certificate: + +```bash +# Create private key +openssl genrsa -out windows.key 4096 + +# Create certificate +openssl req -new -x509 -key windows.key -out windows.crt -days 365 \ + -subj "/CN=Sunstory/O=Sunstory/C=US" + +# Convert to PFX +openssl pkcs12 -export -out codesign.pfx -inkey windows.key -in windows.crt +# Enter password when prompted (save this!) +``` + +For production: Buy a certificate from DigiCert/Sectigo instead. + +## 2. Encode for GitHub Secrets + +```bash +# Base64 encode +cat codesign.pfx | base64 > cert_base64.txt + +# Copy to clipboard (macOS) +cat cert_base64.txt | pbcopy + +# Cleanup sensitive files +rm windows.key windows.crt codesign.pfx +``` + +## 3. Add GitHub Secrets + +Go to: `https://github.com/robinebers/openusage/settings/secrets/actions` + +Add: + +| Secret | Value | +|--------|-------| +| `WINDOWS_CERTIFICATE` | Paste from clipboard (base64) | +| `WINDOWS_CERTIFICATE_PASSWORD` | Password from step 1 | + +## 4. Update Versions + +Set same version in all 3 files: + +```bash +# Check current +grep '"version"' package.json src-tauri/tauri.conf.json +grep '^version' src-tauri/Cargo.toml + +# Edit files (use your editor) +cursor src-tauri/tauri.conf.json # "version": "0.6.4" +cursor src-tauri/Cargo.toml # version = "0.6.4" +cursor package.json # "version": "0.6.4" +``` + +## 5. Create Release + +```bash +# Commit +git add src-tauri/tauri.conf.json src-tauri/Cargo.toml package.json +git commit -m "chore: release v0.6.4" + +# Push +git push origin main + +# Tag +git tag v0.6.4 +git push origin v0.6.4 +``` + +## 6. Monitor Build + +```bash +# Watch workflow +gh run watch + +# Or open browser +open "https://github.com/robinebers/openusage/actions" +``` + +## 7. Verify + +```bash +# Check release assets +gh release view v0.6.4 +``` + +Should have: +- `OpenUsage_0.6.4_aarch64.dmg` +- `OpenUsage_0.6.4_x64.dmg` +- `OpenUsage_0.6.4_x64_en-US.msi` (Windows) +- `OpenUsage_0.6.4_x64-setup.exe` (Windows) +- `latest.json` + +## Troubleshooting + +**Missing WINDOWS_CERTIFICATE** +- Check secret exists in GitHub → Settings → Secrets + +**Version mismatch** +- All 3 files must have same version number + +**No Windows assets** +- Check workflow logs for Windows build errors +- Verify `WINDOWS_CERTIFICATE_PASSWORD` is correct + +## Commands Reference + +```bash +# Check versions +grep -h version package.json src-tauri/tauri.conf.json src-tauri/Cargo.toml + +# List releases +gh release list + +# View workflow runs +gh run list --workflow=publish.yml + +# Check specific run +gh run view +``` diff --git a/bun.lock b/bun.lock index 0b02ac96..b1128c21 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "@tauri-apps/plugin-global-shortcut": "^2", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", "@tauri-apps/plugin-updater": "^2.10.0", @@ -315,6 +316,8 @@ "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ=="], + "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.4.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A=="], diff --git a/docs/breadcrumbs.md b/docs/breadcrumbs.md new file mode 100644 index 00000000..16d577b9 --- /dev/null +++ b/docs/breadcrumbs.md @@ -0,0 +1,25 @@ +# Breadcrumbs + +## 2026-02-09 + +- Windows dev: align AppState plugin/probe types with LoadedPlugin + PluginOutput, and persist arrow offset for tray alignment fallback. +- Arrow alignment: account for panel horizontal padding when positioning tray arrow. +- Side taskbar: compute arrow offset on Y axis and render left/right arrows. +- Side taskbar: use work area bounds to avoid overlapping the taskbar. +- Windows plugin scan: identified OS path/keychain blockers per plugin. +- Windows plugins: add Windows path candidates for Codex/Claude/Cursor/Windsurf and guard keychain usage to macOS. +- Windows OS gating: enable windows in plugin manifests and add actionable missing-path errors for testers. +- Cleanup: moved `WINDOWS_CHANGES.md` and reserved `nul` file to trash; kept `src/contexts/taskbar-context.tsx` for later wiring. +- Windows updater: documented that production updates require Authenticode signing; marked current state as test-only. +- Windows signing: added conditional PFX import step and owner follow-up checklist. +- Cleanup: removed unused `src/contexts/taskbar-context.tsx`. +- PR 77 fixes: re-enabled tray icon updates, restored Linux tray click handling, guarded window clamp, and replaced env-based Windows probes with `~`-based paths under a CODEX_HOME-only allowlist. + +## 2026-02-10 + +- Switched plugin host sqlite to embedded `rusqlite` with bundled SQLite for cross-platform availability. +- Replaced Windows process discovery `wmic` with PowerShell CIM JSON parsing in `src-tauri/src/plugin_engine/host_api.rs`. +- Added Windows DPAPI-backed `host.vault` and updated Copilot auth to use it. +- Enforced Windows signing secrets in publish workflow to prevent unsigned releases. +- Adjusted dynamic tray icon rendering to stay visible on dark Windows taskbars. +- Restored Linux placeholders for tray positioning and plugin availability. diff --git a/docs/choices.md b/docs/choices.md new file mode 100644 index 00000000..3ffcf256 --- /dev/null +++ b/docs/choices.md @@ -0,0 +1,153 @@ +# Design Choices + +This document records opinionated defaults chosen during development. + +## 2026-02-07 + +### Zen Plugin: No Billing API Available + +**Context:** The OpenCode Zen provider plugin needs to display usage/billing data. + +**Finding:** After analyzing the OpenCode source code (`packages/console/`), the billing data is only exposed via SolidStart SSR routes (`"use server"`) which require web session authentication (cookies), not API key authentication. + +**Decision:** The Zen plugin currently: +1. Validates API key by calling `GET /zen/v1/models` +2. Shows "Connected" status with model count +3. Directs users to `opencode.ai/billing` for usage data + +**Alternative considered:** Scraping the web console - rejected as fragile and potentially against ToS. + +**Next step:** Request a public usage API from OpenCode team via GitHub Discussions. + +**Technical details:** +- Balance stored in micro-cents (`/ 100_000_000` for dollars) +- Key fields: `balance`, `monthlyUsage`, `monthlyLimit`, `reload`, `reloadAmount`, `reloadTrigger` +- Source: `packages/console/app/src/routes/workspace/common.tsx:93-120` + +## 2026-02-09 + +### Persist Arrow Offset in AppState + +**Context:** The frontend sometimes misses the `window:positioned` event and falls back to centered arrow placement. + +**Decision:** Persist `last_arrow_offset` alongside `last_taskbar_position` in `AppState`, so `get_arrow_offset` can restore the correct tray arrow alignment on focus. + +### Arrow Offset Accounts for Panel Padding + +**Context:** Arrow offset is computed from window left edge, but the arrow is rendered inside a container with horizontal padding (`px-4`). + +**Decision:** Subtract 16px container padding when applying `marginLeft` so the arrow tip aligns with the clicked tray icon. + +### Side Taskbar Arrow Uses Y Offset + +**Context:** When the Windows taskbar is on the left or right, the arrow should align using Y offset and render from the side. + +**Decision:** Compute arrow offset using icon center Y for left/right taskbars, and render side arrows with `marginTop` adjusted by vertical padding. + +### Use Work Area For Side Positioning + +**Context:** On Windows with side taskbars, using full monitor bounds causes the panel to overlap the taskbar. + +**Decision:** Use `monitor.work_area()` bounds for left/right window X positioning and clamping so the panel sits fully in the available work area. + +### Windows Plugin Paths: Use Common AppData Candidates + +**Context:** There is no authoritative published path for Codex/Claude auth files or Cursor/Windsurf `state.vscdb` on Windows. + +**Decision:** Probe common Windows locations based on `APPDATA`, `LOCALAPPDATA`, and `USERPROFILE` (e.g., `APPDATA\Cursor\User\globalStorage\state.vscdb`, `USERPROFILE\.claude\.credentials.json`, `APPDATA\codex\auth.json`) and use the first existing path; otherwise fall back to the first candidate. + +### Keychain Guard On Windows + +**Context:** The host keychain API is only supported on macOS and throws on Windows. + +**Decision:** Only call keychain APIs when `ctx.app.platform === "macos"`; use file-based auth paths otherwise. + +### OS Gating For Windows Testers + +**Context:** We need Windows testers to run plugins while we validate paths and auth locations. + +**Decision:** Add `os` to plugin manifests and enable Windows for all user-facing plugins so testers can validate behavior; keep errors explicit when paths are missing. + +### Windows Error Messages Include Expected Paths + +**Context:** Testers need actionable path hints when auth/state files cannot be found. + +**Decision:** Provide Windows-specific error strings that mention likely file locations (AppData/UserProfile) and ask testers to report actual paths. + +### Windows Auto-Update Requires Signing + +**Context:** Updater flow builds for Windows but CI has no Windows code signing. + +**Decision:** Document Windows auto-update as test-only until Authenticode signing is configured. + +### Remove Unused Taskbar Context + +**Context:** Taskbar position is already handled in `src/App.tsx` with Rust events and local state; `src/contexts/taskbar-context.tsx` was unused. + +**Decision:** Delete the unused context file to avoid dead code. + +### Restrict Env Allowlist To CODEX_HOME + +**Context:** Plugin host env access is intended to be minimal, and only `CODEX_HOME` is approved for exposure. + +**Decision:** Limit the env allowlist to `CODEX_HOME` and switch Windows path probes to `~`-based candidates instead of env vars. + +### Re-Enable Frontend Tray Icon Updates + +**Context:** Frontend settings/probe flows still call tray update hooks, but the update path was disabled, leaving the icon stale. + +**Decision:** Restore frontend tray icon rendering and updates on init/settings/probe to keep the tray icon consistent with state. + +## 2026-02-10 + +### Embed SQLite Instead Of External CLI + +**Context:** Plugin host `sqlite` API used `sqlite3` CLI, which is missing on clean Windows machines. + +**Decision:** Use `rusqlite` with the `bundled` feature so SQLite is embedded in the app; remove `sqlite3` process calls. + +**Technical details:** +- Read-only queries open `file:...?...immutable=1` with `SQLITE_OPEN_URI`. +- Writes use `SQLITE_OPEN_READ_WRITE | SQLITE_OPEN_CREATE` and `execute_batch`. +- Blob columns serialize to base64 strings to keep JSON output stable. + +### Replace WMIC With PowerShell CIM + +**Context:** Windows LS discovery used `wmic`, which is deprecated and scheduled for removal. + +**Decision:** Use PowerShell `Get-CimInstance Win32_Process` with `ConvertTo-Json` for process listings. + +**Technical details:** +- Force UTF-8 output via `[Console]::OutputEncoding`. +- Parse JSON into `{ ProcessId, CommandLine }` entries; skip null command lines. + +### Add Windows Vault (DPAPI) + +**Context:** Windows token storage relied on plaintext files when keychain was unavailable. + +**Decision:** Add `host.vault` backed by DPAPI user-scope encryption and use it for Copilot token caching on Windows. + +**Technical details:** +- Encrypted bytes are stored as base64 under `appDataDir/vault/`. +- `host.vault` throws on non-Windows platforms. + +### Fail Release If Windows Signing Secrets Missing + +**Context:** Release workflow allowed unsigned Windows artifacts when signing secrets were absent. + +**Decision:** Fail the Windows publish job if `WINDOWS_CERTIFICATE` or `WINDOWS_CERTIFICATE_PASSWORD` is missing. + +### Enable Linux Plugin Placeholders + +**Context:** OS gating disabled plugins on Linux and tray positioning lacked a Linux implementation. + +**Decision:** Re-enable Linux in plugin manifests and add a basic Linux tray positioning implementation. + +**Technical details:** +- LS discovery on Linux assumes `language_server_linux_x64` as the process name. + +### Improve Windows Tray Icon Contrast + +**Context:** Dynamic tray icons used black fills and became invisible on dark Windows taskbars. + +**Decision:** Render dynamic tray icons in light ink (`#f8f8f8`) on Windows. diff --git a/docs/plugins/api.md b/docs/plugins/api.md index 96896a31..da2e36b7 100644 --- a/docs/plugins/api.md +++ b/docs/plugins/api.md @@ -112,6 +112,7 @@ Reads an environment variable by name. - Returns variable value as string when set - Returns `null` when missing - Variable must be whitelisted first in `src-tauri/src/plugin_engine/host_api.rs` +- Currently only `CODEX_HOME` is whitelisted ### Example @@ -214,6 +215,33 @@ if (ctx.host.fs.exists("~/.myapp/credentials.json")) { } ``` +## Vault (Windows only) + +```typescript +host.vault.read(name: string): string | null +host.vault.write(name: string, value: string): void +host.vault.delete(name: string): void +``` + +DPAPI-backed storage for sensitive values on Windows. + +### Behavior + +- **Windows only**: Throws on other platforms +- **User scope**: Encrypted data is tied to the current Windows user +- **Returns null if missing**: `read` returns `null` when no value is stored + +### Example + +```javascript +const key = "my-plugin:token" +const raw = ctx.host.vault.read(key) +if (!raw) throw "Login required." + +const parsed = JSON.parse(raw) +ctx.host.vault.write(key, JSON.stringify({ token: parsed.token })) +``` + ## SQLite ### Query (Read-Only) diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md index d37245da..7838c5b7 100644 --- a/docs/providers/antigravity.md +++ b/docs/providers/antigravity.md @@ -21,13 +21,20 @@ The language server listens on a random localhost port. Three values must be dis ```bash # 1. Find process and extract CSRF token +# macOS/Linux: ps -ax -o pid=,command= | grep 'language_server_macos.*antigravity' +# Windows: +wmic process where "name='language_server_windows_x64.exe'" get ProcessId,CommandLine + # Match: --app_data_dir antigravity OR path contains /antigravity/ # Extract: --csrf_token # Extract: --extension_server_port (HTTP fallback) # 2. Find listening ports +# macOS/Linux: lsof -nP -iTCP -sTCP:LISTEN -a -p +# Windows: +netstat -ano | findstr # 3. Probe each port to find the Connect-RPC endpoint POST https://127.0.0.1:/.../GetUnleashData → first 200 OK wins @@ -269,3 +276,5 @@ The Cloud Code model set is a superset of the LS model set. The LS returns only c. If all fail with 401/403 and refresh token available: refresh via Google OAuth, cache result to pluginDataDir, retry once d. Parse model quota: skip `isInternal` models, empty-displayName models, and blacklisted model IDs 5. If both strategies fail: error "Start Antigravity and try again." + +**Platform support:** macOS, Linux, Windows diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index 5cf0354f..ddefe5d6 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -6,9 +6,10 @@ Tracks GitHub Copilot usage quotas for both paid and free tier users. The plugin looks for a GitHub token in this order: -1. **OpenUsage Keychain** (`OpenUsage-copilot`) — Token previously cached by the plugin -2. **GitHub CLI Keychain** (`gh:github.com`) — Token from `gh auth login` -3. **State File** (`auth.json`) — Fallback file-based storage +1. **OpenUsage Vault (Windows)** (`copilot:token`) — DPAPI-encrypted cache +2. **OpenUsage Keychain (macOS)** (`OpenUsage-copilot`) — Token previously cached by the plugin +3. **GitHub CLI Keychain (macOS)** (`gh:github.com`) — Token from `gh auth login` +4. **GitHub CLI config file (Windows)** (`hosts.yml`) — Fallback when keychain is unavailable ### Setup diff --git a/docs/specs/2026-02-09-pr77-code-review-fixes.md b/docs/specs/2026-02-09-pr77-code-review-fixes.md new file mode 100644 index 00000000..7b571c1b --- /dev/null +++ b/docs/specs/2026-02-09-pr77-code-review-fixes.md @@ -0,0 +1,24 @@ +2026-02-09 + +# PR 77 code review fixes + +## Goal +- Validate reported regressions and apply minimal fixes. + +## Issues +- Tray icon updates are no-op in frontend. +- Linux tray click no longer shows/hides window. +- Window position clamp can panic if window exceeds work area. +- Env allowlist exceeds stated minimal exposure (CODEX_HOME only). +- Claude plugin helper scopes cause ReferenceError. +- Windsurf plugin helper scopes leak to global. + +## Plan +- Re-enable tray icon updates via `renderTrayBarsIcon` and `TrayIcon.setIcon`. +- Restore Linux tray click handling to match Windows show/hide behavior. +- Guard clamp bounds when window size exceeds work area. +- Restrict env allowlist to `CODEX_HOME` and adjust plugin helpers accordingly. +- Move helper functions inside plugin IIFEs to restore correct scope. + +## Testing +- Not run (manual reasoning + compilation expected). diff --git a/docs/specs/2026-02-09-windows-dev.md b/docs/specs/2026-02-09-windows-dev.md new file mode 100644 index 00000000..ce549709 --- /dev/null +++ b/docs/specs/2026-02-09-windows-dev.md @@ -0,0 +1,18 @@ +# Windows Dev Fixes (2026-02-09) + +## Goals + +- Fix Windows dev build errors in `src-tauri` caused by mismatched plugin state types. +- Ensure tray arrow can align to icon even if `window:positioned` event is missed. + +## Non-Goals + +- No new UI behavior changes beyond arrow offset alignment. +- No plugin runtime changes. + +## Changes + +- Store `LoadedPlugin` in `AppState` and store probe results as `PluginOutput`. +- Persist `last_arrow_offset` in window positioning so `get_arrow_offset` is reliable. +- Use Y-axis arrow offsets and side arrows for left/right taskbars. +- Use work area bounds for side-taskbar window positioning/clamping. diff --git a/docs/specs/2026-02-09-windows-os-gating.md b/docs/specs/2026-02-09-windows-os-gating.md new file mode 100644 index 00000000..2eb26cac --- /dev/null +++ b/docs/specs/2026-02-09-windows-os-gating.md @@ -0,0 +1,22 @@ +# Windows OS Gating + Error Reporting (2026-02-09) + +## Goals + +- Declare OS support in plugin manifests so Windows can load targeted plugins. +- Provide clear, actionable error messages for Windows testers when paths are missing. + +## Non-Goals + +- Prove exact Windows paths for every app. +- Add new runtime APIs or change plugin protocol. + +## Plan + +- Add `os` to plugin.json for each plugin. +- Improve Windows-specific error messages in Codex, Claude, Cursor, and Windsurf. +- Keep keychain access macOS-only and rely on file-based auth for Windows. + +## Notes + +- Windows is enabled for all user-facing plugins to allow tester validation. +- Errors include expected Windows file locations and request testers report actual paths. diff --git a/docs/specs/2026-02-09-windows-plugin-porting.md b/docs/specs/2026-02-09-windows-plugin-porting.md new file mode 100644 index 00000000..99c0aa9b --- /dev/null +++ b/docs/specs/2026-02-09-windows-plugin-porting.md @@ -0,0 +1,26 @@ +# Windows Plugin Porting Scan (2026-02-09) + +## Goals + +- Identify Windows blockers for remaining plugins. +- Outline path normalization or keychain requirements per plugin. + +## Non-Goals + +- Implement Windows support for each plugin. +- Modify plugin runtime behavior. + +## Findings + +- antigravity: already Windows-aware for LS process name; no path blockers. +- codex: auth file now checks Windows candidates (`APPDATA`, `LOCALAPPDATA`, `USERPROFILE`) before Unix paths. +- claude: credentials file now checks Windows candidates (`USERPROFILE`, `APPDATA`, `LOCALAPPDATA`) before `~/.claude`. +- copilot: keychain access is now guarded to macOS; Windows relies on `auth.json` fallback. +- cursor: now checks Windows candidates for `globalStorage/state.vscdb` plus Linux/macOS defaults. +- windsurf: now checks Windows candidates for `globalStorage/state.vscdb` plus Linux/macOS defaults; Windows LS process name already handled. +- mock: test-only. + +## Next Steps + +- Validate actual on-disk paths for Codex/Claude/Cursor/Windsurf on Windows installs. +- Add Windows OS gating in plugin.json if needed and surface user-friendly errors when paths are missing. diff --git a/docs/specs/2026-02-09-windows-signing.md b/docs/specs/2026-02-09-windows-signing.md new file mode 100644 index 00000000..07ae9d95 --- /dev/null +++ b/docs/specs/2026-02-09-windows-signing.md @@ -0,0 +1,18 @@ +2026-02-09 + +# Windows code signing (prep) + +## Goal +- Prepare CI to import a Windows signing certificate when secrets exist. + +## Non-goals +- Do not enable signing without owner-provided secrets. +- Do not add placeholder thumbprints to `tauri.conf.json`. + +## Plan +- Add a Windows-only CI step that imports a PFX from secrets into the user cert store. +- Document required secrets and `tauri.conf.json` fields for the owner. + +## Definition of done +- Publish workflow has a conditional Windows cert import step. +- `OWNER_FOLLOW.md` lists secrets + config the owner must set. diff --git a/docs/specs/2026-02-09-windows-updater.md b/docs/specs/2026-02-09-windows-updater.md new file mode 100644 index 00000000..df3afcca --- /dev/null +++ b/docs/specs/2026-02-09-windows-updater.md @@ -0,0 +1,16 @@ +2026-02-09 + +# Windows updater status + +## Goal +- Make Windows auto-update production-ready once signing is configured. + +## Current state +- Updater plugin is enabled and publishes `latest.json` for Windows builds. +- Windows signing is not configured in CI. + +## Decision +- Treat Windows auto-update as test-only until Authenticode signing is added. + +## Follow-up +- Add Windows code signing to publish workflow before enabling production updates. diff --git a/docs/specs/2026-02-10-linux-placeholders.md b/docs/specs/2026-02-10-linux-placeholders.md new file mode 100644 index 00000000..ae9bd053 --- /dev/null +++ b/docs/specs/2026-02-10-linux-placeholders.md @@ -0,0 +1,31 @@ +# Linux Placeholder Enablement + +## Goal +- Restore Linux builds and plugin availability after OS gating changes. + +## Scope +- Add Linux implementation for tray window positioning. +- Include Linux in supported OS lists for major plugins. +- Update language server discovery to handle Linux process names. +- Allow Copilot to read gh CLI hosts file on Linux. + +## Non-Goals +- Guarantee full Linux feature parity. +- Add Linux-specific UI or packaging work. + +## Approach +- Provide a Linux `position_window_at_tray` that positions the window using the tray icon location. +- Add `linux` to plugin `os` arrays for Claude, Codex, Cursor, Windsurf, Copilot, Antigravity. +- Treat the Linux LS process name as `language_server_linux_x64` (placeholder). +- Use `~/.config/gh/hosts.yml` for Copilot token on Linux. + +## Testing +- `cargo test` (if Linux runner available) +- `bunx vitest run plugins/copilot/plugin.test.js` + +## Risks +- LS process name may differ on Linux distributions. +- Some providers may still fail due to upstream app path differences. + +## Open Questions +- None. diff --git a/docs/specs/2026-02-10-tray-icon-contrast.md b/docs/specs/2026-02-10-tray-icon-contrast.md new file mode 100644 index 00000000..ddf55b32 --- /dev/null +++ b/docs/specs/2026-02-10-tray-icon-contrast.md @@ -0,0 +1,25 @@ +# Windows Tray Icon Contrast + +## Goal +- Keep dynamic tray icons visible on dark Windows taskbars. + +## Scope +- Add configurable ink color for dynamic tray icons. +- Use light ink on Windows. + +## Non-Goals +- Theme detection or automatic light/dark switching. +- Changes to static tray icon assets. + +## Approach +- Add `color` to tray icon rendering API. +- Use `#f8f8f8` on Windows and black elsewhere. + +## Testing +- Manual: verify dynamic tray icon remains visible on Windows dark taskbar. + +## Risks +- Light ink may be too bright on light taskbars (acceptable until theme detection is added). + +## Open Questions +- None. diff --git a/docs/specs/2026-02-10-windows-signing-guardrail.md b/docs/specs/2026-02-10-windows-signing-guardrail.md new file mode 100644 index 00000000..128121e6 --- /dev/null +++ b/docs/specs/2026-02-10-windows-signing-guardrail.md @@ -0,0 +1,23 @@ +# Windows Signing Guardrail + +## Goal +- Fail releases when Windows signing secrets are missing. + +## Scope +- Add a validation step in `.github/workflows/publish.yml` for Windows signing secrets. + +## Non-Goals +- Change signing identity, certificate format, or release process. + +## Approach +- On `windows-latest`, error out if `WINDOWS_CERTIFICATE` or `WINDOWS_CERTIFICATE_PASSWORD` is unset. +- Always run the import step after validation. + +## Testing +- No automated tests; validate via workflow run. + +## Risks +- Manual releases without secrets will now fail (intended). + +## Open Questions +- None. diff --git a/docs/specs/2026-02-10-windows-vault-dpapi.md b/docs/specs/2026-02-10-windows-vault-dpapi.md new file mode 100644 index 00000000..d8f77faa --- /dev/null +++ b/docs/specs/2026-02-10-windows-vault-dpapi.md @@ -0,0 +1,31 @@ +# Windows Vault (DPAPI) + +## Goal +- Protect OpenUsage-managed tokens on Windows using DPAPI instead of plaintext files. + +## Scope +- Add `host.vault` API (read/write/delete) backed by DPAPI user-scope encryption. +- Store vault entries under `appDataDir/vault/`. +- Update Copilot plugin to use vault on Windows. +- Document vault API and Copilot auth order. + +## Non-Goals +- Replace provider-owned credential files (Claude/Codex). +- Integrate with Windows Credential Manager for third-party secrets. +- Add UI for manual token entry. + +## Approach +- Use `windows-dpapi` crate (`encrypt_data`/`decrypt_data`, `Scope::User`). +- Base64-encode encrypted bytes for storage on disk. +- Encode vault key names using URL-safe base64 to avoid filesystem issues. + +## Testing +- `cargo test` (src-tauri) +- `bun test plugins/copilot/plugin.test.js` + +## Risks +- DPAPI ties data to user profile; tokens are not portable across machines. +- gh CLI token access on Windows depends on whether `hosts.yml` contains `oauth_token`. + +## Open Questions +- None. diff --git a/docs/specs/tauri-updater-publish.md b/docs/specs/tauri-updater-publish.md new file mode 100644 index 00000000..bd11e461 --- /dev/null +++ b/docs/specs/tauri-updater-publish.md @@ -0,0 +1,29 @@ +2026-02-02 + +# Tauri updater + publish workflow + +## Goal +- GitHub Releases publishes: bundles + updater manifest for Tauri v2 updater plugin. +- App checks `latest.json` from GitHub Releases and updates safely. + +## Source of truth +- Release tag `vX.Y.Z` is the version. +- Must match: + - `src-tauri/tauri.conf.json` `.version` + - `src-tauri/Cargo.toml` `[package].version` + - `package.json` `.version` + +## CI definition-of-done +- `publish.yml` builds per target arch and uploads: + - bundle artifacts (e.g. `.dmg`, `.app.tar.gz`) + - updater signatures (`.sig`) + - updater manifest `latest.json` +- `latest.json` contains both macOS arches (sequential matrix run merges platforms). + +## Runtime definition-of-done +- `src-tauri/tauri.conf.json` updater `endpoints` points to `.../releases/latest/download/latest.json`. +- Release `latest.json` exists and references the same release’s assets. + +## Windows notes +- Windows auto-update requires Authenticode-signed installers. +- Until signing is configured, treat Windows updater as test-only (downloads may be blocked by SmartScreen/UAC). diff --git a/package.json b/package.json index b9e74e13..096b0896 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openusage", "private": true, - "version": "0.6.2", + "version": "0.6.3", "type": "module", "scripts": { "dev": "vite", @@ -25,6 +25,7 @@ "@tauri-apps/plugin-autostart": "^2.5.1", "@tauri-apps/plugin-log": "^2.8.0", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-store": "^2.4.2", "@tauri-apps/plugin-updater": "^2.10.0", diff --git a/plugins/antigravity/plugin.js b/plugins/antigravity/plugin.js index ed098dbd..401bab72 100644 --- a/plugins/antigravity/plugin.js +++ b/plugins/antigravity/plugin.js @@ -175,8 +175,12 @@ // --- LS discovery --- function discoverLs(ctx) { + var processName = ctx.app.platform === "windows" + ? "language_server_windows_x64.exe" + : (ctx.app.platform === "linux" ? "language_server_linux_x64" : "language_server_macos") + return ctx.host.ls.discover({ - processName: "language_server_macos", + processName: processName, markers: ["antigravity"], csrfFlag: "--csrf_token", portFlag: "--extension_server_port", @@ -199,7 +203,7 @@ extensionVersion: "unknown", ide: "antigravity", ideVersion: "unknown", - os: "macos", + os: ctx.app.platform === "windows" ? "windows" : (ctx.app.platform === "linux" ? "linux" : "macos"), }, }, }), diff --git a/plugins/antigravity/plugin.json b/plugins/antigravity/plugin.json index eb7b4880..06e0193b 100644 --- a/plugins/antigravity/plugin.json +++ b/plugins/antigravity/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#4285F4", + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Gemini 3 Pro", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Gemini 3 Flash", "scope": "overview" }, diff --git a/plugins/claude/plugin.js b/plugins/claude/plugin.js index a719ef01..8bb12e49 100644 --- a/plugins/claude/plugin.js +++ b/plugins/claude/plugin.js @@ -7,6 +7,32 @@ const SCOPES = "user:profile user:inference user:sessions:claude_code user:mcp_servers" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration + function getWindowsCredentialCandidates() { + return [ + "~/.claude/.credentials.json", + "~/AppData/Roaming/Claude/.credentials.json", + "~/AppData/Local/Claude/.credentials.json", + ] + } + + function getCredentialsPath(ctx) { + if (ctx.app && ctx.app.platform === "windows") { + const candidates = getWindowsCredentialCandidates() + for (const path of candidates) { + if (ctx.host.fs.exists(path)) return path + } + + if (candidates.length > 0) return candidates[0] + } + + return CRED_FILE + } + + function isKeychainAvailable(ctx) { + if (!ctx.app) return false + return ctx.app.platform === "macos" || ctx.app.platform === "darwin" + } + function utf8DecodeBytes(bytes) { // Prefer native TextDecoder when available (QuickJS may not expose it). if (typeof TextDecoder !== "undefined") { @@ -123,9 +149,10 @@ function loadCredentials(ctx) { // Try file first - if (ctx.host.fs.exists(CRED_FILE)) { + const credPath = getCredentialsPath(ctx) + if (credPath && ctx.host.fs.exists(credPath)) { try { - const text = ctx.host.fs.readText(CRED_FILE) + const text = ctx.host.fs.readText(credPath) const parsed = tryParseCredentialJSON(ctx, text) if (parsed) { const oauth = parsed.claudeAiOauth @@ -141,21 +168,23 @@ } // Try keychain fallback - try { - const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) - if (keychainValue) { - const parsed = tryParseCredentialJSON(ctx, keychainValue) - if (parsed) { - const oauth = parsed.claudeAiOauth - if (oauth && oauth.accessToken) { - ctx.host.log.info("credentials loaded from keychain") - return { oauth, source: "keychain", fullData: parsed } + if (isKeychainAvailable(ctx)) { + try { + const keychainValue = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE) + if (keychainValue) { + const parsed = tryParseCredentialJSON(ctx, keychainValue) + if (parsed) { + const oauth = parsed.claudeAiOauth + if (oauth && oauth.accessToken) { + ctx.host.log.info("credentials loaded from keychain") + return { oauth, source: "keychain", fullData: parsed } + } } + ctx.host.log.warn("keychain has data but no valid oauth") } - ctx.host.log.warn("keychain has data but no valid oauth") + } catch (e) { + ctx.host.log.info("keychain read failed (may not exist): " + String(e)) } - } catch (e) { - ctx.host.log.info("keychain read failed (may not exist): " + String(e)) } ctx.host.log.warn("no credentials found") @@ -168,13 +197,22 @@ const text = JSON.stringify(fullData) if (source === "file") { try { - ctx.host.fs.writeText(CRED_FILE, text) + const credPath = getCredentialsPath(ctx) + if (credPath) { + ctx.host.fs.writeText(credPath, text) + } else { + ctx.host.log.error("Failed to resolve Claude credentials path") + } } catch (e) { ctx.host.log.error("Failed to write Claude credentials file: " + String(e)) } } else if (source === "keychain") { try { - ctx.host.keychain.writeGenericPassword(KEYCHAIN_SERVICE, text) + if (isKeychainAvailable(ctx)) { + ctx.host.keychain.writeGenericPassword(KEYCHAIN_SERVICE, text) + } else { + ctx.host.log.error("Keychain not available on this platform") + } } catch (e) { ctx.host.log.error("Failed to write Claude credentials keychain: " + String(e)) } @@ -276,6 +314,13 @@ const creds = loadCredentials(ctx) if (!creds || !creds.oauth || !creds.oauth.accessToken || !creds.oauth.accessToken.trim()) { ctx.host.log.error("probe failed: not logged in") + if (ctx.app && ctx.app.platform === "windows") { + const candidates = getWindowsCredentialCandidates() + const preview = candidates.length > 0 + ? candidates.slice(0, 3).join(", ") + : "%USERPROFILE%\\.claude\\.credentials.json" + throw "Claude credentials not found on Windows. Expected one of: " + preview + ". Run `claude` to authenticate or report the actual path." + } throw "Not logged in. Run `claude` to authenticate." } diff --git a/plugins/claude/plugin.json b/plugins/claude/plugin.json index 97550345..800c46d2 100644 --- a/plugins/claude/plugin.json +++ b/plugins/claude/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#DE7356", + "os": ["macos", "windows", "linux"], "links": [ { "label": "Status", "url": "https://status.anthropic.com/" }, { "label": "Console", "url": "https://console.anthropic.com/" } diff --git a/plugins/codex/plugin.js b/plugins/codex/plugin.js index efe201a4..eb957750 100644 --- a/plugins/codex/plugin.js +++ b/plugins/codex/plugin.js @@ -7,16 +7,24 @@ const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage" const REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000 - function joinPath(base, leaf) { - return base.replace(/[\\/]+$/, "") + "/" + leaf + function joinPath(base, leaf, separator) { + const sep = separator || "/" + return base.replace(/[\\/]+$/, "") + sep + leaf } - function readCodexHome(ctx) { - if (!ctx.host.env || typeof ctx.host.env.get !== "function") { - return null - } + function getWindowsAuthPaths() { + const basePaths = [ + "~/AppData/Roaming/codex", + "~/AppData/Local/codex", + "~/.codex", + "~/.config/codex", + ] + return basePaths.map((base) => joinPath(base, AUTH_FILE)) + } + function readCodexHome(ctx) { try { + if (!ctx.host.env || typeof ctx.host.env.get !== "function") return null const value = ctx.host.env.get("CODEX_HOME") if (typeof value !== "string") return null const trimmed = value.trim() @@ -75,6 +83,14 @@ return [joinPath(codexHome, AUTH_FILE)] } + if (ctx.app && ctx.app.platform === "windows") { + const windowsPaths = getWindowsAuthPaths() + for (const authPath of windowsPaths) { + if (ctx.host.fs.exists(authPath)) return [authPath] + } + return windowsPaths + } + return CONFIG_AUTH_PATHS.map((basePath) => joinPath(basePath, AUTH_FILE)) } @@ -291,6 +307,13 @@ const authState = loadAuth(ctx) if (!authState || !authState.auth) { ctx.host.log.error("probe failed: not logged in") + if (ctx.app && ctx.app.platform === "windows") { + const candidates = getWindowsAuthPaths(ctx) + const preview = candidates.length > 0 + ? candidates.slice(0, 3).join(", ") + : "%USERPROFILE%\\.codex\\auth.json" + throw "Codex auth file not found on Windows. Expected one of: " + preview + ". Run `codex` to authenticate or report the actual path." + } throw "Not logged in. Run `codex` to authenticate." } const auth = authState.auth @@ -411,6 +434,40 @@ } } + if (Array.isArray(data.additional_rate_limits)) { + for (const entry of data.additional_rate_limits) { + if (!entry || !entry.rate_limit) continue + const name = typeof entry.limit_name === "string" ? entry.limit_name : "" + let shortName = name.replace(/^GPT-[\d.]+-Codex-/, "") + if (!shortName) shortName = name || "Model" + const rl = entry.rate_limit + if (rl.primary_window && typeof rl.primary_window.used_percent === "number") { + lines.push(ctx.line.progress({ + label: shortName, + used: rl.primary_window.used_percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: getResetsAtIso(ctx, nowSec, rl.primary_window), + periodDurationMs: typeof rl.primary_window.limit_window_seconds === "number" + ? rl.primary_window.limit_window_seconds * 1000 + : PERIOD_SESSION_MS + })) + } + if (rl.secondary_window && typeof rl.secondary_window.used_percent === "number") { + lines.push(ctx.line.progress({ + label: shortName + " Weekly", + used: rl.secondary_window.used_percent, + limit: 100, + format: { kind: "percent" }, + resetsAt: getResetsAtIso(ctx, nowSec, rl.secondary_window), + periodDurationMs: typeof rl.secondary_window.limit_window_seconds === "number" + ? rl.secondary_window.limit_window_seconds * 1000 + : PERIOD_WEEKLY_MS + })) + } + } + } + if (reviewWindow) { const used = reviewWindow.used_percent if (typeof used === "number") { diff --git a/plugins/codex/plugin.json b/plugins/codex/plugin.json index 6cd7d93f..3dc97039 100644 --- a/plugins/codex/plugin.json +++ b/plugins/codex/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#74AA9C", + "os": ["macos", "windows", "linux"], "links": [ { "label": "Status", "url": "https://status.openai.com/" }, { "label": "Usage dashboard", "url": "https://platform.openai.com/usage" } @@ -13,6 +14,8 @@ "lines": [ { "type": "progress", "label": "Session", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Weekly", "scope": "overview" }, + { "type": "progress", "label": "Spark", "scope": "detail" }, + { "type": "progress", "label": "Spark Weekly", "scope": "detail" }, { "type": "progress", "label": "Reviews", "scope": "detail" }, { "type": "progress", "label": "Credits", "scope": "detail" } ] diff --git a/plugins/codex/plugin.test.js b/plugins/codex/plugin.test.js index 3055bb80..b87290e2 100644 --- a/plugins/codex/plugin.test.js +++ b/plugins/codex/plugin.test.js @@ -374,6 +374,141 @@ describe("codex plugin", () => { expect(() => plugin.probe(ctx)).toThrow("Usage request failed after refresh") }) + it("surfaces additional_rate_limits as Spark lines", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "token" }, + last_refresh: new Date().toISOString(), + })) + const now = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(now) + const nowSec = Math.floor(now / 1000) + + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 60 }, + secondary_window: { used_percent: 10, reset_after_seconds: 120 }, + }, + additional_rate_limits: [ + { + limit_name: "GPT-5.3-Codex-Spark", + metered_feature: "codex_bengalfox", + rate_limit: { + primary_window: { + used_percent: 25, + limit_window_seconds: 18000, + reset_after_seconds: 3600, + reset_at: nowSec + 3600, + }, + secondary_window: { + used_percent: 40, + limit_window_seconds: 604800, + reset_after_seconds: 86400, + reset_at: nowSec + 86400, + }, + }, + }, + ], + }), + }) + + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + + const spark = result.lines.find((l) => l.label === "Spark") + expect(spark).toBeTruthy() + expect(spark.used).toBe(25) + expect(spark.limit).toBe(100) + expect(spark.periodDurationMs).toBe(18000000) + expect(spark.resetsAt).toBe(new Date((nowSec + 3600) * 1000).toISOString()) + + const sparkWeekly = result.lines.find((l) => l.label === "Spark Weekly") + expect(sparkWeekly).toBeTruthy() + expect(sparkWeekly.used).toBe(40) + expect(sparkWeekly.limit).toBe(100) + expect(sparkWeekly.periodDurationMs).toBe(604800000) + expect(sparkWeekly.resetsAt).toBe(new Date((nowSec + 86400) * 1000).toISOString()) + + nowSpy.mockRestore() + }) + + it("handles additional_rate_limits with missing fields and fallback labels", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "token" }, + last_refresh: new Date().toISOString(), + })) + ctx.host.http.request.mockReturnValue({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + additional_rate_limits: [ + // Entry with no limit_name, no limit_window_seconds, no secondary + { + limit_name: "", + rate_limit: { + primary_window: { used_percent: 10, reset_after_seconds: 60 }, + secondary_window: null, + }, + }, + // Malformed entry (no rate_limit) + { limit_name: "Bad" }, + // Null entry + null, + ], + }), + }) + const plugin = await loadPlugin() + const result = plugin.probe(ctx) + const modelLine = result.lines.find((l) => l.label === "Model") + expect(modelLine).toBeTruthy() + expect(modelLine.used).toBe(10) + expect(modelLine.periodDurationMs).toBe(5 * 60 * 60 * 1000) // fallback PERIOD_SESSION_MS + // No weekly line for this entry since secondary_window is null + expect(result.lines.find((l) => l.label === "Model Weekly")).toBeUndefined() + // Malformed and null entries should be skipped + expect(result.lines.find((l) => l.label === "Bad")).toBeUndefined() + }) + + it("handles missing or empty additional_rate_limits gracefully", async () => { + const ctx = makeCtx() + ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ + tokens: { access_token: "token" }, + last_refresh: new Date().toISOString(), + })) + + // Missing field + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 60 }, + }, + }), + }) + const plugin = await loadPlugin() + const result1 = plugin.probe(ctx) + expect(result1.lines.find((l) => l.label === "Spark")).toBeUndefined() + + // Empty array + ctx.host.http.request.mockReturnValueOnce({ + status: 200, + headers: {}, + bodyText: JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 5, reset_after_seconds: 60 }, + }, + additional_rate_limits: [], + }), + }) + const result2 = plugin.probe(ctx) + expect(result2.lines.find((l) => l.label === "Spark")).toBeUndefined() + }) + it("throws token expired when refresh retry is unauthorized", async () => { const ctx = makeCtx() ctx.host.fs.writeText("~/.codex/auth.json", JSON.stringify({ diff --git a/plugins/copilot/plugin.js b/plugins/copilot/plugin.js index 3d67f489..96bb0c55 100644 --- a/plugins/copilot/plugin.js +++ b/plugins/copilot/plugin.js @@ -1,49 +1,70 @@ (function () { const KEYCHAIN_SERVICE = "OpenUsage-copilot"; const GH_KEYCHAIN_SERVICE = "gh:github.com"; + const VAULT_KEY = "copilot:token"; const USAGE_URL = "https://api.github.com/copilot_internal/user"; - function readJson(ctx, path) { - try { - if (!ctx.host.fs.exists(path)) return null; - const text = ctx.host.fs.readText(path); - return ctx.util.tryParseJson(text); - } catch (e) { - ctx.host.log.warn("readJson failed for " + path + ": " + String(e)); - return null; - } + function isKeychainAvailable(ctx) { + if (!ctx.app) return false; + return ctx.app.platform === "macos" || ctx.app.platform === "darwin"; } - function writeJson(ctx, path, value) { - try { - ctx.host.fs.writeText(path, JSON.stringify(value)); - } catch (e) { - ctx.host.log.warn("writeJson failed for " + path + ": " + String(e)); - } + function isVaultAvailable(ctx) { + if (!ctx.app) return false; + return ctx.app.platform === "windows"; + } + + function isWindows(ctx) { + if (!ctx.app) return false; + return ctx.app.platform === "windows"; + } + + function isLinux(ctx) { + if (!ctx.app) return false; + return ctx.app.platform === "linux"; } function saveToken(ctx, token) { - try { - ctx.host.keychain.writeGenericPassword( - KEYCHAIN_SERVICE, - JSON.stringify({ token: token }), - ); - } catch (e) { - ctx.host.log.warn("keychain write failed: " + String(e)); + if (isVaultAvailable(ctx)) { + try { + ctx.host.vault.write(VAULT_KEY, JSON.stringify({ token: token })); + } catch (e) { + ctx.host.log.warn("vault write failed: " + String(e)); + } + return; + } + if (isKeychainAvailable(ctx)) { + try { + ctx.host.keychain.writeGenericPassword( + KEYCHAIN_SERVICE, + JSON.stringify({ token: token }), + ); + } catch (e) { + ctx.host.log.warn("keychain write failed: " + String(e)); + } } - writeJson(ctx, ctx.app.pluginDataDir + "/auth.json", { token: token }); } function clearCachedToken(ctx) { - try { - ctx.host.keychain.deleteGenericPassword(KEYCHAIN_SERVICE); - } catch (e) { - ctx.host.log.info("keychain delete failed: " + String(e)); + if (isVaultAvailable(ctx)) { + try { + ctx.host.vault.delete(VAULT_KEY); + } catch (e) { + ctx.host.log.info("vault delete failed: " + String(e)); + } + return; + } + if (isKeychainAvailable(ctx)) { + try { + ctx.host.keychain.deleteGenericPassword(KEYCHAIN_SERVICE); + } catch (e) { + ctx.host.log.info("keychain delete failed: " + String(e)); + } } - writeJson(ctx, ctx.app.pluginDataDir + "/auth.json", null); } function loadTokenFromKeychain(ctx) { + if (!isKeychainAvailable(ctx)) return null; try { const raw = ctx.host.keychain.readGenericPassword(KEYCHAIN_SERVICE); if (raw) { @@ -59,42 +80,111 @@ return null; } - function loadTokenFromGhCli(ctx) { + function loadTokenFromVault(ctx) { + if (!isVaultAvailable(ctx)) return null; try { - const raw = ctx.host.keychain.readGenericPassword(GH_KEYCHAIN_SERVICE); + const raw = ctx.host.vault.read(VAULT_KEY); if (raw) { - let token = raw; - if ( - typeof token === "string" && - token.indexOf("go-keyring-base64:") === 0 - ) { - token = ctx.base64.decode(token.slice("go-keyring-base64:".length)); - } - if (token) { - ctx.host.log.info("token loaded from gh CLI keychain"); - return { token: token, source: "gh-cli" }; + const parsed = ctx.util.tryParseJson(raw); + if (parsed && parsed.token) { + ctx.host.log.info("token loaded from OpenUsage vault"); + return { token: parsed.token, source: "vault" }; } } } catch (e) { - ctx.host.log.info("gh CLI keychain read failed: " + String(e)); + ctx.host.log.info("OpenUsage vault read failed: " + String(e)); } return null; } - function loadTokenFromStateFile(ctx) { - const data = readJson(ctx, ctx.app.pluginDataDir + "/auth.json"); - if (data && data.token) { - ctx.host.log.info("token loaded from state file"); - return { token: data.token, source: "state" }; + function loadTokenFromGhCli(ctx) { + if (isKeychainAvailable(ctx)) { + try { + const raw = ctx.host.keychain.readGenericPassword(GH_KEYCHAIN_SERVICE); + if (raw) { + let token = raw; + if ( + typeof token === "string" && + token.indexOf("go-keyring-base64:") === 0 + ) { + token = ctx.base64.decode(token.slice("go-keyring-base64:".length)); + } + if (token) { + ctx.host.log.info("token loaded from gh CLI keychain"); + return { token: token, source: "gh-cli" }; + } + } + } catch (e) { + ctx.host.log.info("gh CLI keychain read failed: " + String(e)); + } + return null; + } + + if (isWindows(ctx) || isLinux(ctx)) { + const token = loadTokenFromGhCliFile(ctx); + if (token) { + ctx.host.log.info("token loaded from gh CLI config file"); + return { token: token, source: "gh-cli" }; + } + } + + return null; + } + + function loadTokenFromGhCliFile(ctx) { + const paths = [ + "~/.config/gh/hosts.yml", + "~/AppData/Roaming/GitHub CLI/hosts.yml", + "~/AppData/Roaming/gh/hosts.yml", + ]; + for (const path of paths) { + try { + if (!ctx.host.fs.exists(path)) continue; + const text = ctx.host.fs.readText(path); + const token = parseGhHostsToken(text, "github.com"); + if (token) return token; + } catch (e) { + ctx.host.log.warn("gh hosts file read failed: " + String(e)); + } + } + return null; + } + + function parseGhHostsToken(text, host) { + const lines = String(text || "").split(/\r?\n/); + let currentHost = null; + for (const line of lines) { + const raw = String(line || ""); + const trimmed = raw.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const isTopLevel = raw.length > 0 && raw[0] !== " " && raw[0] !== "\t"; + if (isTopLevel && trimmed.endsWith(":")) { + let key = trimmed.slice(0, -1).trim(); + if ((key.startsWith("\"") && key.endsWith("\"")) || (key.startsWith("'") && key.endsWith("'"))) { + key = key.slice(1, -1); + } + currentHost = key || null; + continue; + } + + if (currentHost === host && trimmed.startsWith("oauth_token:")) { + let value = trimmed.slice("oauth_token:".length).trim(); + if (!value) return null; + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + return value || null; + } } return null; } function loadToken(ctx) { return ( + loadTokenFromVault(ctx) || loadTokenFromKeychain(ctx) || - loadTokenFromGhCli(ctx) || - loadTokenFromStateFile(ctx) + loadTokenFromGhCli(ctx) ); } diff --git a/plugins/copilot/plugin.json b/plugins/copilot/plugin.json index 2c322139..12e54944 100644 --- a/plugins/copilot/plugin.json +++ b/plugins/copilot/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#A855F7", + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Premium", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Chat", "scope": "overview", "primaryOrder": 2 }, diff --git a/plugins/copilot/plugin.test.js b/plugins/copilot/plugin.test.js index 48a89989..94282ae0 100644 --- a/plugins/copilot/plugin.test.js +++ b/plugins/copilot/plugin.test.js @@ -42,9 +42,9 @@ function setGhCliKeychain(ctx, value) { }); } -function setStateFileToken(ctx, token) { - ctx.host.fs.writeText( - ctx.app.pluginDataDir + "/auth.json", +function setVaultToken(ctx, token) { + ctx.host.vault.write( + "copilot:token", JSON.stringify({ token }), ); } @@ -102,9 +102,10 @@ describe("copilot plugin", () => { expect(call.headers.Authorization).toBe("token gho_encoded_token"); }); - it("loads token from state file", async () => { + it("loads token from vault on Windows", async () => { const ctx = makePluginTestContext(); - setStateFileToken(ctx, "ghu_state"); + ctx.app.platform = "windows"; + setVaultToken(ctx, "ghu_state"); mockUsageOk(ctx); const plugin = await loadPlugin(); const result = plugin.probe(ctx); @@ -128,18 +129,19 @@ describe("copilot plugin", () => { expect(call.headers.Authorization).toBe("token ghu_keychain"); }); - it("prefers keychain over state file", async () => { + it("prefers vault on Windows", async () => { const ctx = makePluginTestContext(); + ctx.app.platform = "windows"; setKeychainToken(ctx, "ghu_keychain"); - setStateFileToken(ctx, "ghu_state"); + setVaultToken(ctx, "ghu_state"); mockUsageOk(ctx); const plugin = await loadPlugin(); plugin.probe(ctx); const call = ctx.host.http.request.mock.calls[0][0]; - expect(call.headers.Authorization).toBe("token ghu_keychain"); + expect(call.headers.Authorization).toBe("token ghu_state"); }); - it("persists token from gh-cli to keychain and state file", async () => { + it("persists token from gh-cli to keychain", async () => { const ctx = makePluginTestContext(); setGhCliKeychain(ctx, "gho_persist"); mockUsageOk(ctx); @@ -149,10 +151,7 @@ describe("copilot plugin", () => { "OpenUsage-copilot", JSON.stringify({ token: "gho_persist" }), ); - const stateFile = ctx.host.fs.readText( - ctx.app.pluginDataDir + "/auth.json", - ); - expect(JSON.parse(stateFile).token).toBe("gho_persist"); + expect(ctx.host.vault.write).not.toHaveBeenCalled(); }); it("does not persist token loaded from OpenUsage keychain", async () => { diff --git a/plugins/cursor/plugin.js b/plugins/cursor/plugin.js index 41b54b00..deb21d09 100644 --- a/plugins/cursor/plugin.js +++ b/plugins/cursor/plugin.js @@ -1,6 +1,7 @@ (function () { - const STATE_DB = + const MAC_STATE_DB = "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + const LINUX_STATE_DB = "~/.config/Cursor/User/globalStorage/state.vscdb" const BASE_URL = "https://api2.cursor.sh" const USAGE_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCurrentPeriodUsage" const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo" @@ -10,11 +11,41 @@ const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB" const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration - function readStateValue(ctx, key) { + function joinPath(base, leaf, separator) { + if (!base) return leaf + if (base.endsWith("/") || base.endsWith("\\")) return base + leaf + return base + separator + leaf + } + + function getStateDbPath(ctx) { + if (ctx.app.platform === "windows") { + const candidates = [ + "~/AppData/Roaming/Cursor/User", + "~/AppData/Local/Cursor/User", + ] + for (const base of candidates) { + const dbPath = joinPath(base, "globalStorage/state.vscdb", "/") + if (ctx.host.fs.exists(dbPath)) return dbPath + } + if (candidates.length > 0) { + return joinPath(candidates[0], "globalStorage/state.vscdb", "/") + } + return null + } + + if (ctx.app.platform === "linux") return LINUX_STATE_DB + return MAC_STATE_DB + } + + function readStateValueFromDb(ctx, stateDb, key) { try { + if (!stateDb) { + ctx.host.log.warn("state db path not found for Cursor") + return null + } const sql = "SELECT value FROM ItemTable WHERE key = '" + key + "' LIMIT 1;" - const json = ctx.host.sqlite.query(STATE_DB, sql) + const json = ctx.host.sqlite.query(stateDb, sql) const rows = ctx.util.tryParseJson(json) if (!Array.isArray(rows)) { throw new Error("sqlite returned invalid json") @@ -28,8 +59,18 @@ return null } + function readStateValue(ctx, key) { + const stateDb = getStateDbPath(ctx) + return readStateValueFromDb(ctx, stateDb, key) + } + function writeStateValue(ctx, key, value) { try { + const stateDb = getStateDbPath(ctx) + if (!stateDb) { + ctx.host.log.warn("state db path not found for Cursor") + return false + } // Escape single quotes in value for SQL const escaped = String(value).replace(/'/g, "''") const sql = @@ -38,7 +79,7 @@ "', '" + escaped + "');" - ctx.host.sqlite.exec(STATE_DB, sql) + ctx.host.sqlite.exec(stateDb, sql) return true } catch (e) { ctx.host.log.warn("sqlite write failed for " + key + ": " + String(e)) @@ -221,8 +262,15 @@ } function probe(ctx) { - let accessToken = readStateValue(ctx, "cursorAuth/accessToken") - const refreshTokenValue = readStateValue(ctx, "cursorAuth/refreshToken") + const stateDb = getStateDbPath(ctx) + if (ctx.app.platform === "windows") { + if (!stateDb || !ctx.host.fs.exists(stateDb)) { + throw "Cursor data store not found on Windows. Expected %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb (or Local). Please report the actual path." + } + } + + let accessToken = readStateValueFromDb(ctx, stateDb, "cursorAuth/accessToken") + const refreshTokenValue = readStateValueFromDb(ctx, stateDb, "cursorAuth/refreshToken") if (!accessToken && !refreshTokenValue) { ctx.host.log.error("probe failed: no access or refresh token in sqlite") diff --git a/plugins/cursor/plugin.json b/plugins/cursor/plugin.json index 3e9290a3..041a4d6e 100644 --- a/plugins/cursor/plugin.json +++ b/plugins/cursor/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#000000", + "os": ["macos", "windows", "linux"], "links": [ { "label": "Status", "url": "https://status.cursor.com/" }, { "label": "Dashboard", "url": "https://www.cursor.com/dashboard" } diff --git a/plugins/mock/plugin.json b/plugins/mock/plugin.json index 83de0b09..c59d56ab 100644 --- a/plugins/mock/plugin.json +++ b/plugins/mock/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#EF4444", + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Ahead pace", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "On Track pace", "scope": "overview", "primaryOrder": 2 }, diff --git a/plugins/test-helpers.js b/plugins/test-helpers.js index cfddec6a..67443440 100644 --- a/plugins/test-helpers.js +++ b/plugins/test-helpers.js @@ -2,6 +2,7 @@ import { vi } from "vitest" export const makeCtx = () => { const files = new Map() + const vaultStore = new Map() const ctx = { nowIso: "2026-02-02T00:00:00.000Z", @@ -25,6 +26,11 @@ export const makeCtx = () => { writeGenericPassword: vi.fn(), deleteGenericPassword: vi.fn(), }, + vault: { + read: vi.fn((name) => (vaultStore.has(name) ? vaultStore.get(name) : null)), + write: vi.fn((name, value) => vaultStore.set(name, value)), + delete: vi.fn((name) => vaultStore.delete(name)), + }, sqlite: { query: vi.fn(() => "[]"), exec: vi.fn(), diff --git a/plugins/windsurf/plugin.js b/plugins/windsurf/plugin.js index 414d410c..b03e8a0a 100644 --- a/plugins/windsurf/plugin.js +++ b/plugins/windsurf/plugin.js @@ -1,5 +1,7 @@ (function () { var LS_SERVICE = "exa.language_server_pb.LanguageServerService" + var MAC_STATE_DB = "~/Library/Application Support/Windsurf/User/globalStorage/state.vscdb" + var LINUX_STATE_DB = "~/.config/Windsurf/User/globalStorage/state.vscdb" // Windsurf variants — tried in order (Windsurf first, then Windsurf Next). // Markers use --ide_name exact matching in the Rust discover code. @@ -16,11 +18,41 @@ }, ] + function joinPath(base, leaf, separator) { + if (!base) return leaf + if (base.endsWith("/") || base.endsWith("\\")) return base + leaf + return base + separator + leaf + } + + function getStateDbPath(ctx) { + if (ctx.app.platform === "windows") { + var candidates = [ + "~/AppData/Roaming/Windsurf/User", + "~/AppData/Local/Windsurf/User", + ] + for (var i = 0; i < candidates.length; i++) { + var dbPath = joinPath(candidates[i], "globalStorage/state.vscdb", "/") + if (ctx.host.fs.exists(dbPath)) return dbPath + } + if (candidates.length > 0) { + return joinPath(candidates[0], "globalStorage/state.vscdb", "/") + } + return null + } + + if (ctx.app.platform === "linux") return LINUX_STATE_DB + return MAC_STATE_DB + } + // --- LS discovery --- function discoverLs(ctx, variant) { + var processName = ctx.app.platform === "windows" + ? "language_server_windows_x64.exe" + : (ctx.app.platform === "linux" ? "language_server_linux_x64" : "language_server_macos") + return ctx.host.ls.discover({ - processName: "language_server_macos", + processName: processName, markers: [variant.marker], csrfFlag: "--csrf_token", portFlag: "--extension_server_port", @@ -30,8 +62,13 @@ function loadApiKey(ctx, variant) { try { + var stateDb = ctx.app.platform === "windows" ? getStateDbPath(ctx) : variant.stateDb + if (!stateDb) { + ctx.host.log.warn("state db path not found for Windsurf") + return null + } var rows = ctx.host.sqlite.query( - variant.stateDb, + stateDb, "SELECT value FROM ItemTable WHERE key = 'windsurfAuthStatus' LIMIT 1" ) var parsed = ctx.util.tryParseJson(rows) @@ -61,7 +98,7 @@ extensionVersion: "unknown", ide: ideName, ideVersion: "unknown", - os: "macos", + os: ctx.app.platform === "windows" ? "windows" : (ctx.app.platform === "linux" ? "linux" : "macos"), }, }, }), @@ -205,6 +242,12 @@ // --- Probe --- function probe(ctx) { + if (ctx.app.platform === "windows") { + var stateDb = getStateDbPath(ctx) + if (!stateDb || !ctx.host.fs.exists(stateDb)) { + throw "Windsurf data store not found on Windows. Expected %APPDATA%\\Windsurf\\User\\globalStorage\\state.vscdb (or Local). Please report the actual path." + } + } // Try each variant in order: Windsurf → Windsurf Next for (var i = 0; i < VARIANTS.length; i++) { var result = probeVariant(ctx, VARIANTS[i]) diff --git a/plugins/windsurf/plugin.json b/plugins/windsurf/plugin.json index 6c83aa05..3131c8a0 100644 --- a/plugins/windsurf/plugin.json +++ b/plugins/windsurf/plugin.json @@ -6,6 +6,7 @@ "entry": "plugin.js", "icon": "icon.svg", "brandColor": "#111111", + "os": ["macos", "windows", "linux"], "lines": [ { "type": "progress", "label": "Prompt credits", "scope": "overview", "primaryOrder": 1 }, { "type": "progress", "label": "Flex credits", "scope": "overview" } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 19ceaf0d..2bcb915a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1155,6 +1155,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -1222,6 +1234,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1763,6 +1781,15 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1771,7 +1798,16 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", ] [[package]] @@ -2343,6 +2379,17 @@ dependencies = [ "redox_syscall 0.7.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8935b44e7c13394a179a438e0cebba0fe08fe01b54f152e29a93b5cf993fd4" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2951,7 +2998,7 @@ dependencies = [ [[package]] name = "openusage" -version = "0.6.2" +version = "0.6.3" dependencies = [ "base64 0.22.1", "dirs 6.0.0", @@ -2962,6 +3009,7 @@ dependencies = [ "regex-lite", "reqwest 0.13.2", "rquickjs", + "rusqlite", "serde", "serde_json", "tauri", @@ -2972,12 +3020,14 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-os", "tauri-plugin-process", "tauri-plugin-store", "tauri-plugin-updater", "time", "tokio", "uuid", + "windows-dpapi", ] [[package]] @@ -3941,6 +3991,20 @@ dependencies = [ "cc", ] +[[package]] +name = "rusqlite" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" +dependencies = [ + "bitflags 2.10.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -4958,6 +5022,24 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-os" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f08346c8deb39e96f86973da0e2d76cbb933d7ac9b750f6dc4daf955a6f997" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-process" version = "2.3.1" @@ -5995,6 +6077,17 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-dpapi" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162a325089267c13a318d5b0356c785e0f548ca5a5584e1b6b7b49ecd163121a" +dependencies = [ + "anyhow", + "log", + "winapi", +] + [[package]] name = "windows-future" version = "0.2.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4501723f..7258b7c9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openusage" -version = "0.6.2" +version = "0.6.3" description = "OpenUsage is an open source AI subscription limit tracker" authors = ["Robin Ebers"] edition = "2024" @@ -20,9 +20,9 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] } tauri-plugin-opener = "2" +tauri-plugin-os = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } time = { version = "0.3.47", features = ["formatting"] } dirs = "6" log = "0.4" @@ -39,8 +39,13 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2.5.1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } regex-lite = "0.1.9" +rusqlite = { version = "0.33", features = ["bundled"] } + +[target.'cfg(target_os = "windows")'.dependencies] +windows-dpapi = "0.1.0" [target.'cfg(target_os = "macos")'.dependencies] +tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSProcessInfo", "NSString"] } objc2-web-kit = { version = "0.3", features = ["WKPreferences", "WKWebView", "WKWebViewConfiguration"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cbf724c8..0b214530 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,7 +7,10 @@ "core:default", "core:tray:default", "core:image:default", + "os:default", "core:window:allow-set-size", + "core:window:allow-set-position", + "core:window:allow-outer-position", "core:window:allow-outer-size", "core:window:allow-inner-size", "core:window:allow-scale-factor", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f559b76..613cdca9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,10 @@ #[cfg(target_os = "macos")] mod app_nap; +#[cfg(target_os = "macos")] mod panel; mod plugin_engine; mod tray; +mod window_manager; #[cfg(target_os = "macos")] mod webkit_config; @@ -12,11 +14,14 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use serde::Serialize; -use tauri::Emitter; -use tauri_plugin_aptabase::EventTracker; +use tauri::{Emitter, Manager, State}; use tauri_plugin_log::{Target, TargetKind}; use uuid::Uuid; +use crate::plugin_engine::manifest::LoadedPlugin; +use crate::plugin_engine::runtime::PluginOutput; +use crate::window_manager::TaskbarPosition; + #[cfg(desktop)] use tauri_plugin_global_shortcut::{GlobalShortcutExt, ShortcutState}; @@ -87,7 +92,6 @@ fn managed_shortcut_slot() -> &'static Mutex> { SLOT.get_or_init(|| Mutex::new(None)) } -/// Shared shortcut handler that toggles the panel when the shortcut is pressed. #[cfg(desktop)] fn handle_global_shortcut(app: &tauri::AppHandle, event: tauri_plugin_global_shortcut::ShortcutEvent) { if event.state == ShortcutState::Pressed { @@ -97,11 +101,30 @@ fn handle_global_shortcut(app: &tauri::AppHandle, event: tauri_plugin_global_sho } pub struct AppState { - pub plugins: Vec, - pub app_data_dir: PathBuf, + pub plugins: Vec, + pub app_data_dir: std::path::PathBuf, pub app_version: String, + pub latest_probe_results: std::collections::HashMap, + pub last_taskbar_position: Option, + pub last_arrow_offset: Option, +} + +#[tauri::command] +fn get_taskbar_position(state: State<'_, Mutex>) -> Option { + state.lock().unwrap().last_taskbar_position.as_ref().map(|p| match p { + TaskbarPosition::Top => "top", + TaskbarPosition::Bottom => "bottom", + TaskbarPosition::Left => "left", + TaskbarPosition::Right => "right", + }.to_string()) } +#[tauri::command] +fn get_arrow_offset(state: State<'_, Mutex>) -> Option { + state.lock().unwrap().last_arrow_offset +} + + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PluginMeta { @@ -154,17 +177,16 @@ pub struct ProbeBatchComplete { #[tauri::command] fn init_panel(app_handle: tauri::AppHandle) { - panel::init(&app_handle).expect("Failed to initialize panel"); + window_manager::WindowManager::init(&app_handle).expect("Failed to initialize window"); } #[tauri::command] fn hide_panel(app_handle: tauri::AppHandle) { - use tauri_nspanel::ManagerExt; - if let Ok(panel) = app_handle.get_webview_panel("main") { - panel.hide(); - } + window_manager::WindowManager::hide(&app_handle).expect("Failed to hide window"); } + + #[tauri::command] async fn start_probe_batch( app_handle: tauri::AppHandle, @@ -194,7 +216,7 @@ async fn start_probe_batch( let selected_plugins = match plugin_ids { Some(ids) => { - let mut by_id: HashMap = plugins + let mut by_id: HashMap = plugins .into_iter() .map(|plugin| (plugin.manifest.id.clone(), plugin)) .collect(); @@ -261,6 +283,15 @@ async fn start_probe_batch( } else { log::info!("probe {} completed ok ({} lines)", plugin_id, output.lines.len()); } + + // Store result in AppState for tray menu access + { + let state = handle.state::>(); + if let Ok(mut app_state) = state.lock() { + app_state.latest_probe_results.insert(plugin_id.clone(), output.clone()); + } + } + let _ = handle.emit("probe:result", ProbeResult { batch_id: bid, output }); } Err(_) => { @@ -276,6 +307,8 @@ async fn start_probe_batch( batch_id: completion_bid, }, ); + // Refresh tray menu with new data + let _ = tray::update_tray_menu(&completion_handle); } }); } @@ -405,11 +438,12 @@ pub fn run() { let runtime = tokio::runtime::Runtime::new().expect("Failed to create Tokio runtime"); let _guard = runtime.enter(); - tauri::Builder::default() + #[cfg_attr(not(target_os = "macos"), allow(unused_mut))] + let mut builder = tauri::Builder::default() .plugin(tauri_plugin_aptabase::Builder::new("A-US-6435241436").build()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_store::Builder::default().build()) - .plugin(tauri_nspanel::init()) + .plugin(tauri_plugin_os::init()) .plugin( tauri_plugin_log::Builder::new() .targets([ @@ -430,11 +464,21 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ init_panel, hide_panel, + get_taskbar_position, + get_arrow_offset, start_probe_batch, list_plugins, get_log_path, + tray::refresh_tray_menu, update_global_shortcut - ]) + ]); + + #[cfg(target_os = "macos")] + { + builder = builder.plugin(tauri_nspanel::init()); + } + + builder .setup(|app| { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Accessory); @@ -461,8 +505,12 @@ pub fn run() { plugins, app_data_dir, app_version: app.package_info().version.to_string(), + latest_probe_results: std::collections::HashMap::new(), + last_taskbar_position: None, + last_arrow_offset: None, })); + tray::create(app.handle())?; app.handle().plugin(tauri_plugin_updater::Builder::new().build())?; diff --git a/src-tauri/src/plugin_engine/host_api.rs b/src-tauri/src/plugin_engine/host_api.rs index 33c949b2..5635e2e2 100644 --- a/src-tauri/src/plugin_engine/host_api.rs +++ b/src-tauri/src/plugin_engine/host_api.rs @@ -1,9 +1,16 @@ +use base64::Engine; use rquickjs::{Ctx, Exception, Function, Object}; +use rusqlite::types::ValueRef; +use rusqlite::{Connection, OpenFlags, Row}; +use serde_json::{Number, Value}; use std::collections::HashMap; use std::path::PathBuf; use std::process::Command; use std::sync::{Mutex, OnceLock}; +#[cfg(target_os = "windows")] +use windows_dpapi::{decrypt_data, encrypt_data, Scope}; + const WHITELISTED_ENV_VARS: [&str; 3] = ["CODEX_HOME", "ZAI_API_KEY", "GLM_API_KEY"]; fn last_non_empty_trimmed_line(text: &str) -> Option { @@ -54,19 +61,27 @@ fn redact_value(value: &str) -> String { "[REDACTED]".to_string() } else { let first4: String = chars.iter().take(4).collect(); - let last4: String = chars.iter().rev().take(4).collect::>().into_iter().rev().collect(); + let last4: String = chars + .iter() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); format!("{}...{}", first4, last4) } } /// Redact sensitive query parameters in URL fn redact_url(url: &str) -> String { + let sensitive_params = [ let sensitive_params = [ "key", "api_key", "apikey", "token", "access_token", "secret", "password", "auth", "authorization", "bearer", "credential", "user", "user_id", "userid", "account_id", "accountid", "email", "login", ]; - + if let Some(query_start) = url.find('?') { let (base, query) = url.split_at(query_start + 1); let redacted_params: Vec = query @@ -76,7 +91,8 @@ fn redact_url(url: &str) -> String { let (name, value) = param.split_at(eq_pos); let value = &value[1..]; // skip '=' let name_lower = name.to_lowercase(); - if sensitive_params.iter().any(|s| name_lower.contains(s)) && !value.is_empty() { + if sensitive_params.iter().any(|s| name_lower.contains(s)) && !value.is_empty() + { format!("{}={}", name, redact_value(value)) } else { param.to_string() @@ -95,50 +111,89 @@ fn redact_url(url: &str) -> String { /// Redact sensitive patterns in response body for logging fn redact_body(body: &str) -> String { let mut result = body.to_string(); - + // Redact JWTs (eyJ... pattern with dots) - let jwt_pattern = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(); - result = jwt_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { - redact_value(&caps[0]) - }).to_string(); - + let jwt_pattern = + regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(); + result = jwt_pattern + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); + // Redact common API key patterns (sk-xxx, pk-xxx, api_xxx, etc.) - let api_key_pattern = regex_lite::Regex::new(r#"["']?(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}["']?"#).unwrap(); - result = api_key_pattern.replace_all(&result, |caps: ®ex_lite::Captures| { - let key = caps[0].trim_matches(|c| c == '"' || c == '\''); - redact_value(key) - }).to_string(); - + let api_key_pattern = + regex_lite::Regex::new(r#"["']?(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}["']?"#) + .unwrap(); + result = api_key_pattern + .replace_all(&result, |caps: ®ex_lite::Captures| { + let key = caps[0].trim_matches(|c| c == '"' || c == '\''); + redact_value(key) + }) + .to_string(); + // Redact JSON values for sensitive keys let sensitive_keys = [ - "name", "password", "token", "access_token", "refresh_token", "secret", - "api_key", "apiKey", "authorization", "bearer", "credential", - "session_token", "sessionToken", "auth_token", "authToken", - "id_token", "idToken", "accessToken", "refreshToken", - "user_id", "userId", "account_id", "accountId", "email", "login", "analytics_tracking_id", + "name", + "password", + "token", + "access_token", + "refresh_token", + "secret", + "api_key", + "apiKey", + "authorization", + "bearer", + "credential", + "session_token", + "sessionToken", + "auth_token", + "authToken", + "id_token", + "idToken", + "accessToken", + "refreshToken", + "user_id", + "userId", + "account_id", + "accountId", + "email", + "login", + "analytics_tracking_id", ]; for key in sensitive_keys { // Match "key": "value" or "key":"value" let pattern = format!(r#""{}":\s*"([^"]+)""#, key); if let Ok(re) = regex_lite::Regex::new(&pattern) { - result = re.replace_all(&result, |caps: ®ex_lite::Captures| { - let value = &caps[1]; - format!("\"{}\": \"{}\"", key, redact_value(value)) - }).to_string(); + result = re + .replace_all(&result, |caps: ®ex_lite::Captures| { + let value = &caps[1]; + format!("\"{}\": \"{}\"", key, redact_value(value)) + }) + .to_string(); } } - + result } /// Lightweight redaction for plugin log messages (JWT + API key patterns only). fn redact_log_message(msg: &str) -> String { let mut result = msg.to_string(); - if let Ok(jwt_re) = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") { - result = jwt_re.replace_all(&result, |caps: ®ex_lite::Captures| redact_value(&caps[0])).to_string(); + if let Ok(jwt_re) = regex_lite::Regex::new(r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+") + { + result = jwt_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); } if let Ok(api_re) = regex_lite::Regex::new(r#"(sk-|pk-|api_|key_|secret_)[A-Za-z0-9_-]{12,}"#) { - result = api_re.replace_all(&result, |caps: ®ex_lite::Captures| redact_value(&caps[0])).to_string(); + result = api_re + .replace_all(&result, |caps: ®ex_lite::Captures| { + redact_value(&caps[0]) + }) + .to_string(); } result } @@ -178,6 +233,7 @@ pub fn inject_host_api<'js>( inject_env(ctx, &host, plugin_id)?; inject_http(ctx, &host, plugin_id)?; inject_keychain(ctx, &host)?; + inject_vault(ctx, &host, app_data_dir)?; inject_sqlite(ctx, &host)?; inject_ls(ctx, &host, plugin_id)?; @@ -187,11 +243,7 @@ pub fn inject_host_api<'js>( Ok(()) } -fn inject_log<'js>( - ctx: &Ctx<'js>, - host: &Object<'js>, - plugin_id: &str, -) -> rquickjs::Result<()> { +fn inject_log<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let log_obj = Object::new(ctx.clone())?; let pid = plugin_id.to_string(); @@ -239,9 +291,8 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { ctx.clone(), move |ctx_inner: Ctx<'_>, path: String| -> rquickjs::Result { let expanded = expand_path(&path); - std::fs::read_to_string(&expanded).map_err(|e| { - Exception::throw_message(&ctx_inner, &e.to_string()) - }) + std::fs::read_to_string(&expanded) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) }, )?, )?; @@ -252,9 +303,8 @@ fn inject_fs<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { ctx.clone(), move |ctx_inner: Ctx<'_>, path: String, content: String| -> rquickjs::Result<()> { let expanded = expand_path(&path); - std::fs::write(&expanded, &content).map_err(|e| { - Exception::throw_message(&ctx_inner, &e.to_string()) - }) + std::fs::write(&expanded, &content) + .map_err(|e| Exception::throw_message(&ctx_inner, &e.to_string())) }, )?, )?; @@ -363,7 +413,8 @@ fn inject_http<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rqui let redacted_body = redact_body(&body); let body_preview = if redacted_body.len() > 500 { // UTF-8 safe truncation: find valid char boundary at or before 500 - let truncated: String = redacted_body.char_indices() + let truncated: String = redacted_body + .char_indices() .take_while(|(i, _)| *i < 500) .map(|(_, c)| c) .collect(); @@ -756,11 +807,7 @@ struct LsDiscoverResult { extension_port: Option, } -fn inject_ls<'js>( - ctx: &Ctx<'js>, - host: &Object<'js>, - plugin_id: &str, -) -> rquickjs::Result<()> { +fn inject_ls<'js>(ctx: &Ctx<'js>, host: &Object<'js>, plugin_id: &str) -> rquickjs::Result<()> { let ls_obj = Object::new(ctx.clone())?; let pid = plugin_id.to_string(); @@ -770,10 +817,7 @@ fn inject_ls<'js>( ctx.clone(), move |ctx_inner: Ctx<'_>, opts_json: String| -> rquickjs::Result { let opts: LsDiscoverOpts = serde_json::from_str(&opts_json).map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("invalid discover opts: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("invalid discover opts: {}", e)) })?; log::info!( @@ -783,19 +827,34 @@ fn inject_ls<'js>( opts.markers ); - let ps_output = match std::process::Command::new("/bin/ps") - .args(["-ax", "-o", "pid=,command="]) - .output() - { - Ok(o) => o, - Err(e) => { - log::warn!("[plugin:{}] ps failed: {}", pid, e); - return Ok("null".to_string()); + // Platform-specific process listing + let ps_output = if cfg!(target_os = "windows") { + const WINDOWS_PS_CMD: &str = "[Console]::OutputEncoding=[System.Text.UTF8Encoding]::UTF8; Get-CimInstance Win32_Process | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"; + match std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", WINDOWS_PS_CMD]) + .output() + { + Ok(o) => o, + Err(e) => { + log::warn!("[plugin:{}] powershell process listing failed: {}", pid, e); + return Ok("null".to_string()); + } + } + } else { + match std::process::Command::new("/bin/ps") + .args(["-ax", "-o", "pid=,command="]) + .output() + { + Ok(o) => o, + Err(e) => { + log::warn!("[plugin:{}] ps failed: {}", pid, e); + return Ok("null".to_string()); + } } }; if !ps_output.status.success() { - log::warn!("[plugin:{}] ps returned non-zero", pid); + log::warn!("[plugin:{}] process listing returned non-zero", pid); return Ok("null".to_string()); } @@ -811,52 +870,77 @@ fn inject_ls<'js>( // 2. Path substring (//) as fallback when no flags found let mut found: Option<(i32, String)> = None; - for line in ps_stdout.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - - let mut parts = trimmed.splitn(2, char::is_whitespace); - let pid_str = match parts.next() { - Some(s) => s.trim(), - None => continue, - }; - let command = match parts.next() { - Some(s) => s.trim(), - None => continue, + if cfg!(target_os = "windows") { + let entries = match ls_windows_process_list(&ps_output.stdout) { + Ok(list) => list, + Err(err) => { + log::warn!("[plugin:{}] powershell parse failed: {}", pid, err); + return Ok("null".to_string()); + } }; - let command_lower = command.to_lowercase(); - - if !command_lower.contains(&process_name_lower) { - continue; + for (pid, command) in entries { + let cmd_lower = command.to_lowercase(); + if !cmd_lower.contains(&process_name_lower) { + continue; + } + let has_marker = markers_lower.iter().any(|m| { + cmd_lower.contains(&format!("--app_data_dir {}", m)) + || cmd_lower.contains(&format!("\\{}\\", m)) + }); + if has_marker { + found = Some((pid, command)); + break; + } } + } else { + // Unix: parse ps output (space-separated pid + command) + for line in ps_stdout.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let mut parts = trimmed.splitn(2, char::is_whitespace); + let pid_str = match parts.next() { + Some(s) => s.trim(), + None => continue, + }; + let command = match parts.next() { + Some(s) => s.trim(), + None => continue, + }; - let ide_name = ls_extract_flag(command, "--ide_name") - .map(|v| v.to_lowercase()); - let app_data = ls_extract_flag(command, "--app_data_dir") - .map(|v| v.to_lowercase()); + let command_lower = command.to_lowercase(); - let has_marker = markers_lower.iter().any(|m| { - // Prefer exact flag match; skip path fallback when - // a distinguishing flag exists. - if let Some(ref name) = ide_name { - return *name == *m; + if !command_lower.contains(&process_name_lower) { + continue; } - if let Some(ref dir) = app_data { - return *dir == *m; + + let has_marker = markers_lower.iter().any(|m| { + // Prefer exact flag match; skip path fallback when + // a distinguishing flag exists. + let ide_name = ls_extract_flag(command, "--ide_name") + .map(|v| v.to_lowercase()); + let app_data = ls_extract_flag(command, "--app_data_dir") + .map(|v| v.to_lowercase()); + if let Some(ref name) = ide_name { + return *name == *m; + } + if let Some(ref dir) = app_data { + return *dir == *m; + } + // Fallback: path substring + command_lower.contains(&format!("/{}/", m)) + }); + if !has_marker { + continue; } - // Fallback: path substring - command_lower.contains(&format!("/{}/", m)) - }); - if !has_marker { - continue; - } - if let Ok(p) = pid_str.parse::() { - found = Some((p, command.to_string())); - break; + if let Ok(p) = pid_str.parse::() { + found = Some((p, command.to_string())); + break; + } } } @@ -872,22 +956,15 @@ fn inject_ls<'js>( let csrf = match ls_extract_flag(&command, &opts.csrf_flag) { Some(c) => c, None => { - log::warn!( - "[plugin:{}] CSRF token not found in process args", - pid - ); + log::warn!("[plugin:{}] CSRF token not found in process args", pid); return Ok("null".to_string()); } }; // Extract extension port (optional) - let extension_port = opts - .port_flag - .as_ref() - .and_then(|flag| { - ls_extract_flag(&command, flag) - .and_then(|v| v.parse::().ok()) - }); + let extension_port = opts.port_flag.as_ref().and_then(|flag| { + ls_extract_flag(&command, flag).and_then(|v| v.parse::().ok()) + }); // Extract extra flags (optional) let mut extra = std::collections::HashMap::new(); @@ -901,44 +978,60 @@ fn inject_ls<'js>( } } - // Find lsof binary - let lsof_path = ["/usr/sbin/lsof", "/usr/bin/lsof"] - .iter() - .find(|p| std::path::Path::new(p).exists()) - .copied(); - - let ports = if let Some(lsof) = lsof_path { - match std::process::Command::new(lsof) - .args([ - "-nP", - "-iTCP", - "-sTCP:LISTEN", - "-a", - "-p", - &process_pid.to_string(), - ]) + // Find listening ports + let ports = if cfg!(target_os = "windows") { + // Use netstat on Windows + match std::process::Command::new("netstat") + .args(["-ano", "-p", "TCP"]) .output() { Ok(o) if o.status.success() => { - ls_parse_listening_ports( - &String::from_utf8_lossy(&o.stdout), - ) + ls_parse_netstat_ports(&String::from_utf8_lossy(&o.stdout), process_pid) } Ok(_) => { - log::warn!( - "[plugin:{}] lsof returned non-zero", - pid - ); + log::warn!("[plugin:{}] netstat returned non-zero", pid); Vec::new() } Err(e) => { - log::warn!("[plugin:{}] lsof failed: {}", pid, e); + log::warn!("[plugin:{}] netstat failed: {}", pid, e); Vec::new() } } } else { - log::warn!("[plugin:{}] lsof not found", pid); - Vec::new() + // Find lsof binary on Unix + let lsof_path = ["/usr/sbin/lsof", "/usr/bin/lsof"] + .iter() + .find(|p| std::path::Path::new(p).exists()) + .copied(); + + if let Some(lsof) = lsof_path { + match std::process::Command::new(lsof) + .args([ + "-nP", + "-iTCP", + "-sTCP:LISTEN", + "-a", + "-p", + &process_pid.to_string(), + ]) + .output() + { + Ok(o) if o.status.success() => { + ls_parse_listening_ports(&String::from_utf8_lossy(&o.stdout)) + } + Ok(_) => { + log::warn!("[plugin:{}] lsof returned non-zero", pid); + Vec::new() + } + Err(e) => { + log::warn!("[plugin:{}] lsof failed: {}", pid, e); + Vec::new() + } + } + } else { + log::warn!("[plugin:{}] lsof not found", pid); + Vec::new() + } }; if ports.is_empty() && extension_port.is_none() { @@ -966,10 +1059,7 @@ fn inject_ls<'js>( }; serde_json::to_string(&result).map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("serialize failed: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("serialize failed: {}", e)) }) }, )?, @@ -1014,6 +1104,39 @@ fn ls_extract_flag(command: &str, flag: &str) -> Option { None } +#[derive(serde::Deserialize)] +struct WindowsProcessEntry { + #[serde(rename = "ProcessId")] + process_id: i32, + #[serde(rename = "CommandLine")] + command_line: Option, +} + +fn ls_windows_process_list(output: &[u8]) -> Result, String> { + if output.iter().all(|b| b.is_ascii_whitespace()) { + return Ok(Vec::new()); + } + + let value: serde_json::Value = + serde_json::from_slice(output).map_err(|e| format!("invalid JSON: {}", e))?; + + let entries: Vec = match value { + serde_json::Value::Array(items) => items + .into_iter() + .map(|item| serde_json::from_value(item).map_err(|e| format!("invalid entry: {}", e))) + .collect::, _>>()?, + serde_json::Value::Object(_) => { + vec![serde_json::from_value(value).map_err(|e| format!("invalid entry: {}", e))?] + } + _ => return Err("unexpected JSON shape".to_string()), + }; + + Ok(entries + .into_iter() + .filter_map(|entry| entry.command_line.map(|cmd| (entry.process_id, cmd))) + .collect()) +} + /// Parse listening port numbers from `lsof -nP -iTCP -sTCP:LISTEN` output. fn ls_parse_listening_ports(output: &str) -> Vec { let mut ports = std::collections::BTreeSet::new(); @@ -1038,6 +1161,43 @@ fn ls_parse_listening_ports(output: &str) -> Vec { ports.into_iter().collect() } +/// Parse listening port numbers from Windows `netstat -ano` output. +fn ls_parse_netstat_ports(output: &str, target_pid: i32) -> Vec { + let mut ports = std::collections::BTreeSet::new(); + for line in output.lines() { + if !line.contains("LISTENING") { + continue; + } + // netstat output: TCP 127.0.0.1:PORT 0.0.0.0:0 LISTENING PID + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.len() < 5 { + continue; + } + + // Last token is the PID + if let Ok(pid) = tokens[tokens.len() - 1].parse::() { + if pid != target_pid { + continue; + } + } else { + continue; + } + + // Second token should be the local address (127.0.0.1:PORT or 0.0.0.0:PORT) + if let Some(addr_port) = tokens.get(1) { + if let Some(colon_pos) = addr_port.rfind(':') { + let port_str = &addr_port[colon_pos + 1..]; + if let Ok(port) = port_str.parse::() { + if port > 0 && port < 65536 { + ports.insert(port); + } + } + } + } + } + ports.into_iter().collect() +} + fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { let keychain_obj = Object::new(ctx.clone())?; @@ -1126,21 +1286,11 @@ fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result< .output() } else { std::process::Command::new("security") - .args([ - "add-generic-password", - "-s", - &service, - "-w", - &value, - "-U", - ]) + .args(["add-generic-password", "-s", &service, "-w", &value, "-U"]) .output() } .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("keychain write failed: {}", e), - ) + Exception::throw_message(&ctx_inner, &format!("keychain write failed: {}", e)) })?; if !output.status.success() { @@ -1161,6 +1311,49 @@ fn inject_keychain<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result< Ok(()) } +fn inject_vault<'js>( + ctx: &Ctx<'js>, + host: &Object<'js>, + app_data_dir: &PathBuf, +) -> rquickjs::Result<()> { + let vault_obj = Object::new(ctx.clone())?; + let base_dir = app_data_dir.clone(); + vault_obj.set( + "read", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, name: String| -> rquickjs::Result> { + vault_read(&ctx_inner, &base_dir, &name) + }, + )?, + )?; + + let base_dir = app_data_dir.clone(); + vault_obj.set( + "write", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, name: String, value: String| -> rquickjs::Result<()> { + vault_write(&ctx_inner, &base_dir, &name, &value) + }, + )?, + )?; + + let base_dir = app_data_dir.clone(); + vault_obj.set( + "delete", + Function::new( + ctx.clone(), + move |ctx_inner: Ctx<'_>, name: String| -> rquickjs::Result<()> { + vault_delete(&ctx_inner, &base_dir, &name) + }, + )?, + )?; + + host.set("vault", vault_obj)?; + Ok(()) +} + fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<()> { let sqlite_obj = Object::new(ctx.clone())?; @@ -1200,30 +1393,33 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() .replace('#', "%23") .replace('?', "%3F"); let uri_path = format!("file:{}?immutable=1", encoded); - let fallback = std::process::Command::new("sqlite3") - .args(["-readonly", "-json", &uri_path, &sql]) - .output() - .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("sqlite3 exec failed: {}", e), - ) - })?; + let conn = Connection::open_with_flags( + &uri_path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, + ) + .map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite open failed: {}", e)) + })?; - if !fallback.status.success() { - let stderr_primary = String::from_utf8_lossy(&primary.stderr); - let stderr_fallback = String::from_utf8_lossy(&fallback.stderr); - return Err(Exception::throw_message( - &ctx_inner, - &format!( - "sqlite3 error: {} (fallback: {})", - stderr_primary.trim(), - stderr_fallback.trim() - ), - )); + let mut stmt = conn.prepare(&sql).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite prepare failed: {}", e)) + })?; + + let rows = stmt.query_map([], sqlite_row_to_json).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite query failed: {}", e)) + })?; + + let mut result: Vec = Vec::new(); + for row in rows { + let value = row.map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite row failed: {}", e)) + })?; + result.push(value); } - Ok(String::from_utf8_lossy(&fallback.stdout).to_string()) + serde_json::to_string(&result).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite json failed: {}", e)) + }) }, )?, )?; @@ -1240,25 +1436,17 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() )); } let expanded = expand_path(&db_path); - let output = std::process::Command::new("sqlite3") - .args([&expanded, &sql]) - .output() - .map_err(|e| { - Exception::throw_message( - &ctx_inner, - &format!("sqlite3 exec failed: {}", e), - ) - })?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(Exception::throw_message( - &ctx_inner, - &format!("sqlite3 error: {}", stderr.trim()), - )); - } + let conn = Connection::open_with_flags( + &expanded, + OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE, + ) + .map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite open failed: {}", e)) + })?; - Ok(()) + conn.execute_batch(&sql).map_err(|e| { + Exception::throw_message(&ctx_inner, &format!("sqlite exec failed: {}", e)) + }) }, )?, )?; @@ -1267,6 +1455,125 @@ fn inject_sqlite<'js>(ctx: &Ctx<'js>, host: &Object<'js>) -> rquickjs::Result<() Ok(()) } +fn vault_read( + ctx_inner: &Ctx<'_>, + app_data_dir: &PathBuf, + name: &str, +) -> rquickjs::Result> { + let path = vault_entry_path(ctx_inner, app_data_dir, name)?; + if !path.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(&path) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault read failed: {}", e)))?; + let encrypted = base64::engine::general_purpose::STANDARD + .decode(raw.trim().as_bytes()) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault decode failed: {}", e)))?; + let decrypted = vault_decrypt(&encrypted).map_err(|e| { + Exception::throw_message(ctx_inner, &format!("vault decrypt failed: {}", e)) + })?; + let value = String::from_utf8(decrypted) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault utf8 failed: {}", e)))?; + Ok(Some(value)) +} + +fn vault_write( + ctx_inner: &Ctx<'_>, + app_data_dir: &PathBuf, + name: &str, + value: &str, +) -> rquickjs::Result<()> { + let path = vault_entry_path(ctx_inner, app_data_dir, name)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + Exception::throw_message(ctx_inner, &format!("vault dir failed: {}", e)) + })?; + } + let encrypted = vault_encrypt(value.as_bytes()).map_err(|e| { + Exception::throw_message(ctx_inner, &format!("vault encrypt failed: {}", e)) + })?; + let encoded = base64::engine::general_purpose::STANDARD.encode(encrypted); + std::fs::write(&path, encoded) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault write failed: {}", e)))?; + Ok(()) +} + +fn vault_delete(ctx_inner: &Ctx<'_>, app_data_dir: &PathBuf, name: &str) -> rquickjs::Result<()> { + let path = vault_entry_path(ctx_inner, app_data_dir, name)?; + if !path.exists() { + return Ok(()); + } + std::fs::remove_file(&path) + .map_err(|e| Exception::throw_message(ctx_inner, &format!("vault delete failed: {}", e)))?; + Ok(()) +} + +fn vault_entry_path( + ctx_inner: &Ctx<'_>, + app_data_dir: &PathBuf, + name: &str, +) -> rquickjs::Result { + if name.trim().is_empty() { + return Err(Exception::throw_message( + ctx_inner, + "vault name cannot be empty", + )); + } + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(name.as_bytes()); + Ok(app_data_dir.join("vault").join(encoded)) +} + +#[cfg(target_os = "windows")] +fn vault_encrypt(data: &[u8]) -> Result, String> { + encrypt_data(data, Scope::User).map_err(|e| e.to_string()) +} + +#[cfg(target_os = "windows")] +fn vault_decrypt(data: &[u8]) -> Result, String> { + decrypt_data(data, Scope::User).map_err(|e| e.to_string()) +} + +#[cfg(not(target_os = "windows"))] +fn vault_encrypt(_data: &[u8]) -> Result, String> { + Err("vault API is only supported on Windows".to_string()) +} + +#[cfg(not(target_os = "windows"))] +fn vault_decrypt(_data: &[u8]) -> Result, String> { + Err("vault API is only supported on Windows".to_string()) +} + +fn sqlite_row_to_json(row: &Row<'_>) -> rusqlite::Result { + let mut obj = serde_json::Map::new(); + let stmt = row.as_ref(); + let col_count = stmt.column_count(); + for i in 0..col_count { + let name = stmt.column_name(i).unwrap_or(""); + let name = if name.is_empty() { + format!("col{}", i) + } else { + name.to_string() + }; + let value = sqlite_value_to_json(row.get_ref(i)?); + obj.insert(name, value); + } + Ok(Value::Object(obj)) +} + +fn sqlite_value_to_json(value: ValueRef<'_>) -> Value { + match value { + ValueRef::Null => Value::Null, + ValueRef::Integer(v) => Value::Number(Number::from(v)), + ValueRef::Real(v) => Number::from_f64(v) + .map(Value::Number) + .unwrap_or(Value::Null), + ValueRef::Text(bytes) => Value::String(String::from_utf8_lossy(bytes).to_string()), + ValueRef::Blob(bytes) => { + Value::String(base64::engine::general_purpose::STANDARD.encode(bytes)) + } + } +} + fn iso_now() -> String { time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) @@ -1343,28 +1650,49 @@ mod tests { let get: Function = env.get("get").expect("get"); for name in WHITELISTED_ENV_VARS { +<<<<<<< HEAD + let value: Option = + get.call((name.to_string(),)).expect("get whitelisted var"); + assert_eq!( + value, + std::env::var(name).ok(), + "{name} should match process env" + ); +======= let expected = read_env_from_interactive_zsh(name); let value: Option = get.call((name.to_string(),)).expect("get whitelisted var"); assert_eq!(value, expected, "{name} should match interactive zsh env"); +>>>>>>> upstream/main let js_expr = format!(r#"__openusage_ctx.host.env.get("{}")"#, name); let js_value: Option = ctx.eval(js_expr).expect("js get whitelisted var"); assert_eq!( js_value, +<<<<<<< HEAD + std::env::var(name).ok(), + "{name} should match process env from JS" +======= expected, "{name} should match interactive zsh env from JS" +>>>>>>> upstream/main ); } let blocked: Option = get .call(("__OPENUSAGE_TEST_NOT_WHITELISTED__".to_string(),)) .expect("get blocked var"); - assert!(blocked.is_none(), "non-whitelisted vars must not be exposed"); + assert!( + blocked.is_none(), + "non-whitelisted vars must not be exposed" + ); let js_blocked: Option = ctx .eval(r#"__openusage_ctx.host.env.get("__OPENUSAGE_TEST_NOT_WHITELISTED__")"#) .expect("js get blocked var"); - assert!(js_blocked.is_none(), "non-whitelisted vars must not be exposed from JS"); + assert!( + js_blocked.is_none(), + "non-whitelisted vars must not be exposed from JS" + ); }); } @@ -1401,7 +1729,11 @@ mod tests { let body = r#"{"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"}"#; let redacted = redact_body(body); // JWT gets redacted to first4...last4 format - assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "full JWT should be redacted, got: {}", redacted); + assert!( + !redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + "full JWT should be redacted, got: {}", + redacted + ); } #[test] @@ -1415,18 +1747,38 @@ mod tests { fn redact_body_redacts_json_password_field() { let body = r#"{"password": "supersecretpassword123"}"#; let redacted = redact_body(body); - assert!(!redacted.contains("supersecretpassword123"), "password should be redacted, got: {}", redacted); + assert!( + !redacted.contains("supersecretpassword123"), + "password should be redacted, got: {}", + redacted + ); } #[test] fn redact_body_redacts_user_id_and_email() { let body = r#"{"user_id": "user-iupzZ7KFykMLrnzpkHSq7wjo", "email": "rob@sunstory.com"}"#; let redacted = redact_body(body); - assert!(!redacted.contains("user-iupzZ7KFykMLrnzpkHSq7wjo"), "user_id should be redacted, got: {}", redacted); - assert!(!redacted.contains("rob@sunstory.com"), "email should be redacted, got: {}", redacted); + assert!( + !redacted.contains("user-iupzZ7KFykMLrnzpkHSq7wjo"), + "user_id should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("rob@sunstory.com"), + "email should be redacted, got: {}", + redacted + ); // Should show first4...last4 - assert!(redacted.contains("user...7wjo"), "user_id should show first4...last4, got: {}", redacted); - assert!(redacted.contains("rob@....com"), "email should show first4...last4, got: {}", redacted); + assert!( + redacted.contains("user...7wjo"), + "user_id should show first4...last4, got: {}", + redacted + ); + assert!( + redacted.contains("rob@....com"), + "email should show first4...last4, got: {}", + redacted + ); } #[test] @@ -1443,28 +1795,64 @@ mod tests { fn redact_log_message_redacts_jwt_and_api_key() { let msg = "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U key=sk-1234567890abcdef"; let redacted = redact_log_message(msg); - assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), "JWT should be redacted"); - assert!(!redacted.contains("sk-1234567890abcdef"), "API key should be redacted"); + assert!( + !redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"), + "JWT should be redacted" + ); + assert!( + !redacted.contains("sk-1234567890abcdef"), + "API key should be redacted" + ); } #[test] fn redact_body_redacts_login_and_analytics_tracking_id() { - let body = r#"{"login":"robinebers","analytics_tracking_id":"c9df3f012bb8c2eb7aae6868ee8da6cf"}"#; + let body = + r#"{"login":"robinebers","analytics_tracking_id":"c9df3f012bb8c2eb7aae6868ee8da6cf"}"#; let redacted = redact_body(body); - assert!(!redacted.contains("robinebers"), "login should be redacted, got: {}", redacted); - assert!(!redacted.contains("c9df3f012bb8c2eb7aae6868ee8da6cf"), "analytics_tracking_id should be redacted, got: {}", redacted); + assert!( + !redacted.contains("robinebers"), + "login should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("c9df3f012bb8c2eb7aae6868ee8da6cf"), + "analytics_tracking_id should be redacted, got: {}", + redacted + ); // login is short (<=12 chars) so becomes [REDACTED]; analytics_tracking_id is long so first4...last4 - assert!(redacted.contains("[REDACTED]"), "login should be redacted, got: {}", redacted); - assert!(redacted.contains("c9df...a6cf"), "analytics_tracking_id should show first4...last4, got: {}", redacted); + assert!( + redacted.contains("[REDACTED]"), + "login should be redacted, got: {}", + redacted + ); + assert!( + redacted.contains("c9df...a6cf"), + "analytics_tracking_id should show first4...last4, got: {}", + redacted + ); } #[test] fn redact_body_redacts_name_field() { - let body = r#"{"userStatus":{"name":"Robin Ebers","email":"rob@sunstory.com","planStatus":{}}}"#; + let body = + r#"{"userStatus":{"name":"Robin Ebers","email":"rob@sunstory.com","planStatus":{}}}"#; let redacted = redact_body(body); - assert!(!redacted.contains("Robin Ebers"), "name should be redacted, got: {}", redacted); - assert!(!redacted.contains("rob@sunstory.com"), "email should be redacted, got: {}", redacted); + assert!( + !redacted.contains("Robin Ebers"), + "name should be redacted, got: {}", + redacted + ); + assert!( + !redacted.contains("rob@sunstory.com"), + "email should be redacted, got: {}", + redacted + ); // "Robin Ebers" is 11 chars (<=12) so becomes [REDACTED] - assert!(redacted.contains("\"name\": \"[REDACTED]\""), "name should show [REDACTED], got: {}", redacted); + assert!( + redacted.contains("\"name\": \"[REDACTED]\""), + "name should show [REDACTED], got: {}", + redacted + ); } } diff --git a/src-tauri/src/plugin_engine/manifest.rs b/src-tauri/src/plugin_engine/manifest.rs index dba149ed..79e2f495 100644 --- a/src-tauri/src/plugin_engine/manifest.rs +++ b/src-tauri/src/plugin_engine/manifest.rs @@ -14,6 +14,15 @@ pub struct ManifestLine { pub primary_order: Option, } +/// Supported operating systems for a plugin +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum SupportedOs { + Macos, + Windows, + Linux, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PluginLink { @@ -32,6 +41,8 @@ pub struct PluginManifest { pub icon: String, pub brand_color: Option, pub lines: Vec, + /// List of supported operating systems. If not specified, all platforms are supported. + pub os: Option>, #[serde(default)] pub links: Vec, } @@ -128,7 +139,10 @@ fn sanitize_plugin_links(plugin_id: &str, links: Vec) -> Vec SupportedOs { + #[cfg(target_os = "macos")] + return SupportedOs::Macos; + #[cfg(target_os = "windows")] + return SupportedOs::Windows; + #[cfg(target_os = "linux")] + return SupportedOs::Linux; + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + compile_error!("Unsupported target OS"); +} + pub fn initialize_plugins( app_data_dir: &Path, resource_dir: &Path, ) -> (PathBuf, Vec) { + let current = current_os(); + if let Some(dev_dir) = find_dev_plugins_dir() { if !is_dir_empty(&dev_dir) { - let plugins = manifest::load_plugins_from_dir(&dev_dir); + let plugins = filter_plugins_by_os(manifest::load_plugins_from_dir(&dev_dir), current); return (dev_dir, plugins); } } let install_dir = app_data_dir.join("plugins"); if let Err(err) = std::fs::create_dir_all(&install_dir) { - log::warn!("failed to create install dir {}: {}", install_dir.display(), err); + log::warn!( + "failed to create install dir {}: {}", + install_dir.display(), + err + ); } let bundled_dir = resolve_bundled_dir(resource_dir); @@ -26,10 +44,30 @@ pub fn initialize_plugins( copy_dir_recursive(&bundled_dir, &install_dir); } - let plugins = manifest::load_plugins_from_dir(&install_dir); + let plugins = filter_plugins_by_os(manifest::load_plugins_from_dir(&install_dir), current); (install_dir, plugins) } +/// Filter plugins based on OS support. If no OS is specified, plugin is loaded on all platforms. +fn filter_plugins_by_os(plugins: Vec, current_os: SupportedOs) -> Vec { + plugins + .into_iter() + .filter(|p| { + let should_load = match &p.manifest.os { + Some(supported_os_list) => supported_os_list.contains(¤t_os), + None => true, // No OS specified = all platforms + }; + if !should_load { + log::info!( + "skipping plugin '{}' - not supported on this OS", + p.manifest.id + ); + } + should_load + }) + .collect() +} + fn find_dev_plugins_dir() -> Option { let cwd = std::env::current_dir().ok()?; let direct = cwd.join("plugins"); @@ -78,7 +116,11 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { let file_type = match entry.file_type() { Ok(file_type) => file_type, Err(err) => { - log::warn!("failed to read file type for {}: {}", src_path.display(), err); + log::warn!( + "failed to read file type for {}: {}", + src_path.display(), + err + ); continue; } }; @@ -87,11 +129,7 @@ fn copy_dir_recursive(src: &Path, dst: &Path) { } if file_type.is_dir() { if let Err(err) = std::fs::create_dir_all(&dst_path) { - log::warn!( - "failed to create dir {}: {}", - dst_path.display(), - err - ); + log::warn!("failed to create dir {}: {}", dst_path.display(), err); continue; } copy_dir_recursive(&src_path, &dst_path); diff --git a/src-tauri/src/plugin_engine/runtime.rs b/src-tauri/src/plugin_engine/runtime.rs index f83c50c4..f29e9828 100644 --- a/src-tauri/src/plugin_engine/runtime.rs +++ b/src-tauri/src/plugin_engine/runtime.rs @@ -50,11 +50,7 @@ pub struct PluginOutput { pub icon_url: String, } -pub fn run_probe( - plugin: &LoadedPlugin, - app_data_dir: &PathBuf, - app_version: &str, -) -> PluginOutput { +pub fn run_probe(plugin: &LoadedPlugin, app_data_dir: &PathBuf, app_version: &str) -> PluginOutput { let fallback = error_output(plugin, "runtime error".to_string()); let rt = match Runtime::new() { @@ -113,7 +109,9 @@ pub fn run_probe( let result: Object = if result_value.is_promise() { let promise: Promise = match result_value.into_promise() { Some(promise) => promise, - None => return error_output(plugin, "probe() returned invalid promise".to_string()), + None => { + return error_output(plugin, "probe() returned invalid promise".to_string()) + } }; match promise.finish::() { Ok(obj) => obj, @@ -129,7 +127,10 @@ pub fn run_probe( } }; - let plan: Option = result.get::<_, String>("plan").ok().filter(|s| !s.is_empty()); + let plan: Option = result + .get::<_, String>("plan") + .ok() + .filter(|s| !s.is_empty()); let lines = match parse_lines(&result) { Ok(lines) if !lines.is_empty() => lines, @@ -167,13 +168,21 @@ fn parse_lines(result: &Object) -> Result, String> { match line_type.as_str() { "text" => { let value = line.get::<_, String>("value").unwrap_or_default(); - out.push(MetricLine::Text { label, value, color, subtitle }); + out.push(MetricLine::Text { + label, + value, + color, + subtitle, + }); } "progress" => { let used_value: Value = match line.get("used") { Ok(v) => v, Err(_) => { - out.push(error_line(format!("progress line at index {} missing used", idx))); + out.push(error_line(format!( + "progress line at index {} missing used", + idx + ))); continue; } }; @@ -324,9 +333,8 @@ fn parse_lines(result: &Object) -> Result, String> { Some(value) } else { // ISO-like but missing timezone: assume UTC. - let is_missing_tz = value.contains('T') - && !value.ends_with('Z') - && { + let is_missing_tz = + value.contains('T') && !value.ends_with('Z') && { let tail = value.splitn(2, 'T').nth(1).unwrap_or(""); !tail.contains('+') && !tail.contains('-') }; @@ -365,7 +373,8 @@ fn parse_lines(result: &Object) -> Result, String> { }; // Parse optional periodDurationMs - let period_duration_ms: Option = match line.get::<_, Value>("periodDurationMs") { + let period_duration_ms: Option = match line.get::<_, Value>("periodDurationMs") + { Ok(val) => { if val.is_null() || val.is_undefined() { None @@ -374,11 +383,17 @@ fn parse_lines(result: &Object) -> Result, String> { if ms > 0 { Some(ms) } else { - log::warn!("periodDurationMs at index {} must be positive, omitting", idx); + log::warn!( + "periodDurationMs at index {} must be positive, omitting", + idx + ); None } } else { - log::warn!("invalid periodDurationMs at index {} (non-number), omitting", idx); + log::warn!( + "invalid periodDurationMs at index {} (non-number), omitting", + idx + ); None } } @@ -397,7 +412,12 @@ fn parse_lines(result: &Object) -> Result, String> { } "badge" => { let text = line.get::<_, String>("text").unwrap_or_default(); - out.push(MetricLine::Badge { label, text, color, subtitle }); + out.push(MetricLine::Badge { + label, + text, + color, + subtitle, + }); } _ => { out.push(error_line(format!( @@ -464,6 +484,7 @@ mod tests { icon: "icon.svg".to_string(), brand_color: None, lines: vec![], + os: None, links: vec![], }, plugin_dir: PathBuf::from("."), @@ -532,6 +553,9 @@ mod tests { let json: JsonValue = serde_json::to_value(&line).expect("serialize"); let obj = json.as_object().expect("object"); assert!(obj.get("resetsAt").is_some(), "expected resetsAt key"); - assert!(obj.get("resets_at").is_none(), "did not expect resets_at key"); + assert!( + obj.get("resets_at").is_none(), + "did not expect resets_at key" + ); } } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 044c1427..0b46232a 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,12 +1,17 @@ +use std::collections::HashMap; +use std::sync::Mutex; + use tauri::image::Image; use tauri::menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::path::BaseDirectory; use tauri::tray::{MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::{AppHandle, Emitter, Manager}; -use tauri_nspanel::ManagerExt; use tauri_plugin_store::StoreExt; use crate::panel::{get_or_init_panel, position_panel_at_tray_icon, show_panel}; +use crate::plugin_engine::runtime::{MetricLine, PluginOutput}; +use crate::window_manager::{position_window_at_tray, WindowManager}; +use crate::AppState; const LOG_LEVEL_STORE_KEY: &str = "logLevel"; @@ -23,7 +28,7 @@ fn get_stored_log_level(app_handle: &AppHandle) -> log::LevelFilter { Some("info") => log::LevelFilter::Info, Some("debug") => log::LevelFilter::Debug, Some("trace") => log::LevelFilter::Trace, - _ => log::LevelFilter::Error, // Default: least verbose + _ => log::LevelFilter::Error, } } @@ -44,25 +49,76 @@ fn set_stored_log_level(app_handle: &AppHandle, level: log::LevelFilter) { log::set_max_level(level); } -pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { - let tray_icon_path = app_handle - .path() - .resolve("icons/tray-icon.png", BaseDirectory::Resource)?; - let icon = Image::from_path(tray_icon_path)?; - // Load persisted log level - let current_level = get_stored_log_level(app_handle); - log::set_max_level(current_level); +/// Build a dynamic tray menu with plugin data +fn build_tray_menu( + app_handle: &AppHandle, + probe_results: &HashMap, +) -> tauri::Result> { + let state = app_handle.state::>(); + let plugins = if let Ok(app_state) = state.lock() { + app_state.plugins.clone() + } else { + vec![] + }; + // Build static menu items first let show_stats = MenuItem::with_id(app_handle, "show_stats", "Show Stats", true, None::<&str>)?; - let go_to_settings = MenuItem::with_id(app_handle, "go_to_settings", "Go to Settings", true, None::<&str>)?; - - // Log level submenu - clone items for use in event handler - let log_error = CheckMenuItem::with_id(app_handle, "log_error", "Error", true, current_level == log::LevelFilter::Error, None::<&str>)?; - let log_warn = CheckMenuItem::with_id(app_handle, "log_warn", "Warn", true, current_level == log::LevelFilter::Warn, None::<&str>)?; - let log_info = CheckMenuItem::with_id(app_handle, "log_info", "Info", true, current_level == log::LevelFilter::Info, None::<&str>)?; - let log_debug = CheckMenuItem::with_id(app_handle, "log_debug", "Debug", true, current_level == log::LevelFilter::Debug, None::<&str>)?; - let log_trace = CheckMenuItem::with_id(app_handle, "log_trace", "Trace", true, current_level == log::LevelFilter::Trace, None::<&str>)?; + let go_to_settings = MenuItem::with_id( + app_handle, + "go_to_settings", + "Go to Settings", + true, + None::<&str>, + )?; + "Go to Settings", + true, + None::<&str>, + )?; + + // Log level submenu + let current_level = get_stored_log_level(app_handle); + let log_error = CheckMenuItem::with_id( + app_handle, + "log_error", + "Error", + true, + current_level == log::LevelFilter::Error, + None::<&str>, + )?; + let log_warn = CheckMenuItem::with_id( + app_handle, + "log_warn", + "Warn", + true, + current_level == log::LevelFilter::Warn, + None::<&str>, + )?; + let log_info = CheckMenuItem::with_id( + app_handle, + "log_info", + "Info", + true, + current_level == log::LevelFilter::Info, + None::<&str>, + )?; + let log_debug = CheckMenuItem::with_id( + app_handle, + "log_debug", + "Debug", + true, + current_level == log::LevelFilter::Debug, + None::<&str>, + )?; + let log_trace = CheckMenuItem::with_id( + app_handle, + "log_trace", + "Trace", + true, + current_level == log::LevelFilter::Trace, + None::<&str>, + )?; + let log_level_submenu = Submenu::with_items( app_handle, "Debug Level", @@ -70,40 +126,309 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { &[&log_error, &log_warn, &log_info, &log_debug, &log_trace], )?; - // Clone for capture in event handler - let log_items = [ - (log_error.clone(), log::LevelFilter::Error), - (log_warn.clone(), log::LevelFilter::Warn), - (log_info.clone(), log::LevelFilter::Info), - (log_debug.clone(), log::LevelFilter::Debug), - (log_trace.clone(), log::LevelFilter::Trace), - ]; - let separator = PredefinedMenuItem::separator(app_handle)?; + let separator2 = PredefinedMenuItem::separator(app_handle)?; + let about = MenuItem::with_id(app_handle, "about", "About OpenUsage", true, None::<&str>)?; let quit = MenuItem::with_id(app_handle, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app_handle, &[&show_stats, &go_to_settings, &log_level_submenu, &separator, &about, &quit])?; + // Build provider items (max 5) + let mut provider_items: Vec> = vec![]; + for plugin in plugins.iter().take(5) { + let plugin_id = &plugin.manifest.id; + let plugin_name = &plugin.manifest.name; + + if let Some(output) = probe_results.get(plugin_id) { + // Find primary metric to display + let primary_line = output.lines.iter().find(|line| { + matches!(line, MetricLine::Progress { label, .. } if { + plugin.manifest.lines.iter().any(|manifest_line| { + manifest_line.line_type == "progress" + && manifest_line.label == *label + && manifest_line.primary_order.is_some() + }) + }) + }); + + let display_text = if let Some(MetricLine::Progress { used, limit, .. }) = primary_line + { + let percentage = if *limit > 0.0 { + ((*used / *limit) * 100.0) as i32 + } else { + 0 + }; + format!("{}: {}%", plugin_name, percentage) + } else if let Some(first_line) = output.lines.first() { + match first_line { + MetricLine::Progress { used, limit, .. } => { + let percentage = if *limit > 0.0 { + ((*used / *limit) * 100.0) as i32 + } else { + 0 + }; + format!("{}: {}%", plugin_name, percentage) + } + MetricLine::Text { value, .. } => { + format!("{}: {}", plugin_name, value) + } + MetricLine::Badge { text, .. } => { + format!("{}: {}", plugin_name, text) + } + } + } else { + plugin_name.clone() + }; + + let item = MenuItem::with_id( + app_handle, + format!("provider_{}", plugin_id), + display_text, + true, + None::<&str>, + )?; + provider_items.push(item); + } + } + + // Build final menu based on how many provider items we have + // Use references to avoid move issues + let menu = match provider_items.len() { + 0 => Menu::with_items( + app_handle, + &[ + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 1 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 2 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 3 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &provider_items[2], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + 4 => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &provider_items[2], + &provider_items[3], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + _ => Menu::with_items( + app_handle, + &[ + &provider_items[0], + &provider_items[1], + &provider_items[2], + &provider_items[3], + &provider_items[4], + &separator2, + &show_stats, + &go_to_settings, + &log_level_submenu, + &separator, + &about, + &quit, + ], + )?, + }; + + Ok(menu) +} + +/// Update the tray menu with latest probe results +pub fn update_tray_menu(app_handle: &AppHandle) -> tauri::Result<()> { + let probe_results = { + let state = app_handle.state::>(); + if let Ok(app_state) = state.lock() { + app_state.latest_probe_results.clone() + } else { + HashMap::new() + } + }; + + let new_menu = build_tray_menu(app_handle, &probe_results)?; + + // Get the tray and update its menu + if let Some(tray) = app_handle.tray_by_id("tray") { + tray.set_menu(Some(new_menu))?; + } + + Ok(()) +} + +pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { + // Platform-specific tray icon - Windows uses larger PNG, macOS uses template PNG + #[cfg(target_os = "windows")] + let icon_candidates = ["icons/64x64.png", "icons/icon.png"]; + + #[cfg(not(target_os = "windows"))] + let icon_candidates = ["icons/tray-icon.png", "icons/icon.png"]; + + // Try multiple icon locations (for dev mode compatibility) + let mut icon = None; + let mut last_error = None; + + // First try Resource directory (works in release builds) + for icon_path_str in &icon_candidates { + match app_handle + .path() + .resolve(icon_path_str, BaseDirectory::Resource) + { + Ok(path) => { + log::info!("Trying tray icon (Resource): {:?}", path); + match Image::from_path(&path) { + Ok(img) => { + log::info!("Tray icon loaded successfully from: {:?}", path); + icon = Some(img); + break; + } + Err(e) => { + log::warn!("Failed to load icon from {:?}: {}", path, e); + last_error = Some(e); + } + } + } + Err(e) => { + log::warn!("Failed to resolve icon path '{}': {}", icon_path_str, e); + last_error = Some(e); + } + } + } + + // If Resource didn't work, try App directory (dev mode fallback) + if icon.is_none() { + for icon_path_str in &icon_candidates { + match app_handle + .path() + .resolve(icon_path_str, BaseDirectory::AppLocalData) + { + Ok(path) => { + log::info!("Trying tray icon (AppLocalData): {:?}", path); + match Image::from_path(&path) { + Ok(img) => { + log::info!("Tray icon loaded successfully from: {:?}", path); + icon = Some(img); + break; + } + Err(e) => { + log::warn!("Failed to load icon from {:?}: {}", path, e); + last_error = Some(e); + } + } + } + Err(e) => { + log::warn!( + "Failed to resolve AppLocalData icon path '{}': {}", + icon_path_str, + e + ); + last_error = Some(e); + } + } + } + } + + let icon = match icon { + Some(img) => img, + None => { + log::error!("Could not load any tray icon. Last error: {:?}", last_error); + return Err(last_error.unwrap_or(tauri::Error::UnknownPath)); + } + }; + + // Load persisted log level + let current_level = get_stored_log_level(app_handle); + log::set_max_level(current_level); + + // Build initial menu (empty probe results) + let menu = build_tray_menu(app_handle, &HashMap::new())?; - TrayIconBuilder::with_id("tray") + // Platform-specific tray icon builder + #[cfg(target_os = "windows")] + let builder = TrayIconBuilder::with_id("tray") + .icon(icon) + .tooltip("OpenUsage") + .menu(&menu) + .show_menu_on_left_click(false); + + #[cfg(not(target_os = "windows"))] + let builder = TrayIconBuilder::with_id("tray") .icon(icon) .icon_as_template(true) .tooltip("OpenUsage") .menu(&menu) - .show_menu_on_left_click(false) + .show_menu_on_left_click(false); + + builder .on_menu_event(move |app_handle, event| { log::debug!("tray menu: {}", event.id.as_ref()); match event.id.as_ref() { + id if id.starts_with("provider_") => { + // Provider item clicked - show stats and navigate to provider + let plugin_id = id.strip_prefix("provider_").unwrap_or(id); + let _ = WindowManager::show(app_handle); + let _ = app_handle.emit("tray:navigate", "home"); + let _ = app_handle.emit("tray:select-provider", plugin_id); + } "show_stats" => { - show_panel(app_handle); + let _ = WindowManager::show(app_handle); let _ = app_handle.emit("tray:navigate", "home"); } "go_to_settings" => { - show_panel(app_handle); + let _ = WindowManager::show(app_handle); let _ = app_handle.emit("tray:navigate", "settings"); } "about" => { - show_panel(app_handle); + let _ = WindowManager::show(app_handle); let _ = app_handle.emit("tray:show-about", ()); } "quit" => { @@ -120,10 +445,8 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { _ => unreachable!(), }; set_stored_log_level(app_handle, selected_level); - // Update all checkmarks - only the selected level should be checked - for (item, level) in &log_items { - let _ = item.set_checked(*level == selected_level); - } + // Update the menu to reflect new log level + let _ = update_tray_menu(app_handle); } _ => {} } @@ -136,20 +459,86 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { } = event { if button_state == MouseButtonState::Up { - let Some(panel) = get_or_init_panel!(app_handle) else { - return; - }; + #[cfg(target_os = "macos")] + { + // macOS: Use panel behavior + use tauri_nspanel::ManagerExt; + + let panel = match app_handle.get_webview_panel("main") { + Ok(p) => Some(p), + Err(_) => { + if let Err(err) = crate::panel::init(&app_handle) { + log::error!("Failed to init panel: {}", err); + None + } else { + app_handle.get_webview_panel("main").ok() + } + } + }; - if panel.is_visible() { - log::debug!("tray click: hiding panel"); - panel.hide(); - return; + if let Some(panel) = panel { + if panel.is_visible() { + log::debug!("tray click: hiding panel"); + panel.hide(); + return; + } + log::debug!("tray click: showing panel"); + panel.show_and_make_key(); + position_window_at_tray(app_handle, rect.position, rect.size); + } } - log::debug!("tray click: showing panel"); - // macOS quirk: must show window before positioning to another monitor - panel.show_and_make_key(); - position_panel_at_tray_icon(app_handle, rect.position, rect.size); + #[cfg(target_os = "windows")] + { + // Windows: Use regular window + let window = app_handle.get_webview_window("main"); + + if let Some(window) = window { + if window.is_visible().unwrap_or(false) { + log::debug!("tray click: hiding window"); + let _ = window.hide(); + return; + } + + log::debug!("tray click: showing window"); + + // Position window near tray icon + if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) = + (rect.position, rect.size) + { + let _ = position_window_at_tray(&app_handle, pos, size); + } + + let _ = window.show(); + let _ = window.set_focus(); + } + } + + #[cfg(target_os = "linux")] + { + // Linux: Use regular window + let window = app_handle.get_webview_window("main"); + + if let Some(window) = window { + if window.is_visible().unwrap_or(false) { + log::debug!("tray click: hiding window"); + let _ = window.hide(); + return; + } + + log::debug!("tray click: showing window"); + + // Position window near tray icon + if let (tauri::Position::Physical(pos), tauri::Size::Physical(size)) = + (rect.position, rect.size) + { + let _ = position_window_at_tray(&app_handle, pos, size); + } + + let _ = window.show(); + let _ = window.set_focus(); + } + } } } }) @@ -157,3 +546,9 @@ pub fn create(app_handle: &AppHandle) -> tauri::Result<()> { Ok(()) } + +/// Tauri command to update tray menu from frontend +#[tauri::command] +pub fn refresh_tray_menu(app_handle: tauri::AppHandle) -> Result<(), String> { + update_tray_menu(&app_handle).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/window_manager.rs b/src-tauri/src/window_manager.rs new file mode 100644 index 00000000..66e01cba --- /dev/null +++ b/src-tauri/src/window_manager.rs @@ -0,0 +1,344 @@ +use tauri::{AppHandle, Emitter, Manager, PhysicalPosition}; + +#[cfg(target_os = "macos")] +use tauri::{Position, Size}; + +/// Platform-specific window manager +pub struct WindowManager; + +impl WindowManager { + /// Initialize the window for the current platform + pub fn init(app_handle: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + crate::panel::init(app_handle)?; + } + + #[cfg(target_os = "windows")] + { + setup_windows_window(app_handle)?; + } + + Ok(()) + } + + /// Show the window + pub fn show(app_handle: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.show_and_make_key(); + } else { + crate::panel::init(app_handle)?; + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.show_and_make_key(); + } + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(window) = app_handle.get_webview_window("main") { + window.show()?; + window.set_focus()?; + } + } + + Ok(()) + } + + /// Hide the window + pub fn hide(app_handle: &AppHandle) -> tauri::Result<()> { + #[cfg(target_os = "macos")] + { + if let Ok(panel) = app_handle.get_webview_panel("main") { + panel.hide(); + } + } + + #[cfg(not(target_os = "macos"))] + { + if let Some(window) = app_handle.get_webview_window("main") { + window.hide()?; + } + } + + Ok(()) + } +} + +/// Set up Windows-specific window styles for transparency +#[cfg(target_os = "windows")] +pub fn setup_windows_window(app_handle: &AppHandle) -> tauri::Result<()> { + use tauri::Manager; + + if let Some(window) = app_handle.get_webview_window("main") { + // Disable WebView2 hardware acceleration to fix transparency issues + let _ = + window.eval("document.documentElement.style.setProperty('background', 'transparent')"); + + // Ensure window has no background + let _ = window.eval( + " + if (document.body) { + document.body.style.background = 'transparent'; + document.body.style.margin = '0'; + document.body.style.padding = '0'; + } + ", + ); + } + + Ok(()) +} + +/// Taskbar position enum - available on all platforms for AppState compatibility +#[derive(Debug, Clone, Copy, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TaskbarPosition { + Top, + Bottom, + Left, + Right, +} + +/// Position the window at the tray icon location +#[cfg(target_os = "windows")] +pub fn position_window_at_tray( + app_handle: &AppHandle, + icon_position: PhysicalPosition, + icon_size: tauri::PhysicalSize, +) -> tauri::Result<()> { + use tauri::LogicalPosition; + + let window = app_handle + .get_webview_window("main") + .ok_or(tauri::Error::WindowNotFound)?; + + // Get window size + let window_size = window.outer_size()?; + let window_width = window_size.width as i32; + let window_height = window_size.height as i32; + + // Calculate monitor and scale factor + let monitors = window.available_monitors()?; + let mut target_monitor = None; + + for monitor in monitors { + let pos = monitor.position(); + let size = monitor.size(); + let x_in = icon_position.x >= pos.x && icon_position.x < pos.x + size.width as i32; + let y_in = icon_position.y >= pos.y && icon_position.y < pos.y + size.height as i32; + + if x_in && y_in { + target_monitor = Some(monitor); + break; + } + } + + let scale_factor = target_monitor + .as_ref() + .map(|m| m.scale_factor()) + .unwrap_or(1.0); + + let monitor = target_monitor.as_ref(); + let monitor_rect = monitor.map(|m| { + let pos = m.position(); + let size = m.size(); + (pos.x, pos.y, size.width as i32, size.height as i32) + }); + let work_rect = monitor.map(|m| { + let area = m.work_area(); + let pos = area.position; + let size = area.size; + (pos.x, pos.y, size.width as i32, size.height as i32) + }); + + // Detect taskbar position based on icon location + let taskbar_position = detect_taskbar_position(icon_position, icon_size, monitor_rect); + + // Calculate window position based on taskbar location + let (window_x, window_y) = calculate_window_position( + icon_position, + icon_size, + window_width, + window_height, + taskbar_position, + monitor_rect, + work_rect, + ); + + // Convert to logical position + let logical_pos = LogicalPosition::new( + window_x as f64 / scale_factor, + window_y as f64 / scale_factor, + ); + + window.set_position(tauri::Position::Logical(logical_pos))?; + + // Calculate arrow offset: where the icon center is relative to window edge + // Top/Bottom -> X offset, Left/Right -> Y offset + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + let arrow_offset_physical = match taskbar_position { + TaskbarPosition::Left | TaskbarPosition::Right => icon_center_y - window_y, + TaskbarPosition::Top | TaskbarPosition::Bottom => icon_center_x - window_x, + }; + let arrow_offset_logical = (arrow_offset_physical as f64 / scale_factor) as i32; + + // Store taskbar position + arrow offset for frontend fallback + if let Some(state) = app_handle.try_state::>() { + if let Ok(mut app_state) = state.lock() { + app_state.last_taskbar_position = Some(taskbar_position); + app_state.last_arrow_offset = Some(arrow_offset_logical); + } + } + + println!( + "DEBUG: Positioning window. Arrow Offset: {}, Taskbar: {:?}", + arrow_offset_logical, taskbar_position + ); + + // Emit event to frontend with arrow position info + if let Err(e) = window.emit( + "window:positioned", + serde_json::json!({ + "arrowOffset": arrow_offset_logical, + "taskbarPosition": taskbar_position, + }), + ) { + println!("ERROR: Failed to emit window:positioned event: {}", e); + } + + Ok(()) +} + +/// Detect taskbar position based on tray icon location +#[cfg(target_os = "windows")] +fn detect_taskbar_position( + icon_position: PhysicalPosition, + icon_size: tauri::PhysicalSize, + monitor_rect: Option<(i32, i32, i32, i32)>, +) -> TaskbarPosition { + let Some((monitor_x, monitor_y, monitor_width, monitor_height)) = monitor_rect else { + return TaskbarPosition::Bottom; // Default to bottom + }; + + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + + // Calculate distance to each edge + let dist_to_left = icon_center_x - monitor_x; + let dist_to_right = (monitor_x + monitor_width) - icon_center_x; + let dist_to_top = icon_center_y - monitor_y; + let dist_to_bottom = (monitor_y + monitor_height) - icon_center_y; + + // Find the closest edge + let min_dist = dist_to_left + .min(dist_to_right) + .min(dist_to_top) + .min(dist_to_bottom); + + if min_dist == dist_to_top { + TaskbarPosition::Top + } else if min_dist == dist_to_bottom { + TaskbarPosition::Bottom + } else if min_dist == dist_to_left { + TaskbarPosition::Left + } else { + TaskbarPosition::Right + } +} + +/// Calculate window position based on taskbar position +#[cfg(target_os = "windows")] +fn calculate_window_position( + icon_position: PhysicalPosition, + icon_size: tauri::PhysicalSize, + window_width: i32, + window_height: i32, + taskbar_position: TaskbarPosition, + monitor_rect: Option<(i32, i32, i32, i32)>, + work_rect: Option<(i32, i32, i32, i32)>, +) -> (i32, i32) { + let Some((monitor_x, monitor_y, monitor_width, monitor_height)) = monitor_rect else { + // Fallback: center above icon + let x = icon_position.x + (icon_size.width as i32 / 2) - (window_width / 2); + let y = icon_position.y - window_height; + return (x.max(0), y.max(0)); + }; + + let (bounds_x, bounds_y, bounds_width, bounds_height) = + work_rect.unwrap_or((monitor_x, monitor_y, monitor_width, monitor_height)); + + let padding = 8; // Gap between window and taskbar + + let (x, y) = match taskbar_position { + TaskbarPosition::Bottom => { + // Window appears above the taskbar + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let window_x = icon_center_x - (window_width / 2); + let window_y = icon_position.y - window_height - padding; + (window_x, window_y) + } + TaskbarPosition::Top => { + // Window appears below the taskbar + let icon_center_x = icon_position.x + (icon_size.width as i32 / 2); + let window_x = icon_center_x - (window_width / 2); + let window_y = icon_position.y + icon_size.height as i32 + padding; + (window_x, window_y) + } + TaskbarPosition::Left => { + // Window appears to the right of the taskbar + let window_x = bounds_x + padding; + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + let window_y = icon_center_y - (window_height / 2); + (window_x, window_y) + } + TaskbarPosition::Right => { + // Window appears to the left of the taskbar + let window_x = bounds_x + bounds_width - window_width - padding; + let icon_center_y = icon_position.y + (icon_size.height as i32 / 2); + let window_y = icon_center_y - (window_height / 2); + (window_x, window_y) + } + }; + + // Clamp to work area bounds + let max_x = bounds_x + bounds_width - window_width; + let max_y = bounds_y + bounds_height - window_height; + + let final_x = if max_x < bounds_x { + bounds_x + } else { + x.clamp(bounds_x, max_x) + }; + + let final_y = if max_y < bounds_y { + bounds_y + } else { + y.clamp(bounds_y, max_y) + }; + + (final_x, final_y) +} + +/// macOS version delegates to existing panel implementation +#[cfg(target_os = "macos")] +pub fn position_window_at_tray(app_handle: &AppHandle, icon_position: Position, icon_size: Size) { + crate::panel::position_panel_at_tray_icon(app_handle, icon_position, icon_size); +} + +/// Linux version positions the window near the tray icon +#[cfg(target_os = "linux")] +pub fn position_window_at_tray( + app_handle: &AppHandle, + icon_position: PhysicalPosition, + _icon_size: tauri::PhysicalSize, +) -> tauri::Result<()> { + let window = app_handle + .get_webview_window("main") + .ok_or(tauri::Error::WindowNotFound)?; + window.set_position(tauri::Position::Physical(icon_position))?; + Ok(()) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 39cb258e..a1f9e650 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenUsage", - "version": "0.6.2", + "version": "0.6.3", "identifier": "com.sunstory.openusage", "build": { "beforeDevCommand": "bun run bundle:plugins && bun run dev", @@ -19,7 +19,9 @@ "resizable": false, "decorations": false, "transparent": true, - "visible": false + "visible": false, + "shadow": false, + "skipTaskbar": true } ], "security": { @@ -55,7 +57,8 @@ ], "resources": [ "resources/bundled_plugins/**/*", - "icons/tray-icon.png" + "icons/tray-icon.png", + "icons/64x64.png" ], "createUpdaterArtifacts": true }, diff --git a/src/App.test.tsx b/src/App.test.tsx index cd717729..088f4515 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -8,7 +8,11 @@ const state = vi.hoisted(() => ({ isTauriMock: vi.fn(() => false), trackMock: vi.fn(), setSizeMock: vi.fn(), + setPositionMock: vi.fn(), currentMonitorMock: vi.fn(), + outerSizeMock: vi.fn(async () => ({ width: 400, height: 500 })), + outerPositionMock: vi.fn(async () => ({ x: 0, y: 0 })), + onFocusChangedMock: vi.fn(async () => () => {}), startBatchMock: vi.fn(), savePluginSettingsMock: vi.fn(), loadPluginSettingsMock: vi.fn(), @@ -119,7 +123,13 @@ vi.mock("@tauri-apps/api/path", () => ({ })) vi.mock("@tauri-apps/api/window", () => ({ - getCurrentWindow: () => ({ setSize: state.setSizeMock }), + getCurrentWindow: () => ({ + setSize: state.setSizeMock, + setPosition: state.setPositionMock, + outerSize: state.outerSizeMock, + outerPosition: state.outerPositionMock, + onFocusChanged: state.onFocusChangedMock, + }), PhysicalSize: class { width: number height: number @@ -161,10 +171,105 @@ vi.mock("@/hooks/use-probe-events", () => ({ }, })) -vi.mock("@/lib/settings", async () => { - const actual = await vi.importActual("@/lib/settings") +vi.mock("@/lib/settings", () => { + const DEFAULT_AUTO_UPDATE_INTERVAL = 15 + const DEFAULT_THEME_MODE = "system" + const DEFAULT_DISPLAY_MODE = "left" + const DEFAULT_TRAY_ICON_STYLE = "bars" + const DEFAULT_TRAY_SHOW_PERCENTAGE = false + const REFRESH_COOLDOWN_MS = 300000 + const AUTO_UPDATE_OPTIONS = [ + { value: 5, label: "5 min" }, + { value: 15, label: "15 min" }, + { value: 30, label: "30 min" }, + { value: 60, label: "1 hour" }, + ] + const THEME_OPTIONS = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, + ] + const DISPLAY_MODE_OPTIONS = [ + { value: "left", label: "Left" }, + { value: "used", label: "Used" }, + ] + const TRAY_ICON_STYLE_OPTIONS = [ + { value: "bars", label: "Bars" }, + { value: "circle", label: "Circle" }, + { value: "provider", label: "Provider" }, + { value: "textOnly", label: "%" }, + ] + const DEFAULT_ENABLED_PLUGINS = new Set(["claude", "codex", "cursor"]) + + function isTrayPercentageMandatory(style: "bars" | "circle" | "provider" | "textOnly") { + return style === "provider" || style === "textOnly" + } + + function normalizePluginSettings( + settings: { order: string[]; disabled: string[] }, + plugins: { id: string }[] + ) { + const knownIds = plugins.map((plugin) => plugin.id) + const knownSet = new Set(knownIds) + const order: string[] = [] + const seen = new Set() + for (const id of settings.order) { + if (!knownSet.has(id) || seen.has(id)) continue + seen.add(id) + order.push(id) + } + const newlyAdded: string[] = [] + for (const id of knownIds) { + if (!seen.has(id)) { + seen.add(id) + order.push(id) + newlyAdded.push(id) + } + } + const disabled = settings.disabled.filter((id) => knownSet.has(id)) + for (const id of newlyAdded) { + if (!DEFAULT_ENABLED_PLUGINS.has(id) && !disabled.includes(id)) { + disabled.push(id) + } + } + return { order, disabled } + } + + function arePluginSettingsEqual( + a: { order: string[]; disabled: string[] }, + b: { order: string[]; disabled: string[] } + ) { + if (a.order.length !== b.order.length) return false + if (a.disabled.length !== b.disabled.length) return false + for (let i = 0; i < a.order.length; i += 1) { + if (a.order[i] !== b.order[i]) return false + } + for (let i = 0; i < a.disabled.length; i += 1) { + if (a.disabled[i] !== b.disabled[i]) return false + } + return true + } + + function getEnabledPluginIds(settings: { order: string[]; disabled: string[] }) { + const disabledSet = new Set(settings.disabled) + return settings.order.filter((id) => !disabledSet.has(id)) + } + return { - ...actual, + DEFAULT_AUTO_UPDATE_INTERVAL, + DEFAULT_THEME_MODE, + DEFAULT_DISPLAY_MODE, + DEFAULT_TRAY_ICON_STYLE, + DEFAULT_TRAY_SHOW_PERCENTAGE, + REFRESH_COOLDOWN_MS, + AUTO_UPDATE_OPTIONS, + THEME_OPTIONS, + DISPLAY_MODE_OPTIONS, + TRAY_ICON_STYLE_OPTIONS, + arePluginSettingsEqual, + normalizePluginSettings, + getEnabledPluginIds, + isTrayPercentageMandatory, loadPluginSettings: state.loadPluginSettingsMock, savePluginSettings: state.savePluginSettingsMock, loadAutoUpdateInterval: state.loadAutoUpdateIntervalMock, @@ -190,13 +295,20 @@ import { App } from "@/App" describe("App", () => { beforeEach(() => { + if (typeof HTMLElement === "undefined") { + Object.defineProperty(globalThis, "HTMLElement", { value: class {}, configurable: true }) + } state.probeHandlers = null state.invokeMock.mockReset() state.isTauriMock.mockReset() state.isTauriMock.mockReturnValue(false) state.trackMock.mockReset() state.setSizeMock.mockReset() + state.setPositionMock.mockReset() state.currentMonitorMock.mockReset() + state.outerSizeMock.mockReset() + state.outerPositionMock.mockReset() + state.onFocusChangedMock.mockReset() state.startBatchMock.mockReset() state.savePluginSettingsMock.mockReset() state.loadPluginSettingsMock.mockReset() @@ -265,8 +377,8 @@ describe("App", () => { state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { return [ - { id: "a", name: "Alpha", iconUrl: "icon-a", primaryProgressLabel: null, lines: [{ type: "text", label: "Now", scope: "overview" }] }, - { id: "b", name: "Beta", iconUrl: "icon-b", primaryProgressLabel: null, lines: [] }, + { id: "a", name: "Alpha", iconUrl: "icon-a", primaryCandidates: [], lines: [{ type: "text", label: "Now", scope: "overview" }] }, + { id: "b", name: "Beta", iconUrl: "icon-b", primaryCandidates: [], lines: [] }, ] } return null @@ -348,9 +460,9 @@ describe("App", () => { if (cmd === "list_plugins") { return [ { - id: "a", - name: "Alpha", - iconUrl: "icon-a", + id: "claude", + name: "Claude", + iconUrl: "icon-claude", primaryCandidates: ["Session"], lines: [{ type: "progress", label: "Session", scope: "overview" }], }, @@ -358,7 +470,7 @@ describe("App", () => { } return null }) - state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["claude"], disabled: [] }) render() await waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) @@ -368,9 +480,9 @@ describe("App", () => { const callsBefore = state.renderTrayBarsIconMock.mock.calls.length state.probeHandlers?.onResult({ - providerId: "a", - displayName: "Alpha", - iconUrl: "icon-a", + providerId: "claude", + displayName: "Claude", + iconUrl: "icon-claude", lines: [{ type: "progress", label: "Session", used: 50, limit: 100, format: { kind: "percent" } }], }) @@ -507,9 +619,9 @@ describe("App", () => { if (cmd === "list_plugins") { return [ { - id: "a", - name: "Alpha", - iconUrl: "icon-a", + id: "claude", + name: "Claude", + iconUrl: "icon-claude", primaryCandidates: ["Session"], lines: [{ type: "progress", label: "Session", scope: "overview" }], }, @@ -517,7 +629,7 @@ describe("App", () => { } return null }) - state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["claude"], disabled: [] }) render() await waitFor(() => expect(state.renderTrayBarsIconMock).toHaveBeenCalled()) @@ -1408,21 +1520,21 @@ describe("App", () => { window.requestAnimationFrame = rafSpy // Setup plugin with primary progress - state.invokeMock.mockImplementationOnce(async (cmd: string) => { + state.invokeMock.mockImplementation(async (cmd: string) => { if (cmd === "list_plugins") { return [ { - id: "a", - name: "Alpha", - iconUrl: "icon-a", - primaryProgressLabel: "Session", + id: "claude", + name: "Claude", + iconUrl: "icon-claude", + primaryCandidates: ["Session"], lines: [{ type: "progress", label: "Session", scope: "overview" }], }, ] } return null }) - state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["a"], disabled: [] }) + state.loadPluginSettingsMock.mockResolvedValueOnce({ order: ["claude"], disabled: [] }) render() await vi.waitFor(() => expect(state.startBatchMock).toHaveBeenCalled()) @@ -1435,9 +1547,9 @@ describe("App", () => { // Trigger a probe result state.probeHandlers?.onResult({ - providerId: "a", - displayName: "Alpha", - iconUrl: "icon-a", + providerId: "claude", + displayName: "Claude", + iconUrl: "icon-claude", lines: [{ type: "progress", label: "Session", used: 50, limit: 100, format: { kind: "percent" } }], }) @@ -1446,7 +1558,7 @@ describe("App", () => { // Tray icon should have been updated even though requestAnimationFrame was never called expect(rafSpy).not.toHaveBeenCalled() - expect(state.traySetIconMock).toHaveBeenCalled() + await vi.waitFor(() => expect(state.traySetIconMock).toHaveBeenCalled()) window.requestAnimationFrame = originalRaf vi.useRealTimers() diff --git a/src/App.tsx b/src/App.tsx index 5ce00eba..c431734e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { invoke, isTauri } from "@tauri-apps/api/core" import { listen } from "@tauri-apps/api/event" -import { getCurrentWindow, PhysicalSize, currentMonitor } from "@tauri-apps/api/window" +import { getCurrentWindow, PhysicalSize, PhysicalPosition, currentMonitor } from "@tauri-apps/api/window" import { getVersion } from "@tauri-apps/api/app" -import { resolveResource } from "@tauri-apps/api/path" import { TrayIcon } from "@tauri-apps/api/tray" +import { resolveResource } from "@tauri-apps/api/path" +import { platform } from "@tauri-apps/plugin-os" import { disable as disableAutostart, enable as enableAutostart, @@ -65,7 +66,7 @@ import { const PANEL_WIDTH = 400; const MAX_HEIGHT_FALLBACK_PX = 600; const MAX_HEIGHT_FRACTION_OF_MONITOR = 0.8; -const ARROW_OVERHEAD_PX = 37; // .tray-arrow (7px) + wrapper pt-1.5 (6px) + bottom p-6 (24px) +const ARROW_OVERHEAD_PX = 32; // Arrow (~16px) + container padding (16px) const TRAY_SETTINGS_DEBOUNCE_MS = 2000; const TRAY_PROBE_DEBOUNCE_MS = 500; @@ -95,21 +96,27 @@ function App() { DEFAULT_RESET_TIMER_DISPLAY_MODE ) const [trayIconStyle, setTrayIconStyle] = useState(DEFAULT_TRAY_ICON_STYLE) + const [isWindows, setIsWindows] = useState(false) const [trayShowPercentage, setTrayShowPercentage] = useState(DEFAULT_TRAY_SHOW_PERCENTAGE) const [globalShortcut, setGlobalShortcut] = useState(DEFAULT_GLOBAL_SHORTCUT) const [startOnLogin, setStartOnLogin] = useState(DEFAULT_START_ON_LOGIN) const [maxPanelHeightPx, setMaxPanelHeightPx] = useState(null) const maxPanelHeightPxRef = useRef(null) const [appVersion, setAppVersion] = useState("...") + // Track taskbar position for anchor-aware resizing (Windows) + type TaskbarPosition = "top" | "bottom" | "left" | "right" | null + const [taskbarPosition, setTaskbarPosition] = useState(null) + // Track last window height to calculate delta for repositioning + const lastWindowHeightRef = useRef(null) + // Arrow offset from left edge (in logical px) - where tray icon center is relative to window + const [arrowOffset, setArrowOffset] = useState(null) const { updateStatus, triggerInstall, checkForUpdates } = useAppUpdate() const [showAbout, setShowAbout] = useState(false) + // Tray icon handle for frontend updates const trayRef = useRef(null) - const trayGaugeIconPathRef = useRef(null) - const trayUpdateTimerRef = useRef(null) - const trayUpdatePendingRef = useRef(false) - const [trayReady, setTrayReady] = useState(false) + const trayUpdateTimeoutRef = useRef | null>(null) // Store state in refs so scheduleTrayIconUpdate can read current values without recreating the callback const pluginsMetaRef = useRef(pluginsMeta) @@ -118,119 +125,114 @@ function App() { const displayModeRef = useRef(displayMode) const trayIconStyleRef = useRef(trayIconStyle) const trayShowPercentageRef = useRef(trayShowPercentage) + const isWindowsRef = useRef(isWindows) useEffect(() => { pluginsMetaRef.current = pluginsMeta }, [pluginsMeta]) useEffect(() => { pluginSettingsRef.current = pluginSettings }, [pluginSettings]) useEffect(() => { pluginStatesRef.current = pluginStates }, [pluginStates]) useEffect(() => { displayModeRef.current = displayMode }, [displayMode]) useEffect(() => { trayIconStyleRef.current = trayIconStyle }, [trayIconStyle]) useEffect(() => { trayShowPercentageRef.current = trayShowPercentage }, [trayShowPercentage]) + useEffect(() => { isWindowsRef.current = isWindows }, [isWindows]) - // Fetch app version on mount + // Fetch app version and detect platform on mount useEffect(() => { getVersion().then(setAppVersion) + // Detect if Windows for arrow positioning + try { + const p = platform() + setIsWindows(p === 'windows') + } catch { + setIsWindows(false) + } }, []) - // Stable callback that reads from refs - never recreated, so debounce works correctly - const scheduleTrayIconUpdate = useCallback((_reason: "probe" | "settings" | "init", delayMs = 0) => { - if (trayUpdateTimerRef.current !== null) { - window.clearTimeout(trayUpdateTimerRef.current) - trayUpdateTimerRef.current = null + const [isTrayReady, setIsTrayReady] = useState(false) + const scheduleTrayIconUpdate = useCallback((reason: "probe" | "settings" | "init", delayMs = 0) => { + if (trayUpdateTimeoutRef.current !== null) { + clearTimeout(trayUpdateTimeoutRef.current) } - trayUpdateTimerRef.current = window.setTimeout(() => { - trayUpdateTimerRef.current = null - if (trayUpdatePendingRef.current) return - trayUpdatePendingRef.current = true - - const tray = trayRef.current - if (!tray) { - trayUpdatePendingRef.current = false - return - } + trayUpdateTimeoutRef.current = setTimeout(async () => { + trayUpdateTimeoutRef.current = null + const currentSettings = pluginSettingsRef.current + const currentMeta = pluginsMetaRef.current + if (!currentSettings || currentMeta.length === 0) return const style = trayIconStyleRef.current const maxBars = style === "bars" ? 4 : 1 const bars = getTrayPrimaryBars({ - pluginsMeta: pluginsMetaRef.current, - pluginSettings: pluginSettingsRef.current, + pluginsMeta: currentMeta, + pluginSettings: currentSettings, pluginStates: pluginStatesRef.current, - maxBars, displayMode: displayModeRef.current, + maxBars, }) + if (bars.length === 0) return + const shouldShowPercentage = isTrayPercentageMandatory(style) + ? true + : trayShowPercentageRef.current + const primaryFraction = bars[0]?.fraction + const percentText = + shouldShowPercentage && typeof primaryFraction === "number" + ? `${Math.round(primaryFraction * 100)}%` + : undefined + const providerIconUrl = + style === "provider" + ? currentMeta.find((plugin) => plugin.id === bars[0]?.id)?.iconUrl + : undefined + const dpr = typeof window === "undefined" ? 1 : window.devicePixelRatio || 1 + const sizePx = getTrayIconSizePx(dpr) + const trayColor = isWindowsRef.current ? "#f8f8f8" : "#000000" - // 0 bars: revert to the packaged gauge tray icon. - if (bars.length === 0) { - const gaugePath = trayGaugeIconPathRef.current - if (gaugePath) { - Promise.all([ - tray.setIcon(gaugePath), - tray.setIconAsTemplate(true), - ]) - .catch((e) => { - console.error("Failed to restore tray gauge icon:", e) - }) - .finally(() => { - trayUpdatePendingRef.current = false - }) - } else { - trayUpdatePendingRef.current = false + try { + const image = await renderTrayBarsIcon({ + bars, + sizePx, + style, + percentText, + providerIconUrl, + color: trayColor, + }) + let tray = trayRef.current + if (!tray) { + tray = await TrayIcon.getById("tray").catch(() => null) + if (tray) trayRef.current = tray } - return - } - - const percentageMandatory = isTrayPercentageMandatory(style) - - let percentText: string | undefined - if (percentageMandatory || trayShowPercentageRef.current) { - const firstFraction = bars[0]?.fraction - if (typeof firstFraction === "number" && Number.isFinite(firstFraction)) { - const clamped = Math.max(0, Math.min(1, firstFraction)) - const rounded = Math.round(clamped * 100) - percentText = `${rounded}%` + if (tray) { + await tray.setIcon(image) } + } catch (error) { + console.error(`Failed to update tray icon (${reason}):`, error) } + }, delayMs) + }, []) - if (style === "textOnly" && !percentText) { - const gaugePath = trayGaugeIconPathRef.current - if (gaugePath) { - Promise.all([ - tray.setIcon(gaugePath), - tray.setIconAsTemplate(true), - ]) - .catch((e) => { - console.error("Failed to restore tray gauge icon:", e) - }) - .finally(() => { - trayUpdatePendingRef.current = false - }) - } else { - trayUpdatePendingRef.current = false - } - return - } + useEffect(() => { + if (!isTrayReady) return + if (!pluginSettings || pluginsMeta.length === 0) return + scheduleTrayIconUpdate("init", 0) + }, [isTrayReady, pluginSettings, pluginsMeta, scheduleTrayIconUpdate]) - const sizePx = getTrayIconSizePx(window.devicePixelRatio) - const firstProviderId = bars[0]?.id - const providerIconUrl = - style === "provider" - ? pluginsMetaRef.current.find((plugin) => plugin.id === firstProviderId)?.iconUrl - : undefined + useEffect(() => { + let cancelled = false + resolveResource("icons/tray-icon.png").catch((error) => { + if (cancelled) return + console.error("Failed to resolve tray icon resource:", error) + }) + return () => { + cancelled = true + } + }, []) - renderTrayBarsIcon({ bars, sizePx, style, percentText, providerIconUrl }) - .then(async (img) => { - await tray.setIcon(img) - await tray.setIconAsTemplate(true) - }) - .catch((e) => { - console.error("Failed to update tray icon:", e) - }) - .finally(() => { - trayUpdatePendingRef.current = false - }) - }, delayMs) + useEffect(() => { + return () => { + if (trayUpdateTimeoutRef.current !== null) { + clearTimeout(trayUpdateTimeoutRef.current) + } + } }, []) - // Initialize tray handle once (separate from tray updates) + // Initialize tray handle once const trayInitializedRef = useRef(false) useEffect(() => { if (trayInitializedRef.current) return @@ -241,12 +243,7 @@ function App() { if (cancelled) return trayRef.current = tray trayInitializedRef.current = true - setTrayReady(true) - try { - trayGaugeIconPathRef.current = await resolveResource("icons/tray-icon.png") - } catch (e) { - console.error("Failed to resolve tray gauge icon resource:", e) - } + setIsTrayReady(true) } catch (e) { console.error("Failed to load tray icon handle:", e) } @@ -256,15 +253,6 @@ function App() { } }, []) - // Trigger tray update once tray + plugin metadata/settings are available. - // This prevents missing the first paint if probe results arrive before the tray handle resolves. - useEffect(() => { - if (!trayReady) return - if (!pluginSettings) return - if (pluginsMeta.length === 0) return - scheduleTrayIconUpdate("init", 0) - }, [pluginsMeta.length, pluginSettings, scheduleTrayIconUpdate, trayReady]) - const displayPlugins = useMemo(() => { if (!pluginSettings) return [] @@ -335,7 +323,16 @@ function App() { const unlisteners: (() => void)[] = [] async function setup() { - const u1 = await listen("tray:navigate", (event) => { + const currentWindow = getCurrentWindow() + + const u1 = await listen("tray:navigate", async (event) => { + // Capture current height before navigation so we can calculate delta + try { + const size = await currentWindow.outerSize() + lastWindowHeightRef.current = size.height + } catch { + lastWindowHeightRef.current = null + } setActiveView(event.payload as ActiveView) }) if (cancelled) { u1(); return } @@ -346,6 +343,41 @@ function App() { }) if (cancelled) { u2(); return } unlisteners.push(u2) + + // Listen for window focus events to capture current height as baseline + // This ensures we can calculate proper deltas for anchor-aware resizing + const u3 = await currentWindow.onFocusChanged(async ({ payload: focused }) => { + if (focused) { + // Window just gained focus - capture current height as baseline for delta calculation + try { + const size = await currentWindow.outerSize() + lastWindowHeightRef.current = size.height + console.log('[FOCUS] Window focused, captured baseline height:', size.height) + + // Fetch latest positioning info (in case event was missed) + const pos = await invoke('get_taskbar_position'); + if (pos) setTaskbarPosition(pos as TaskbarPosition); + + const offset = await invoke('get_arrow_offset'); + if (offset !== null) setArrowOffset(offset); + } catch { + // Fallback: null will cause first resize to just set size without repositioning + lastWindowHeightRef.current = null + console.log('[FOCUS] Window focused, failed to capture height') + } + } + }) + if (cancelled) { u3(); return } + unlisteners.push(u3) + + // Listen for window positioning events to align arrow with tray icon + const u4 = await listen<{ arrowOffset: number; taskbarPosition: string }>("window:positioned", (event) => { + setArrowOffset(event.payload.arrowOffset) + setTaskbarPosition(event.payload.taskbarPosition as TaskbarPosition) + console.log('[POSITIONED] Arrow offset:', event.payload.arrowOffset, 'Taskbar:', event.payload.taskbarPosition) + }) + if (cancelled) { u4(); return } + unlisteners.push(u4) } void setup() @@ -356,60 +388,105 @@ function App() { }, []) // Auto-resize window to fit content using ResizeObserver + // CRITICAL: Anchor-aware resizing - keep the edge closest to taskbar fixed useEffect(() => { const container = containerRef.current; if (!container) return; - const resizeWindow = async () => { - const factor = window.devicePixelRatio; + let debounceTimer: ReturnType | null = null; + let isResizing = false; - const width = Math.ceil(PANEL_WIDTH * factor); - const desiredHeightLogical = Math.max(1, container.scrollHeight); + const resizeWindow = async () => { + // Prevent concurrent resize operations + if (isResizing) return; + isResizing = true; - let maxHeightPhysical: number | null = null; - let maxHeightLogical: number | null = null; try { - const monitor = await currentMonitor(); - if (monitor) { - maxHeightPhysical = Math.floor(monitor.size.height * MAX_HEIGHT_FRACTION_OF_MONITOR); - maxHeightLogical = Math.floor(maxHeightPhysical / factor); + const factor = window.devicePixelRatio; + const width = Math.ceil(PANEL_WIDTH * factor); + const desiredHeightLogical = Math.max(1, container.scrollHeight); + + let maxHeightPhysical: number | null = null; + let maxHeightLogical: number | null = null; + try { + const monitor = await currentMonitor(); + if (monitor) { + maxHeightPhysical = Math.floor(monitor.size.height * MAX_HEIGHT_FRACTION_OF_MONITOR); + maxHeightLogical = Math.floor(maxHeightPhysical / factor); + } + } catch { + // fall through to fallback } - } catch { - // fall through to fallback - } - if (maxHeightLogical === null) { - const screenAvailHeight = Number(window.screen?.availHeight) || MAX_HEIGHT_FALLBACK_PX; - maxHeightLogical = Math.floor(screenAvailHeight * MAX_HEIGHT_FRACTION_OF_MONITOR); - maxHeightPhysical = Math.floor(maxHeightLogical * factor); - } + if (maxHeightLogical === null) { + const screenAvailHeight = Number(window.screen?.availHeight) || MAX_HEIGHT_FALLBACK_PX; + maxHeightLogical = Math.floor(screenAvailHeight * MAX_HEIGHT_FRACTION_OF_MONITOR); + maxHeightPhysical = Math.floor(maxHeightLogical * factor); + } - if (maxPanelHeightPxRef.current !== maxHeightLogical) { - maxPanelHeightPxRef.current = maxHeightLogical; - setMaxPanelHeightPx(maxHeightLogical); - } + if (maxPanelHeightPxRef.current !== maxHeightLogical) { + maxPanelHeightPxRef.current = maxHeightLogical; + setMaxPanelHeightPx(maxHeightLogical); + } - const desiredHeightPhysical = Math.ceil(desiredHeightLogical * factor); - const height = Math.ceil(Math.min(desiredHeightPhysical, maxHeightPhysical!)); + const desiredHeightPhysical = Math.ceil(desiredHeightLogical * factor); + const newHeight = Math.ceil(Math.min(desiredHeightPhysical, maxHeightPhysical!)); + const previousHeight = lastWindowHeightRef.current; - try { const currentWindow = getCurrentWindow(); - await currentWindow.setSize(new PhysicalSize(width, height)); + + // Fetch current taskbar position from backend (Windows stores this on tray click) + let currentTaskbarPos: TaskbarPosition = null; + try { + currentTaskbarPos = await invoke("get_taskbar_position"); + setTaskbarPosition(currentTaskbarPos); + } catch { + // Fallback: not available or macOS + } + + // On Windows with bottom/right taskbar, we need to reposition when height changes + // to keep the bottom/right edge anchored + if (previousHeight !== null && previousHeight !== newHeight && currentTaskbarPos) { + const heightDelta = newHeight - previousHeight; + + // Get current window position + const pos = await currentWindow.outerPosition(); + + if (currentTaskbarPos === "bottom") { + // Bottom taskbar: keep bottom edge fixed → move window UP when growing + const newY = pos.y - heightDelta; + await currentWindow.setPosition(new PhysicalPosition(pos.x, newY)); + } + // Top/Left taskbar: default behavior (top-left anchored) + // Right taskbar: no vertical adjustment needed for height changes + } + + await currentWindow.setSize(new PhysicalSize(width, newHeight)); + lastWindowHeightRef.current = newHeight; } catch (e) { console.error("Failed to resize window:", e); + } finally { + isResizing = false; } }; - // Initial resize + // Debounced resize to prevent rapid consecutive calls + const debouncedResize = () => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(resizeWindow, 16); // ~1 frame at 60fps + }; + + // Initial resize (no debounce) resizeWindow(); - // Observe size changes - const observer = new ResizeObserver(() => { - resizeWindow(); - }); + // Observe size changes with debouncing + const observer = new ResizeObserver(debouncedResize); observer.observe(container); - return () => observer.disconnect(); + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + observer.disconnect(); + }; }, [activeView, displayPlugins]); const getErrorMessage = useCallback((output: PluginOutput) => { @@ -422,25 +499,25 @@ function App() { }, []) const setLoadingForPlugins = useCallback((ids: string[]) => { - setPluginStates((prev) => { - const next = { ...prev } - for (const id of ids) { - const existing = prev[id] - next[id] = { data: null, loading: true, error: null, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } - } - return next - }) + const prev = pluginStatesRef.current + const next = { ...prev } + for (const id of ids) { + const existing = prev[id] + next[id] = { data: null, loading: true, error: null, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } + } + pluginStatesRef.current = next + setPluginStates(next) }, []) const setErrorForPlugins = useCallback((ids: string[], error: string) => { - setPluginStates((prev) => { - const next = { ...prev } - for (const id of ids) { - const existing = prev[id] - next[id] = { data: null, loading: false, error, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } - } - return next - }) + const prev = pluginStatesRef.current + const next = { ...prev } + for (const id of ids) { + const existing = prev[id] + next[id] = { data: null, loading: false, error, lastManualRefreshAt: existing?.lastManualRefreshAt ?? null } + } + pluginStatesRef.current = next + setPluginStates(next) }, []) // Track which plugin IDs are being manually refreshed (vs initial load / enable toggle) @@ -459,7 +536,8 @@ function App() { if (isManual) { manualRefreshIdsRef.current.delete(output.providerId) } - setPluginStates((prev) => ({ + const prev = pluginStatesRef.current + const next = { ...prev, [output.providerId]: { data: errorMessage ? null : output, @@ -470,7 +548,9 @@ function App() { ? Date.now() : (prev[output.providerId]?.lastManualRefreshAt ?? null), }, - })) + } + pluginStatesRef.current = next + setPluginStates(next) // Regenerate tray icon on every probe result (debounced to avoid churn). scheduleTrayIconUpdate("probe", TRAY_PROBE_DEBOUNCE_MS) @@ -505,6 +585,7 @@ function App() { const availablePlugins = await invoke("list_plugins") if (!isMounted) return setPluginsMeta(availablePlugins) + pluginsMetaRef.current = availablePlugins const storedSettings = await loadPluginSettings() const normalized = normalizePluginSettings( @@ -584,12 +665,21 @@ function App() { if (isMounted) { setPluginSettings(normalized) + pluginSettingsRef.current = normalized setAutoUpdateInterval(storedInterval) setThemeMode(storedThemeMode) setDisplayMode(storedDisplayMode) + setPluginSettings(normalized) + pluginSettingsRef.current = normalized + setAutoUpdateInterval(storedInterval) + setThemeMode(storedThemeMode) + setDisplayMode(storedDisplayMode) + displayModeRef.current = storedDisplayMode setResetTimerDisplayMode(storedResetTimerDisplayMode) setTrayIconStyle(storedTrayIconStyle) + trayIconStyleRef.current = storedTrayIconStyle setTrayShowPercentage(normalizedTrayShowPercentage) + trayShowPercentageRef.current = normalizedTrayShowPercentage setGlobalShortcut(storedGlobalShortcut) setStartOnLogin(storedStartOnLogin) const enabledIds = getEnabledPluginIds(normalized) @@ -985,12 +1075,49 @@ function App() { ) } + const isSideTaskbar = taskbarPosition === "left" || taskbarPosition === "right" + const isTopTaskbar = taskbarPosition === "top" + const isLeftTaskbar = taskbarPosition === "left" + const isRightTaskbar = taskbarPosition === "right" + + // Padding for shadow: needs ~16px to not clip the box-shadow + // Arrow side gets 8px (arrow is 16px), opposite side gets 16px for shadow + const containerClasses = isWindows + ? isSideTaskbar + ? "flex flex-row items-center w-full py-4 px-2 bg-transparent" + : isTopTaskbar + ? "flex flex-col items-center justify-start w-full px-4 pt-2 pb-4 bg-transparent" + : "flex flex-col items-center justify-end w-full px-4 pt-4 pb-2 bg-transparent" + : "flex flex-col items-center justify-start w-full px-4 pt-2 pb-4 bg-transparent"; + + // Dynamic arrow positioning to align with tray icon + const ARROW_HALF_SIZE_PX = 7; + const PANEL_HORIZONTAL_PADDING_PX = 16; // px-4 + const PANEL_VERTICAL_PADDING_PX = 16; // py-4 + const arrowStyle = arrowOffset !== null + ? isSideTaskbar + ? ({ + alignSelf: "flex-start", + marginTop: `${arrowOffset - ARROW_HALF_SIZE_PX - PANEL_VERTICAL_PADDING_PX}px`, + } as const) + : ({ + alignSelf: "flex-start", + marginLeft: `${arrowOffset - ARROW_HALF_SIZE_PX - PANEL_HORIZONTAL_PADDING_PX}px`, + } as const) + : undefined; + return ( -
-
+
+ {/* macOS: top arrow; Windows: top/bottom/side based on taskbar */} + {(!isWindows || isTopTaskbar) &&
} + {isWindows && isLeftTaskbar &&
}
+ {isWindows && isRightTaskbar &&
} + {/* Windows: Arrow at bottom pointing down toward taskbar */} + {isWindows && !isSideTaskbar && !isTopTaskbar &&
}
); } diff --git a/src/index.css b/src/index.css index ec8200cc..0684a9f1 100644 --- a/src/index.css +++ b/src/index.css @@ -144,10 +144,20 @@ html, body, #root { - background: transparent; + background: transparent !important; overflow: hidden; margin: 0; padding: 0; + border: none !important; + outline: none !important; + box-shadow: none !important; +} + +/* Windows-specific fix for WebView2 white border */ +webview { + background: transparent !important; + border: none !important; + outline: none !important; } /* Hide scrollbar track globally */ @@ -158,7 +168,7 @@ body, display: none; /* Chrome / Safari / WebKit */ } -/* Arrow pointing up toward the tray icon */ +/* Arrow pointing up toward the tray icon (macOS - top menu bar) */ .tray-arrow { width: 0; height: 0; @@ -180,6 +190,72 @@ body, border-bottom: 6px solid var(--card); } +/* Arrow pointing down toward the tray icon (Windows - bottom taskbar) */ +.tray-arrow-down { + width: 0; + height: 0; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 7px solid var(--border); + position: relative; + flex-shrink: 0; +} +.tray-arrow-down::after { + content: ""; + position: absolute; + left: -6px; + bottom: 1.5px; + width: 0; + height: 0; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-top: 6px solid var(--card); +} + +/* Arrow pointing left toward the tray icon (Windows - left taskbar) */ +.tray-arrow-left { + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 7px solid var(--border); + position: relative; + flex-shrink: 0; +} +.tray-arrow-left::after { + content: ""; + position: absolute; + top: -6px; + left: 1.5px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-right: 6px solid var(--card); +} + +/* Arrow pointing right toward the tray icon (Windows - right taskbar) */ +.tray-arrow-right { + width: 0; + height: 0; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-left: 7px solid var(--border); + position: relative; + flex-shrink: 0; +} +.tray-arrow-right::after { + content: ""; + position: absolute; + top: -6px; + right: 1.5px; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 6px solid var(--card); +} + /* Border beam for the update button */ .update-border-beam { position: relative; diff --git a/src/lib/tray-bars-icon.ts b/src/lib/tray-bars-icon.ts index 547455c9..359b9608 100644 --- a/src/lib/tray-bars-icon.ts +++ b/src/lib/tray-bars-icon.ts @@ -183,8 +183,10 @@ export function makeTrayBarsSvg(args: { style?: TrayIconStyle percentText?: string providerIconUrl?: string + color?: string }): string { - const { bars, sizePx, style = "bars", percentText, providerIconUrl } = args + const { bars, sizePx, style = "bars", percentText, providerIconUrl, color } = args + const ink = typeof color === "string" && color.length > 0 ? color : "black" const barsForStyle = getBarsForStyle(style, bars) const n = Math.max(1, Math.min(4, barsForStyle.length || 1)) const text = normalizePercentText(style, percentText) @@ -228,7 +230,7 @@ export function makeTrayBarsSvg(args: { const radius = Math.max(1, Math.floor(chartSize / 2 - strokeW / 2) + 0.5) parts.push( - `` + `` ) const fraction = barsForStyle[0]?.fraction @@ -238,7 +240,7 @@ export function makeTrayBarsSvg(args: { const circumference = 2 * Math.PI * radius const dash = circumference * clamped parts.push( - `` + `` ) } } @@ -258,7 +260,7 @@ export function makeTrayBarsSvg(args: { const radius = Math.max(2, iconSize / 2 - 1.5) const strokeW = Math.max(1.5, Math.round(iconSize * 0.14)) parts.push( - `` + `` ) } } else if (style !== "textOnly") { @@ -269,7 +271,7 @@ export function makeTrayBarsSvg(args: { // Track parts.push( - `` + `` ) const fraction = bar?.fraction @@ -279,7 +281,7 @@ export function makeTrayBarsSvg(args: { const movingEdgeRadius = Math.max(0, Math.floor(rx * 0.35)) if (fillW >= trackW) { parts.push( - `` + `` ) } else { const fillPath = makeRoundedBarPath({ @@ -290,7 +292,7 @@ export function makeTrayBarsSvg(args: { leftRadius: rx, rightRadius: movingEdgeRadius, }) - parts.push(``) + parts.push(``) } } @@ -304,7 +306,7 @@ export function makeTrayBarsSvg(args: { leftRadius: Math.max(0, Math.floor(rx * 0.2)), rightRadius: rx, }) - parts.push(``) + parts.push(``) } } } @@ -312,7 +314,7 @@ export function makeTrayBarsSvg(args: { if (text) { parts.push( - `${escapeXmlText(text)}` + `${escapeXmlText(text)}` ) } @@ -359,8 +361,9 @@ export async function renderTrayBarsIcon(args: { style?: TrayIconStyle percentText?: string providerIconUrl?: string + color?: string }): Promise { - const { bars, sizePx, style = "bars", percentText, providerIconUrl } = args + const { bars, sizePx, style = "bars", percentText, providerIconUrl, color } = args const text = normalizePercentText(style, percentText) const svg = makeTrayBarsSvg({ bars, @@ -368,6 +371,7 @@ export async function renderTrayBarsIcon(args: { style, percentText: text, providerIconUrl, + color, }) const layout = getSvgLayout({ sizePx,