diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..183669e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{json,yml,yaml}] +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c78902..443765f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,29 +7,25 @@ on: branches: [main, dev] jobs: - shellcheck: + verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Install shellcheck - run: sudo apt-get install -y shellcheck - - name: Lint shell scripts - run: shellcheck scripts/freeby.sh scripts/copilot-setup.sh - - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Install dependencies - run: sudo apt-get install -y meson ninja-build libglib2.0-dev-bin python3 - - name: Build - run: meson setup build --prefix=$HOME/.local && meson install -C build - - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 22 - name: Install dependencies - run: sudo apt-get install -y bats python3 curl - - name: Run tests - run: bats tests/ + run: sudo apt-get update && sudo apt-get install -y gjs gir1.2-soup-3.0 libglib2.0-dev-bin meson ninja-build python3 + - name: Verify JavaScript, schemas, fixtures, and package + run: npm run verify + - name: Stress history, notifications, and subprocess bounds + run: npm run test:stress + - name: Verify isolated install + run: | + meson setup build --prefix="$RUNNER_TEMP/usagebeam-install" + meson install -C build + test -f "$RUNNER_TEMP/usagebeam-install/share/gnome-shell/extensions/usagebeam@oo7kc.github.io/schemas/gschemas.compiled" + - uses: actions/upload-artifact@v7 + with: + name: usagebeam-extension + path: dist/*.zip diff --git a/.gitignore b/.gitignore index 40d87c3..007f19f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,17 @@ -build/ +/build/ +/dist/ +/.cache/ +/.ruff_cache/ +meson-private/ + +# Local agent workspace and visual research are not product source. +/.AGENTS/ +/AGENTS.md +/reference-images/ + +__pycache__/ +*.py[cod] *.swp *.swo *~ -.cache/ -meson-private/ -plan.md +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c3f947..1863de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,89 +1,117 @@ # Changelog -## v1.0.2 +All notable UsageBeam changes are documented here. -### Fixed -- Added GNOME 49 and 50 to shell-version compatibility -- Removed deprecated `version` field from metadata.json -- Dropdown now stays open when clicking refresh (deferred reopen) -- Removed invalid `accessible_name` from PopupMenuItem (caused extension to fail loading) - -## v1.0.1 +## Unreleased ### Added -- Accessible names for screen readers on panel indicator, dots, and summaries -- System theme support in CSS (uses `currentColor` and opacity for better theme integration) -- CONTRIBUTING.md with development setup and code style guidelines -- GitHub Actions CI: shellcheck, meson build, bats tests -- Bats test suite for shell script (JSON structure, required fields, cleanup) + +- UsageBeam product identity with the permanent extension UUID + `usagebeam@oo7kc.github.io` and settings schema + `org.gnome.shell.extensions.usagebeam`. +- Active-provider panel readout with provider icon, shortest current quota, and + reset countdown. +- Left area, right area, left-of-calendar, and right-of-calendar placement. +- Expandable seven-day activity chart and compact per-model token totals. +- Native preferences for placement, refresh interval, and notifications. +- Explicit quota severity states: caution at 80%, warning at 90%, and exhausted + at 100%. +- Product screenshots for the panel indicator, account limits, and local + activity views. ### Changed -- CSS colors use `currentColor` where possible for better theme compatibility -- Status dot and summary text use opacity for dimmed states instead of hardcoded colors -## v1.0.0 +- Rebuilt the popup as a compact, accent-aware GNOME surface with aligned quota + metrics and stable panel geometry. +- Account limits, local activity, and saved results now remain available + independently when one source cannot refresh. +- Codex discovery supports common user-local and Node version-manager layouts. +- Recognized Freeby alpha settings and derived data migrate without copying + credentials or overwriting existing UsageBeam data. ### Fixed -- Crash when subprocess returns empty/null output -- Race condition from concurrent refresh calls (clicking during auto-refresh) -- Stale async callback after extension disable -- False "limit reached" notification on first refresh -- Schema not compiled during install (gsettings now works out of the box) -- Codex percentage parser using string instead of boolean for `has_remaining` -- Copilot percentage exceeding 100% on overages -- Cursor showing "resets now" when billing cycle end is unknown -- `copilot-setup.sh` infinite loop (now times out after 5 minutes) -- Shell script temp directory shadowing system `$TMPDIR` -- Shell script hanging forever when curl or npx times out -### Changed -- Consistent percentage display across all providers -- Metadata: added `version` and `settings-schema` fields -- Metadata: removed unreleased GNOME shell versions from compatibility list -- GSettings: enforced 30–3600s range on refresh interval -- Build: schema compilation now runs automatically during `meson install` -- README: added prerequisites, uninstall instructions, corrected copilot-setup path +- Expanded menus adapt to smaller work areas with keyboard-accessible pagination, + preserving access to all quotas and model totals without nested scrolling. +- Calendar-gap centering now accounts for neighboring panel extensions and their + size changes. +- Local activity totals recover when provider history files are rewritten or + contain an unreadable record. +- Large or malformed provider responses no longer interrupt future refreshes. +- Calendar-side readouts anchor toward the clock on both sides, keeping reserved + width outside the visible gap when provider names and countdowns change. +- Each quota now shows its reset countdown below the bar as `Resets in …`, with + its percentage right-aligned above the bar. +- Daily chart columns allocate visible bottom-aligned bars for non-zero activity. +- Model fills remain compact and stable across repeated layout passes. +- Calendar placement remains fixed while providers and quota values change. +- Reset values, percentages, and separator dots retain consistent alignment. +- The active-provider readout uses compact internal and calendar-side spacing, + while retaining a fixed width across provider changes. +- Panel usage metrics share the provider label's visual baseline. +- The panel always prefers the shortest available quota period, even at 0% or + 100%, instead of switching to a longer window with higher usage. +- Seven-day activity emphasizes its local/account scope without repeating a + date range already represented by the chart. +- The activity disclosure now shows only its title and chevron; period, totals, + and source remain in the expanded content where they are needed. +- Usage alerts now progress through configured, warning, and exhausted milestones + once per quota period and combine simultaneous crossings into one notification. +- Quota labels use compact `5H Session` and `Weekly Reserve` names, while expanded + activity keeps scope implicit and gives the chart more breathing room. +- Expired quota windows are not presented as current cached data. +- Empty, malformed, or unavailable provider responses are never shown as zero + usage. + +### Removed + +- Unverified Cursor and Copilot preview adapters. Providers now ship only after + their data sources and failure states have completed validation. +- Superseded runtime scripts and historical UI artifacts that were not part of + the product. + +## 2.0.0-alpha.2 - 2026-09-06 -### Security -- Fixed Python code injection risk when `$HOME` contains single quotes -- Removed unused `json_escape` function +### Added -## v0.5.0 +- Claude Code installation and saved OAuth sign-in detection. +- Supported 5-hour, weekly, and model-scoped Claude quota windows. +- Incremental Claude Code activity with per-model input, output, cache-read, and + cache-write totals. +- Shared provider switching between Codex and Claude Code. ### Fixed -- Dropdown now stays open when clicking the refresh icon -- Consistent percentage display for Copilot (was showing token counts) -## v0.4.0 +- Expired cached quota windows are discarded after their reset time. +- Missing, absent, and expired Claude authentication remain distinct states. -### Added -- Desktop notifications on provider limit hit -- Auto-refresh on wake from sleep (systemd PrepareForSleep) -- Configurable refresh interval via gsettings -- Settings UI in Extension Manager (prefs.js) -- Parallel provider fetches (4.6s → 1.9s) +### Compatibility -### Changed -- Split extension.js into extension.js + indicator.js + prefs.js +- Tested with Claude Code 2.1.218. +- Supports standard and model-scoped limits, expired sign-ins, duplicate local + activity records, and separate cache-token categories. +- Live account-limit validation was not available for this prerelease. -## v0.3.0 +## 2.0.0-alpha.1 - 2026-09-06 ### Added -- Click panel to refresh -- Clickable refresh icon in dropdown -- `has_remaining` field for accurate provider count + +- Codex account limits with distinct quota periods and reset times. +- Incremental local Codex history with seven-day and model aggregates. +- Private local storage and temporary saved-value recovery during refresh + failures. ### Fixed -- Consistent comma separator and countdown format -- Codex parsed via `--json` flag for reliable parsing -## v0.2.0 +- Refresh occurs after resume instead of before suspend. +- Repeated refreshes no longer overlap. +- Notifications appear once for each reached milestone and quota period. -### Added -- Real provider integrations: Codex, Cursor, Copilot -- `copilot-setup.sh` device-flow auth script -- Panel indicator with colored status +## 1.0.2 - 2026-04-27 -## v0.1.0 +- Added GNOME 49 and 50 metadata compatibility and removed deprecated extension + version metadata. +- Kept the dropdown open after manual refresh and fixed an invalid popup + accessibility property. -- Initial release with placeholder data +Earlier history remains available in the repository's Git tags and releases. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 27606ca..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,83 +0,0 @@ -# Contributing - -Thanks for your interest in Freeby. - -## Development - -```bash -git clone https://github.com/kcnewman/freeby.git && cd freeby -git checkout dev -meson setup build --prefix=$HOME/.local -meson install -C build -``` - -Restart GNOME Shell (Alt+F2, type `r`, Enter) and enable: - -```bash -gnome-extensions enable freeby@kelvin.local -``` - -## Project structure - -``` -freeby/ -├── extension.js # Entry point (enable/disable) -├── indicator.js # Panel UI, dropdown, refresh -├── prefs.js # Settings UI in Extension Manager -├── scripts/ -│ ├── freeby.sh # Data aggregator (parallel provider fetches) -│ └── copilot-setup.sh # GitHub device-flow auth -├── schemas/ -│ └── *.gschema.xml # GSettings schema -├── build-aux/ -│ └── compile-schemas.sh -├── tests/ -│ └── freeby.bats # Shell script tests -├── .github/workflows/ -│ └── ci.yml # shellcheck, meson build, bats tests -└── docs/ - ├── s1.png - └── s2.png -``` - -## Code style - -- Keep it simple. No unnecessary abstraction. -- Lowercase sentence case for user-facing text. -- Follow existing patterns in the codebase. - -## Commits - -Use [conventional commits](https://www.conventionalcommits.org/): - -- `feat:` new feature -- `fix:` bug fix -- `docs:` documentation only -- `chore:` maintenance, tests, CI - -## Testing - -```bash -# test the data script -bash scripts/freeby.sh | python3 -m json.tool - -# run shellcheck -shellcheck scripts/freeby.sh scripts/copilot-setup.sh - -# run bats tests -bats tests/ -``` - -CI runs automatically on push to `main` or `dev`, and on all pull requests. - -## Branches - -- `main` — stable releases -- `dev` — active development - -## Pull requests - -1. Fork and create a branch from `dev` -2. Make your changes -3. Test manually (the extension runs in your live desktop) -4. Open a PR against `dev` diff --git a/README.md b/README.md index 8ecdf50..a830a12 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,133 @@ -

-

Freeby

-

Track your free-tier AI coding tool usage at a glance.

-

- -

- Panel indicator - Dropdown menu -

- -

- Features · - Providers · - Install · - Settings · - Contributing · - Releases -

- -### Features - -- **Panel indicator** — colored `ai·N` shows available providers at a glance -- **Dropdown** — per-provider usage, limits, and reset countdown -- **Notifications** — desktop alert when a provider hits its limit -- **Auto-refresh on wake** — refreshes immediately after sleep -- **Parallel fetches** — all providers queried in parallel (~2s) -- **Configurable** — adjust refresh interval and notifications in settings -- **Accessible** — screen reader support for all UI elements -- **Theme-aware** — works with light and dark GNOME themes - -### Supported providers - -| Provider | Auth source | Data source | -|---|---|---| -| 🟡 **Codex** | `~/.codex/auth.json` | `codex-check` CLI | -| 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API | -| 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API | - -### Install - -**Prerequisites** - -- GNOME Shell 45+ (Wayland or X11) -- `python3`, `curl`, `meson`, `ninja-build` -- `npx` (for Codex) -- `gh` CLI (for Copilot, optional) - -
-Fedora +# UsageBeam -```bash -sudo dnf install meson ninja-build python3 curl glib2-devel -``` -
+UsageBeam puts Codex and Claude Code usage where it is easiest to see: in the +GNOME top panel. The compact indicator shows the active provider's shortest quota +and reset countdown; its popup reveals every reported limit plus seven days of +local token and model activity. -
-Ubuntu / Debian +The product concept was adapted for GNOME Shell from the +[Agents plugin in Omarchy](https://github.com/omacom/omarchy/blob/quattro/shell/plugins/agents/README.md). +UsageBeam is an independent implementation designed around GNOME's native panel, +preferences, accessibility, and lifecycle conventions. -```bash -sudo apt install meson ninja-build python3 curl libglib2.0-dev-bin -``` -
+## At a glance -**Build and install** +- Live account limits and reset windows for Codex and Claude Code. +- A stable, single-provider panel indicator. +- Four panel placements: left area, right area, left of calendar, or right of + calendar. +- Seven-day local activity chart and per-model token totals. +- Independent live, local, cached, syncing, and setup states. +- Light and dark surfaces derived from the active GNOME accent color. +- Private local storage with no prompt, response, transcript, or credential + retention. -```bash -git clone https://github.com/kcnewman/freeby.git && cd freeby -meson setup build --prefix=$HOME/.local -meson install -C build -``` +UsageBeam displays only data reported by a provider or found in local usage +records. Local activity covers this device; it is not billing data and is never +converted into an account quota. -Then restart your session and enable: +## Screenshots -```bash -gnome-extensions enable freeby@kelvin.local -``` +### Panel indicator + +![UsageBeam showing the active Codex quota beside the GNOME calendar](docs/images/panel-indicator.png) + +### Account limits + +![UsageBeam showing Codex account limits and reset times](docs/images/account-limits.png) + +### Local activity + +![UsageBeam showing a seven-day activity chart and token totals by model](docs/images/local-activity.png) + +### Preferences + +_Screenshot placeholder — provider, placement, refresh, and notification settings._ + +## Supported providers -> **Copilot users:** If `gh` isn't installed, run `copilot-setup.sh` first. +### Codex -### Settings +UsageBeam reads account quota windows through the installed Codex CLI app-server +and scans local Codex session records for activity. The CLI must be installed +and signed in to show live limits. Common user-local, fnm, nvm, mise, asdf, and +Volta installations are discovered even when GNOME Shell has a restricted +`PATH`. -| Setting | Default | Range | Description | -|---|---|---|---| -| Refresh interval | `120s` | 30–3600s | How often to check usage | -| Notifications | `on` | — | Alert when a provider hits its limit | +### Claude Code -Configure via Extension Manager or CLI: +UsageBeam reads supported account limits from Claude Code's saved OAuth sign-in +and scans local Claude Code project records for activity. Account limits require +an active sign-in; local activity can remain available independently. + +See the detailed [Codex](docs/providers/codex.md) and +[Claude Code](docs/providers/claude.md) provider notes for source and +compatibility details. + +## Requirements + +- GNOME Shell 50. +- GJS with Gio, GLib, and Soup 3 introspection data. +- The Codex CLI and/or Claude Code, installed and signed in for account limits. + +## Install + +Download the UsageBeam ZIP from [GitHub Releases](https://github.com/oo7kc/usagebeam/releases), +then run: ```bash -gsettings --schemadir ~/.local/share/glib-2.0/schemas \ - set org.gnome.shell.extensions.freeby refresh-interval 60 +gnome-extensions install ./usagebeam@oo7kc.github.io-VERSION.zip +gnome-extensions enable usagebeam@oo7kc.github.io ``` -### Uninstall +Log out and back in when installing UsageBeam for the first time so GNOME Shell +can discover the new extension identity. When replacing an existing UsageBeam +installation, add `--force`; it tells `gnome-extensions` that overwriting the +installed copy is intentional. + +## Settings + +Open the preferences window from the popup or with: ```bash -gnome-extensions disable freeby@kelvin.local -rm -rf ~/.local/share/gnome-shell/extensions/freeby@kelvin.local -rm -f ~/.local/bin/freeby.sh ~/.local/bin/copilot-setup.sh -rm -f ~/.local/share/glib-2.0/schemas/org.gnome.shell.extensions.freeby.gschema.xml -rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled +gnome-extensions prefs usagebeam@oo7kc.github.io ``` -### Debug +Preferences control panel placement, refresh frequency, and quota notifications. -```bash -# test the data script -bash ~/.local/bin/freeby.sh | python3 -m json.tool +## Privacy and storage -# watch extension logs -journalctl -f -o cat /usr/bin/gnome-shell -``` +UsageBeam processes provider data locally and stores only derived usage metadata: + +- State: `$XDG_STATE_HOME/usagebeam` +- Incremental history cache: `$XDG_CACHE_HOME/usagebeam` + +Files are created with private permissions. Credentials are read only when a +provider requires them and are never copied, cached, or logged. Account and +session identifiers used for change detection or deduplication are hashed before +persistence. Prompt and response content is ignored. + +Recognized settings and derived data from older Freeby alpha builds are migrated +once without overwriting existing UsageBeam data. Credentials are excluded from +that migration. -### Contributing +## Troubleshooting -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. +- **Limits show Setup:** open the provider's CLI and confirm it is signed in. +- **Activity is local only:** this is expected; UsageBeam does not merge records + from other devices. +- **Values show Cached:** the last valid values remain visible during a temporary + provider or network failure. +- **The indicator is absent after installation:** log out and back in, then enable + the extension again. +- **Another panel extension changes placement:** UsageBeam follows GNOME's panel + boxes, so layout extensions may override the final arrangement. -### License +## Remove + +```bash +gnome-extensions disable usagebeam@oo7kc.github.io +gnome-extensions uninstall usagebeam@oo7kc.github.io +``` -[MIT](LICENSE) +UsageBeam is licensed under the [MIT License](LICENSE). diff --git a/docs/images/account-limits.png b/docs/images/account-limits.png new file mode 100644 index 0000000..4bb6724 Binary files /dev/null and b/docs/images/account-limits.png differ diff --git a/docs/images/local-activity.png b/docs/images/local-activity.png new file mode 100644 index 0000000..69bea8c Binary files /dev/null and b/docs/images/local-activity.png differ diff --git a/docs/images/panel-indicator.png b/docs/images/panel-indicator.png new file mode 100644 index 0000000..070fdea Binary files /dev/null and b/docs/images/panel-indicator.png differ diff --git a/docs/providers/claude.md b/docs/providers/claude.md new file mode 100644 index 0000000..b4dbe6f --- /dev/null +++ b/docs/providers/claude.md @@ -0,0 +1,37 @@ +# Claude Code support + +UsageBeam can show supported Claude Code account limits alongside seven days of +activity recorded on the current device. + +## Set up Claude Code + +1. Install Claude Code. +2. Open Claude Code and complete its normal sign-in flow. +3. Enable Claude in UsageBeam preferences. +4. Select **Refresh** from the UsageBeam menu. + +## What UsageBeam shows + +- The shortest available quota window in the top panel, including when it is + exhausted. +- The current 5-hour, weekly, and model-scoped limits reported for the signed-in + account in the menu. +- A seven-day token chart and per-model totals from local Claude Code activity. + +Account limits and local activity are independent. Local activity is not an +account-wide total, subscription quota, or billable cost. + +## Privacy + +UsageBeam reads only the sign-in and usage fields needed for these views. It +does not store access tokens, prompts, responses, credentials, or raw session +identifiers. + +## If Claude shows Setup + +- Open Claude Code and confirm the same user is signed in. +- Refresh UsageBeam after completing sign-in. +- If limits remain unavailable, local activity may still continue to work. + +Claude Code's account-usage interface can change independently of UsageBeam. An +unsupported response is shown as unavailable rather than as zero usage. diff --git a/docs/providers/codex.md b/docs/providers/codex.md new file mode 100644 index 0000000..dbb7cd7 --- /dev/null +++ b/docs/providers/codex.md @@ -0,0 +1,38 @@ +# Codex support + +UsageBeam can show live Codex account limits alongside seven days of activity +recorded on the current device. + +## Set up Codex + +1. Install the Codex CLI. +2. Sign in with `codex login`. +3. Enable Codex in UsageBeam preferences. +4. Select **Refresh** from the UsageBeam menu. + +UsageBeam recognizes standard command locations as well as common fnm, nvm, +mise, asdf, and Volta installations. + +## What UsageBeam shows + +- The shortest available quota window in the top panel, including when it is + exhausted. +- Every session, weekly, reserve, or scoped limit reported for the signed-in + account in the menu. +- A seven-day token chart and per-model totals from local Codex activity. + +Account limits and local activity are independent. Local activity is not an +account-wide total, subscription quota, or billable cost. + +## Privacy + +UsageBeam reads only the account and usage fields needed for these views. It +does not store account email addresses, prompts, responses, credentials, or raw +session identifiers. + +## If Codex shows Setup + +- Confirm `codex` runs from a terminal for the same user. +- Confirm the CLI is signed in. +- Refresh UsageBeam after signing in. +- If the CLI was just installed, log out of GNOME and back in once. diff --git a/docs/s1.png b/docs/s1.png deleted file mode 100644 index 02694cc..0000000 Binary files a/docs/s1.png and /dev/null differ diff --git a/docs/s2.png b/docs/s2.png deleted file mode 100644 index 1858500..0000000 Binary files a/docs/s2.png and /dev/null differ diff --git a/extension.js b/extension.js index 00aae09..a71a532 100644 --- a/extension.js +++ b/extension.js @@ -1,16 +1,48 @@ -import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; -import { FreebyIndicator } from './indicator.js'; +import {notificationBody} from './src/core/notifications.js'; +import {UsageBeamIndicator} from './src/ui/indicator.js'; +import {clearIndicatorPlacement, placeIndicator} from './src/ui/panelPlacement.js'; +import {migrateLegacyInstall} from './src/services/migration.js'; +import {UsageService} from './src/services/usageService.js'; -export default class FreebyExtension extends Extension { +export default class UsageBeamExtension extends Extension { enable() { - this._settings = this.getSettings('org.gnome.shell.extensions.freeby'); - this._settingsId = this._settings.connect('changed::refresh-interval', () => this._indicator?._setupTimer()); - this._indicator = new FreebyIndicator(this._settings); - Main.panel.addToStatusArea(this.uuid, this._indicator); + this._settings = this.getSettings('org.gnome.shell.extensions.usagebeam'); + migrateLegacyInstall(this._settings); + this._indicator = new UsageBeamIndicator(this._settings, this.path, () => this.openPreferences()); + Main.panel.addToStatusArea(this.uuid, this._indicator, 0, 'center'); + this._placeIndicator(); + this._service = new UsageService(this._settings, this.path, () => this._indicator?.render(), alerts => { + const message = notificationBody(alerts); + if (message) + Main.notify('UsageBeam usage alert', message); + }); + this._indicator.attach(this._service); + this._settingsIds = []; + this._settingsIds.push(this._settings.connect('changed::enabled-providers', () => { + this._service.configure(); + this._indicator.render(); + this._service.refreshAll(); + })); + this._settingsIds.push(this._settings.connect('changed::default-provider', () => this._indicator.render())); + this._settingsIds.push(this._settings.connect('changed::panel-position', () => this._placeIndicator())); + this._settingsIds.push(this._settings.connect('changed::history-retention-days', () => + this._service.refreshAll(true))); + this._service.refreshAll(); } + + _placeIndicator() { + placeIndicator(this._indicator, this._settings.get_string('panel-position')); + } + disable() { - if (this._settingsId) { this._settings.disconnect(this._settingsId); this._settingsId = null; } + for (const id of this._settingsIds ?? []) + this._settings.disconnect(id); + this._settingsIds = null; + this._service?.destroy(); + this._service = null; + clearIndicatorPlacement(this._indicator); this._indicator?.destroy(); this._indicator = null; this._settings = null; diff --git a/icons/claude.svg b/icons/claude.svg new file mode 100644 index 0000000..0489ce8 --- /dev/null +++ b/icons/claude.svg @@ -0,0 +1,8 @@ + + Claude + + diff --git a/icons/codex-symbolic.svg b/icons/codex-symbolic.svg new file mode 100644 index 0000000..0f55071 --- /dev/null +++ b/icons/codex-symbolic.svg @@ -0,0 +1,6 @@ + + Codex + + diff --git a/indicator.js b/indicator.js deleted file mode 100644 index b2aaa8a..0000000 --- a/indicator.js +++ /dev/null @@ -1,140 +0,0 @@ -import Clutter from 'gi://Clutter'; -import GLib from 'gi://GLib'; -import Gio from 'gi://Gio'; -import St from 'gi://St'; -import GObject from 'gi://GObject'; - -import * as Main from 'resource:///org/gnome/shell/ui/main.js'; -import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; -import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; - -const PROVIDERS = ['codex', 'cursor', 'copilot']; -const LABELS = { codex: 'Codex', cursor: 'Cursor', copilot: 'Copilot' }; -const SCRIPT = GLib.build_filenamev([GLib.get_home_dir(), '.local', 'bin', 'freeby.sh']); - -export const FreebyIndicator = GObject.registerClass( - class FreebyIndicator extends PanelMenu.Button { - _init(settings) { - super._init(0.0, 'Freeby', false); - this._settings = settings; - this._prev = {}; - for (const k of PROVIDERS) this._prev[k] = null; - this._refreshSeq = 0; - - this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' }); - this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' }); - this._panelBox.add_child(this._label); - this.add_child(this._panelBox); - this.connect('button-press-event', () => { this._refresh(); return false; }); - - this._items = {}; - for (const k of PROVIDERS) { - const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` }); - const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` }); - const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER }); - const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` }); - box.add_child(dot); - box.add_child(name); - box.add_child(summary); - const item = new PopupMenu.PopupMenuItem('', { reactive: false, can_focus: false }); - item.add_child(box); - this.menu.addMenuItem(item); - this._items[k] = { dot, summary }; - } - - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false }); - this._statusItem.label.add_style_class_name('freeby-status'); - this._statusItem.connect('activate', () => { this._refresh(); GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { this.menu.open(); return GLib.SOURCE_REMOVE; }); }); - this.menu.addMenuItem(this._statusItem); - - this._timerId = null; - this._setupTimer(); - this._refresh(); - this._monitorWake(); - } - - _setupTimer() { - if (this._timerId) GLib.source_remove(this._timerId); - const sec = Math.max(30, this._settings.get_int('refresh-interval')); - this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, sec, () => { this._refresh(); return GLib.SOURCE_CONTINUE; }); - } - - _monitorWake() { - try { - this._sleepId = Gio.DBus.system.signal_subscribe( - 'org.freedesktop.login1', 'org.freedesktop.login1.Manager', - 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE, - (_, __, ___, ____, _____, params) => { - if (params.get_child_value(0).get_boolean()) { - log('freeby: woke from sleep, refreshing'); - this._refresh(); - } - }); - } catch (e) { logError(e, 'freeby: could not monitor sleep/wake'); } - } - - _notify(title, body) { - if (!this._settings.get_boolean('notifications-enabled')) return; - try { - const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic'); - Main.messageTray.add(src); - src.addNotification(new Main.messageTray.Notification({ source: src, title, body })); - } catch (e) { logError(e, 'freeby: notification failed'); } - } - - _setError() { - this._label.text = 'ai'; - this._label.style_class = 'freeby-panel-label'; - } - - _refresh() { - this._refreshSeq++; - const seq = this._refreshSeq; - let proc; - try { proc = Gio.Subprocess.new(['/bin/bash', SCRIPT], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); } - catch (e) { this._setError(); logError(e, 'freeby: failed to spawn script'); return; } - - proc.communicate_utf8_async(null, null, (p, res) => { - if (seq !== this._refreshSeq) return; - let stdout, stderr; - try { [, stdout, stderr] = p.communicate_utf8_finish(res); } - catch (e) { this._setError(); logError(e, 'freeby: subprocess failed'); return; } - if (!p.get_successful()) { this._setError(); log(`freeby: script error: ${stderr}`); return; } - this._apply(stdout); - }); - } - - _apply(stdout) { - let data; - try { data = JSON.parse(stdout); } - catch (e) { this._setError(); logError(e, 'freeby: bad JSON'); return; } - if (!data || typeof data !== 'object') { this._setError(); log('freeby: empty or invalid data'); return; } - - const active = PROVIDERS.filter(k => data[k]?.has_remaining).length; - const total = PROVIDERS.filter(k => data[k]?.available).length; - this._label.text = active > 0 ? `ai\u00B7${active}` : 'ai'; - this._label.style_class = 'freeby-panel-label' + (active === total && total > 0 ? ' freeby-panel-green' : active > 0 ? ' freeby-panel-yellow' : total > 0 ? ' freeby-panel-red' : ''); - - const hit = PROVIDERS.filter(k => data[k]?.available && !data[k].has_remaining && this._prev[k]?.has_remaining !== false).map(k => LABELS[k]); - this._prev = {}; - for (const k of PROVIDERS) this._prev[k] = data[k] ? { has_remaining: data[k].has_remaining } : null; - if (hit.length) this._notify('Usage limit reached', `${hit.join(', ')} ${hit.length === 1 ? 'has' : 'have'} hit their limit`); - - for (const k of PROVIDERS) { - const d = data[k], { dot, summary } = this._items[k]; - if (!d) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = 'no data'; summary.style_class = 'freeby-item-summary'; } - else if (!d.available) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = d.summary || 'not available'; summary.style_class = 'freeby-item-summary freeby-dim'; } - else if (d.summary?.includes('limit reached')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-red'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-red'; } - else if (d.summary?.includes('expired') || d.summary?.includes('parse error')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-yellow'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-yellow'; } - else { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-green'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-green'; } - } - this._statusItem.label.text = `\u21BB Last checked: ${GLib.DateTime.new_now_local().format('%H:%M:%S')}`; - } - - destroy() { - if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; } - if (this._sleepId) { Gio.DBus.system.signal_unsubscribe(this._sleepId); this._sleepId = null; } - super.destroy(); - } - }); diff --git a/meson.build b/meson.build index 6af6f8f..5116c41 100644 --- a/meson.build +++ b/meson.build @@ -1,21 +1,19 @@ -project('freeby', version: '1.0.2', license: 'MIT', +project('usagebeam', version: '2.0.0-alpha.3-dev.0', license: 'MIT', meson_version: '>= 0.56.0', default_options: ['warning_level=0']) -uuid = 'freeby@kelvin.local' +uuid = 'usagebeam@oo7kc.github.io' prefix = get_option('prefix') extdir = join_paths(prefix, 'share', 'gnome-shell', 'extensions', uuid) -bindir = join_paths(prefix, 'bin') -schemadir = join_paths(prefix, 'share', 'glib-2.0', 'schemas') +schemadir = join_paths(extdir, 'schemas') -install_data('extension.js', 'indicator.js', 'prefs.js', 'metadata.json', 'stylesheet.css', +install_data('extension.js', 'prefs.js', 'metadata.json', 'stylesheet.css', 'LICENSE', install_dir: extdir) -install_data('scripts/freeby.sh', 'scripts/copilot-setup.sh', - install_dir: bindir, - install_mode: 'rwxr-xr-x') +install_subdir('src', install_dir: extdir) +install_subdir('icons', install_dir: extdir) -install_data('schemas/org.gnome.shell.extensions.freeby.gschema.xml', +install_data('schemas/org.gnome.shell.extensions.usagebeam.gschema.xml', install_dir: schemadir) meson.add_install_script('build-aux/compile-schemas.sh', schemadir) diff --git a/metadata.json b/metadata.json index f131767..19b94a8 100644 --- a/metadata.json +++ b/metadata.json @@ -1,8 +1,8 @@ { - "uuid": "freeby@kelvin.local", - "name": "Freeby", - "description": "Minimal panel indicator for local AI coding tool usage (Codex, Cursor, Copilot)", - "shell-version": ["45", "46", "47", "48", "49", "50"], - "settings-schema": "org.gnome.shell.extensions.freeby", - "url": "https://github.com/kcnewman/freeby" + "uuid": "usagebeam@oo7kc.github.io", + "name": "UsageBeam", + "description": "Private, at-a-glance usage monitoring for Codex and Claude Code, with account limits, reset windows, local token activity and model breakdowns.", + "shell-version": ["50"], + "settings-schema": "org.gnome.shell.extensions.usagebeam", + "url": "https://github.com/oo7kc/usagebeam" } diff --git a/package.json b/package.json new file mode 100644 index 0000000..992f777 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "usagebeam", + "version": "2.0.0-alpha.3-dev.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test tests/unit/*.test.js", + "lint": "node tools/check.js", + "test:integration": "gjs -m tests/integration/collector.js", + "test:stress": "gjs -m tests/integration/stress.js", + "check": "npm run lint && npm test && npm run test:integration", + "pack": "python3 tools/package.py", + "verify": "npm run check && npm run pack", + "smoke:shell": "python3 tools/smoke-shell.py" + }, + "engines": { "node": ">=20" } +} diff --git a/prefs.js b/prefs.js index b67f37f..c892c61 100644 --- a/prefs.js +++ b/prefs.js @@ -1,29 +1,46 @@ import Adw from 'gi://Adw'; import Gtk from 'gi://Gtk'; -import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; +import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; -export default class FreebyPreferences extends ExtensionPreferences { +export default class UsageBeamPreferences extends ExtensionPreferences { fillPreferencesWindow(window) { - const settings = this.getSettings('org.gnome.shell.extensions.freeby'); + const settings = this.getSettings('org.gnome.shell.extensions.usagebeam'); const page = new Adw.PreferencesPage(); window.add(page); - const group = new Adw.PreferencesGroup({ title: 'General' }); + const group = new Adw.PreferencesGroup({title: 'General'}); page.add(group); + const positions = ['left', 'right', 'left-of-calendar', 'right-of-calendar']; + const positionRow = new Adw.ComboRow({ + title: 'Panel position', + subtitle: 'Choose a side area or place usage beside the calendar', + model: Gtk.StringList.new(['Left panel', 'Right panel', + 'Left of calendar', 'Right of calendar']), + }); + const syncPosition = () => { + const selected = positions.indexOf(settings.get_string('panel-position')); + positionRow.selected = selected >= 0 ? selected : 3; + }; + syncPosition(); + positionRow.connect('notify::selected', () => + settings.set_string('panel-position', positions[positionRow.selected])); + settings.connect('changed::panel-position', syncPosition); + group.add(positionRow); + const intervalRow = new Adw.SpinRow({ title: 'Refresh interval', subtitle: 'How often to check usage (seconds, min 30)', - adjustment: new Gtk.Adjustment({ lower: 30, upper: 3600, step_increment: 10, page_increment: 60 }), + adjustment: new Gtk.Adjustment({lower: 30, upper: 3600, step_increment: 10, page_increment: 60}), }); settings.bind('refresh-interval', intervalRow, 'value', 0); group.add(intervalRow); const notifyRow = new Adw.SwitchRow({ title: 'Notifications', - subtitle: 'Alert when a provider hits its limit', + subtitle: 'Alert once when usage crosses each warning milestone', }); settings.bind('notifications-enabled', notifyRow, 'active', 0); group.add(notifyRow); diff --git a/schemas/org.gnome.shell.extensions.freeby.gschema.xml b/schemas/org.gnome.shell.extensions.freeby.gschema.xml deleted file mode 100644 index 4af8f75..0000000 --- a/schemas/org.gnome.shell.extensions.freeby.gschema.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - 120 - - Refresh interval - How often to refresh usage data, in seconds (30–3600) - - - true - Enable notifications - Show desktop notifications when a provider hits its limit - - - diff --git a/schemas/org.gnome.shell.extensions.usagebeam.gschema.xml b/schemas/org.gnome.shell.extensions.usagebeam.gschema.xml new file mode 100644 index 0000000..131bb67 --- /dev/null +++ b/schemas/org.gnome.shell.extensions.usagebeam.gschema.xml @@ -0,0 +1,47 @@ + + + + + 120 + + Refresh interval + How often to refresh usage data, in seconds (30–3600) + + + true + Enable notifications + Show one desktop notification when usage crosses each configured warning milestone + + + ['codex', 'claude'] + Enabled providers in display order + + + 'codex' + Selected usage provider + + + + + + + + + 'right-of-calendar' + Panel position + Place UsageBeam in the left or right panel area, or immediately beside the calendar + + + 30 + + Days of usage metadata to retain + + + 90 + + First usage percentage that triggers a warning + + + + diff --git a/scripts/copilot-setup.sh b/scripts/copilot-setup.sh deleted file mode 100644 index c872d5f..0000000 --- a/scripts/copilot-setup.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# One-time GitHub Copilot device-flow authentication. -# Saves token to ~/.config/freeby/copilot-token. -set -euo pipefail - -# --- prereq checks ----------------------------------------------------------- -for cmd in curl python3; do - if ! command -v "$cmd" >/dev/null 2>&1; then - echo "error: $cmd is required but not installed" >&2 - exit 1 - fi -done - -CONFIG_DIR="$HOME/.config/freeby" -TOKEN_FILE="$CONFIG_DIR/copilot-token" -CLIENT_ID="Iv1.b507a08c87ecfe98" -SCOPE="read:user" -MAX_ATTEMPTS=60 - -mkdir -p "$CONFIG_DIR" - -if [ -f "$TOKEN_FILE" ]; then - echo "Token already exists at $TOKEN_FILE" - echo "Delete it and re-run to re-authenticate." - exit 0 -fi - -echo "Requesting device code..." -DEVICE_RESP="$(curl -s -X POST 'https://github.com/login/device/code' \ - -H 'Accept: application/json' \ - -d "client_id=$CLIENT_ID&scope=$SCOPE")" - -DEVICE_CODE="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('device_code',''))")" -USER_CODE="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('user_code',''))")" -VERIFICATION_URI="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('verification_uri',''))")" -INTERVAL="$(printf '%s' "$DEVICE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('interval',5))")" - -if [ -z "$DEVICE_CODE" ] || [ -z "$USER_CODE" ]; then - echo "Failed to get device code. Response:" - printf '%s\n' "$DEVICE_RESP" - exit 1 -fi - -echo "" -echo "1. Go to: $VERIFICATION_URI" -echo "2. Enter code: $USER_CODE" -echo "" -echo "Waiting for authentication... (timeout after $MAX_ATTEMPTS attempts)" - -attempt=0 -while [ "$attempt" -lt "$MAX_ATTEMPTS" ]; do - attempt=$((attempt + 1)) - sleep "$INTERVAL" - TOKEN_RESP="$(curl -s -X POST 'https://github.com/login/oauth/access_token' \ - -H 'Accept: application/json' \ - -d "client_id=$CLIENT_ID&device_code=$DEVICE_CODE&grant_type=urn:ietf:params:oauth:grant-type:device_code")" - - TOKEN="$(printf '%s' "$TOKEN_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('access_token',''))")" - ERROR="$(printf '%s' "$TOKEN_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('error',''))")" - - if [ -n "$TOKEN" ]; then - printf '%s' "$TOKEN" > "$TOKEN_FILE" - chmod 600 "$TOKEN_FILE" - echo "Token saved to $TOKEN_FILE" - exit 0 - fi - - if [ "$ERROR" != "authorization_pending" ]; then - echo "Error: $ERROR" - printf '%s\n' "$TOKEN_RESP" - exit 1 - fi - - printf '.' -done - -echo "" -echo "Timed out after $MAX_ATTEMPTS attempts. Try again when ready." -exit 1 diff --git a/scripts/freeby.sh b/scripts/freeby.sh deleted file mode 100755 index 4da906b..0000000 --- a/scripts/freeby.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env bash -# Aggregates local AI coding tool usage into one JSON blob. -# Run standalone: bash ~/.local/bin/freeby.sh | python3 -m json.tool - -set -uo pipefail - -FREEBY_TMP="$(mktemp -d)" || { echo '{"error":"failed to create temp dir"}'; exit 1; } -trap 'rm -rf "$FREEBY_TMP"' EXIT - -# --- codex ------------------------------------------------------------------- -fetch_codex() { - local out="$FREEBY_TMP/codex" - if [ ! -f "$HOME/.codex/auth.json" ]; then - echo '{"available":false,"has_remaining":false,"summary":"not detected"}' > "$out" - return - fi - local json - json="$(npx --yes codex-check --auth "$HOME/.codex/auth.json" --json 2>/dev/null)" - if [ -z "$json" ]; then - echo '{"available":true,"has_remaining":false,"summary":"auth.json found, codex-check produced no output"}' > "$out" - return - fi - printf '%s' "$json" | python3 -c " -import json, sys -from datetime import datetime, timezone -try: - data = json.load(sys.stdin) - if isinstance(data, list): - if not data: - raise ValueError('empty response from codex-check') - data = data[0] - w = data.get('windows', {}).get('primary', {}) - pct = float(w.get('percentUsed', 0)) - resets_at = w.get('resetsAt', '') - if resets_at: - dt = datetime.fromisoformat(resets_at.replace('Z', '+00:00')) - now = datetime.now(timezone.utc) - diff = dt - now - if diff.total_seconds() <= 0: cd = 'now' - elif diff.days > 0: cd = f'in {diff.days}d {diff.seconds//3600}h' - else: cd = f'in {diff.seconds//3600}h {(diff.seconds%3600)//60}m' - else: cd = 'unknown' - has = pct < 100 - summary = f'limit reached, resets {cd}' if pct >= 100 else f'{min(100, pct):.0f}% used, resets {cd}' - print(json.dumps({'available': True, 'has_remaining': has, 'summary': summary})) -except Exception as e: - print(json.dumps({'available': True, 'has_remaining': False, 'summary': f'parse error: {e}'})) -" > "$out" 2>/dev/null -} - -# --- cursor ------------------------------------------------------------------ -fetch_cursor() { - local out="$FREEBY_TMP/cursor" - local auth_file="$HOME/.config/cursor/auth.json" - if [ ! -f "$auth_file" ]; then - echo '{"available":false,"has_remaining":false,"summary":"not detected"}' > "$out" - return - fi - local token - token="$(python3 -c " -import json, sys -print(json.load(open(sys.argv[1])).get('accessToken','')) -" "$auth_file" 2>/dev/null)" - if [ -z "$token" ]; then - echo '{"available":true,"has_remaining":false,"summary":"auth.json found but no accessToken"}' > "$out" - return - fi - local resp http body - resp="$(curl -s -w '\n%{http_code}' --connect-timeout 5 --max-time 10 \ - -X POST 'https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage' \ - -H "Authorization: Bearer $token" \ - -H 'Content-Type: application/json' \ - -H 'Connect-Protocol-Version: 1' \ - -d '{}' 2>/dev/null)" - http="$(printf '%s' "$resp" | tail -1)" - body="$(printf '%s' "$resp" | sed '$d')" - - if [ "$http" = "401" ]; then - echo '{"available":true,"has_remaining":false,"summary":"token expired, reopen cursor to refresh"}' > "$out" - elif [ "$http" = "200" ] && [ -n "$body" ]; then - printf '%s' "$body" | python3 -c " -import json, sys -from datetime import datetime, timezone -try: - d = json.load(sys.stdin) - pct = float(d.get('planUsage', {}).get('totalPercentUsed', 0)) - end_ms = int(d.get('billingCycleEnd', '0')) - if end_ms == 0: - print(json.dumps({'available': True, 'has_remaining': pct < 100, 'summary': f'{min(100, pct):.0f}% used, resets unknown'})) - else: - dt = datetime.fromtimestamp(end_ms/1000, tz=timezone.utc) - now = datetime.now(timezone.utc) - diff = dt - now - if diff.total_seconds() <= 0: cd = 'now' - elif diff.days > 0: cd = f'in {diff.days}d {diff.seconds//3600}h' - else: cd = f'in {diff.seconds//3600}h {(diff.seconds%3600)//60}m' - has = pct < 100 - summary = f'limit reached, resets {cd}' if pct >= 100 else f'{min(100, pct):.0f}% used, resets {cd}' - print(json.dumps({'available': True, 'has_remaining': has, 'summary': summary})) -except Exception as e: - print(json.dumps({'available': True, 'has_remaining': False, 'summary': f'parse error: {e}'})) -" > "$out" 2>/dev/null - else - printf '{"available":true,"has_remaining":false,"summary":"API returned HTTP %s"}' "$http" > "$out" - fi -} - -# --- copilot ----------------------------------------------------------------- -fetch_copilot() { - local out="$FREEBY_TMP/copilot" - local token="" - if command -v gh >/dev/null 2>&1; then - token="$(gh auth token 2>/dev/null | tr -d '[:space:]')" - fi - if [ -z "$token" ] && [ -f "$HOME/.config/freeby/copilot-token" ]; then - token="$(tr -d '[:space:]' < "$HOME/.config/freeby/copilot-token" 2>/dev/null)" - fi - if [ -z "$token" ]; then - if [ ! -f "$HOME/.config/freeby/copilot-token" ] && ! command -v gh >/dev/null 2>&1; then - echo '{"available":false,"has_remaining":false,"summary":"run copilot-setup to authenticate"}' > "$out" - else - echo '{"available":false,"has_remaining":false,"summary":"not detected"}' > "$out" - fi - return - fi - local resp http body - resp="$(curl -s -w '\n%{http_code}' --connect-timeout 5 --max-time 10 \ - 'https://api.github.com/copilot_internal/user' \ - -H "Authorization: Bearer $token" \ - -H 'Content-Type: application/json' 2>/dev/null)" - http="$(printf '%s' "$resp" | tail -1)" - body="$(printf '%s' "$resp" | sed '$d')" - - if [ "$http" = "401" ] || [ "$http" = "403" ]; then - echo '{"available":true,"has_remaining":false,"summary":"auth expired, re-run copilot-setup"}' > "$out" - elif [ "$http" = "200" ] && [ -n "$body" ]; then - printf '%s' "$body" | python3 -c " -import json, sys -from datetime import datetime, timezone -try: - d = json.load(sys.stdin) - reset_str = d.get('quota_reset_date_utc', '') - dt = datetime.fromisoformat(reset_str.replace('Z', '+00:00')) if reset_str else None - q = d.get('quota_snapshots', {}).get('chat', {}) - if q.get('entitlement', 0) == 0: - q = d.get('quota_snapshots', {}).get('completions', {}) - remaining = q.get('remaining', 0) - entitlement = q.get('entitlement', 0) - used = q.get('credits_used', 0) - if dt: - now = datetime.now(timezone.utc) - diff = dt - now - if diff.total_seconds() <= 0: cd = 'now' - elif diff.days > 0: cd = f'in {diff.days}d {diff.seconds//3600}h' - else: cd = f'in {diff.seconds//3600}h {(diff.seconds%3600)//60}m' - else: cd = 'unknown' - has = remaining > 0 - if entitlement == 0: summary = f'no quota, resets {cd}' - elif remaining <= 0: summary = f'limit reached, resets {cd}' - else: summary = f'{min(100, round(used/entitlement*100))}% used, resets {cd}' - print(json.dumps({'available': True, 'has_remaining': has, 'summary': summary})) -except Exception as e: - print(json.dumps({'available': True, 'has_remaining': False, 'summary': f'parse error: {e}'})) -" > "$out" 2>/dev/null - else - printf '{"available":true,"has_remaining":false,"summary":"API returned HTTP %s"}' "$http" > "$out" - fi -} - -# --- run all in parallel ----------------------------------------------------- -fetch_codex & -fetch_cursor & -fetch_copilot & -wait - -# --- output ------------------------------------------------------------------ -python3 -c " -import json, sys -result = {} -for name in ('codex', 'cursor', 'copilot'): - try: - with open('$FREEBY_TMP/' + name) as f: - result[name] = json.load(f) - except Exception: - result[name] = {'available': False, 'has_remaining': False, 'summary': 'no data'} -print(json.dumps(result)) -" diff --git a/src/collector/main.js b/src/collector/main.js new file mode 100644 index 0000000..3bfbd0b --- /dev/null +++ b/src/collector/main.js @@ -0,0 +1,72 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GLibUnix from 'gi://GLibUnix'; +import System from 'system'; +import {record, section, validateRecord} from '../core/usage.js'; +import {collectClaude} from '../providers/claude.js'; +import {collectCodex} from '../providers/codex.js'; +import {commandSpec, findCommand} from '../services/commands.js'; +import {fingerprint, join, readJson} from '../services/files.js'; +import {scanHistory} from '../services/history.js'; +import {requestJson} from '../services/http.js'; +import {RpcClient} from '../services/process.js'; + +const COLLECTORS = Object.freeze({codex: collectCodex, claude: collectClaude}); +const HISTORY_ROOTS = { + codex: GLib.getenv('CODEX_HOME') || join(GLib.get_home_dir(), '.codex'), + claude: GLib.getenv('CLAUDE_CONFIG_DIR') || join(GLib.get_home_dir(), '.claude'), +}; +const id = ARGV[0]; +if (!Object.prototype.hasOwnProperty.call(COLLECTORS, id)) { + printerr('Usage: gjs -m src/collector/main.js [retention-days]'); + System.exit(2); +} +const retentionText = ARGV[1] ?? ''; +const requestedRetention = /^\d+$/.test(retentionText) ? Number(retentionText) : 30; +const retention = Math.max(7, Math.min(90, Number.isSafeInteger(requestedRetention) ? requestedRetention : 30)); +const cancellable = new Gio.Cancellable(); +const loop = new GLib.MainLoop(null, false); +let signal = GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, 15, () => { + signal = 0; + cancellable.cancel(); + return GLib.SOURCE_REMOVE; +}); +const io = { + fingerprint, + now: () => Date.now(), + hasCommand(name) { + try { findCommand(name); return true; } catch { return false; } + }, + scan(provider, suffixes, parser) { + const root = HISTORY_ROOTS[provider]; + if (!root) + throw new Error(`Unsupported local history provider: ${provider}`); + return scanHistory(provider, suffixes.map(suffix => join(root, suffix)), parser, {retention}); + }, + credentials(provider) { + if (provider === 'claude') + return readJson(join(HISTORY_ROOTS.claude, '.credentials.json'), null, 1024 * 1024); + return null; + }, + codexClient: () => new RpcClient(commandSpec('codex', ['app-server'], {runtimes: ['node']}), cancellable), + http: (url, options) => requestJson(url, {...options, cancellable}), +}; + +(async () => { + try { + const result = validateRecord(await COLLECTORS[id](io), id); + if (!cancellable.is_cancelled()) + print(JSON.stringify(result)); + } catch { + const result = record(id); + result.limits = {...result.limits, ...section('unavailable', 'Could not collect usage. Retry or check provider sign-in.')}; + result.history = {...result.history, ...section('unavailable', 'Could not collect local history.')}; + if (!cancellable.is_cancelled()) + print(JSON.stringify(result)); + } finally { + if (signal) + GLib.source_remove(signal); + loop.quit(); + } +})(); +loop.run(); diff --git a/src/core/format.js b/src/core/format.js new file mode 100644 index 0000000..5b91b1b --- /dev/null +++ b/src/core/format.js @@ -0,0 +1,53 @@ +export function tokens(value) { + if (!Number.isFinite(value)) + return '—'; + for (const [unit, divisor] of [['B', 1e9], ['M', 1e6], ['K', 1e3]]) { + if (value >= divisor) + return `${(value / divisor).toFixed(1)}${unit}`; + } + return String(Math.round(value)); +} + +export function compactTokens(value) { + return tokens(value).replace(/\.0(?=[BMK]$)/, ''); +} + +export function resetTime(time, now = Date.now()) { + if (!Number.isFinite(time) || time <= 0) + return 'Reset time unavailable'; + const minutes = Math.ceil((time - now) / 60000); + if (minutes <= 0) + return 'Reset due · awaiting update'; + if (minutes >= 1440) + return `Resets in ${Math.floor(minutes / 1440)}d ${Math.floor(minutes % 1440 / 60)}h`; + if (minutes >= 60) + return `Resets in ${Math.floor(minutes / 60)}h ${minutes % 60}m`; + return `Resets in ${minutes}m`; +} + +export function resetCountdown(time, now = Date.now()) { + const value = resetTime(time, now); + if (value.startsWith('Resets in ')) + return value.slice('Resets in '.length); + if (value.startsWith('Reset due')) + return 'due'; + return null; +} + +export function age(time, now = Date.now()) { + if (!time) + return 'Never updated'; + const minutes = Math.max(0, Math.floor((now - time) / 60000)); + if (!minutes) + return 'Updated just now'; + if (minutes < 60) + return `Updated ${minutes}m ago`; + const hours = Math.floor(minutes / 60); + return hours < 24 ? `Updated ${hours}h ago` : `Updated ${Math.floor(hours / 24)}d ago`; +} + +export function modelName(value) { + const acronyms = new Map([['gpt', 'GPT'], ['api', 'API']]); + return String(value || 'Unknown model').replaceAll(/[-_/]+/g, ' ').split(/\s+/).filter(Boolean) + .map(word => acronyms.get(word.toLowerCase()) ?? word[0].toUpperCase() + word.slice(1)).join(' '); +} diff --git a/src/core/notifications.js b/src/core/notifications.js new file mode 100644 index 0000000..056509b --- /dev/null +++ b/src/core/notifications.js @@ -0,0 +1,36 @@ +import {notificationMilestones} from './thresholds.js'; + +export function notificationBody(alerts) { + return [...new Set(alerts.map(alert => { + const state = alert.threshold >= 100 ? 'limit reached' : `reached ${alert.threshold}%`; + return `${alert.provider}: ${alert.label} ${state}.`; + }))].join('\n'); +} + +export class ThresholdTracker { + constructor() { + this.previous = new Map(); + } + + update(record, threshold = 90) { + if (!['ready', 'partial'].includes(record.limits.status)) + return []; + const alerts = []; + const milestones = notificationMilestones(threshold); + for (const window of record.limits.windows) { + if (window.unlimited || !Number.isFinite(window.usedPercent)) + continue; + const key = `${record.id}:${record.accountKey ?? 'default'}:${window.id}:${window.resetsAt ?? 'unknown'}`; + const previous = this.previous.get(key); + const reached = milestones.filter(value => window.usedPercent >= value).at(-1) ?? 0; + if (previous !== undefined && reached > previous) + alerts.push({provider: record.name, label: window.label, threshold: reached}); + // Refresh insertion order so active windows survive eviction of old periods. + this.previous.delete(key); + this.previous.set(key, Math.max(previous ?? 0, reached)); + } + while (this.previous.size > 200) + this.previous.delete(this.previous.keys().next().value); + return alerts; + } +} diff --git a/src/core/thresholds.js b/src/core/thresholds.js new file mode 100644 index 0000000..57c988a --- /dev/null +++ b/src/core/thresholds.js @@ -0,0 +1,26 @@ +export const QUOTA_THRESHOLDS = Object.freeze({ + caution: 80, + warning: 90, + danger: 100, +}); + +export function quotaSeverity(percent) { + if (!Number.isFinite(percent)) + return null; + if (percent >= QUOTA_THRESHOLDS.danger) + return 'danger'; + if (percent >= QUOTA_THRESHOLDS.warning) + return 'warning'; + if (percent >= QUOTA_THRESHOLDS.caution) + return 'caution'; + return null; +} + +export function notificationMilestones(minimum = QUOTA_THRESHOLDS.warning) { + const floor = Number.isFinite(minimum) + ? Math.max(0, Math.min(100, Math.round(minimum))) + : QUOTA_THRESHOLDS.warning; + return [...new Set([floor, ...Object.values(QUOTA_THRESHOLDS)])] + .filter(value => value >= floor) + .sort((left, right) => left - right); +} diff --git a/src/core/usage.js b/src/core/usage.js new file mode 100644 index 0000000..0ec49b5 --- /dev/null +++ b/src/core/usage.js @@ -0,0 +1,273 @@ +export const SCHEMA_VERSION = 2; +export const NAMES = Object.freeze({codex: 'Codex', claude: 'Claude Code'}); + +const STATES = new Set(['loading', 'ready', 'partial', 'stale', 'missing-auth', 'unsupported', 'unavailable']); +const WINDOW_STATES = new Set(['active', 'exhausted', 'unlimited']); + +const MAX_WINDOWS = 32; +const MAX_DAYS = 90; +const MAX_MODELS = 128; +const READY_STATES = new Set(['ready', 'partial', 'stale']); + +function fail(message) { + throw new Error(`Invalid usage record: ${message}`); +} + +function shape(value, field, allowed) { + if (!value || typeof value !== 'object' || Array.isArray(value) || + Object.keys(value).some(key => !allowed.includes(key))) + fail(field); +} + +function text(value, field, {nullable = false, max = 240} = {}) { + if (nullable && value === null) + return; + if (typeof value !== 'string' || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) + fail(field); +} + +function numeric(value, field, {nullable = false, integer = false} = {}) { + if (nullable && value === null) + return; + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || (integer && !Number.isSafeInteger(value))) + fail(field); +} + +function isoDate(value, field) { + const parsed = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) + ? Date.parse(`${value}T00:00:00Z`) + : NaN; + if (!Number.isFinite(parsed) || new Date(parsed).toISOString().slice(0, 10) !== value) + fail(field); +} + +export function number(value) { + if (value === null || value === undefined || typeof value === 'boolean' || + !['number', 'string'].includes(typeof value) || (typeof value === 'string' && !value.trim())) + return null; + const result = Number(value); + return Number.isFinite(result) && result >= 0 ? result : null; +} + +export function section(status = 'loading', message = '') { + return {status, message, updatedAt: null}; +} + +export function isProvider(id) { + return typeof id === 'string' && Object.prototype.hasOwnProperty.call(NAMES, id); +} + +export function record(id) { + if (!isProvider(id)) + throw new Error(`Unsupported provider: ${id}`); + return { + schemaVersion: SCHEMA_VERSION, id, name: NAMES[id], plan: null, accountKey: null, + capabilities: {limits: false, history: false, models: false}, + limits: {...section(), scope: 'account', source: null, windows: []}, + history: {...section('unsupported', 'This provider does not expose local token history.'), + scope: 'local', period: null, days: [], models: [], source: null}, + }; +} + +export function windowUsage({id, label, usedPercent, used, limit, unit = 'percent', durationMinutes = null, + resetsAt = null, unlimited = false}) { + let percent = number(usedPercent); + used = number(used); + limit = number(limit); + if (percent === null && used !== null && limit > 0) + percent = used / limit * 100; + if (percent === null && !unlimited) + return null; + const normalizedPercent = unlimited ? null : percent; + return {id, label, usedPercent: normalizedPercent, used, limit, unit, unlimited, + state: unlimited ? 'unlimited' : normalizedPercent >= 100 ? 'exhausted' : 'active', + durationMinutes: number(durationMinutes), resetsAt: validTime(resetsAt)}; +} + +export function validTime(value) { + if (value === null || value === undefined || value === '') + return null; + const numeric = typeof value === 'number' || /^\d+(\.\d+)?$/.test(String(value).trim()) ? Number(value) : null; + const ms = numeric !== null ? (numeric < 1e12 ? numeric * 1000 : numeric) : Date.parse(value); + return Number.isFinite(ms) && ms > 0 ? ms : null; +} + +export function validateRecord(value, expectedId) { + shape(value, 'record shape', [ + 'schemaVersion', 'id', 'name', 'plan', 'accountKey', 'capabilities', 'limits', 'history', + ]); + if (!value || value.schemaVersion !== SCHEMA_VERSION || value.id !== expectedId || !isProvider(value.id)) + fail('provider identity'); + if (value.name !== NAMES[value.id]) + fail('provider name'); + text(value.plan, 'plan', {nullable: true, max: 80}); + if (value.accountKey !== null && (typeof value.accountKey !== 'string' || !/^[a-f0-9]{64}$/.test(value.accountKey))) + fail('account key'); + shape(value.capabilities, 'provider capabilities', ['limits', 'history', 'models']); + if (['limits', 'history', 'models'].some(name => typeof value.capabilities[name] !== 'boolean')) + fail('provider capabilities'); + for (const name of ['limits', 'history']) { + shape(value[name], `${name} shape`, name === 'limits' + ? ['status', 'message', 'updatedAt', 'scope', 'source', 'windows'] + : ['status', 'message', 'updatedAt', 'scope', 'source', 'period', 'days', 'models', 'scannedFiles']); + if (!value[name] || !STATES.has(value[name].status)) + fail(`${name} state`); + if (typeof value[name].message !== 'string' || value[name].message.length > 500 || + /[\u0000-\u001f\u007f]/.test(value[name].message)) + fail(`${name} message`); + numeric(value[name].updatedAt, `${name} timestamp`, {nullable: true}); + if (READY_STATES.has(value[name].status) && (!value[name].updatedAt || value[name].updatedAt <= 0)) + fail(`${name} freshness`); + if (!READY_STATES.has(value[name].status) && value[name].updatedAt !== null) + fail(`${name} freshness`); + if (value[name].source !== null) + text(value[name].source, `${name} source`, {max: 160}); + if (READY_STATES.has(value[name].status) && value[name].source === null) + fail(`${name} source`); + } + if (value.history.scannedFiles !== undefined) + numeric(value.history.scannedFiles, 'history scanned files', {integer: true}); + if (value.limits.scope !== 'account' || !['local', 'account'].includes(value.history.scope)) + fail('source scope'); + if (!Array.isArray(value.limits.windows) || !Array.isArray(value.history.days) || !Array.isArray(value.history.models)) + fail('usage arrays'); + if (value.limits.windows.length > MAX_WINDOWS || value.history.days.length > MAX_DAYS || + value.history.models.length > MAX_MODELS) + fail('collection bounds'); + if (value.limits.windows.length && !value.capabilities.limits) + fail('limits capability'); + if (READY_STATES.has(value.limits.status) && !value.limits.windows.length) + fail('limits readiness'); + if (value.history.days.length && !value.capabilities.history) + fail('history capability'); + if (value.history.models.length && !value.capabilities.models) + fail('models capability'); + const windows = new Set(); + for (const item of value.limits.windows) { + shape(item, 'quota window', [ + 'id', 'label', 'usedPercent', 'used', 'limit', 'unit', 'unlimited', 'state', 'durationMinutes', 'resetsAt', + ]); + text(item.id, 'quota id', {max: 160}); + text(item.label, 'quota label', {max: 160}); + text(item.unit, 'quota unit', {max: 40}); + if (typeof item.unlimited !== 'boolean' || !WINDOW_STATES.has(item.state)) + fail('quota state'); + numeric(item.usedPercent, 'quota percentage', {nullable: true}); + numeric(item.used, 'quota usage', {nullable: true}); + numeric(item.limit, 'quota limit', {nullable: true}); + numeric(item.durationMinutes, 'quota duration', {nullable: true, integer: true}); + numeric(item.resetsAt, 'quota reset', {nullable: true, integer: true}); + if (item.unlimited ? item.state !== 'unlimited' || item.usedPercent !== null : item.usedPercent === null || + item.state !== (item.usedPercent >= 100 ? 'exhausted' : 'active')) + fail('quota consistency'); + if (windows.has(item.id)) + fail('duplicate quota'); + windows.add(item.id); + } + if (value.history.period === null) { + if (value.history.days.length || value.history.models.length) + fail('history period'); + } else { + shape(value.history.period, 'history period', ['start', 'end']); + isoDate(value.history.period?.start, 'history start date'); + isoDate(value.history.period?.end, 'history end date'); + if (value.history.period.start > value.history.period.end) + fail('history date order'); + } + const dates = new Set(); + let previousDate = null; + for (const item of value.history.days) { + shape(item, 'daily entry', ['date', 'total', 'sessions', 'events']); + isoDate(item.date, 'daily date'); + numeric(item.total, 'daily total', {integer: true}); + numeric(item.sessions, 'daily sessions', {integer: true}); + numeric(item.events, 'daily events', {integer: true}); + if (dates.has(item.date) || (previousDate && item.date <= previousDate) || + item.date < value.history.period.start || item.date > value.history.period.end) + fail('daily ordering'); + dates.add(item.date); + previousDate = item.date; + } + const models = new Set(); + for (const item of value.history.models) { + shape(item, 'model entry', ['model', 'total', 'input', 'output', 'cacheRead', 'cacheWrite']); + text(item.model, 'model name', {max: 160}); + for (const field of ['total', 'input', 'output', 'cacheRead', 'cacheWrite']) + numeric(item[field], `model ${field}`, {integer: true}); + if (models.has(item.model) || item.total !== item.input + item.output + item.cacheRead + item.cacheWrite) + fail('model totals'); + models.add(item.model); + } + return value; +} + +export function mergeRecord(previous, next, now = Date.now()) { + if (!previous || (previous.accountKey && next.accountKey && previous.accountKey !== next.accountKey)) + return next; + const result = {...next}; + for (const key of ['limits', 'history']) { + const old = previous[key]; + const current = next[key]; + if (current.status === 'unavailable' && old?.updatedAt && + ['ready', 'partial', 'stale'].includes(old.status)) { + const preserved = key === 'limits' + ? {...old, windows: old.windows.filter(window => !window.resetsAt || window.resetsAt > now)} + : old; + if (key !== 'limits' || preserved.windows.length) + result[key] = {...preserved, status: 'stale', message: current.message}; + } + } + if (result.limits.status === 'stale') { + result.plan ??= previous.plan; + result.accountKey ??= previous.accountKey; + } + return result; +} + +export function localDate(time) { + if (time === null || time === undefined || time === '') + return null; + const d = new Date(time); + if (!Number.isFinite(d.getTime())) + return null; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export function recentDates(now, count = 7) { + const today = new Date(now); + today.setHours(12, 0, 0, 0); + return Array.from({length: count}, (_, i) => { + const day = new Date(today); + day.setDate(day.getDate() - (count - 1 - i)); + return localDate(day); + }); +} + +export function aggregateEvents(events, now = Date.now(), count = 7) { + const dates = recentDates(now, count); + const days = new Map(dates.map(date => [date, {date, total: 0, sessions: 0, events: 0}])); + const models = new Map(); + const seen = new Set(); + const sessions = new Map(dates.map(date => [date, new Set()])); + for (const event of events) { + const day = days.get(event.date); + if (!day || seen.has(event.id)) + continue; + seen.add(event.id); + const tokens = ['input', 'output', 'cacheRead', 'cacheWrite'].map(k => number(event[k]) ?? 0); + const total = tokens.reduce((a, b) => a + b, 0); + if (!total) + continue; + const model = String(event.model || 'Unknown model'); + const bucket = models.get(model) ?? {model, total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0}; + ['input', 'output', 'cacheRead', 'cacheWrite'].forEach((k, i) => { bucket[k] += tokens[i]; }); + bucket.total += total; + models.set(model, bucket); + day.total += total; + day.events++; + sessions.get(event.date).add(event.session); + day.sessions = sessions.get(event.date).size; + } + return {period: {start: dates[0], end: dates.at(-1)}, days: [...days.values()], + models: [...models.values()].sort((a, b) => b.total - a.total)}; +} diff --git a/src/providers/claude.js b/src/providers/claude.js new file mode 100644 index 0000000..b66fbc0 --- /dev/null +++ b/src/providers/claude.js @@ -0,0 +1,168 @@ +import {localDate, number, record, section, validTime, windowUsage} from '../core/usage.js'; + +const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; +const MAX_WINDOWS = 32; + +function shortText(value, fallback = null, max = 160) { + if (typeof value !== 'string') + return fallback; + const text = value.trim(); + return text && !/[\u0000-\u001f\u007f]/.test(text) ? text.slice(0, max) : fallback; +} + +function planLabel(tier, subscription) { + const match = shortText(tier, '')?.match(/max_(\d+x)/i); + if (match) + return `Max ${match[1]}`; + const value = shortText(subscription, '', 79); + return value ? value[0].toUpperCase() + value.slice(1) : null; +} + +export function claudeLogin(credentials) { + const login = credentials?.claudeAiOauth; + if (!login || typeof login !== 'object') + return {token: null, expiresAt: null, plan: null}; + return {token: shortText(login.accessToken, null, 8192), + expiresAt: number(login.expiresAt), + plan: planLabel(login.rateLimitTier, login.subscriptionType)}; +} + +function rawUtilization(value) { + if (value === null || value === undefined || value === '') + return null; + const result = Number(String(value).trim().replace('%', '')); + return Number.isFinite(result) && result >= 0 ? result : null; +} + +function utilization(value, percentScale) { + const result = rawUtilization(value); + if (result === null) + return null; + return Math.min(100, percentScale || result > 1 ? result : result * 100); +} + +function scopedDuration(kind) { + const value = String(kind || '').toLowerCase(); + if (value.includes('month')) + return {label: 'Monthly', minutes: 43200}; + if (value.includes('week') || value.includes('day')) + return {label: 'Weekly', minutes: 10080}; + if (value.includes('hour') || value.includes('session')) + return {label: 'Session', minutes: 300}; + return {label: '', minutes: null}; +} + +export function claudeLimits(payload, now = Date.now()) { + if (!payload || typeof payload !== 'object') + return {...section('unavailable', 'Claude did not report any quota windows.'), + scope: 'account', source: 'Anthropic OAuth usage', windows: []}; + const session = payload.five_hour; + const weekly = payload.seven_day_oauth_apps ?? payload.seven_day; + const scoped = Array.isArray(payload.limits) ? payload.limits : []; + const raw = [session?.utilization, weekly?.utilization, + ...scoped.map(item => item?.percent)].map(rawUtilization).filter(value => value !== null); + const percentScale = raw.some(value => value >= 1); + const windows = []; + let truncated = false; + const add = (id, label, bucket, durationMinutes) => { + if (!bucket || typeof bucket !== 'object') + return; + const item = windowUsage({id, label, usedPercent: utilization(bucket.utilization, percentScale), + durationMinutes, resetsAt: validTime(bucket.resets_at)}); + if (item) + windows.push(item); + }; + add('five-hour', 'Session · 5 hours', session, 300); + add('weekly', 'Weekly', weekly, 10080); + const seen = new Set(); + for (const item of scoped) { + if (windows.length >= MAX_WINDOWS) { + truncated = true; + break; + } + const model = item?.scope?.model; + const name = shortText(model?.display_name ?? model?.id, '', 100); + const kind = shortText(item?.kind, '', 40); + const key = `${name}:${kind}`; + if (!name || seen.has(key)) + continue; + const duration = scopedDuration(kind); + const value = windowUsage({id: `scoped:${key}`, label: `${name}${duration.label ? ` · ${duration.label}` : ''}`, + usedPercent: utilization(item.percent, percentScale), durationMinutes: duration.minutes, + resetsAt: validTime(item.resets_at)}); + if (value) { + seen.add(key); + windows.push(value); + } + } + return windows.length + ? {...section(truncated ? 'partial' : 'ready', + truncated ? 'Some Claude quota windows were omitted to keep the response bounded.' : ''), + updatedAt: now, scope: 'account', source: 'Anthropic OAuth usage', windows} + : {...section('unavailable', 'Claude did not report any supported quota windows.'), + scope: 'account', source: 'Anthropic OAuth usage', windows: []}; +} + +export function parseClaudeEvent(entry, state) { + const message = entry?.message && typeof entry.message === 'object' ? entry.message : {}; + if (entry?.type !== 'assistant' && message.role !== 'assistant') + return null; + const usage = message.usage ?? entry.usage; + if (!usage || typeof usage !== 'object') + return null; + if (typeof entry.sessionId === 'string' && entry.sessionId) + state.session = entry.sessionId; + const timestamp = entry.timestamp ?? message.timestamp; + const date = localDate(timestamp); + if (!date) + return null; + const input = number(usage.input_tokens ?? usage.inputTokens) ?? 0; + const output = number(usage.output_tokens ?? usage.outputTokens) ?? 0; + const cacheRead = number(usage.cache_read_input_tokens ?? usage.cacheReadInputTokens) ?? 0; + const cacheWrite = number(usage.cache_creation_input_tokens ?? usage.cacheCreationInputTokens) ?? 0; + if (input + output + cacheRead + cacheWrite === 0) + return null; + const model = shortText(message.model ?? entry.model ?? state.model, 'Unknown model'); + state.model = model; + const identity = message.id ?? entry.messageId ?? entry.uuid ?? entry.requestId ?? `${timestamp}:${JSON.stringify(usage)}`; + return {id: `${state.session}:${identity}`, session: state.session, date, model, + input, output, cacheRead, cacheWrite}; +} + +export async function collectClaude(io) { + const now = io.now?.() ?? Date.now(); + const result = record('claude'); + result.capabilities = {limits: true, history: true, models: true}; + result.history = io.scan('claude', ['projects'], parseClaudeEvent); + const login = claudeLogin(io.credentials('claude')); + result.plan = login.plan; + if (!login.token) { + const installed = io.hasCommand?.('claude') !== false; + result.limits = {...result.limits, ...section(installed ? 'missing-auth' : 'unsupported', + installed ? 'Run claude auth login to read account limits.' : 'Install Claude Code, then sign in to read account limits.')}; + return result; + } + result.accountKey = io.fingerprint(login.token); + if (login.expiresAt && login.expiresAt <= now) { + result.limits = {...result.limits, ...section('missing-auth', 'Claude Code sign-in expired. Start Claude Code or run claude auth login.')}; + return result; + } + try { + const response = await io.http(USAGE_URL, {headers: { + Authorization: `Bearer ${login.token}`, + 'anthropic-beta': 'oauth-2025-04-20', + Accept: 'application/json', + }}); + if (response.status === 200) { + result.limits = claudeLimits(response.data, now); + } else { + result.limits = {...result.limits, ...section([401, 403].includes(response.status) ? 'missing-auth' : 'unavailable', + [401, 403].includes(response.status) ? 'Reconnect Claude Code to read account limits.' : + response.status === 429 ? 'Anthropic is rate limiting usage checks. Local history is still available.' : + `Claude usage endpoint unavailable (HTTP ${response.status}).`)}; + } + } catch { + result.limits = {...result.limits, ...section('unavailable', 'Could not reach Claude usage. Local history is still available.')}; + } + return result; +} diff --git a/src/providers/codex.js b/src/providers/codex.js new file mode 100644 index 0000000..226b0fa --- /dev/null +++ b/src/providers/codex.js @@ -0,0 +1,136 @@ +import {localDate, number, record, section, windowUsage} from '../core/usage.js'; + +const MAX_WINDOWS = 32; + +function shortText(value, fallback = null, max = 80) { + if (typeof value !== 'string') + return fallback; + const text = value.trim(); + return text && !/[\u0000-\u001f\u007f]/.test(text) ? text.slice(0, max) : fallback; +} + +function compareBuckets([left], [right]) { + if (left === 'codex') + return -1; + if (right === 'codex') + return 1; + return left.localeCompare(right); +} + +export function codexLimits(response, now = Date.now()) { + const scoped = response?.rateLimitsByLimitId && typeof response.rateLimitsByLimitId === 'object' && + !Array.isArray(response.rateLimitsByLimitId) + ? Object.entries(response.rateLimitsByLimitId).sort(compareBuckets) + : []; + const buckets = scoped.length ? scoped : [['codex', response?.rateLimits]]; + const windows = []; + let truncated = false; + for (const [bucketId, bucket] of buckets) { + if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) + continue; + for (const key of ['primary', 'secondary']) { + if (windows.length >= MAX_WINDOWS) { + truncated = true; + break; + } + const w = bucket?.[key]; + if (!w || typeof w !== 'object' || Array.isArray(w)) + continue; + const reportedMinutes = number(w.windowDurationMins); + const mins = Number.isSafeInteger(reportedMinutes) ? reportedMinutes : null; + const duration = mins === 10080 ? 'Weekly' : mins === 300 ? 'Session · 5 hours' + : mins ? `${mins >= 60 ? `${mins / 60} hours` : `${mins} minutes`}` : key === 'primary' ? 'Primary window' : 'Secondary window'; + const safeBucketId = shortText(bucketId, 'quota', 120); + const bucketName = shortText(bucket.limitName, + safeBucketId === 'codex' ? null : safeBucketId, 120); + const item = windowUsage({id: `${safeBucketId}:${key}`, + label: buckets.length > 1 && bucketName ? `${bucketName} · ${duration}` : duration, + usedPercent: w.usedPercent, durationMinutes: mins, + resetsAt: w.resetsAt}); + if (item) + windows.push(item); + } + if (truncated) + break; + } + return windows.length + ? {...section(truncated ? 'partial' : 'ready', + truncated ? 'Some Codex quota windows were omitted to keep the response bounded.' : ''), + updatedAt: now, scope: 'account', source: 'Codex app-server', windows} + : {...section('unavailable', 'Codex did not report quota windows for this account.'), + scope: 'account', source: 'Codex app-server', windows: []}; +} + +export function parseCodexEvent(entry, state) { + if (entry?.type === 'session_meta') + state.session = entry.payload?.id || state.session; + if (entry?.type === 'turn_context') + state.model = entry.payload?.model || entry.payload?.model_slug || state.model; + const payload = entry?.payload; + if (payload?.type !== 'token_count' || !payload.info) + return null; + const cumulative = payload.info.total_token_usage; + let usage = payload.info.last_token_usage; + let identity; + if (cumulative && number(cumulative.total_tokens) !== null) { + const fields = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_write_input_tokens']; + const current = Object.fromEntries(fields.map(field => [field, number(cumulative[field]) ?? 0])); + current.total_tokens = Number(cumulative.total_tokens); + if (state.cumulative && current.total_tokens === state.cumulative.total_tokens) + return null; + if (state.cumulative && current.total_tokens < state.cumulative.total_tokens) { + state.cumulativeEpoch = (number(state.cumulativeEpoch) ?? 0) + 1; + usage = current; + } else { + const previous = state.cumulative ?? {}; + usage = Object.fromEntries(fields.map(field => + [field, Math.max(0, current[field] - (number(previous[field]) ?? 0))])); + } + state.cumulative = current; + identity = `total:${state.cumulativeEpoch ?? 0}:${current.total_tokens}`; + } else { + identity = `${entry.timestamp}:${JSON.stringify(usage)}`; + } + if (!usage || !localDate(entry.timestamp)) + return null; + const input = number(usage.input_tokens) ?? 0; + const cacheRead = Math.min(input, number(usage.cached_input_tokens) ?? 0); + const cacheWrite = Math.min(input - cacheRead, number(usage.cache_write_input_tokens) ?? 0); + return {id: `${state.session}:${identity}`, session: state.session, date: localDate(entry.timestamp), + model: state.model || 'Unknown model', input: input - cacheRead - cacheWrite, + output: number(usage.output_tokens) ?? 0, cacheRead, cacheWrite}; +} + +export async function collectCodex(io) { + const now = io.now?.() ?? Date.now(); + const result = record('codex'); + result.capabilities = {limits: true, history: true, models: true}; + result.history = io.scan('codex', ['sessions', 'archived_sessions'], parseCodexEvent); + let rpc; + try { + rpc = io.codexClient(); + await rpc.request('initialize', {clientInfo: {name: 'usagebeam', title: 'UsageBeam', version: '2.0.0'}, + capabilities: {experimentalApi: false}}); + rpc.notify('initialized', {}); + const account = await rpc.request('account/read', {refreshToken: false}); + if (!account?.account) { + result.limits = {...result.limits, ...section('missing-auth', 'Sign in with codex login to read account limits.')}; + return result; + } + result.plan = shortText(account.account.planType ?? account.account.type); + const accountIdentity = shortText(account.account.email ?? account.account.chatgptAccountId, null, 320); + result.accountKey = accountIdentity ? io.fingerprint(accountIdentity) : null; + const limits = await rpc.request('account/rateLimits/read', {}); + result.limits = codexLimits(limits, now); + const scopedLimits = limits?.rateLimitsByLimitId && typeof limits.rateLimitsByLimitId === 'object' && + !Array.isArray(limits.rateLimitsByLimitId) ? Object.values(limits.rateLimitsByLimitId) : []; + result.plan = shortText(limits?.rateLimits?.planType ?? + scopedLimits.find(bucket => bucket?.planType)?.planType, result.plan); + } catch (error) { + result.limits = {...result.limits, ...section(error.code === 'NOT_FOUND' ? 'unsupported' : 'unavailable', + error.code === 'NOT_FOUND' ? 'Install the Codex CLI to read account limits.' : 'Could not read Codex limits. Check CLI sign-in and compatibility.')}; + } finally { + rpc?.close(); + } + return result; +} diff --git a/src/services/commands.js b/src/services/commands.js new file mode 100644 index 0000000..1034193 --- /dev/null +++ b/src/services/commands.js @@ -0,0 +1,138 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import {join} from './files.js'; + +const STATIC_DIRECTORIES = [ + '.local/bin', + '.cargo/bin', + '.npm-global/bin', + '.volta/bin', + '.asdf/shims', + '.local/share/mise/shims', + '.bun/bin', +]; + +const VERSIONED_DIRECTORIES = [ + ['.local/share/fnm/node-versions', ['installation', 'bin']], + ['.nvm/versions/node', ['bin']], + ['.local/share/mise/installs/node', ['bin']], + ['.asdf/installs/nodejs', ['bin']], +]; + +function validCommandName(name) { + return typeof name === 'string' && /^[A-Za-z0-9._+-]+$/.test(name) && name !== '.' && name !== '..'; +} + +function executable(path) { + return GLib.file_test(path, GLib.FileTest.IS_REGULAR) && + GLib.file_test(path, GLib.FileTest.IS_EXECUTABLE) ? path : null; +} + +function versionParts(value) { + return (String(value).match(/\d+/g) ?? []).map(Number); +} + +function compareVersions(a, b) { + const left = versionParts(a); + const right = versionParts(b); + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const difference = (left[index] ?? 0) - (right[index] ?? 0); + if (difference) + return difference; + } + return String(a).localeCompare(String(b)); +} + +function commandDirectories(command) { + const directories = []; + let current = command; + for (let depth = 0; depth < 8; depth++) { + const directory = GLib.path_get_dirname(current); + if (!directories.includes(directory)) + directories.push(directory); + if (!GLib.file_test(current, GLib.FileTest.IS_SYMLINK)) + break; + try { + const target = GLib.file_read_link(current); + current = GLib.canonicalize_filename(target, GLib.path_get_dirname(current)); + } catch { + break; + } + } + return directories; +} + +function versionedCommand(root, suffix, name) { + const directory = Gio.File.new_for_path(root); + if (!directory.query_exists(null)) + return null; + + const candidates = []; + let iterator = null; + try { + iterator = directory.enumerate_children('standard::name,standard::type,standard::is-symlink', + Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + let info; + while ((info = iterator.next_file(null))) { + if (info.get_is_symlink() || info.get_file_type() !== Gio.FileType.DIRECTORY) + continue; + const path = executable(join(root, info.get_name(), ...suffix, name)); + if (path) + candidates.push({version: info.get_name(), path}); + } + } catch { + return null; + } finally { + iterator?.close(null); + } + + candidates.sort((a, b) => compareVersions(b.version, a.version)); + return candidates[0]?.path ?? null; +} + +export function findCommand(name, {home = GLib.get_home_dir(), usePath = true} = {}) { + if (!validCommandName(name)) + throw new Error('Invalid command name'); + + const found = usePath ? GLib.find_program_in_path(name) : null; + if (found) + return found; + + for (const directory of STATIC_DIRECTORIES) { + const path = executable(join(home, directory, name)); + if (path) + return path; + } + + for (const [directory, suffix] of VERSIONED_DIRECTORIES) { + const path = versionedCommand(join(home, directory), suffix, name); + if (path) + return path; + } + + const error = new Error(`${name} is not installed`); + error.code = 'NOT_FOUND'; + throw error; +} + +export function commandSpec(name, args = [], {runtimes = [], ...options} = {}) { + if (!Array.isArray(args) || args.some(value => typeof value !== 'string' && + (typeof value !== 'number' || !Number.isFinite(value)))) + throw new Error('Command arguments must be strings or numbers'); + if (!Array.isArray(runtimes) || runtimes.some(runtime => !validCommandName(runtime))) + throw new Error('Command runtimes must be command names'); + const command = findCommand(name, options); + const directories = commandDirectories(command); + for (const runtime of runtimes) { + try { + const directory = GLib.path_get_dirname(findCommand(runtime, options)); + if (!directories.includes(directory)) + directories.push(directory); + } catch { + // The command may be native or resolve its own runtime. + } + } + const inheritedPath = GLib.getenv('PATH') || '/usr/local/bin:/usr/bin:/bin'; + const path = [...directories, ...inheritedPath.split(':').filter(entry => entry && !directories.includes(entry))].join(':'); + return {argv: [command, ...args.map(String)], environment: {PATH: path}}; +} diff --git a/src/services/files.js b/src/services/files.js new file mode 100644 index 0000000..fc12096 --- /dev/null +++ b/src/services/files.js @@ -0,0 +1,88 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +export const join = (...parts) => GLib.build_filenamev(parts); +export const fingerprint = value => GLib.compute_checksum_for_string(GLib.ChecksumType.SHA256, String(value), -1); + +const PRODUCT_DIRECTORY = 'usagebeam'; +const LEGACY_DIRECTORY = 'freeby'; +const PROVIDERS = Object.freeze(['codex', 'claude']); + +function readText(path, fallback = null, maxBytes = 16 * 1024 * 1024) { + if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) + throw new Error('File size limit must be a positive integer'); + try { + const file = Gio.File.new_for_path(path); + if (file.query_info('standard::size', Gio.FileQueryInfoFlags.NONE, null).get_size() > maxBytes) + return fallback; + const [, bytes] = file.load_contents(null); + if (bytes.length > maxBytes) + return fallback; + return new TextDecoder().decode(bytes); + } catch { + return fallback; + } +} + +export function readJson(path, fallback = null, maxBytes = 16 * 1024 * 1024) { + const text = readText(path, null, maxBytes); + if (text === null) + return fallback; + try { + return JSON.parse(text); + } catch { + return fallback; + } +} + +export function writeJson(path, value) { + const file = Gio.File.new_for_path(path); + const parent = file.get_parent(); + const parentPath = parent?.get_path(); + if (!parentPath || GLib.mkdir_with_parents(parentPath, 0o700) < 0 || GLib.chmod(parentPath, 0o700) < 0) + throw new Error('Could not secure the private data directory'); + file.replace_contents(new TextEncoder().encode(JSON.stringify(value)), null, false, + Gio.FileCreateFlags.PRIVATE | Gio.FileCreateFlags.REPLACE_DESTINATION, null); + if (GLib.chmod(path, 0o600) < 0) + throw new Error('Could not secure the private data file'); +} + +export function stateDirectory() { + return join(GLib.get_user_state_dir(), PRODUCT_DIRECTORY); +} + +export function cacheDirectory() { + return join(GLib.get_user_cache_dir(), PRODUCT_DIRECTORY); +} + +export function migrateLegacyData({ + legacyState = join(GLib.get_user_state_dir(), LEGACY_DIRECTORY), + legacyCache = join(GLib.get_user_cache_dir(), LEGACY_DIRECTORY), + state = stateDirectory(), + cache = cacheDirectory(), +} = {}) { + const groups = [ + {source: legacyState, destination: state, names: PROVIDERS.map(id => `${id}.json`), + maxBytes: 16 * 1024 * 1024}, + {source: legacyCache, destination: cache, names: PROVIDERS.map(id => `history-${id}.json`), + maxBytes: 64 * 1024 * 1024}, + ]; + let migrated = 0; + for (const group of groups) { + for (const name of group.names) { + const destination = join(group.destination, name); + if (Gio.File.new_for_path(destination).query_exists(null)) + continue; + const value = readJson(join(group.source, name), null, group.maxBytes); + if (value === null) + continue; + try { + writeJson(destination, value); + migrated++; + } catch { + // Migration is best-effort; collection can rebuild derived data. + } + } + } + return migrated; +} diff --git a/src/services/history.js b/src/services/history.js new file mode 100644 index 0000000..9283ffc --- /dev/null +++ b/src/services/history.js @@ -0,0 +1,315 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import {aggregateEvents, recentDates, section} from '../core/usage.js'; +import {cacheDirectory, fingerprint, join, readJson, writeJson} from './files.js'; + +const CACHE_VERSION = 5; +const MAX_DEPTH = 12; +const MAX_FILES = 20000; +const MAX_LINE_BYTES = 4 * 1024 * 1024; +const MAX_MODELS = 128; +const SCAN_SECONDS = 20; + +function fileStamp(info) { + const modified = info.get_attribute_uint64('time::modified'); + const modifiedUsec = info.get_attribute_uint32('time::modified-usec'); + const changed = info.get_attribute_uint64('time::changed'); + const changedUsec = info.get_attribute_uint32('time::changed-usec'); + return `${modified}:${modifiedUsec}:${changed}:${changedUsec}`; +} + +function listFiles(root, output, deadline, depth = 0) { + if (depth > MAX_DEPTH || output.length >= MAX_FILES || GLib.get_monotonic_time() > deadline) + throw new Error('History directory exceeds scan bounds'); + const file = Gio.File.new_for_path(root); + if (!file.query_exists(null)) + return false; + const iterator = file.enumerate_children('standard::name,standard::type,standard::is-symlink,standard::size,' + + 'time::modified,time::modified-usec,time::changed,time::changed-usec,id::file', + Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + try { + let info; + while ((info = iterator.next_file(null))) { + if (GLib.get_monotonic_time() > deadline) + throw new Error('History directory scan timed out'); + if (info.get_is_symlink()) + continue; + const path = join(root, info.get_name()); + if (info.get_file_type() === Gio.FileType.DIRECTORY) + listFiles(path, output, deadline, depth + 1); + else if (info.get_file_type() === Gio.FileType.REGULAR && info.get_name().endsWith('.jsonl')) { + if (output.length >= MAX_FILES) + throw new Error('History directory exceeds scan bounds'); + output.push({path, size: info.get_size(), stamp: fileStamp(info), + fileId: info.get_attribute_string('id::file') ?? ''}); + } + } + } finally { + iterator.close(null); + } + return true; +} + +function privateIdentity(provider, value) { + const text = String(value ?? ''); + return /^h:[a-f0-9]{64}$/.test(text) ? text : `h:${fingerprint(`${provider}:${text}`)}`; +} + +function parserState(value, fallbackSession) { + const session = typeof value?.session === 'string' && /^h:[a-f0-9]{64}$/.test(value.session) + ? value.session + : fallbackSession; + const state = {session}; + if (typeof value?.model === 'string' && value.model.trim() && value.model.length <= 160 && + !/[\u0000-\u001f\u007f]/.test(value.model)) + state.model = value.model; + if (numberValue(value?.cumulativeEpoch) !== null) + state.cumulativeEpoch = numberValue(value.cumulativeEpoch); + if (value?.cumulative && typeof value.cumulative === 'object') { + const fields = ['total_tokens', 'input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_write_input_tokens']; + if (fields.every(field => numberValue(value.cumulative[field]) !== null)) + state.cumulative = Object.fromEntries(fields.map(field => [field, numberValue(value.cumulative[field])])); + } + return state; +} + +function numberValue(value) { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} + +function cachedFile(value, fallbackSession) { + if (!value || typeof value !== 'object' || !value.events || typeof value.events !== 'object' || + Array.isArray(value.events)) + return null; + const offset = numberValue(value.offset); + const size = value.size === -1 ? -1 : numberValue(value.size); + if (offset === null || size === null || typeof value.stamp !== 'string' || typeof value.fileId !== 'string') + return null; + return {offset, size, stamp: value.stamp, fileId: value.fileId, + digest: typeof value.digest === 'string' && /^[a-f0-9]{64}$/.test(value.digest) ? value.digest : null, + skipping: value.skipping === true, + state: parserState(value.state, fallbackSession), events: value.events, partial: value.partial === true}; +} + +function newFileState(fileId, session) { + return {offset: 0, size: 0, stamp: '', fileId, digest: null, skipping: false, + state: {session}, events: {}, partial: false}; +} + +function hashPrefix(input, length, deadline) { + const checksum = new GLib.Checksum(GLib.ChecksumType.SHA256); + input.seek(0, GLib.SeekType.SET, null); + let remaining = length; + while (remaining > 0) { + if (GLib.get_monotonic_time() > deadline) + throw new Error('History validation timed out'); + const bytes = input.read_bytes(Math.min(65536, remaining), null).toArray(); + if (!bytes.length) + throw new Error('History changed while validating'); + checksum.update(bytes); + remaining -= bytes.length; + } + return checksum; +} + +function validDate(value) { + const parsed = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) + ? Date.parse(`${value}T00:00:00Z`) + : NaN; + return Number.isFinite(parsed) && new Date(parsed).toISOString().slice(0, 10) === value; +} + +function sanitizedEvent(event, provider, stored = false) { + if (!event || typeof event !== 'object' || typeof event.id !== 'string' || !event.id || + typeof event.session !== 'string' || !event.session || typeof event.model !== 'string' || + !event.model.trim() || event.model.length > 160 || event.id.length > 2048 || event.session.length > 2048 || + /[\u0000-\u001f\u007f]/.test(event.model) || !validDate(event.date) || + (stored && (!/^h:[a-f0-9]{64}$/.test(event.id) || !/^h:[a-f0-9]{64}$/.test(event.session)))) + return null; + const values = Object.fromEntries(['input', 'output', 'cacheRead', 'cacheWrite'] + .map(field => [field, numberValue(event[field])])); + if (Object.values(values).some(value => value === null)) + return null; + return {...values, date: event.date, model: event.model, + id: stored ? event.id : privateIdentity(provider, event.id), + session: stored ? event.session : privateIdentity(provider, event.session)}; +} + +export function scanHistory(id, roots, parse, {retention = 30, now = Date.now(), cachePath = null} = {}) { + if (!Number.isSafeInteger(retention) || retention < 7 || retention > 90 || + !Number.isSafeInteger(now) || now <= 0 || !Array.isArray(roots) || !roots.length || + roots.some(root => typeof root !== 'string' || !root) || typeof parse !== 'function' || + (cachePath !== null && (typeof cachePath !== 'string' || !cachePath))) + throw new Error('Invalid history scan options'); + const cutoff = recentDates(now, retention)[0]; + const destination = cachePath ?? join(cacheDirectory(), `history-${id}.json`); + let cached = readJson(destination, {}, 64 * 1024 * 1024); + const identity = fingerprint(JSON.stringify(roots)); + if (cached?.version !== CACHE_VERSION || cached.identity !== identity || !cached.files || typeof cached.files !== 'object' || + Array.isArray(cached.files)) + cached = {version: CACHE_VERSION, identity, files: {}, updatedAt: null}; + const files = []; + let detected = false; + let partial = false; + let parsed = 0; + const started = GLib.get_monotonic_time(); + const deadline = started + SCAN_SECONDS * 1000000; + for (const root of roots) { + try { detected = listFiles(root, files, deadline) || detected; } catch { partial = true; } + } + files.sort((a, b) => a.path.localeCompare(b.path)); + const currentKeys = new Set(files.map(file => fingerprint(file.path))); + for (const file of files) { + if ((GLib.get_monotonic_time() - started) / 1000000 > SCAN_SECONDS) { + partial = true; + break; + } + const key = fingerprint(file.path); + const fallbackSession = privateIdentity(id, key); + let previous = cachedFile(cached.files[key], fallbackSession); + if (previous?.size === file.size && previous.stamp === file.stamp && previous.fileId === file.fileId) { + partial ||= previous.partial; + continue; + } + // Changed or truncated files are rebuilt; append-only files resume at the last complete line. + if (!previous || file.fileId !== previous.fileId || file.size < previous.size || previous.offset > file.size || + (file.size === previous.size && file.stamp !== previous.stamp)) + previous = newFileState(file.fileId, fallbackSession); + let input; + try { + input = Gio.File.new_for_path(file.path).read(null); + // Verify the entire committed prefix before resuming. In-place rewrites + // can grow as well as shrink, so size/mtime or sampled bytes are not proof + // that a file is append-only. Only a digest reaches the private cache. + let checksum = hashPrefix(input, previous.offset, deadline); + if (previous.offset && (!previous.digest || checksum.copy().get_string() !== previous.digest)) { + previous = newFileState(file.fileId, fallbackSession); + checksum = hashPrefix(input, 0, deadline); + } + input.seek(previous.offset, GLib.SeekType.SET, null); + let offset = previous.offset; + let tail = new Uint8Array(); + let stop = false; + while (!stop) { + const bytes = input.read_bytes(65536, null).toArray(); + if (!bytes.length) + break; + const buffer = new Uint8Array(tail.length + bytes.length); + buffer.set(tail); + buffer.set(bytes, tail.length); + let start = 0; + for (let i = 0; i < buffer.length; i++) { + if (buffer[i] !== 10) + continue; + const line = buffer.subarray(start, i); + offset += i - start + 1; + start = i + 1; + if (previous.skipping || line.length > MAX_LINE_BYTES) { + previous.skipping = false; + previous.partial = true; + continue; + } + try { + const text = new TextDecoder().decode(line); + if (!text.trim()) + continue; + const oldSession = previous.state.session; + const event = parse(JSON.parse(text), previous.state); + // Provider session identifiers are useful only for + // deduplication. Hash them before either parser state or + // derived events reach UsageBeam's private cache. + if (previous.state.session !== oldSession) + previous.state.session = privateIdentity(id, previous.state.session); + if (event) { + const sanitized = sanitizedEvent(event, id); + if (!sanitized) + previous.partial = true; + else if (sanitized.date >= cutoff) + previous.events[sanitized.id] = sanitized; + } + } catch { + previous.partial = true; + } + } + checksum.update(buffer.subarray(0, start)); + tail = buffer.slice(start); + if (tail.length > MAX_LINE_BYTES || previous.skipping) { + // Consume, rather than retry, oversized lines. Persist the skip + // state across deadlines and appends, never treating their suffix + // as a new JSON record. Keep processing later complete records. + previous.partial = true; + previous.skipping = true; + checksum.update(tail); + offset += tail.length; + tail = new Uint8Array(); + } + if ((GLib.get_monotonic_time() - started) / 1000000 > SCAN_SECONDS) { + partial = true; + stop = true; + } + } + previous.offset = offset; + previous.digest = checksum.get_string(); + // A time-limited scan must resume even if the source file has not changed. + previous.size = stop ? -1 : Math.max(file.size, offset); + previous.stamp = file.stamp; + previous.fileId = file.fileId; + previous.state = parserState(previous.state, fallbackSession); + cached.files[key] = previous; + partial ||= previous.partial; + parsed++; + } catch { partial = true; } finally { input?.close(null); } + } + const events = []; + for (const [key, file] of Object.entries(cached.files)) { + const normalized = /^[a-f0-9]{64}$/.test(key) ? cachedFile(file, privateIdentity(id, key)) : null; + if (!normalized) { + delete cached.files[key]; + partial = true; + continue; + } + cached.files[key] = normalized; + partial ||= normalized.partial; + for (const [eventKey, event] of Object.entries(normalized.events)) { + const valid = sanitizedEvent(event, id, true); + if (!valid || event.date < cutoff) { + delete normalized.events[eventKey]; + partial ||= !valid; + } else { + if (eventKey !== valid.id) { + delete normalized.events[eventKey]; + partial = true; + } + normalized.events[valid.id] = valid; + events.push(valid); + } + } + if (!currentKeys.has(key) && !Object.keys(normalized.events).length) + delete cached.files[key]; + } + if (files.length) + cached.updatedAt = now; + try { writeJson(destination, cached); } catch { partial = true; } + const sourceAvailable = files.length > 0; + const hasCachedEvents = !sourceAvailable && events.length > 0; + const status = sourceAvailable ? partial ? 'partial' : 'ready' : hasCachedEvents ? 'stale' : 'unsupported'; + const result = {...section(status, + partial ? 'Some local records could not be read. Totals may be incomplete.' : + sourceAvailable ? 'Local records only; activity on other devices is not included.' : + hasCachedEvents ? 'Showing saved local activity; source records are currently unavailable.' : + detected ? 'No local usage records found yet.' : 'No local usage records found.'), + updatedAt: sourceAvailable ? now : hasCachedEvents ? numberValue(cached.updatedAt) : null, + scope: 'local', source: `${id} local usage records`, + ...aggregateEvents(events, now), scannedFiles: parsed}; + if (result.models.length > MAX_MODELS) { + result.models = result.models.slice(0, MAX_MODELS); + result.status = 'partial'; + result.message = 'Model totals were truncated to keep local history bounded.'; + } + if ((!sourceAvailable || partial) && !events.length) { + result.days = []; + result.models = []; + result.period = null; + } + return result; +} diff --git a/src/services/http.js b/src/services/http.js new file mode 100644 index 0000000..034b712 --- /dev/null +++ b/src/services/http.js @@ -0,0 +1,42 @@ +import GLib from 'gi://GLib'; +import Soup from 'gi://Soup?version=3.0'; + +const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; +const MAX_RESPONSE_BYTES = 16 * 1024 * 1024; + +export async function requestJson(url, {method = 'GET', headers = {}, body = null, cancellable = null, + maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES} = {}) { + if (typeof url !== 'string' || !url.startsWith('https://')) + throw new Error('Only HTTPS provider endpoints are allowed'); + if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0 || maxResponseBytes > MAX_RESPONSE_BYTES) + throw new Error('HTTP response limit is outside the supported range'); + if (typeof method !== 'string' || !/^[A-Z]+$/.test(method) || !headers || + typeof headers !== 'object' || Array.isArray(headers)) + throw new Error('Invalid provider request options'); + for (const [key, value] of Object.entries(headers)) { + if (!/^[A-Za-z0-9-]+$/.test(key) || typeof value !== 'string' || /[\r\n]/.test(value)) + throw new Error('Invalid provider request header'); + } + + const message = Soup.Message.new(method, url); + if (!message) + throw new Error('Invalid provider endpoint'); + const session = new Soup.Session({timeout: 12}); + try { + for (const [key, value] of Object.entries(headers)) + message.request_headers.append(key, value); + if (body !== null) + message.set_request_body_from_bytes('application/json', + new GLib.Bytes(new TextEncoder().encode(JSON.stringify(body)))); + const bytes = await new Promise((resolve, reject) => session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, cancellable, (s, res) => { + try { resolve(s.send_and_read_finish(res)); } catch (error) { reject(error); } + })); + if (bytes.get_size() > maxResponseBytes) + throw new Error('Provider response exceeded the configured limit'); + let data = null; + try { data = JSON.parse(new TextDecoder().decode(bytes.toArray())); } catch { /* Status remains available for error handling. */ } + return {status: message.status_code, data}; + } finally { + session.abort(); + } +} diff --git a/src/services/migration.js b/src/services/migration.js new file mode 100644 index 0000000..70c1278 --- /dev/null +++ b/src/services/migration.js @@ -0,0 +1,60 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import {join, migrateLegacyData} from './files.js'; + +const LEGACY_UUID = 'freeby@kelvin.local'; +const LEGACY_SCHEMA = 'org.gnome.shell.extensions.freeby'; +const SETTINGS = Object.freeze([ + 'refresh-interval', + 'notifications-enabled', + 'enabled-providers', + 'default-provider', + 'history-retention-days', + 'notification-threshold', +]); + +function legacySchemaDirectories() { + return [GLib.get_user_data_dir(), ...GLib.get_system_data_dirs()] + .map(directory => join(directory, 'gnome-shell', 'extensions', LEGACY_UUID, 'schemas')); +} + +function legacySettings(directories = legacySchemaDirectories()) { + for (const directory of directories) { + if (!Gio.File.new_for_path(directory).query_exists(null)) + continue; + try { + const source = Gio.SettingsSchemaSource.new_from_directory( + directory, Gio.SettingsSchemaSource.get_default(), false); + const schema = source.lookup(LEGACY_SCHEMA, false); + if (schema) + return new Gio.Settings({settings_schema: schema}); + } catch { + // A missing or invalid former installation must not block startup. + } + } + return null; +} + +export function migrateLegacySettings(settings, directories = undefined) { + const legacy = legacySettings(directories); + if (!legacy) + return 0; + return copyLegacySettings(settings, legacy); +} + +export function copyLegacySettings(settings, legacy) { + let migrated = 0; + for (const key of SETTINGS) { + if (settings.get_user_value(key) !== null) + continue; + const value = legacy.get_user_value(key); + if (value !== null && settings.set_value(key, value)) + migrated++; + } + return migrated; +} + +export function migrateLegacyInstall(settings) { + migrateLegacyData(); + migrateLegacySettings(settings); +} diff --git a/src/services/process.js b/src/services/process.js new file mode 100644 index 0000000..c2b0203 --- /dev/null +++ b/src/services/process.js @@ -0,0 +1,229 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import {readLines, readText, waitProcess, writeText} from './streams.js'; + +const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const MAX_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_OUTPUT_BYTES = 64 * 1024 * 1024; + +export class ProcessError extends Error { + constructor(code, message) { + super(message); + this.name = 'ProcessError'; + this.code = code; + } +} + +function normalizeSpec(spec) { + const value = Array.isArray(spec) ? {argv: spec, environment: {}} : spec; + if (!value || !Array.isArray(value.argv) || !value.argv.length || + value.argv.some(argument => typeof argument !== 'string' || !argument)) + throw new ProcessError('INVALID_COMMAND', 'Command arguments must be non-empty strings'); + if (value.environment && (typeof value.environment !== 'object' || Array.isArray(value.environment))) + throw new ProcessError('INVALID_COMMAND', 'Command environment must be an object'); + for (const [name, entry] of Object.entries(value.environment ?? {})) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof entry !== 'string') + throw new ProcessError('INVALID_COMMAND', 'Command environment entries must be named strings'); + } + return {argv: value.argv, environment: value.environment ?? {}}; +} + +function spawn(spec, flags) { + const {argv, environment} = normalizeSpec(spec); + const launcher = new Gio.SubprocessLauncher({flags}); + for (const [name, value] of Object.entries(environment)) + launcher.setenv(name, value, true); + return launcher.spawnv(argv); +} + +export async function runCommand(spec, {input = null, timeout = 15000, cancellable = null, + maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES} = {}) { + if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS || + !Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0 || maxOutputBytes > MAX_OUTPUT_BYTES) + throw new ProcessError('INVALID_OPTIONS', 'Process limits are outside the supported range'); + if (input !== null && typeof input !== 'string') + throw new ProcessError('INVALID_OPTIONS', 'Process input must be text or null'); + + const proc = spawn(spec, Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE | + (input === null ? 0 : Gio.SubprocessFlags.STDIN_PIPE)); + const local = new Gio.Cancellable(); + let completed = false; + let cancellation = null; + const externalId = cancellable?.connect(() => { + cancellation = 'CANCELLED'; + local.cancel(); + }); + if (cancellable?.is_cancelled()) { + cancellation = 'CANCELLED'; + local.cancel(); + } + let timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, timeout, () => { + timer = 0; + cancellation = 'TIMED_OUT'; + local.cancel(); + return GLib.SOURCE_REMOVE; + }); + try { + const writeInput = async () => { + if (input === null) + return; + const stdin = proc.get_stdin_pipe(); + await writeText(stdin, input, local); + stdin.close(null); + }; + const [stdout] = await Promise.all([ + readText(proc.get_stdout_pipe(), maxOutputBytes, local, + () => new ProcessError('OUTPUT_LIMIT', 'Command output exceeded the configured limit')), + writeInput(), + waitProcess(proc, local).then(() => { completed = true; }), + ]); + if (!proc.get_successful()) + throw new ProcessError('FAILED', 'Command exited unsuccessfully'); + return stdout; + } catch (error) { + if (cancellation === 'TIMED_OUT') + throw new ProcessError('TIMED_OUT', 'Command timed out'); + if (cancellation === 'CANCELLED') + throw new ProcessError('CANCELLED', 'Command was cancelled'); + throw error; + } finally { + if (timer) + GLib.source_remove(timer); + if (externalId) + cancellable.disconnect(externalId); + // Abort remaining stream operations on every failure, including output overflow. + local.cancel(); + // Give managed collectors time to cancel and reap their own CLI child. + if (!completed) { + try { proc.send_signal(15); } catch { /* The child exited between callbacks. */ } + let killTimer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + killTimer = 0; + proc.force_exit(); + return GLib.SOURCE_REMOVE; + }); + let reaped = false; + try { await waitProcess(proc); reaped = true; } finally { + if (killTimer) + GLib.source_remove(killTimer); + if (!reaped) + proc.force_exit(); + } + } + } +} + +export class RpcClient { + constructor(spec, cancellable, timeout = 8000) { + if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAX_TIMEOUT_MS) + throw new ProcessError('INVALID_OPTIONS', 'RPC timeout is outside the supported range'); + this.proc = spawn(spec, Gio.SubprocessFlags.STDIN_PIPE | Gio.SubprocessFlags.STDOUT_PIPE | + Gio.SubprocessFlags.STDERR_SILENCE); + this.input = this.proc.get_stdout_pipe(); + this.cancellable = new Gio.Cancellable(); + this.parent = cancellable; + this.timeout = timeout; + this.nextId = 0; + this.pending = new Map(); + this.closed = false; + this._writes = Promise.resolve(); + this._queuedBytes = 0; + this._queuedMessages = 0; + this.parentId = cancellable?.connect(() => this.close()) ?? 0; + if (!this.closed) + this.read(); + } + + send(value) { + if (this.closed) + throw new Error('RPC closed'); + const text = `${JSON.stringify(value)}\n`; + const size = new TextEncoder().encode(text).length; + if (this._queuedMessages >= 64 || this._queuedBytes + size > DEFAULT_MAX_OUTPUT_BYTES) + throw new ProcessError('OUTPUT_LIMIT', 'RPC request exceeded the configured limit'); + this._queuedBytes += size; + this._queuedMessages++; + this._writes = this._writes.then(() => { + if (!this.closed) + return writeText(this.proc.get_stdin_pipe(), text, this.cancellable); + }).catch(() => this.close()).finally(() => { + this._queuedBytes -= size; + this._queuedMessages--; + }); + } + + notify(method, params) { + this.send({method, params}); + } + + request(method, params = {}) { + if (this.closed || this.pending.size >= 64) + return Promise.reject(new ProcessError('RPC_CLOSED', 'RPC unavailable or too many pending requests')); + return new Promise((resolve, reject) => { + const id = ++this.nextId; + const timer = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this.timeout, () => { + this.pending.delete(id); + reject(new ProcessError('RPC_TIMEOUT', `RPC request timed out: ${method}`)); + return GLib.SOURCE_REMOVE; + }); + this.pending.set(id, {resolve, reject, timer}); + try { + this.send({id, method, params}); + } catch (error) { + this.pending.delete(id); + GLib.source_remove(timer); + reject(error); + } + }); + } + + async read() { + try { + for await (const line of readLines(this.input, DEFAULT_MAX_OUTPUT_BYTES, this.cancellable, + () => new ProcessError('OUTPUT_LIMIT', 'RPC response exceeded the configured limit'))) { + if (this.closed) + return; + const value = JSON.parse(line); + const pending = this.pending.get(value.id); + if (pending) { + this.pending.delete(value.id); + GLib.source_remove(pending.timer); + if (value.error) { + const message = String(value.error.message || 'RPC request unavailable').slice(0, 240); + const error = new ProcessError('RPC_ERROR', message); + error.rpcCode = value.error.code ?? null; + pending.reject(error); + } else { + pending.resolve(value.result); + } + } + } + } catch { + // Malformed, oversized and interrupted streams all close pending work. + } finally { + this.close(); + } + } + + close() { + if (this.closed) + return; + this.closed = true; + for (const pending of this.pending.values()) { + GLib.source_remove(pending.timer); + pending.reject(new ProcessError('RPC_CLOSED', 'RPC connection closed')); + } + this.pending.clear(); + this.cancellable.cancel(); + this.proc.force_exit(); + this.proc.wait_async(null, (p, result) => { try { p.wait_finish(result); } catch { /* Reap best effort. */ } }); + // Disconnecting inside a Gio.Cancellable callback deadlocks; defer it. + if (this.parentId) { + const id = this.parentId; + this.parentId = 0; + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this.parent.disconnect(id); + return GLib.SOURCE_REMOVE; + }); + } + } +} diff --git a/src/services/streams.js b/src/services/streams.js new file mode 100644 index 0000000..6f90496 --- /dev/null +++ b/src/services/streams.js @@ -0,0 +1,91 @@ +import GLib from 'gi://GLib'; + +const CHUNK_BYTES = 65536; + +export function readBytes(stream, cancellable) { + return new Promise((resolve, reject) => { + stream.read_bytes_async(CHUNK_BYTES, GLib.PRIORITY_DEFAULT, cancellable, (input, result) => { + try { resolve(input.read_bytes_finish(result).toArray()); } catch (error) { reject(error); } + }); + }); +} + +export async function readText(stream, maxBytes, cancellable, limitError) { + const parts = []; + let size = 0; + while (true) { + const bytes = await readBytes(stream, cancellable); + if (!bytes.length) + break; + size += bytes.length; + if (size > maxBytes) + throw limitError(); + parts.push(bytes); + } + // GJS does not support TextDecoder's streaming option. Decode only after + // joining the already-bounded bytes so split multibyte characters survive. + const output = new Uint8Array(size); + let offset = 0; + for (const part of parts) { + output.set(part, offset); + offset += part.length; + } + return new TextDecoder('utf-8', {fatal: true}).decode(output); +} + +// RPC lines are bounded before decoding or parsing, including unterminated lines. +export async function* readLines(stream, maxBytes, cancellable, limitError) { + let parts = []; + let size = 0; + while (true) { + const bytes = await readBytes(stream, cancellable); + if (!bytes.length) { + if (size) + throw new Error('Incomplete RPC message'); + return; + } + let start = 0; + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 10) { + const part = bytes.subarray(start, i); + size += part.length; + if (size > maxBytes) + throw limitError(); + const line = new Uint8Array(size); + let offset = 0; + for (const entry of [...parts, part]) { + line.set(entry, offset); + offset += entry.length; + } + yield new TextDecoder('utf-8', {fatal: true}).decode(line); + parts = []; + size = 0; + start = i + 1; + } + } + if (start < bytes.length) { + const part = bytes.slice(start); + parts.push(part); + size += part.length; + } + if (size > maxBytes) + throw limitError(); + } +} + +export function writeText(stream, text, cancellable) { + return new Promise((resolve, reject) => { + stream.write_all_async(new TextEncoder().encode(text), GLib.PRIORITY_DEFAULT, cancellable, + (output, result) => { + try { output.write_all_finish(result); resolve(); } catch (error) { reject(error); } + }); + }); +} + +export function waitProcess(proc, cancellable = null) { + return new Promise((resolve, reject) => { + proc.wait_async(cancellable, (child, result) => { + try { child.wait_finish(result); resolve(); } catch (error) { reject(error); } + }); + }); +} diff --git a/src/services/usageService.js b/src/services/usageService.js new file mode 100644 index 0000000..84ab248 --- /dev/null +++ b/src/services/usageService.js @@ -0,0 +1,172 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import {isProvider, mergeRecord, record, section, validateRecord} from '../core/usage.js'; +import {ThresholdTracker} from '../core/notifications.js'; +import {commandSpec} from './commands.js'; +import {join, readJson, stateDirectory, writeJson} from './files.js'; +import {runCommand} from './process.js'; + +const SCHEDULER_TICK_SECONDS = 15; +const MANUAL_REFRESH_COOLDOWN_MS = 2000; +const MAX_BACKOFF_SECONDS = 3600; + +export class UsageService { + constructor(settings, directory, changed, alerts) { + this._settings = settings; + this._directory = directory; + this._changed = changed; + this._alerts = alerts; + this._records = {}; + this._jobs = new Map(); + this._attempts = new Map(); + this._failures = new Map(); + this._thresholds = new ThresholdTracker(); + this._closed = false; + this._enabled = []; + this.configure(); + this._timer = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, SCHEDULER_TICK_SECONDS, () => { + for (const id of this._enabled) + this.refresh(id); + return GLib.SOURCE_CONTINUE; + }); + this._sleepId = Gio.DBus.system.signal_subscribe('org.freedesktop.login1', 'org.freedesktop.login1.Manager', + 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE, + (_connection, _sender, _path, _interface, _signal, params) => { + if (!params.get_child_value(0).get_boolean()) + this.refreshAll(true); + }); + } + + get enabledProviders() { + return [...this._enabled]; + } + + recordFor(id) { + return this._records[id] ?? null; + } + + isRefreshing(id) { + return this._jobs.has(id); + } + + _emitChanged() { + try { + this._changed(); + } catch (error) { + console.error('UsageBeam could not update its panel', error); + } + } + + _emitAlerts(alerts) { + try { + this._alerts(alerts); + } catch (error) { + console.error('UsageBeam could not display a usage alert', error); + } + } + + configure() { + if (this._closed) + return; + this._enabled = [...new Set(this._settings.get_strv('enabled-providers'))].filter(isProvider); + for (const [id, job] of this._jobs) { + if (!this._enabled.includes(id)) { + job.cancel(); + this._jobs.delete(id); + } + } + for (const id of Object.keys(this._records)) { + if (!this._enabled.includes(id)) { + delete this._records[id]; + this._attempts.delete(id); + this._failures.delete(id); + } + } + for (const id of this._enabled) { + if (this._records[id]) + continue; + try { + const cached = validateRecord(readJson(join(stateDirectory(), `${id}.json`)), id); + for (const key of ['limits', 'history']) { + if (['ready', 'partial', 'stale'].includes(cached[key].status)) + cached[key] = {...cached[key], ...section('stale', 'Showing saved usage while refreshing.'), updatedAt: cached[key].updatedAt}; + } + this._records[id] = cached; + } catch { + this._records[id] = record(id); + } + } + } + + refreshAll(force = false) { + for (const id of this._enabled) + this.refresh(id, force); + } + + async refresh(id, force = false) { + if (this._closed || !this._enabled.includes(id) || this._jobs.has(id)) + return; + const now = Date.now(); + const elapsed = now - (this._attempts.get(id) ?? 0); + const wait = Math.min(MAX_BACKOFF_SECONDS, + this._settings.get_int('refresh-interval') * 2 ** (this._failures.get(id) ?? 0)); + if (elapsed >= 0 && elapsed < (force ? MANUAL_REFRESH_COOLDOWN_MS : wait * 1000)) + return; + const job = new Gio.Cancellable(); + this._jobs.set(id, job); + this._attempts.set(id, now); + this._emitChanged(); + try { + const collector = commandSpec('gjs', ['-m', join(this._directory, 'src', 'collector', 'main.js'), + id, String(this._settings.get_int('history-retention-days'))]); + const stdout = await runCommand(collector, {cancellable: job, timeout: 45000}); + if (this._closed || job.is_cancelled() || this._jobs.get(id) !== job) + return; + const result = validateRecord(JSON.parse(stdout), id); + const failed = ['unavailable', 'missing-auth'].includes(result.limits.status); + this._failures.set(id, failed ? Math.min(4, (this._failures.get(id) ?? 0) + 1) : 0); + const alerts = this._thresholds.update(result, this._settings.get_int('notification-threshold')); + this._records[id] = mergeRecord(this._records[id], result); + try { + writeJson(join(stateDirectory(), `${id}.json`), this._records[id]); + } catch { + // Cache failure must not hide current usage. + } + if (this._settings.get_boolean('notifications-enabled') && alerts.length) + this._emitAlerts(alerts); + } catch { + if (!this._closed && !job.is_cancelled()) { + const failure = record(id); + failure.limits = {...failure.limits, + ...section('unavailable', 'Collection failed or timed out. Retry from the panel.')}; + failure.history = {...failure.history, ...section('unavailable', 'History could not be refreshed.')}; + this._records[id] = mergeRecord(this._records[id], failure); + this._failures.set(id, Math.min(4, (this._failures.get(id) ?? 0) + 1)); + } + } finally { + if (this._jobs.get(id) === job) + this._jobs.delete(id); + if (!this._closed) + this._emitChanged(); + } + } + + destroy() { + if (this._closed) + return; + this._closed = true; + if (this._timer) { + GLib.source_remove(this._timer); + this._timer = 0; + } + if (this._sleepId) { + Gio.DBus.system.signal_unsubscribe(this._sleepId); + this._sleepId = 0; + } + for (const job of this._jobs.values()) + job.cancel(); + this._jobs.clear(); + this._changed = () => {}; + this._alerts = () => {}; + } +} diff --git a/src/ui/bar.js b/src/ui/bar.js new file mode 100644 index 0000000..b336299 --- /dev/null +++ b/src/ui/bar.js @@ -0,0 +1,51 @@ +import Atk from 'gi://Atk'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import {chartBarGeometry} from './presentation.js'; + +// Fills are allocated, never sized from allocation notifications. Their geometry +// must not contribute to preferred size or trigger a layout feedback loop. +export const UsageBeamBar = GObject.registerClass(class UsageBeamBar extends St.Widget { + _init({fraction, style, fillStyle, name, content = null, vertical = false}) { + super._init({style_class: style, x_expand: true, + accessible_name: name, accessible_role: Atk.Role.PROGRESS_BAR}); + this._fraction = Math.max(0, Math.min(1, Number(fraction) || 0)); + this._vertical = vertical; + this._fill = new St.Widget({style_class: fillStyle, visible: this._fraction > 0}); + this._overlay = content; + this.add_child(this._fill); + if (content) + this.add_child(content); + } + + vfunc_get_preferred_width(forHeight) { + const node = this.get_theme_node(); + const sizes = this._overlay?.get_preferred_width(node.adjust_for_height(forHeight)) ?? [0, 0]; + return node.adjust_preferred_width(...sizes); + } + + vfunc_get_preferred_height(forWidth) { + const node = this.get_theme_node(); + const sizes = this._overlay?.get_preferred_height(node.adjust_for_width(forWidth)) ?? [0, 0]; + return node.adjust_preferred_height(...sizes); + } + + vfunc_allocate(box) { + this.set_allocation(box); + const contentBox = this.get_theme_node().get_content_box(box); + const [width, height] = contentBox.get_size(); + const scale = St.ThemeContext.get_for_stage(this.get_stage()).scale_factor; + const geometry = this._vertical + ? Object.fromEntries(Object.entries(chartBarGeometry(this._fraction, 1, width / scale, height / scale)) + .map(([key, value]) => [key, value * scale])) + : {x: 0, y: 0, width: Math.round(width * this._fraction), height}; + const fillBox = new Clutter.ActorBox(); + fillBox.x1 = contentBox.x1 + geometry.x; + fillBox.y1 = contentBox.y1 + geometry.y; + fillBox.x2 = fillBox.x1 + geometry.width; + fillBox.y2 = fillBox.y1 + geometry.height; + this._fill.allocate(fillBox); + this._overlay?.allocate(contentBox); + } +}); diff --git a/src/ui/indicator.js b/src/ui/indicator.js new file mode 100644 index 0000000..385a182 --- /dev/null +++ b/src/ui/indicator.js @@ -0,0 +1,351 @@ +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Pango from 'gi://Pango'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {NAMES, recentDates} from '../core/usage.js'; +import {age, modelName, tokens} from '../core/format.js'; +import {quotaSeverity} from '../core/thresholds.js'; +import {initialLayout, nextLayout, pageSlice} from './layoutPolicy.js'; +import {historyOverview, latestUpdate, panelQuota, periodDays, providerStatus, + quotaPresentation} from './presentation.js'; +import {actionButton, button, dayChart, disclosureButton, label, meter, modelMeter, + limitRow, metricLabel, pageControls, providerIcon, separatorDot} from './widgets.js'; + +const TAB_NAMES = {claude: 'Claude'}; +const PROVIDER_MARKS = {codex: '>_', claude: '✦'}; + +export const UsageBeamIndicator = GObject.registerClass(class UsageBeamIndicator extends PanelMenu.Button { + _init(settings, extensionPath, openPreferences) { + super._init(0.5, 'UsageBeam usage monitor'); + this.add_style_class_name('usagebeam-panel-button'); + this._settings = settings; + this._extensionPath = extensionPath; + this._openPreferences = openPreferences; + this._service = null; + this._detailsExpanded = false; + this._limitPage = 0; + this._activityPage = 0; + this._modelPage = 0; + this._sectionPage = 0; + this._panelStatus = new St.BoxLayout({style_class: 'usagebeam-panel-status', + x_expand: true, x_align: Clutter.ActorAlign.START}); + this._panelIcon = new St.Bin({style_class: 'usagebeam-panel-icon-slot', y_align: Clutter.ActorAlign.CENTER}); + this._panelProvider = label('UsageBeam', 'usagebeam-panel-provider'); + this._panelProvider.clutter_text.ellipsize = Pango.EllipsizeMode.END; + this._panelValue = metricLabel('—', 'usagebeam-panel-value'); + this._panelReset = metricLabel('—', 'usagebeam-panel-reset'); + this._panelSeparator = separatorDot('usagebeam-panel-separator'); + this._panelMetrics = new St.BoxLayout({style_class: 'usagebeam-panel-metrics', + y_align: Clutter.ActorAlign.CENTER}); + for (const actor of [this._panelValue, this._panelSeparator, this._panelReset]) + this._panelMetrics.add_child(actor); + for (const actor of [this._panelIcon, this._panelProvider, this._panelMetrics]) + this._panelStatus.add_child(actor); + this._panelProviderId = null; + // Reserve stable panel space separately from the naturally sized readout. + // The unused space belongs on the side away from the calendar. + this.add_child(new St.Bin({style_class: 'usagebeam-panel-slot', child: this._panelStatus})); + this.menu.actor.add_style_class_name('usagebeam-menu'); + this._shellSettings = St.Settings.get(); + this._shellSettings.connectObject( + 'notify::color-scheme', () => this._syncColorScheme(), + 'notify::shell-color-scheme', () => this._syncColorScheme(), this); + this._syncColorScheme(); + const section = new PopupMenu.PopupMenuSection(); + this.menu.addMenuItem(section); + // `content` is an inherited Clutter.Actor property whose value must be + // ClutterContent, so keep the menu actor under an unambiguous name. + this._contentBox = new St.BoxLayout({vertical: true, style_class: 'usagebeam-content', x_expand: true}); + section.box.add_child(this._contentBox); + const restyle = () => { this._layout = null; this.render(); }; + Main.layoutManager.connectObject('monitors-changed', restyle, this); + St.ThemeContext.get_for_stage(global.stage).connectObject('changed', restyle, this); + this.menu.connect('open-state-changed', (_menu, open) => { + if (open) { + this._layout = null; + this.resize(); + this._service?.refreshAll(); + this.render(); + } else { + this._detailsExpanded = false; + this._layout = null; + } + }); + this.render(); + } + + _syncColorScheme() { + const {colorScheme, shellColorScheme} = this._shellSettings; + const light = shellColorScheme === 'prefer-light' || + (shellColorScheme !== 'prefer-dark' && colorScheme === St.SystemColorScheme.PREFER_LIGHT); + this.menu.actor.remove_style_class_name(light ? 'usagebeam-dark' : 'usagebeam-light'); + this.menu.actor.add_style_class_name(light ? 'usagebeam-light' : 'usagebeam-dark'); + } + + resize() { + const monitor = Main.layoutManager.findMonitorForActor(this) ?? Main.layoutManager.primaryMonitor; + const work = Main.layoutManager.getWorkAreaForMonitor(monitor.index); + const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor; + const availableWidth = Math.max(160, Math.floor(work.width / scale) - 64); + this._contentBox.style = `width: ${Math.min(392, availableWidth)}px;`; + // Allow for the native popup frame, panel gap, and our content padding. + this._contentHeightBudget = Math.max(80, work.height - 48 * scale); + } + + attach(service) { + this._service = service; + this.render(); + } + + alignPanelContent(position) { + this._panelStatus.x_align = position === 'left-of-calendar' + ? Clutter.ActorAlign.END : Clutter.ActorAlign.START; + } + + render() { + if (!this._contentBox) + return; + const focus = global.stage.get_key_focus(); + const restoreName = focus && this._contentBox.contains(focus) ? focus.accessible_name : null; + const enabled = this._service?.enabledProviders ?? []; + const requested = this._settings.get_string('default-provider'); + const id = enabled.includes(requested) ? requested : enabled[0]; + const record = this._service?.recordFor(id); + const busy = this._service?.isRefreshing(id) ?? false; + if (this._renderedProvider !== id) { + this._limitPage = this._activityPage = this._modelPage = this._sectionPage = 0; + this._layout = null; + this._renderedProvider = id; + } + const layoutKey = JSON.stringify([record?.plan, record?.limits.status, record?.limits.message, + record?.limits.windows.map(window => [window.label, window.unlimited]), + record?.history.status, record?.history.message, + record?.history.days.length, record?.history.models.map(model => model.model)]); + if (this._layoutKey !== layoutKey) { + this._layoutKey = layoutKey; + this._layout = null; + this._limitPage = this._modelPage = this._sectionPage = 0; + } + this._renderPanelStatus(id, record); + this.resize(); + // Keep page sizes stable while navigating, even on a shorter last page. + // A new opening, provider, theme or monitor starts a fresh measurement. + this._layout ??= initialLayout(); + // Fit before the next paint. Never rebuild from allocation callbacks, + // which caused the old bar flicker and recursive growth. + while (this._layout) { + this._contentBox.destroy_all_children(); + this._contentBox.style_class = `usagebeam-content${this._layout.compact ? ' usagebeam-compact' : ''}`; + this._buildContent(enabled, id, record, busy); + const width = this._contentBox.get_preferred_width(-1)[1]; + if (this._contentBox.get_preferred_height(width)[1] <= this._contentHeightBudget) + break; + const next = nextLayout(this._layout, this._detailsExpanded); + if (!next) + break; + this._layout = next; + } + this._restoreFocus(restoreName); + } + + _buildContent(enabled, id, record, busy) { + this._renderHeader(id, record, busy); + if (enabled.length > 1) + this._renderSelector(enabled, id); + if (record) { + if (this._layout.splitSections) + this._contentBox.add_child(pageControls('View', this._sectionPage, 2, value => { + this._sectionPage = value; + this.render(); + })); + if (!this._layout.splitSections || this._sectionPage === 0) + this._renderLimits(record.limits); + if (!this._layout.splitSections || this._sectionPage === 1) + this._renderHistory(record.history); + } else { + this._message('Choose your providers in preferences to get started.'); + } + this._renderFooter(record, busy); + } + + _renderPanelStatus(id, record) { + const name = TAB_NAMES[id] ?? NAMES[id] ?? 'UsageBeam'; + if (this._panelProviderId !== id) { + this._panelIcon.get_child()?.destroy(); + this._panelIcon.set_child(providerIcon(id, this._extensionPath, 'usagebeam-panel-icon') ?? + label(PROVIDER_MARKS[id] ?? 'AI', 'usagebeam-panel-mark')); + this._panelProviderId = id; + } + this._panelProvider.text = name; + const quota = panelQuota(record); + this._panelValue.text = quota ? `${quota.percent}%` : '—'; + const severity = quotaSeverity(quota?.percent); + this._panelValue.style_class = `usagebeam-panel-value${severity ? ` usagebeam-${severity}` : ''}`; + this._panelReset.text = quota?.reset ?? '—'; + const resetDescription = quota?.reset === 'due' ? ', reset due' : + quota?.reset ? `, resets in ${quota.reset}` : ''; + this.accessible_name = `UsageBeam, ${name}${quota ? `, ${quota.percent} percent used${resetDescription}` : ''}`; + } + + _renderHeader(id, record, busy) { + const header = new St.BoxLayout({style_class: 'usagebeam-header', x_expand: true}); + const icon = providerIcon(id, this._extensionPath, 'usagebeam-header-icon'); + if (icon) + header.add_child(icon); + else + header.add_child(label(PROVIDER_MARKS[id] ?? 'AI', 'usagebeam-provider-mark')); + const identity = new St.BoxLayout({vertical: true, style_class: 'usagebeam-identity', x_expand: true}); + identity.add_child(label(TAB_NAMES[id] ?? record?.name ?? 'UsageBeam', 'usagebeam-title')); + const plan = label(record?.plan ? String(record.plan).toUpperCase() : 'USAGE MONITOR', 'usagebeam-caption'); + plan.clutter_text.ellipsize = Pango.EllipsizeMode.END; + identity.add_child(plan); + header.add_child(identity); + const statusText = providerStatus(record, busy); + header.add_child(label(`● ${statusText}`, `usagebeam-status usagebeam-status-${statusText.toLowerCase()}`)); + this._contentBox.add_child(header); + } + + _renderSelector(enabled, selected) { + const selector = new St.Widget({style_class: 'usagebeam-selector', x_expand: true, + layout_manager: new Clutter.BoxLayout({orientation: Clutter.Orientation.HORIZONTAL, + homogeneous: true, spacing: 8})}); + for (const provider of enabled) { + selector.add_child(button(TAB_NAMES[provider] ?? NAMES[provider], + () => this._settings.set_string('default-provider', provider), + {active: provider === selected, name: `Show ${NAMES[provider]} usage`})); + } + this._contentBox.add_child(selector); + } + + _renderLimits(limits) { + this._heading('LIMITS'); + const page = pageSlice(limits.windows.filter(window => quotaPresentation(window)), this._limitPage, this._layout.limits); + for (const window of page.items) { + const box = new St.BoxLayout({vertical: true, style_class: 'usagebeam-window'}); + const view = quotaPresentation(window); + if (!view) + continue; + const severity = quotaSeverity(window.usedPercent); + box.add_child(limitRow(view.name, view.value, severity)); + if (!window.unlimited) { + box.add_child(meter(window.usedPercent / 100, + `${view.name}: ${view.value} used, ${view.reset.toLowerCase()}`, + severity ? `usagebeam-${severity}` : '')); + box.add_child(label(view.reset, 'usagebeam-limit-reset')); + } + this._contentBox.add_child(box); + } + if (page.pages > 1) + this._contentBox.add_child(pageControls('Limits', page.page, page.pages, value => { + this._limitPage = value; + this.render(); + })); + if (limits.status !== 'ready') { + this._message(limits.message || + (limits.status === 'loading' ? 'Checking account limits…' : 'Limits unavailable')); + } + if (limits.status === 'stale') + this._message(`Saved limits · ${age(limits.updatedAt).toLowerCase()}`); + } + + _renderHistory(history) { + const overview = historyOverview(history); + if (overview) { + this._contentBox.add_child(disclosureButton(this._detailsExpanded, () => { + this._detailsExpanded = !this._detailsExpanded; + this._layout = null; + this._limitPage = this._activityPage = this._modelPage = this._sectionPage = 0; + this.render(); + })); + + if (this._detailsExpanded) + this._renderActivity(history); + } + if (history.message && history.status !== 'ready') + this._message(history.message); + } + + _renderActivity(history) { + const details = new St.BoxLayout({vertical: true, style_class: 'usagebeam-details', x_expand: true}); + const split = this._layout.splitActivity && history.days.length && history.models.length; + if (split) + details.add_child(pageControls('Activity', this._activityPage, 2, value => { + this._activityPage = value; + this.render(); + })); + if (history.days.length && (!split || this._activityPage === 0)) + this._renderDays(history, details); + if (history.models.length && (!split || this._activityPage === 1)) + this._renderModels(history, details); + this._contentBox.add_child(details); + } + + _renderDays(history, parent = this._contentBox) { + const overview = historyOverview(history); + this._heading(`LAST ${overview?.days ?? history.days.length} DAYS · ${tokens(overview?.total ?? 0)} TOKENS`, parent); + const today = recentDates(Date.now(), 1)[0]; + parent.add_child(dayChart(history.days, today)); + } + + _renderModels(history, parent = this._contentBox) { + const modelScope = history.scope === 'account' ? 'ACCOUNT' : 'LOCAL'; + const days = periodDays(history.period); + this._heading(`TOKENS BY MODEL${days ? ` · ${days}D` : ''} ${modelScope}`, parent); + const max = Math.max(1, ...history.models.map(model => model.total)); + const page = pageSlice(history.models, this._modelPage, this._layout.models); + for (const model of page.items) { + const displayName = modelName(model.model); + const cache = model.cacheRead + model.cacheWrite; + const accessible = `${displayName}, ${model.total} tokens. Input ${model.input}, output ${model.output}, cache ${cache}.`; + parent.add_child(modelMeter(displayName, tokens(model.total), model.total / max, accessible)); + } + if (page.pages > 1) + parent.add_child(pageControls('Models', page.page, page.pages, value => { + this._modelPage = value; + this.render(); + })); + } + + _renderFooter(record, busy) { + const actions = new St.BoxLayout({style_class: 'usagebeam-actions', x_expand: true}); + actions.add_child(label(busy ? 'Refreshing…' : age(latestUpdate(record)), 'usagebeam-caption', true)); + actions.add_child(actionButton('Refresh', () => this._service?.refreshAll(true), 'Refresh usage')); + actions.add_child(actionButton('Settings', () => { this.menu.close(); this._openPreferences(); }, + 'Open extension settings')); + this._contentBox.add_child(actions); + } + + _restoreFocus(name) { + if (!name) + return; + const actors = []; + const visit = actor => { actors.push(actor); actor.get_children().forEach(visit); }; + visit(this._contentBox); + const opposite = name.startsWith('Next ') ? name.replace('Next ', 'Previous ') + : name.startsWith('Previous ') ? name.replace('Previous ', 'Next ') : null; + const target = [name, opposite].filter(Boolean).map(candidate => + actors.find(actor => actor.can_focus && actor.accessible_name === candidate)).find(Boolean); + target?.grab_key_focus(); + } + + _heading(text, parent = this._contentBox) { + parent.add_child(label(text, 'usagebeam-section-title')); + } + + _message(text) { + const message = label(text, 'usagebeam-message'); + message.clutter_text.line_wrap = true; + this._contentBox.add_child(message); + } + + destroy() { + Main.layoutManager.disconnectObject(this); + St.ThemeContext.get_for_stage(global.stage).disconnectObject(this); + this._shellSettings?.disconnectObject(this); + this._shellSettings = null; + super.destroy(); + } +}); diff --git a/src/ui/layoutPolicy.js b/src/ui/layoutPolicy.js new file mode 100644 index 0000000..b18ece8 --- /dev/null +++ b/src/ui/layoutPolicy.js @@ -0,0 +1,24 @@ +// Progressively reduce simultaneous content, never the user's font size. +export function initialLayout() { + return {limits: 3, models: 4, splitActivity: false, compact: false, splitSections: false}; +} + +export function nextLayout(layout, expanded) { + if (expanded && !layout.splitActivity) + return {...layout, splitActivity: true}; + if (layout.limits > 1) + return {...layout, limits: layout.limits - 1}; + if (!layout.compact) + return {...layout, compact: true}; + if (expanded && layout.models > 1) + return {...layout, models: layout.models - 1}; + if (expanded && !layout.splitSections) + return {...layout, splitSections: true}; + return null; +} + +export function pageSlice(items, requested, size) { + const pages = Math.max(1, Math.ceil(items.length / size)); + const page = Math.max(0, Math.min(pages - 1, requested)); + return {items: items.slice(page * size, (page + 1) * size), page, pages}; +} diff --git a/src/ui/panelPlacement.js b/src/ui/panelPlacement.js new file mode 100644 index 0000000..95ddf7a --- /dev/null +++ b/src/ui/panelPlacement.js @@ -0,0 +1,86 @@ +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const CALENDAR_POSITIONS = new Set(['left-of-calendar', 'right-of-calendar']); + +export function clearIndicatorPlacement(indicator) { + const state = indicator?._usageBeamPlacement; + if (!state) + return; + for (const [actor, signal] of state.signals) + actor.disconnect(signal); + for (const spacer of state.spacers) + spacer.destroy(); + delete indicator._usageBeamPlacement; +} + +function centerCalendarGap(indicator, calendar, target, position) { + // GNOME centers the whole box, including actors owned by other extensions. + // Balance every visible sibling around the calendar/readout boundary. + const leading = new St.Widget({style_class: 'usagebeam-center-balance'}); + const trailing = new St.Widget({style_class: 'usagebeam-center-balance'}); + const leftIsIndicator = position === 'left-of-calendar'; + const state = {signals: [], spacers: [leading, trailing]}; + const watched = new Map(); + const sync = () => { + const children = target.get_children().filter(actor => actor.visible); + const widths = children.map(actor => state.spacers.includes(actor) ? 0 : actor.get_preferred_width(-1)[1]); + const index = children.indexOf(leftIsIndicator ? indicator.container : calendar); + if (index < 0) + return; + const spacing = target.get_theme_node().get_length('spacing'); + const boundary = widths.slice(0, index + 1).reduce((sum, width) => sum + width, 0) + spacing * (index + 0.5); + const total = widths.reduce((sum, width) => sum + width, 0) + spacing * Math.max(0, children.length - 1); + const difference = 2 * boundary - total; + leading.set_width(Math.max(0, -difference)); + trailing.set_width(Math.max(0, difference)); + }; + const calendarIndex = target.get_children().indexOf(calendar); + target.insert_child_at_index(leading, calendarIndex); + target.insert_child_at_index(indicator.container, calendarIndex + (leftIsIndicator ? 1 : 2)); + target.insert_child_at_index(trailing, calendarIndex + 3); + const observe = () => { + const children = target.get_children().filter(actor => !state.spacers.includes(actor)); + for (const [actor, signals] of watched) { + if (children.includes(actor)) + continue; + for (const signal of signals) { + actor.disconnect(signal); + state.signals = state.signals.filter(pair => pair[0] !== actor || pair[1] !== signal); + } + watched.delete(actor); + } + for (const actor of children) { + if (watched.has(actor)) + continue; + const signals = ['notify::width', 'notify::visible'].map(name => actor.connect(name, sync)); + watched.set(actor, signals); + state.signals.push(...signals.map(signal => [actor, signal])); + } + sync(); + }; + for (const signal of ['child-added', 'child-removed']) + state.signals.push([target, target.connect(signal, observe)]); + indicator._usageBeamPlacement = state; + observe(); +} + +export function placeIndicator(indicator, position) { + clearIndicatorPlacement(indicator); + const container = indicator.container; + const calendar = Main.panel.statusArea.dateMenu?.container; + const boxes = {left: Main.panel._leftBox, right: Main.panel._rightBox}; + const resolved = CALENDAR_POSITIONS.has(position) || position in boxes + ? position : 'right-of-calendar'; + indicator.alignPanelContent(resolved); + const besideCalendar = CALENDAR_POSITIONS.has(resolved); + const target = besideCalendar ? calendar?.get_parent() ?? Main.panel._centerBox : boxes[resolved]; + container.get_parent()?.remove_child(container); + if (besideCalendar && calendar) { + centerCalendarGap(indicator, calendar, target, resolved); + return; + } + const siblings = target.get_children(); + const index = resolved === 'right' ? 0 : siblings.length; + target.insert_child_at_index(container, index); +} diff --git a/src/ui/presentation.js b/src/ui/presentation.js new file mode 100644 index 0000000..262c583 --- /dev/null +++ b/src/ui/presentation.js @@ -0,0 +1,105 @@ +import {resetCountdown, resetTime} from '../core/format.js'; + +const CURRENT_STATES = new Set(['ready', 'partial']); + +export function providerStatus(record, refreshing = false) { + if (CURRENT_STATES.has(record?.limits?.status)) + return 'LIVE'; + if (CURRENT_STATES.has(record?.history?.status)) + return 'LOCAL'; + if (record?.limits?.status === 'stale' || record?.history?.status === 'stale') + return 'CACHED'; + return refreshing ? 'SYNC' : 'SETUP'; +} + +export function latestUpdate(record) { + const timestamps = [record?.limits?.updatedAt, record?.history?.updatedAt] + .filter(value => Number.isFinite(value) && value > 0); + return timestamps.length ? Math.max(...timestamps) : null; +} + +export function periodDays(period) { + const start = Date.parse(`${period?.start ?? ''}T00:00:00Z`); + const end = Date.parse(`${period?.end ?? ''}T00:00:00Z`); + if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) + return null; + return Math.round((end - start) / 86400000) + 1; +} + +export function historyOverview(history) { + const days = Array.isArray(history?.days) ? history.days : []; + const models = Array.isArray(history?.models) ? history.models : []; + const values = days.length ? days : models; + if (!values.length) + return null; + + const total = values.reduce((sum, item) => sum + + (Number.isFinite(item?.total) ? item.total : 0), 0); + return { + days: periodDays(history.period), + scope: history.scope === 'account' ? 'account' : 'local', + total, + }; +} + +export function quotaName(window) { + const source = `${window?.id ?? ''} ${window?.label ?? ''}`.toLowerCase(); + if (source.includes('five-hour') || source.includes('5 hours')) + return '5H Session'; + if (source.includes('reserve') && + (source.includes('weekly') || window?.durationMinutes === 10080)) + return 'Weekly Reserve'; + return String(window?.label ?? 'Usage limit'); +} + +export function quotaPresentation(window, now = Date.now()) { + if (!window) + return null; + if (window.unlimited) + return {name: quotaName(window), value: 'Unlimited', reset: null}; + if (!Number.isFinite(window.usedPercent)) + return null; + return { + name: quotaName(window), + value: `${Math.round(window.usedPercent)}%`, + reset: resetTime(window.resetsAt, now), + }; +} + +export function panelQuota(record, now = Date.now()) { + if (!CURRENT_STATES.has(record?.limits?.status)) + return null; + let window = null; + let shortest = Infinity; + for (const candidate of record.limits.windows) { + if (candidate.unlimited || !Number.isFinite(candidate.usedPercent)) + continue; + const duration = Number.isFinite(candidate.durationMinutes) && + candidate.durationMinutes > 0 ? candidate.durationMinutes : Infinity; + if (!window || duration < shortest) { + window = candidate; + shortest = duration; + } + } + if (!window) + return null; + return { + percent: Math.round(window.usedPercent), + reset: resetCountdown(window.resetsAt, now), + }; +} + +export function chartBarGeometry(value, maximum, width, height, inset = 4) { + const availableWidth = Math.max(0, width - inset * 2); + const availableHeight = Math.max(0, height); + const ratio = maximum > 0 && value > 0 ? Math.min(1, value / maximum) : 0; + const barHeight = ratio && availableHeight + ? Math.min(availableHeight, Math.max(3, Math.round(availableHeight * ratio))) + : 0; + return { + x: inset, + y: availableHeight - barHeight, + width: availableWidth, + height: barHeight, + }; +} diff --git a/src/ui/widgets.js b/src/ui/widgets.js new file mode 100644 index 0000000..0e28d35 --- /dev/null +++ b/src/ui/widgets.js @@ -0,0 +1,161 @@ +import Atk from 'gi://Atk'; +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; +import {compactTokens} from '../core/format.js'; +import {UsageBeamBar} from './bar.js'; + +const PROVIDER_ICONS = new Set(['claude', 'codex']); +const PROVIDER_ICON_FILES = {claude: 'claude.svg', codex: 'codex-symbolic.svg'}; + +export function label(text, style = '', expand = false) { + return new St.Label({text: String(text ?? ''), style_class: style, + y_align: Clutter.ActorAlign.CENTER, x_expand: expand}); +} + +export function metricLabel(text, style = '', expand = false) { + const actor = label(text, style, expand); + actor.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + return actor; +} + +export function separatorDot(style = '') { + const dot = new St.Widget({style_class: 'usagebeam-separator-dot-core'}); + return new St.Bin({ + child: dot, + style_class: `usagebeam-separator-dot ${style}`.trim(), + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); +} + +export function providerIcon(provider, extensionPath, style = '') { + if (!PROVIDER_ICONS.has(provider)) + return null; + return new St.Icon({ + gicon: new Gio.FileIcon({ + file: Gio.File.new_for_path(`${extensionPath}/icons/${PROVIDER_ICON_FILES[provider]}`), + }), + style_class: `usagebeam-provider-icon usagebeam-${provider}-icon ${style}`.trim(), + }); +} + +export function limitRow(name, value, severity = null) { + const box = new St.BoxLayout({style_class: 'usagebeam-limit-row', x_expand: true}); + const title = label(name, 'usagebeam-limit-name', true); + title.clutter_text.ellipsize = Pango.EllipsizeMode.END; + box.add_child(title); + box.add_child(metricLabel(value, + `usagebeam-limit-percent${severity ? ` usagebeam-${severity}` : ''}`)); + return box; +} + +export function meter(fraction, name, style = '') { + return new UsageBeamBar({fraction, name, style: `usagebeam-track ${style}`, fillStyle: 'usagebeam-fill'}); +} + +export function modelMeter(left, right, fraction, name) { + const content = new St.BoxLayout({style_class: 'usagebeam-model-content'}); + const title = label(left, 'usagebeam-model-name', true); + title.clutter_text.ellipsize = Pango.EllipsizeMode.END; + content.add_child(title); + content.add_child(metricLabel(right, 'usagebeam-number')); + return new UsageBeamBar({fraction, name, content, + style: 'usagebeam-model-meter', fillStyle: 'usagebeam-model-fill'}); +} + +export function dayChart(days, today) { + const values = Array.isArray(days) ? days : []; + const max = Math.max(1, ...values.map(day => day.total)); + const chart = new St.Widget({ + style_class: 'usagebeam-day-chart', + x_expand: true, + layout_manager: new Clutter.BoxLayout({ + homogeneous: true, + orientation: Clutter.Orientation.HORIZONTAL, + spacing: 6, + }), + accessible_name: 'Daily token activity chart', + accessible_role: Atk.Role.PANEL, + }); + for (const day of values) { + const isToday = day.date === today; + const weekday = new Date(`${day.date}T12:00:00`).toLocaleDateString(undefined, {weekday: 'short'}); + const column = new St.BoxLayout({ + vertical: true, + style_class: `usagebeam-chart-column${isToday ? ' usagebeam-today' : ''}`, + x_expand: true, + accessible_name: `${isToday ? 'Today, ' : ''}${weekday}, ${day.total} tokens, ${day.sessions ?? 0} sessions`, + }); + column.add_child(label(compactTokens(day.total), 'usagebeam-chart-value')); + const plot = new UsageBeamBar({ + fraction: day.total / max, + name: `${day.date}: ${day.total} tokens`, + style: 'usagebeam-chart-plot', + fillStyle: 'usagebeam-chart-bar', + vertical: true, + }); + column.add_child(plot); + column.add_child(label(weekday, 'usagebeam-chart-day')); + chart.add_child(column); + } + return chart; +} + +export function button(text, callback, {active = false, name = text} = {}) { + const actor = new St.Button({label: text, can_focus: true, reactive: true, track_hover: true, + accessible_name: name, style_class: 'usagebeam-button', x_expand: true}); + if (active) + actor.add_style_pseudo_class('checked'); + actor.connect('clicked', callback); + return actor; +} + +export function disclosureButton(expanded, callback) { + const actor = new St.Button({ + accessible_name: 'Activity details', + accessible_role: Atk.Role.TOGGLE_BUTTON, + can_focus: true, + checked: expanded, + reactive: true, + style_class: 'usagebeam-disclosure', + toggle_mode: true, + track_hover: true, + x_expand: true, + }); + const content = new St.BoxLayout({style_class: 'usagebeam-disclosure-content', x_expand: true}); + content.add_child(label('Activity', 'usagebeam-disclosure-title', true)); + content.add_child(new St.Icon({ + icon_name: expanded ? 'pan-up-symbolic' : 'pan-down-symbolic', + style_class: 'usagebeam-disclosure-icon', + })); + actor.set_child(content); + actor.connect('clicked', callback); + return actor; +} + +export function actionButton(text, callback, name = text) { + const actor = button(text, callback, {name}); + actor.x_expand = false; + actor.add_style_class_name('usagebeam-action-button'); + return actor; +} + +export function pageControls(name, page, pages, changed) { + const row = new St.BoxLayout({style_class: 'usagebeam-pagination', x_expand: true}); + for (const direction of [-1, 0, 1]) { + if (!direction) { + row.add_child(label(`${name} · ${page + 1} / ${pages}`, 'usagebeam-page-label', true)); + continue; + } + const control = actionButton(direction < 0 ? '‹' : '›', () => changed(page + direction), + `${direction < 0 ? 'Previous' : 'Next'} ${name.toLowerCase()} page`); + control.reactive = page + direction >= 0 && page + direction < pages; + control.can_focus = control.reactive; + if (!control.reactive) + control.add_style_pseudo_class('insensitive'); + row.add_child(control); + } + return row; +} diff --git a/stylesheet.css b/stylesheet.css index ac2912b..f4bfb90 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -1,83 +1,470 @@ -.freeby-panel { - spacing: 4px; +.usagebeam-menu { + font-family: "SF Pro Text"; + font-size: 10.5pt; + font-feature-settings: "tnum"; + -arrow-background-color: transparent; + -arrow-border-color: transparent; + -arrow-border-width: 0px; + -arrow-rise: 6px; + -arrow-base: 0px; + background-color: transparent; + box-shadow: none; } -.freeby-panel-label { - font-weight: bold; - font-size: 10pt; - min-width: 16px; - color: currentColor; +.usagebeam-menu .popup-menu-content { + font-family: "SF Pro Text"; + font-size: 10.5pt; + font-feature-settings: "tnum"; + border: 1px solid st-transparentize(st-lighten(-st-accent-color, 20%), 0.34); + border-radius: 12px; + box-shadow: none; } -.freeby-panel-green { - color: #4ade80; +.usagebeam-menu.usagebeam-dark .popup-menu-content { + color: #edf4f6; + background-color: st-mix(-st-accent-color, #182b35, 6%); } -.freeby-panel-yellow { - color: #facc15; +.usagebeam-menu.usagebeam-light .popup-menu-content { + color: #17252c; + background-color: st-mix(-st-accent-color, #f4f8f9, 4%); } -.freeby-panel-red { - color: #f87171; +.usagebeam-panel-button { + -natural-hpadding: 4px; + -minimum-hpadding: 3px; } -.freeby-item-box { - spacing: 8px; +.usagebeam-panel-slot { + width: 12.5em; + font-family: "SF Pro Text"; + font-size: 9.5pt; + font-feature-settings: "tnum"; } -.freeby-dot { - font-size: 8pt; - min-width: 10px; +.usagebeam-panel-status { + spacing: 4px; } -.freeby-dot-off { - color: currentColor; - opacity: 0.4; +.usagebeam-panel-metrics { + spacing: 4px; + padding-top: 2px; } -.freeby-dot-green { - color: #4ade80; +.usagebeam-panel-icon-slot { width: 16px; } + +.usagebeam-provider-icon { + icon-size: 20px; +} + +.usagebeam-panel-icon { + icon-size: 15px; +} + +.usagebeam-panel-provider, +.usagebeam-panel-mark { + font-size: 9.5pt; +} + +.usagebeam-panel-provider { + font-weight: 650; +} + +.usagebeam-panel-value, +.usagebeam-panel-reset { + color: st-lighten(-st-accent-color, 25%); + font-size: 8.75pt; +} + +.usagebeam-panel-value { + font-weight: 620; +} + +.usagebeam-panel-reset { + font-weight: 500; + opacity: 0.86; + text-align: left; +} + +.usagebeam-separator-dot { + min-width: 4px; + min-height: 4px; +} + +.usagebeam-separator-dot-core { + width: 4px; + height: 4px; + border-radius: 2px; + background-color: rgba(220, 232, 237, 0.62); +} + +.usagebeam-panel-separator .usagebeam-separator-dot-core { + background-color: rgba(255, 255, 255, 0.58); +} + +.usagebeam-content { + padding: 12px 14px 12px; + spacing: 0; +} + +.usagebeam-header { + spacing: 10px; + padding: 1px 0 10px; +} + +.usagebeam-header-icon { + icon-size: 29px; + margin: 1px 2px 1px 0; +} + +.usagebeam-identity { + spacing: 0; +} + +.usagebeam-provider-mark { + min-width: 29px; + min-height: 29px; + color: st-lighten(-st-accent-color, 22%); + font-family: "SF Pro Text"; + font-size: 10pt; + font-weight: 650; + text-align: center; +} + +.usagebeam-title { + font-family: "SF Pro Display"; + font-size: 14pt; + font-weight: 700; +} + +.usagebeam-caption { + font-feature-settings: "tnum"; + font-size: 9.5pt; + opacity: 0.76; +} + +.usagebeam-status { + padding: 2px 1px; + font-size: 8.5pt; + font-weight: 650; + opacity: 0.94; +} + +.usagebeam-status-live { + color: #62e6a0; +} + +.usagebeam-status-sync { + color: st-lighten(-st-accent-color, 25%); +} + +.usagebeam-status-local { + color: #f8d66d; +} + +.usagebeam-status-cached { + opacity: 0.72; +} + +.usagebeam-selector { + spacing: 8px; + padding-bottom: 10px; +} + +.usagebeam-button { + min-height: 23px; + padding: 4px 10px; + border: 1px solid rgba(128, 150, 160, 0.30); + border-radius: 6px; + background-color: rgba(128, 150, 160, 0.055); +} + +.usagebeam-button:hover, +.usagebeam-button:focus { + border-color: st-transparentize(st-lighten(-st-accent-color, 22%), 0.34); + background-color: st-transparentize(-st-accent-color, 0.88); +} + +.usagebeam-button:checked { + border-color: st-transparentize(st-lighten(-st-accent-color, 24%), 0.24); + background-color: st-transparentize(-st-accent-color, 0.80); + font-weight: 650; +} + +.usagebeam-button:focus, +.usagebeam-disclosure:focus { + box-shadow: inset 0 0 0 2px st-transparentize(-st-accent-color, 0.24); +} + +.usagebeam-section-title { + padding: 8px 0 6px; + border-top: 1px solid rgba(128, 150, 160, 0.22); + font-size: 9.5pt; + font-weight: 700; + opacity: 0.92; +} + +.usagebeam-limit-row { + min-height: 21px; + spacing: 10px; +} + +.usagebeam-limit-name { + font-size: 10.5pt; + font-weight: 520; +} + +.usagebeam-limit-percent { + color: st-mix(-st-accent-color, #d7e4eb, 28%); + font-size: 9.5pt; + font-weight: 620; + text-align: right; +} + +.usagebeam-limit-reset { + color: st-mix(-st-accent-color, #9baab4, 12%); + font-size: 9.5pt; + font-weight: 500; + text-align: left; +} + +.usagebeam-number { + font-feature-settings: "tnum"; + font-weight: 550; +} + +.usagebeam-window { + spacing: 4px; + padding-bottom: 8px; +} + +.usagebeam-track { + height: 5px; + min-width: 20px; + border-radius: 3px; + background-color: rgba(143, 166, 176, 0.22); +} + +.usagebeam-fill { + border-radius: 3px; + background-color: st-mix(-st-accent-color, #b7cbd5, 42%); +} + +.usagebeam-disclosure { + min-height: 25px; + margin: 3px 0 4px; + padding: 6px 9px; + border: 1px solid rgba(128, 150, 160, 0.30); + border-radius: 7px; + background-color: rgba(128, 150, 160, 0.065); +} + +.usagebeam-disclosure:hover, +.usagebeam-disclosure:focus, +.usagebeam-disclosure:checked { + border-color: st-transparentize(st-lighten(-st-accent-color, 22%), 0.35); + background-color: st-transparentize(-st-accent-color, 0.89); +} + +.usagebeam-disclosure-content { + spacing: 8px; +} + +.usagebeam-disclosure-title { + font-size: 10pt; + font-weight: 650; +} + +.usagebeam-disclosure-icon { + icon-size: 10px; + opacity: 0.76; +} + +.usagebeam-details { + spacing: 0; + padding-top: 1px; +} + +.usagebeam-day-chart { + padding: 7px 0 8px; +} + +.usagebeam-chart-column { + spacing: 4px; +} + +.usagebeam-chart-value, +.usagebeam-chart-day { + font-feature-settings: "tnum"; + text-align: center; +} + +.usagebeam-chart-value { + font-size: 9pt; + opacity: 0.79; +} + +.usagebeam-chart-day { + font-size: 9.5pt; + opacity: 0.72; +} + +.usagebeam-chart-plot { + height: 52px; +} + +.usagebeam-chart-bar { + border-radius: 4px 4px 2px 2px; + background-color: st-mix(-st-accent-color, #adbec7, 24%); +} + +.usagebeam-today .usagebeam-chart-value, +.usagebeam-today .usagebeam-chart-day { + color: st-mix(-st-accent-color, #e4f1f6, 40%); + font-weight: 700; + opacity: 1; +} + +.usagebeam-today .usagebeam-chart-bar { + background-color: st-mix(-st-accent-color, #c7dce6, 52%); +} + +.usagebeam-model-meter { + min-height: 27px; + margin-bottom: 5px; + border: 1px solid rgba(128, 150, 160, 0.12); + border-radius: 4px; + background-color: rgba(128, 150, 160, 0.11); +} + +.usagebeam-model-fill { + border-radius: 3px; + background-color: st-transparentize(st-mix(-st-accent-color, #aebfc8, 36%), 0.34); +} + +.usagebeam-model-content { + spacing: 10px; + padding: 4px 8px; + font-size: 9.25pt; +} + +.usagebeam-model-name { + font-weight: 520; +} + +.usagebeam-model-content .usagebeam-number { + font-size: 8.75pt; + font-weight: 650; +} + +.usagebeam-actions { + spacing: 7px; + padding-top: 9px; + margin-top: 4px; + border-top: 1px solid rgba(128, 150, 160, 0.22); +} + +.usagebeam-pagination { + spacing: 8px; + padding: 3px 0 7px; +} + +.usagebeam-page-label { + text-align: center; + font-size: 9pt; + opacity: 0.8; +} + +.usagebeam-pagination .usagebeam-button:insensitive { + opacity: 0.35; +} + +.usagebeam-compact .usagebeam-header { padding-bottom: 5px; } +.usagebeam-compact .usagebeam-selector { padding-bottom: 5px; } +.usagebeam-compact .usagebeam-window { padding-bottom: 4px; } +.usagebeam-compact .usagebeam-message { padding: 4px 8px; margin-bottom: 4px; } + +.usagebeam-action-button { + min-height: 21px; + padding: 3px 9px; + font-size: 9.5pt; +} + +.usagebeam-message { + margin: 0 0 8px; + padding: 8px 10px; + border-left: 2px solid #f6c85f; + border-radius: 4px; + background-color: rgba(246, 200, 95, 0.08); + font-size: 9pt; + opacity: 0.92; +} + +.usagebeam-menu.usagebeam-light .usagebeam-limit-percent { + color: st-mix(-st-accent-color, #263b46, 38%); +} + +.usagebeam-menu.usagebeam-light .usagebeam-limit-reset { + color: st-mix(-st-accent-color, #586b76, 12%); +} + +.usagebeam-menu.usagebeam-light .usagebeam-separator-dot-core { + background-color: rgba(38, 59, 70, 0.54); +} + +.usagebeam-menu.usagebeam-light .usagebeam-track { + background-color: rgba(55, 76, 86, 0.18); +} + +.usagebeam-menu.usagebeam-light .usagebeam-chart-bar { + background-color: st-mix(-st-accent-color, #6f8793, 28%); +} + +.usagebeam-menu.usagebeam-light .usagebeam-today .usagebeam-chart-value, +.usagebeam-menu.usagebeam-light .usagebeam-today .usagebeam-chart-day { + color: st-mix(-st-accent-color, #263b46, 38%); +} + +.usagebeam-menu.usagebeam-light .usagebeam-status-live { + color: #167345; } -.freeby-dot-yellow { - color: #facc15; +/* Quota severity is deliberately encoded in both color and numeric text. */ +.usagebeam-panel-value.usagebeam-caution, +.usagebeam-menu .usagebeam-limit-percent.usagebeam-caution { + color: #e8c65d; } -.freeby-dot-red { - color: #f87171; +.usagebeam-panel-value.usagebeam-warning, +.usagebeam-menu .usagebeam-limit-percent.usagebeam-warning { + color: #ee9d50; } -.freeby-item-name { - font-weight: bold; - font-size: 9pt; - min-width: 52px; - color: currentColor; +.usagebeam-panel-value.usagebeam-danger, +.usagebeam-menu .usagebeam-limit-percent.usagebeam-danger { + color: #f05f78; } -.freeby-item-summary { - font-size: 9pt; - color: currentColor; - opacity: 0.8; +.usagebeam-track.usagebeam-caution .usagebeam-fill { + background-color: #e8c65d; } -.freeby-dim { - opacity: 0.4; +.usagebeam-track.usagebeam-warning .usagebeam-fill { + background-color: #ee9d50; } -.freeby-green { - color: #4ade80; +.usagebeam-track.usagebeam-danger .usagebeam-fill { + background-color: #f05f78; } -.freeby-yellow { - color: #facc15; +.usagebeam-menu.usagebeam-light .usagebeam-limit-percent.usagebeam-caution { + color: #7a5b00; } -.freeby-red { - color: #f87171; +.usagebeam-menu.usagebeam-light .usagebeam-limit-percent.usagebeam-warning { + color: #9a4f00; } -.freeby-status { - font-size: 9pt; - color: currentColor; - opacity: 0.7; +.usagebeam-menu.usagebeam-light .usagebeam-limit-percent.usagebeam-danger { + color: #b12643; } diff --git a/tests/freeby.bats b/tests/freeby.bats deleted file mode 100644 index b092960..0000000 --- a/tests/freeby.bats +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bats - -SCRIPT="$BATS_TEST_DIRNAME/../scripts/freeby.sh" - -@test "outputs valid JSON" { - run bash "$SCRIPT" - [ "$status" -eq 0 ] - echo "$output" | python3 -c "import json,sys; json.load(sys.stdin)" -} - -@test "JSON contains codex, cursor, copilot keys" { - run bash "$SCRIPT" - echo "$output" | python3 -c " -import json,sys -d = json.load(sys.stdin) -assert 'codex' in d, 'missing codex' -assert 'cursor' in d, 'missing cursor' -assert 'copilot' in d, 'missing copilot' -" -} - -@test "each provider has required fields" { - run bash "$SCRIPT" - echo "$output" | python3 -c " -import json,sys -d = json.load(sys.stdin) -for k in ('codex','cursor','copilot'): - p = d[k] - assert 'available' in p, f'{k} missing available' - assert 'has_remaining' in p, f'{k} missing has_remaining' - assert 'summary' in p, f'{k} missing summary' - assert isinstance(p['available'], bool), f'{k} available not bool' - assert isinstance(p['has_remaining'], bool), f'{k} has_remaining not bool' -" -} - -@test "script cleans up temp directory" { - before=$(ls /tmp | grep -c 'tmp\.' || true) - run bash "$SCRIPT" - after=$(ls /tmp | grep -c 'tmp\.' || true) - [ "$after" -le "$before" ] -} diff --git a/tests/integration/collector.js b/tests/integration/collector.js new file mode 100644 index 0000000..d36e5dc --- /dev/null +++ b/tests/integration/collector.js @@ -0,0 +1,208 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import System from 'system'; +import {scanHistory} from '../../src/services/history.js'; +import {parseCodexEvent} from '../../src/providers/codex.js'; +import {parseClaudeEvent} from '../../src/providers/claude.js'; +import {commandSpec, findCommand} from '../../src/services/commands.js'; +import {join, migrateLegacyData, readJson, writeJson} from '../../src/services/files.js'; +import {requestJson} from '../../src/services/http.js'; +import {copyLegacySettings} from '../../src/services/migration.js'; +import {RpcClient, runCommand} from '../../src/services/process.js'; + +function assert(value, message) { + if (!value) + throw new Error(message); +} +const scratch = GLib.dir_make_tmp('usagebeam-integration-XXXXXX'); +const sessions = join(scratch, 'sessions'); +GLib.mkdir_with_parents(sessions, 0o700); +const fixture = join(sessions, 'synthetic.jsonl'); +const cachePath = join(scratch, 'cache.json'); +const now = new Date('2026-09-06T12:00:00').getTime(); +const event = total => JSON.stringify({type: 'event_msg', timestamp: '2026-09-06T10:00:00Z', payload: {type: 'token_count', + info: {total_token_usage: {input_tokens: total, output_tokens: 0, cached_input_tokens: 0, total_tokens: total}}}}); +const text = `${JSON.stringify({type: 'session_meta', payload: {id: 'synthetic'}})}\n${event(100)}\n`; +GLib.file_set_contents(fixture, text); +const first = scanHistory('codex', [sessions], parseCodexEvent, {now, cachePath}); +assert(first.days.at(-1).total === 100, 'initial scan'); +const second = scanHistory('codex', [sessions], parseCodexEvent, {now, cachePath}); +assert(second.scannedFiles === 0 && second.days.at(-1).total === 100, 'unchanged file must reuse cache'); +GLib.file_set_contents(fixture, `${text}${event(180)}\n{"partial":`); +const third = scanHistory('codex', [sessions], parseCodexEvent, {now, cachePath}); +assert(third.days.at(-1).total === 180, 'append must count delta only'); +const cache = readJson(cachePath); +assert(cache.version === 5, 'versioned cache'); +assert(!JSON.stringify(cache).includes('synthetic'), 'session identity must be sanitized in cache'); +assert(third.days.at(-1).sessions === 1, 'resumed scans must retain one stable private session identity'); +const privateDirectory = join(scratch, 'private-state'); +GLib.mkdir_with_parents(privateDirectory, 0o755); +GLib.chmod(privateDirectory, 0o755); +const privateRecord = join(privateDirectory, 'record.json'); +writeJson(privateRecord, {safe: true}); +assert(readJson(privateRecord).safe, 'atomic JSON roundtrip'); +const directoryInfo = Gio.File.new_for_path(privateDirectory).query_info('unix::mode', Gio.FileQueryInfoFlags.NONE, null); +assert((directoryInfo.get_attribute_uint32('unix::mode') & 0o777) === 0o700, 'private JSON directory mode'); +const stateInfo = Gio.File.new_for_path(privateRecord).query_info('unix::mode', Gio.FileQueryInfoFlags.NONE, null); +assert((stateInfo.get_attribute_uint32('unix::mode') & 0o777) === 0o600, 'private JSON file mode'); +print('PASS: GJS history initial scan, cached scan, append, partial line, and private JSON cache'); + +const legacyState = join(scratch, 'legacy-state'); +const legacyCache = join(scratch, 'legacy-cache'); +const migratedState = join(scratch, 'usagebeam-state'); +const migratedCache = join(scratch, 'usagebeam-cache'); +writeJson(join(legacyState, 'codex.json'), {provider: 'codex'}); +writeJson(join(legacyCache, 'history-codex.json'), {version: 4}); +writeJson(join(legacyState, 'unrelated.json'), {private: true}); +assert(migrateLegacyData({legacyState, legacyCache, state: migratedState, cache: migratedCache}) === 2, + 'legacy migration must copy only recognized derived data'); +assert(readJson(join(migratedState, 'codex.json')).provider === 'codex', 'provider state migration'); +assert(readJson(join(migratedCache, 'history-codex.json')).version === 4, 'history cache migration'); +assert(!Gio.File.new_for_path(join(migratedState, 'unrelated.json')).query_exists(null), + 'legacy migration must ignore unrelated files'); +assert(migrateLegacyData({legacyState, legacyCache, state: migratedState, cache: migratedCache}) === 0, + 'legacy migration must not overwrite migrated data'); +print('PASS: GJS legacy derived-data migration'); + +const currentSettings = new Map([['default-provider', 'claude']]); +const formerSettings = new Map([['default-provider', 'codex'], ['refresh-interval', 30]]); +const fakeSettings = values => ({ + get_user_value: key => values.get(key) ?? null, + set_value(key, value) { values.set(key, value); return true; }, +}); +assert(copyLegacySettings(fakeSettings(currentSettings), fakeSettings(formerSettings)) === 1, + 'settings migration must copy only missing explicit values'); +assert(currentSettings.get('default-provider') === 'claude' && currentSettings.get('refresh-interval') === 30, + 'settings migration must preserve UsageBeam values'); +print('PASS: GJS legacy settings migration policy'); + +const replacementRoot = join(scratch, 'replacement'); +GLib.mkdir_with_parents(replacementRoot, 0o700); +const replacement = join(replacementRoot, 'same-size.jsonl'); +const replacementCache = join(scratch, 'replacement-cache.json'); +GLib.file_set_contents(replacement, `${event(100)}\n`); +assert(scanHistory('codex', [replacementRoot], parseCodexEvent, {now, cachePath: replacementCache}) + .days.at(-1).total === 100, 'same-size baseline'); +GLib.usleep(2000); +GLib.file_set_contents(replacement, `${event(200)}\n`); +assert(scanHistory('codex', [replacementRoot], parseCodexEvent, {now, cachePath: replacementCache}) + .days.at(-1).total === 200, 'same-size replacement must invalidate the cache'); +Gio.File.new_for_path(replacement).delete(null); +const saved = scanHistory('codex', [replacementRoot], parseCodexEvent, {now: now + 1000, cachePath: replacementCache}); +assert(saved.status === 'stale' && saved.days.at(-1).total === 200, 'missing source must retain recent cached history'); +print('PASS: GJS history replacement detection and stale-source recovery'); + +const claudeProjects = join(scratch, 'claude-projects'); +GLib.mkdir_with_parents(claudeProjects, 0o700); +const claudeLine = JSON.stringify({type: 'assistant', sessionId: 'claude-session', timestamp: '2026-09-06T11:00:00Z', + message: {id: 'claude-message', role: 'assistant', model: 'claude-test', usage: {input_tokens: 2, + output_tokens: 3, cache_read_input_tokens: 40, cache_creation_input_tokens: 5}}}); +GLib.file_set_contents(join(claudeProjects, 'session.jsonl'), `${claudeLine}\n${claudeLine}\n`); +const claudeCache = join(scratch, 'claude-cache.json'); +const claude = scanHistory('claude', [claudeProjects], parseClaudeEvent, {now, cachePath: claudeCache}); +assert(claude.days.at(-1).total === 50, 'duplicate Claude messages must count once'); +assert(claude.models[0].cacheRead === 40 && claude.models[0].cacheWrite === 5, 'Claude cache categories'); +assert(!JSON.stringify(readJson(claudeCache)).includes('claude-session'), 'Claude session identity must be sanitized'); +print('PASS: GJS Claude history deduplication, model totals, and private cache'); + +for (const version of ['v20.12.0', 'v24.16.0']) { + const bin = join(scratch, '.local', 'share', 'fnm', 'node-versions', version, 'installation', 'bin'); + GLib.mkdir_with_parents(bin, 0o700); + const command = join(bin, 'fixture-cli'); + GLib.file_set_contents(command, '#!/usr/bin/env fixture-runtime\n'); + GLib.chmod(command, 0o700); + const runtime = join(bin, 'fixture-runtime'); + GLib.file_set_contents(runtime, `#!/bin/sh\nprintf '${version}'\n`); + GLib.chmod(runtime, 0o700); +} +assert(findCommand('fixture-cli', {home: scratch, usePath: false}).includes('v24.16.0'), + 'version-manager lookup must choose the newest installed runtime'); +let invalidName = false; +try { findCommand('..'); } catch { invalidName = true; } +assert(invalidName, 'command discovery must reject path-like command names'); +const fixtureSpec = commandSpec('fixture-cli', ['status'], {home: scratch, usePath: false}); +assert(fixtureSpec.argv[0].endsWith('/fixture-cli') && fixtureSpec.argv[1] === 'status', + 'version-managed command must retain its native entry point'); +assert(fixtureSpec.environment.PATH.split(':')[0].endsWith('/v24.16.0/installation/bin'), + 'version-managed command directory must lead the child search path'); +const npmBin = join(scratch, '.npm-global', 'bin'); +GLib.mkdir_with_parents(npmBin, 0o700); +const runtimeCli = join(npmBin, 'runtime-cli'); +GLib.file_set_contents(runtimeCli, '#!/usr/bin/env fixture-runtime\n'); +GLib.chmod(runtimeCli, 0o700); +const runtimeSpec = commandSpec('runtime-cli', [], + {home: scratch, usePath: false, runtimes: ['fixture-runtime']}); +assert(runtimeSpec.environment.PATH.includes('/v24.16.0/installation/bin'), + 'user-local commands must receive a discovered runtime path'); +print('PASS: GJS version-manager command discovery'); + +const loop = new GLib.MainLoop(null, false); +let failed = false; +(async () => { + try { + assert((await runCommand(['/bin/echo', 'fixture'])).trim() === 'fixture', 'subprocess output'); + assert((await runCommand(fixtureSpec)).trim() === 'v24.16.0', + 'command environment must resolve its sibling runtime'); + assert((await runCommand(runtimeSpec)).trim() === 'v24.16.0', + 'user-local command environment must resolve a version-managed runtime'); + let invalidEnvironment = false; + try { + await runCommand({argv: ['/bin/echo', 'fixture'], environment: {PATH: 10}}); + } catch (error) { + invalidEnvironment = error.code === 'INVALID_COMMAND'; + } + assert(invalidEnvironment, 'subprocess environments must contain only strings'); + let timedOut = false; + try { + await runCommand(['/bin/sleep', '5'], {timeout: 50}); + } catch (error) { + timedOut = error.code === 'TIMED_OUT'; + } + assert(timedOut, 'subprocess timeout'); + let limited = false; + try { + await runCommand(['/bin/echo', 'too-large'], {maxOutputBytes: 3}); + } catch (error) { + limited = error.code === 'OUTPUT_LIMIT'; + } + assert(limited, 'subprocess output limit'); + const cancelled = new Gio.Cancellable(); + cancelled.cancel(); + let cancellationReported = false; + try { + await runCommand(['/bin/sleep', '5'], {cancellable: cancelled}); + } catch (error) { + cancellationReported = error.code === 'CANCELLED'; + } + assert(cancellationReported, 'pre-cancelled subprocess must report cancellation'); + const rpc = new RpcClient(['/bin/cat'], cancelled); + assert(rpc.closed, 'pre-cancelled RPC client must close during construction'); + rpc.close(); + let insecureEndpoint = false; + try { + await requestJson('http://example.invalid'); + } catch (error) { + insecureEndpoint = error.message.includes('HTTPS'); + } + assert(insecureEndpoint, 'provider requests must reject insecure endpoints before network access'); + print('PASS: GJS subprocess communication, deadline and termination'); + } catch (error) { + printerr(error.message); + failed = true; + } finally { + loop.quit(); + } +})(); +loop.run(); +function remove(file) { + if (file.query_file_type(Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) === Gio.FileType.DIRECTORY) { + const entries = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + let entry; + while ((entry = entries.next_file(null))) + remove(file.get_child(entry.get_name())); + entries.close(null); + } + file.delete(null); +} +remove(Gio.File.new_for_path(scratch)); +System.exit(failed ? 1 : 0); diff --git a/tests/integration/stress.js b/tests/integration/stress.js new file mode 100644 index 0000000..4a36ce3 --- /dev/null +++ b/tests/integration/stress.js @@ -0,0 +1,213 @@ +// Explicit stress suite, separate from the fast release checks. Synthetic data only. +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import System from 'system'; +import {ThresholdTracker} from '../../src/core/notifications.js'; +import {scanHistory} from '../../src/services/history.js'; +import {parseCodexEvent} from '../../src/providers/codex.js'; +import {join, readJson} from '../../src/services/files.js'; +import {RpcClient, runCommand} from '../../src/services/process.js'; + +const scratch = GLib.dir_make_tmp('usagebeam-data-stress-XXXXXX'); +const now = new Date('2026-09-06T12:00:00Z').getTime(); +const results = []; +const assert = (value, message) => { if (!value) throw new Error(message); }; +const event = total => JSON.stringify({type: 'event_msg', timestamp: '2026-09-06T10:00:00Z', payload: {type: 'token_count', + info: {total_token_usage: {input_tokens: total, output_tokens: 0, cached_input_tokens: 0, total_tokens: total}}}}); +const total = record => record.days.reduce((sum, day) => sum + day.total, 0); +const fixture = name => { + const root = join(scratch, name); + GLib.mkdir_with_parents(root, 0o700); + const cachePath = join(scratch, `${name}-cache.json`); + return {root, path: join(root, 'fixture.jsonl'), cachePath, + scan: () => scanHistory('codex', [root], parseCodexEvent, {now, cachePath})}; +}; +async function test(name, callback) { + const start = GLib.get_monotonic_time(); + try { + const details = await callback(); + results.push({name, ok: true, details}); + } catch (error) { + results.push({name, ok: false, error: error.message}); + } + results.at(-1).milliseconds = Math.round((GLib.get_monotonic_time() - start) / 1000); + print(JSON.stringify(results.at(-1))); +} +function remove(file) { + if (file.query_file_type(Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null) === Gio.FileType.DIRECTORY) { + const iterator = file.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + try { + let entry; + while ((entry = iterator.next_file(null))) + remove(file.get_child(entry.get_name())); + } finally { + iterator.close(null); + } + } + file.delete(null); +} + +async function run() { + await test('50,000 cumulative events across 50 files; warm cache and privacy', () => { + const source = fixture('volume'); + const contents = Array.from({length: 1000}, (_, index) => event((index + 1) * 10)).join('\n') + '\n'; + for (let file = 0; file < 50; file++) + GLib.file_set_contents(join(source.root, `${file}.jsonl`), contents); + const cold = source.scan(); + assert(total(cold) === 500000 && cold.status === 'ready', 'Cold scan lost cumulative events'); + const warm = source.scan(); + assert(total(warm) === 500000 && warm.scannedFiles === 0, 'Warm scan recounted unchanged files'); + assert(!JSON.stringify(readJson(source.cachePath)).includes(source.root), 'Cache exposes source paths'); + return {events: 50000, files: 50, total: total(warm)}; + }); + await test('same-inode same-length rewrite invalidates cached totals', () => { + const source = fixture('rewrite'); + GLib.file_set_contents(source.path, `${event(100)}\n`); + assert(total(source.scan()) === 100, 'Baseline differs'); + GLib.usleep(2000); + const stream = Gio.File.new_for_path(source.path).open_readwrite(null); + try { + stream.get_output_stream().write_all(new TextEncoder().encode(`${event(200)}\n`), null); + } finally { + stream.close(null); + } + const actual = total(source.scan()); + assert(actual === 200, `Expected 200 after in-place rewrite, got ${actual}`); + }); + await test('oversized local line does not permanently hide later valid usage', () => { + const source = fixture('oversized'); + GLib.file_set_contents(source.path, `${event(100)}\n${' '.repeat(5 * 1024 * 1024)}\n${event(200)}\n`); + source.scan(); + const next = source.scan(); + assert(total(next) === 200, `Expected recovery to 200, got ${total(next)} (${next.status})`); + }); + await test('growing in-place rewrite verifies the entire committed prefix', () => { + const source = fixture('growing-rewrite'); + GLib.file_set_contents(source.path, `${event(100)}\n`); + assert(total(source.scan()) === 100, 'Baseline differs'); + const stream = Gio.File.new_for_path(source.path).open_readwrite(null); + try { + stream.get_output_stream().write_all(new TextEncoder().encode(`${event(300)}\n${event(400)}\n`), null); + } finally { stream.close(null); } + assert(total(source.scan()) === 400, 'Growing rewrite was mistaken for an append'); + }); + await test('oversized unterminated record resumes past its later newline', () => { + const source = fixture('oversized-append'); + GLib.file_set_contents(source.path, `${event(100)}\n${' '.repeat(5 * 1024 * 1024)}`); + assert(total(source.scan()) === 100, 'Oversized incomplete line changed usage'); + const output = Gio.File.new_for_path(source.path).append_to(Gio.FileCreateFlags.NONE, null); + try { + output.write_all(new TextEncoder().encode(`remaining oversized text\n${event(200)}\n`), null); + } finally { output.close(null); } + const result = source.scan(); + assert(total(result) === 200 && result.status === 'partial', 'Skip state was not retained across scans'); + }); + await test('null or malformed cache is safely rebuilt', () => { + const source = fixture('bad-cache'); + GLib.file_set_contents(source.path, `${event(100)}\n`); + for (const value of ['null', '{"files":null}', 'not json']) { + GLib.file_set_contents(source.cachePath, value); + assert(total(source.scan()) === 100, 'Bad cache prevented source recovery'); + } + }); + await test('malformed JSON preserves readable events with partial status', () => { + const source = fixture('malformed'); + GLib.file_set_contents(source.path, `${event(100)}\nnot json\n${event(200)}\n`); + const result = source.scan(); + assert(total(result) === 200 && result.status === 'partial', 'Malformed line corrupted readable usage'); + }); + await test('incomplete write resumes after append without double counting', () => { + const source = fixture('append'); + const next = `${event(200)}\n`; + GLib.file_set_contents(source.path, `${event(100)}\n${next.slice(0, 50)}`); + assert(total(source.scan()) === 100, 'Partial write was counted'); + const output = Gio.File.new_for_path(source.path).append_to(Gio.FileCreateFlags.NONE, null); + try { + output.write_all(new TextEncoder().encode(next.slice(50)), null); + } finally { + output.close(null); + } + assert(total(source.scan()) === 200 && total(source.scan()) === 200, 'Append lost or duplicated usage'); + }); + const record = (percent, period = 1, count = 1) => ({id: 'codex', name: 'Codex', accountKey: '0'.repeat(64), + limits: {status: 'ready', windows: Array.from({length: count}, (_, index) => ({id: `window-${index}`, + label: `Allowance ${index}`, resetsAt: period, usedPercent: percent, unlimited: false}))}}); + await test('10,000 repeated reads emit each milestone exactly once per period', () => { + const tracker = new ThresholdTracker(); + const alerts = []; + for (const percent of [0, 80, 79, 80, 90, 89, 90, 100]) + alerts.push(...tracker.update(record(percent), 80)); + for (let index = 0; index < 10000; index++) + alerts.push(...tracker.update(record(index % 2 ? 100 : 79), 80)); + assert(alerts.map(alert => alert.threshold).join(',') === '80,90,100', 'Milestone alerts repeat'); + }); + await test('notification state stays bounded after 1,000 quota resets', () => { + const tracker = new ThresholdTracker(); + for (let period = 1; period <= 1000; period++) + tracker.update(record(20, period, 32), 80); + assert(tracker.previous.size <= 200, `Expected at most 200 retained keys, got ${tracker.previous.size}`); + }); + await test('20 concurrent subprocesses and 20 RPC lifecycles', async () => { + const output = await Promise.all(Array.from({length: 20}, () => runCommand(['/bin/echo', 'synthetic']))); + assert(output.every(value => value.trim() === 'synthetic'), 'Concurrent output mismatch'); + for (let index = 0; index < 20; index++) { + const rpc = new RpcClient(['/bin/cat'], null, 500); + try { await rpc.request('synthetic'); } finally { rpc.close(); } + assert(rpc.closed && rpc.pending.size === 0, 'RPC retained requests after close'); + } + }); + await test('20 in-flight cancellations terminate promptly', async () => { + const jobs = Array.from({length: 20}, async () => { + const cancellation = new Gio.Cancellable(); + const promise = runCommand(['/bin/sleep', '5'], {cancellable: cancellation}); + cancellation.cancel(); + try { await promise; return false; } catch (error) { return error.code === 'CANCELLED'; } + }); + assert((await Promise.all(jobs)).every(Boolean), 'Cancellation was lost'); + }); + await test('stdout cap is enforced before producer exit', async () => { + // Finite output and a short deadline: exercises pressure without risking OOM. + let code; + try { + await runCommand(['/usr/bin/python3', '-c', + 'import sys,time; sys.stdout.write("x" * 1048576); sys.stdout.flush(); time.sleep(2)'], + {maxOutputBytes: 1024, timeout: 1000}); + } catch (error) { code = error.code; } + assert(code === 'OUTPUT_LIMIT', `Expected early OUTPUT_LIMIT, got ${code}`); + }); + await test('Unicode crossing read boundaries and large stdin round-trip intact', async () => { + const input = 'x'.repeat(65535) + '🛰'.repeat(20000); + const actual = await runCommand(['/bin/cat'], {input}); + assert(actual === input, 'Chunk boundaries corrupted Unicode'); + }); + await test('RPC output and queued writes remain bounded', async () => { + const rpc = new RpcClient(['/usr/bin/python3', '-c', + 'import sys,time; sys.stdout.write("x" * 9437184); sys.stdout.flush(); time.sleep(2)'], null, 5000); + try { + let closed = false; + try { await rpc.request('synthetic'); } catch (error) { closed = error.code === 'RPC_CLOSED'; } + assert(closed && rpc.pending.size === 0, 'Oversized RPC response was not rejected'); + } finally { rpc.close(); } + const stalled = new RpcClient(['/bin/sleep', '5'], null, 500); + try { + let bounded = false; + try { + for (let index = 0; index < 65; index++) + stalled.notify('synthetic', {value: index}); + } catch (error) { bounded = error.code === 'OUTPUT_LIMIT'; } + assert(bounded, 'RPC notification queue is unbounded'); + } finally { stalled.close(); } + }); +} +const loop = new GLib.MainLoop(null, false); +run().catch(error => results.push({name: 'stress harness', ok: false, error: error.message})).finally(() => { + try { + if (ARGV[0]) + GLib.file_set_contents(ARGV[0], JSON.stringify(results, null, 2)); + remove(Gio.File.new_for_path(scratch)); + } finally { + loop.quit(); + } +}); +loop.run(); +System.exit(results.every(result => result.ok) ? 0 : 1); diff --git a/tests/shell/extension.js b/tests/shell/extension.js new file mode 100644 index 0000000..1ff6e98 --- /dev/null +++ b/tests/shell/extension.js @@ -0,0 +1,336 @@ +// Loaded only by tools/smoke-shell.py in its isolated, synthetic desktop. +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import Pango from 'gi://Pango'; +import Shell from 'gi://Shell'; +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; +import {runStress} from './stress.js'; + +const UUID = 'usagebeam@oo7kc.github.io'; +const descendants = actor => [actor, ...actor.get_children().flatMap(descendants)]; +const matching = (actor, style) => descendants(actor).filter(child => child.has_style_class_name?.(style)); +const bounds = actor => { + const [x, y] = actor.get_transformed_position(); + const [width, height] = actor.get_transformed_size(); + return {x, y, width, height}; +}; +// Clutter reports logical sizes; Pango pixel sizes can include monitor scale. +const textWidth = actor => actor.clutter_text.get_preferred_width(-1)[1]; +const textBaseline = actor => bounds(actor.clutter_text).y + + actor.clutter_text.get_layout().get_baseline() / Pango.SCALE; +const assert = (condition, message) => { + if (!condition) + throw new Error(message); +}; + +export default class UsageBeamUITest extends Extension { + enable() { + this._timers = new Map(); + this._output = GLib.getenv('USAGEBEAM_TEST_OUTPUT'); + if (!this._output) + throw new Error('UI tests require a private test output directory'); + this._run().then(result => this._save({ok: true, ...result})).catch(async error => { + try { + await this._screenshot('failure'); + } finally { + this._save({ok: false, error: `${error}\n${error.stack ?? ''}`}); + } + }); + } + + _save(result) { + GLib.file_set_contents(`${this._output}/ui-results.json`, JSON.stringify(result, null, 2)); + } + + _wait(milliseconds = 150) { + return new Promise((resolve, reject) => { + const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, milliseconds, () => { + this._timers.delete(id); + resolve(); + return GLib.SOURCE_REMOVE; + }); + this._timers.set(id, reject); + }); + } + + async _screenshot(name) { + const file = Gio.File.new_for_path(`${this._output}/${name}.png`); + const stream = file.replace(null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); + try { + await new Shell.Screenshot().screenshot(false, stream); + } finally { + stream.close(null); + } + } + + _displayCall(method, parameters = null) { + return new Promise((resolve, reject) => Gio.DBus.session.call('org.gnome.Mutter.DisplayConfig', + '/org/gnome/Mutter/DisplayConfig', 'org.gnome.Mutter.DisplayConfig', method, + parameters, null, Gio.DBusCallFlags.NONE, 5000, null, (connection, result) => { + try { + resolve(connection.call_finish(result).deep_unpack()); + } catch (error) { + reject(error); + } + })); + } + + _checkPanelSpacing(indicator, calendar, position, textScale) { + const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor; + const provider = bounds(indicator._panelProvider); + const value = bounds(indicator._panelValue); + const separator = bounds(indicator._panelSeparator); + const reset = bounds(indicator._panelReset); + const providerBaseline = textBaseline(indicator._panelProvider); + for (const actor of [indicator._panelValue, indicator._panelReset]) { + const offset = textBaseline(actor) - providerBaseline; + assert(Math.abs(offset) <= 1, + `${position}: panel text baseline differs by ${offset}px`); + } + assert(value.x - provider.x - provider.width <= 6 * scale, + `${position}: provider and percentage are spaced too far apart`); + assert(Math.abs(separator.x - value.x - value.width - + (reset.x - separator.x - separator.width)) <= 1, + `${position}: panel separator spacing is uneven`); + assert(bounds(indicator.container).width / scale < 180 * textScale, + `${position}: panel indicator is not compact`); + for (const actor of [indicator._panelProvider, indicator._panelValue, indicator._panelReset]) { + assert(!actor.clutter_text.get_layout().is_ellipsized(), `${position}: panel text clipped`); + assert(textWidth(actor) <= actor.width + 1, + `${position}: panel text exceeds its allocation`); + } + if (!position.includes('calendar')) + return null; + const clockLabels = descendants(calendar).filter(actor => + actor instanceof St.Label && actor.visible && actor.mapped && actor.width > 0); + assert(clockLabels.length > 0, 'Calendar has no visible label'); + const clockLeft = Math.min(...clockLabels.map(actor => bounds(actor).x)); + const clockRight = Math.max(...clockLabels.map(actor => bounds(actor).x + actor.width)); + // Measure the visible text, not the label's allocated expansion space. + const resetRight = reset.x + textWidth(indicator._panelReset); + const gap = position === 'left-of-calendar' ? clockLeft - resetRight + : bounds(indicator._panelIcon).x - clockRight; + assert(gap >= 0 && gap <= 20 * scale, + `${position}: calendar/readout visual gap is not compact: ${gap / scale}`); + return gap / scale; + } + + async _run() { + let indicator; + for (let attempt = 0; attempt < 80 && !indicator; attempt++) { + await this._wait(); + indicator = Main.panel.statusArea[UUID]; + } + assert(indicator, 'UsageBeam did not appear in the panel'); + indicator._service.destroy(); + const [, fixture] = GLib.file_get_contents(`${this._output}/ui-fixtures.json`); + const records = JSON.parse(new TextDecoder().decode(fixture)); + indicator.attach({enabledProviders: ['codex', 'claude'], recordFor: id => records[id], + isRefreshing: () => false, refreshAll: () => {}}); + const settings = indicator._settings; + const calendar = Main.panel.statusArea.dateMenu.container; + const positions = ['left', 'right', 'left-of-calendar', 'right-of-calendar']; + const placement = []; + Main.overview.hide(); + await this._wait(500); + // Configure only this private virtual monitor, temporarily. Setting just + // St's scale factor would scale lengths without updating the font DPI. + const expectedScale = Number(GLib.getenv('USAGEBEAM_TEST_SCALE') ?? 1); + if (expectedScale !== 1) { + const [serial, monitors] = await this._displayCall('GetCurrentState'); + const [[connector], modes] = monitors[0]; + const [mode] = modes.find(item => item[5].includes(expectedScale)) ?? []; + assert(mode, `Virtual monitor does not support scale ${expectedScale}`); + await this._displayCall('ApplyMonitorsConfig', new GLib.Variant('(uua(iiduba(ssa{sv}))a{sv})', + [serial, 1, [[0, 0, expectedScale, 0, true, [[connector, mode, {}]]]], {}])); + await this._wait(500); + } + const [, , logicalMonitors] = await this._displayCall('GetCurrentState'); + const monitorScale = logicalMonitors[0][2]; + assert(monitorScale === expectedScale, `Expected monitor scale ${expectedScale}, got ${monitorScale}`); + const interfaceSettings = new Gio.Settings({schema_id: 'org.gnome.desktop.interface'}); + const textScale = interfaceSettings.get_double('text-scaling-factor'); + if (GLib.getenv('USAGEBEAM_STRESS') === '1') + return runStress({indicator, records, settings, calendar, + wait: milliseconds => this._wait(milliseconds), screenshot: name => this._screenshot(name), + checkPanel: position => this._checkPanelSpacing(indicator, calendar, position, textScale), + monitorScale, textScale}); + for (const position of positions) { + settings.set_string('panel-position', position); + settings.set_string('default-provider', 'codex'); + await this._wait(); + const parent = position === 'left' ? Main.panel._leftBox : position === 'right' + ? Main.panel._rightBox : Main.panel._centerBox; + assert(indicator.container.get_parent() === parent, `${position}: wrong panel area`); + const before = bounds(indicator.container); + const clockBefore = bounds(calendar); + const gaps = [this._checkPanelSpacing(indicator, calendar, position, textScale)]; + // Check both a different provider and unavailable quota text. + const original = records.claude; + for (const available of [true, false]) { + records.claude = available ? original : {...original, + limits: {...original.limits, windows: []}}; + settings.set_string('default-provider', 'claude'); + indicator.render(); + await this._wait(); + gaps.push(this._checkPanelSpacing(indicator, calendar, position, textScale)); + assert(Math.abs(bounds(calendar).x - clockBefore.x) <= 1, + `${position}: calendar moved on provider change`); + assert(Math.abs(bounds(indicator.container).width - before.width) <= 1, + `${position}: indicator width changed`); + assert(!indicator._panelProvider.clutter_text.get_layout().is_ellipsized(), + `${position}: panel provider name clipped: ${JSON.stringify({ + status: indicator._panelStatus.width, + children: indicator._panelStatus.get_children().map(actor => + ({style: actor.style_class, width: actor.width, preferred: actor.get_preferred_width(-1)})), + })}`); + } + records.claude = original; + if (position.includes('calendar')) { + const children = parent.get_children(); + const offset = children.indexOf(indicator.container) - children.indexOf(calendar); + assert(offset === (position === 'left-of-calendar' ? -1 : 1), 'Calendar adjacency lost'); + } + if (position.includes('calendar')) { + const left = position === 'left-of-calendar' ? before : clockBefore; + const right = position === 'left-of-calendar' ? clockBefore : before; + const panel = bounds(Main.panel); + const gapCenter = (left.x + left.width + right.x) / 2; + assert(Math.abs(gapCenter - (panel.x + panel.width / 2)) <= 1, + `${position}: calendar/indicator gap is not centered (${gapCenter})`); + } + settings.set_string('default-provider', 'codex'); + await this._wait(); + if (position.includes('calendar')) + await this._screenshot(position); + placement.push({position, indicator: before, calendar: clockBefore, visualGaps: gaps}); + } + for (const provider of ['codex', 'claude']) { + const windows = records[provider].limits.windows; + const shortest = windows.reduce((selected, window) => + window.durationMinutes < selected.durationMinutes ? window : selected); + const longer = windows.find(window => window.durationMinutes > shortest.durationMinutes); + const original = [shortest.usedPercent, longer.usedPercent]; + settings.set_string('default-provider', provider); + shortest.usedPercent = 0; + longer.usedPercent = 100; + indicator.render(); + await this._wait(); + assert(indicator._panelValue.text === '0%', + `${provider}: panel did not prefer the freshly reset short window`); + shortest.usedPercent = 100; + longer.usedPercent = 0; + indicator.render(); + await this._wait(); + assert(indicator._panelValue.text === '100%', + `${provider}: panel dropped the exhausted short window`); + [shortest.usedPercent, longer.usedPercent] = original; + } + settings.set_string('default-provider', 'codex'); + indicator.menu.open(0); + await this._wait(); + await this._screenshot('collapsed'); + matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1); + await this._wait(); + assert(indicator._detailsExpanded, 'Activity button did not expand'); + const content = indicator._contentBox; + const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor; + const initial = bounds(indicator.menu.actor); + assert(initial.height / scale < 720 * textScale, `Expanded menu too tall: ${initial.height / scale}`); + assert(!descendants(content).some(actor => actor instanceof St.ScrollView), 'Activity is scrollable'); + const models = matching(content, 'usagebeam-model-meter'); + assert(models.length === 3, 'Expected three synthetic models'); + for (const actor of models) { + assert(actor.height / scale >= 27 && actor.height / scale <= 40 * textScale, + `Model row height is not compact: ${actor.height / scale}`); + assert(actor._fill.width > 0 && actor._fill.height > 0, 'Model fill is empty'); + } + const plots = matching(content, 'usagebeam-chart-plot'); + assert(plots.length === 7, 'Expected seven chart columns'); + for (const plot of plots) { + assert(plot.height / scale === 52, `Unexpected chart height: ${plot.height / scale}`); + assert(plot._fill.visible === (plot._fraction > 0), 'Incorrect zero/nonzero bar visibility'); + if (plot._fraction > 0) + assert(plot._fill.width > 0 && plot._fill.height > 0, 'Daily bar has no area'); + } + for (const window of matching(content, 'usagebeam-window')) { + const [row, track, reset] = window.get_children(); + const percent = matching(row, 'usagebeam-limit-percent')[0]; + assert(!percent.clutter_text.get_layout().is_ellipsized(), 'Quota percentage clipped'); + assert(!reset.clutter_text.get_layout().is_ellipsized(), 'Reset description clipped'); + const trackBox = bounds(track); + const resetBox = bounds(reset); + const valueBox = bounds(percent); + const valueWidth = textWidth(percent); + assert(Math.abs(valueBox.x + valueWidth - trackBox.x - trackBox.width) <= 1, + 'Quota percentage does not align with the right edge of its bar'); + assert(resetBox.y >= trackBox.y + trackBox.height && Math.abs(resetBox.x - trackBox.x) <= 1, + 'Reset description should be left-aligned below its bar'); + assert(/^Resets in \d+[dhm]/.test(reset.text), 'Reset description lacks its explanatory prefix'); + } + const dots = [indicator._panelSeparator, ...matching(content, 'usagebeam-separator-dot')]; + for (const dot of dots) { + const dotBounds = bounds(dot); + const coreBounds = bounds(dot.get_child()); + assert(Math.abs(coreBounds.x + coreBounds.width / 2 - (dotBounds.x + dotBounds.width / 2)) <= 1 && + Math.abs(coreBounds.y + coreBounds.height / 2 - (dotBounds.y + dotBounds.height / 2)) <= 1, + 'Separator dot is not optically centered'); + } + const limitName = matching(content, 'usagebeam-limit-name')[0]; + const limitValue = matching(content, 'usagebeam-limit-percent')[0]; + assert(limitValue.get_theme_node().get_font().get_size() < + limitName.get_theme_node().get_font().get_size(), 'Limit metrics lack font hierarchy'); + assert(matching(content, 'usagebeam-disclosure-summary').length === 0, + 'Collapsed activity control should not repeat detail metadata'); + assert(matching(content, 'usagebeam-disclosure-icon').length === 1, + 'Activity control should retain its disclosure affordance'); + assert(matching(content, 'usagebeam-history-scope').length === 0, + 'Expanded activity should not repeat its local source scope'); + const todayColumns = matching(content, 'usagebeam-today'); + assert(todayColumns.length === 1, + 'Daily activity should distinguish exactly one current-day column'); + assert(matching(content, 'usagebeam-chart-bar').length === 7, + 'Every daily activity column should use the same bar style'); + for (const severity of ['caution', 'warning', 'danger']) { + const actors = matching(content, `usagebeam-${severity}`); + assert(actors.some(actor => actor.has_style_class_name('usagebeam-limit-percent')), + `${severity}: percentage does not expose quota severity`); + assert(actors.some(actor => actor.has_style_class_name('usagebeam-track')), + `${severity}: meter does not expose quota severity`); + } + assert(indicator._panelValue.has_style_class_name('usagebeam-danger'), + 'Panel indicator does not expose the shortest quota severity'); + const modelContent = matching(content, 'usagebeam-model-content')[0]; + const [modelName, modelTotal] = modelContent.get_children(); + assert(modelTotal.get_theme_node().get_font().get_size() < + modelName.get_theme_node().get_font().get_size(), 'Model row lacks font hierarchy'); + const heights = models.map(actor => actor.height); + for (let frame = 0; frame < 8; frame++) { + content.queue_relayout(); + await this._wait(60); + assert(Math.abs(bounds(indicator.menu.actor).height - initial.height) <= 1, + 'Menu geometry drifts on repeated layout'); + models.forEach((actor, index) => assert(actor.height === heights[index], 'Model row grows on layout')); + } + const font = content.get_theme_node().get_font().to_string(); + assert(font.includes('SF Pro Text'), `Requested font family was overridden: ${font}`); + await this._screenshot('expanded-dark'); + indicator.menu.actor.remove_style_class_name('usagebeam-dark'); + indicator.menu.actor.add_style_class_name('usagebeam-light'); + await this._wait(); + await this._screenshot('expanded-light'); + indicator.menu.close(0); + return {placement, expanded: initial, modelHeights: heights, + font, scale, monitorScale, textScale}; + } + + disable() { + for (const [id, reject] of this._timers) { + GLib.source_remove(id); + reject(new Error('UI check disabled before completion')); + } + this._timers.clear(); + } +} diff --git a/tests/shell/metadata.json b/tests/shell/metadata.json new file mode 100644 index 0000000..37a8e15 --- /dev/null +++ b/tests/shell/metadata.json @@ -0,0 +1,6 @@ +{ + "uuid": "usagebeam-ui-test@tests.invalid", + "name": "UsageBeam private UI checks", + "description": "Test-only companion; never shipped in the product archive.", + "shell-version": ["50"] +} diff --git a/tests/shell/stress.js b/tests/shell/stress.js new file mode 100644 index 0000000..f238a8e --- /dev/null +++ b/tests/shell/stress.js @@ -0,0 +1,184 @@ +// Runs only in the synthetic private desktop; never imported by the product. +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const descendants = actor => [actor, ...actor.get_children().flatMap(descendants)]; +const matching = (actor, style) => descendants(actor).filter(child => child.has_style_class_name?.(style)); +const bounds = actor => { + const [x, y] = actor.get_transformed_position(); + const [width, height] = actor.get_transformed_size(); + return {x, y, width, height}; +}; +const copy = value => JSON.parse(JSON.stringify(value)); + +export async function runStress({indicator, records, settings, calendar, wait, screenshot, + checkPanel, monitorScale, textScale}) { + const checks = []; + const geometry = []; + const check = (name, ok, details = {}) => checks.push({name, ok: Boolean(ok), ...details}); + const baseline = copy(records); + const work = Main.layoutManager.getWorkAreaForMonitor(Main.layoutManager.primaryIndex); + const scale = St.ThemeContext.get_for_stage(global.stage).scale_factor; + const textFits = actor => actor.clutter_text.get_preferred_width(-1)[1] <= actor.width + 1; + const fit = name => { + const popup = bounds(indicator.menu.actor); + const footer = bounds(indicator._contentBox.get_last_child()); + const inside = rect => rect.x >= work.x - 1 && rect.y >= work.y - 1 && + rect.x + rect.width <= work.x + work.width + 1 && + rect.y + rect.height <= work.y + work.height + 1; + check(`${name}: popup and footer fit work area`, inside(popup) && inside(footer), {popup, footer}); + geometry.push({name, popup, footer}); + }; + + for (const position of ['left', 'right', 'left-of-calendar', 'right-of-calendar']) { + settings.set_string('panel-position', position); + settings.set_string('default-provider', 'codex'); + await wait(150); + const clock = bounds(calendar); + const slot = bounds(indicator.container); + for (const provider of ['claude', 'codex']) { + settings.set_string('default-provider', provider); + await wait(100); + check(`${position}/${provider}: clock and slot stable`, + Math.abs(bounds(calendar).x - clock.x) <= 1 && + Math.abs(bounds(indicator.container).width - slot.width) <= 1); + check(`${position}/${provider}: panel labels fit`, + [indicator._panelProvider, indicator._panelValue, indicator._panelReset].every(textFits)); + checkPanel(position); + } + if (position.includes('calendar')) { + const panel = bounds(Main.panel); + const left = position === 'left-of-calendar' ? slot : clock; + const right = position === 'left-of-calendar' ? clock : slot; + const center = (left.x + left.width + right.x) / 2; + check(`${position}: gap centered`, Math.abs(center - panel.x - panel.width / 2) <= 1); + } + indicator.menu.open(0); + await wait(100); + fit(`${position}/collapsed`); + indicator.menu.close(0); + } + indicator.menu.open(0); + matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1); + await wait(150); + fit('expanded'); + const normalHeight = bounds(indicator.menu.actor).height; + const actorCount = descendants(indicator._contentBox).length; + await screenshot('stress-expanded'); + const plots = matching(indicator._contentBox, 'usagebeam-chart-plot'); + check('seven daily bars allocate correctly', plots.length === 7 && plots.every(plot => + plot.height / scale === 52 && (plot._fraction === 0 || (plot._fill.width > 0 && plot._fill.height > 0)))); + check('activity has no nested scrolling', !descendants(indicator._contentBox).some(actor => actor instanceof St.ScrollView)); + for (let iteration = 0; iteration < 100; iteration++) { + settings.set_string('default-provider', iteration % 2 ? 'codex' : 'claude'); + indicator.render(); + matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1); + matching(indicator._contentBox, 'usagebeam-disclosure')[0].emit('clicked', 1); + await wait(16); + } + check('100 refresh/switch/expand cycles retain actor count', descendants(indicator._contentBox).length === actorCount); + check('100 refresh/switch/expand cycles retain height', Math.abs(bounds(indicator.menu.actor).height - normalHeight) <= 1); + + for (const state of ['loading', 'unavailable', 'missing-auth', 'unsupported', 'stale', 'partial']) { + records.codex = copy(baseline.codex); + records.codex.limits.status = state; + records.codex.history.status = state; + records.codex.limits.message = 'Synthetic provider state; no account was contacted.'; + records.codex.history.message = 'Synthetic local history state.'; + if (!['stale', 'partial'].includes(state)) + records.codex.limits.windows = []; + indicator.render(); + await wait(70); + fit(state); + check(`${state}: panel text fits`, [indicator._panelValue, indicator._panelReset].every(textFits)); + } + + records.codex = copy(baseline.codex); + records.codex.plan = 'A long synthetic subscription plan '.repeat(2); + records.codex.limits.windows[0].label = 'A provider-specific allowance with an unusually long display name'; + records.codex.history.models[0].model = 'synthetic-model-with-a-very-long-name-and-preview-version-2026'; + indicator.render(); + await wait(100); + fit('long-labels'); + check('long labels retain bounded popup width', bounds(indicator.menu.actor).width <= work.width); + + records.codex = copy(baseline.codex); + records.codex.limits.windows = Array.from({length: 32}, (_, index) => + ({...baseline.codex.limits.windows[0], id: `synthetic-${index}`, label: `Allowance ${index + 1}`})); + indicator.render(); + await wait(100); + fit('maximum-32-allowances'); + const seenAllowances = new Set(); + for (let page = 0; page < 32; page++) { + matching(indicator._contentBox, 'usagebeam-limit-name').forEach(actor => seenAllowances.add(actor.text)); + const next = descendants(indicator._contentBox).find(actor => + actor.accessible_name === 'Next limits page' && actor.reactive); + if (!next) + break; + next.grab_key_focus(); + next.emit('clicked', 1); + await wait(60); + fit(`allowances-page-${page + 2}`); + } + check('all 32 allowances reachable without scrolling', seenAllowances.size === 32, {seen: seenAllowances.size}); + check('quota pagination retains keyboard focus', Boolean(global.stage.get_key_focus()?.can_focus)); + records.codex = copy(baseline.codex); + records.codex.history.models = Array.from({length: 12}, (_, index) => + ({...baseline.codex.history.models[0], model: `synthetic-model-${index + 1}`})); + indicator._limitPage = 0; + indicator._sectionPage = 1; + indicator._activityPage = 1; + indicator.render(); + await wait(100); + const seenModels = new Set(); + for (let page = 0; page < 12; page++) { + matching(indicator._contentBox, 'usagebeam-model-name').forEach(actor => seenModels.add(actor.text)); + const next = descendants(indicator._contentBox).find(actor => + actor.accessible_name === 'Next models page' && actor.reactive); + if (!next) + break; + next.emit('clicked', 1); + await wait(60); + fit(`models-page-${page + 2}`); + } + check('all model pages reachable without scrolling', seenModels.size === 12, {seen: seenModels.size}); + indicator._activityPage = indicator._modelPage = 0; + records.codex = copy(baseline.codex); + indicator.render(); + await wait(100); + indicator.menu.actor.remove_style_class_name('usagebeam-dark'); + indicator.menu.actor.add_style_class_name('usagebeam-light'); + await wait(100); + fit('light-theme'); + await screenshot('stress-light'); + indicator.menu.close(0); + + // Other center-panel extensions must not invalidate our alignment math. + const neighbor = new St.Bin({width: 80, height: 20}); + Main.panel._centerBox.add_child(neighbor); + try { + for (const position of ['left-of-calendar', 'right-of-calendar']) { + settings.set_string('panel-position', position); + await wait(100); + const slot = bounds(indicator.container); + const clock = bounds(calendar); + const panel = bounds(Main.panel); + const center = position === 'left-of-calendar' ? (slot.x + slot.width + clock.x) / 2 + : (clock.x + clock.width + slot.x) / 2; + check(`${position}: centered with neighboring extension`, Math.abs(center - panel.x - panel.width / 2) <= 1, + {offset: center - panel.x - panel.width / 2}); + neighbor.width = 120; + await wait(100); + const afterSlot = bounds(indicator.container); + const afterClock = bounds(calendar); + const afterCenter = position === 'left-of-calendar' ? (afterSlot.x + afterSlot.width + afterClock.x) / 2 + : (afterClock.x + afterClock.width + afterSlot.x) / 2; + check(`${position}: neighboring resize remains centered`, Math.abs(afterCenter - panel.x - panel.width / 2) <= 1); + neighbor.width = 80; + } + } finally { + neighbor.destroy(); + } + return {ok: checks.every(result => result.ok), checks, geometry, + workArea: {x: work.x, y: work.y, width: work.width, height: work.height}, monitorScale, textScale}; +} diff --git a/tests/unit/claude.test.js b/tests/unit/claude.test.js new file mode 100644 index 0000000..e43a948 --- /dev/null +++ b/tests/unit/claude.test.js @@ -0,0 +1,68 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {claudeLimits, claudeLogin, collectClaude, parseClaudeEvent} from '../../src/providers/claude.js'; + +test('reads percent-scaled standard and model-scoped Claude windows', () => { + const result = claudeLimits({ + five_hour: {utilization: 1, resets_at: '2026-09-06T15:00:00Z'}, + seven_day_oauth_apps: {utilization: 24.5, resets_at: '2026-09-13T10:00:00Z'}, + limits: [ + {kind: 'seven_day_scoped', percent: 70, resets_at: '2026-09-13T10:00:00Z', + scope: {model: {id: 'opus', display_name: 'Opus'}}}, + {kind: 'seven_day_scoped', percent: 70, scope: {model: {id: 'opus', display_name: 'Opus'}}}, + ], + }, 100); + assert.equal(result.status, 'ready'); + assert.equal(result.windows.length, 3); + assert.equal(result.windows[0].usedPercent, 1); + assert.equal(result.windows[0].durationMinutes, 300); + assert.equal(result.windows[2].label, 'Opus · Weekly'); +}); + +test('supports older fractional utilization without turning one percent into full usage', () => { + const result = claudeLimits({five_hour: {utilization: 0.25}, seven_day: {utilization: 0.5}}); + assert.deepEqual(result.windows.map(window => window.usedPercent), [25, 50]); + assert.equal(claudeLimits({}).status, 'unavailable'); +}); + +test('Claude login exposes only the needed token, expiry and display plan', () => { + assert.deepEqual(claudeLogin({}), {token: null, expiresAt: null, plan: null}); + assert.deepEqual(claudeLogin({claudeAiOauth: {accessToken: 'secret', expiresAt: 20, + rateLimitTier: 'default_claude_max_20x', subscriptionType: 'max'}}), + {token: 'secret', expiresAt: 20, plan: 'Max 20x'}); +}); + +test('parses Claude cache categories independently and provides stable message identity', () => { + const state = {session: 'file'}; + const event = parseClaudeEvent({type: 'assistant', sessionId: 'session', timestamp: '2026-09-06T10:00:00Z', + message: {id: 'message', role: 'assistant', model: 'claude-opus', usage: {input_tokens: 2, + output_tokens: 3, cache_read_input_tokens: 40, cache_creation_input_tokens: 5}}}, state); + assert.deepEqual({input: event.input, output: event.output, cacheRead: event.cacheRead, cacheWrite: event.cacheWrite}, + {input: 2, output: 3, cacheRead: 40, cacheWrite: 5}); + assert.equal(event.id, 'session:message'); + assert.equal(event.model, 'claude-opus'); +}); + +test('missing or expired Claude auth preserves local history and skips the endpoint', async () => { + const history = {status: 'ready', days: [], models: []}; + let requests = 0; + const io = credentials => ({scan: () => history, credentials: () => credentials, + fingerprint: () => 'hash', http: async () => { requests++; return {status: 200, data: {}}; }}); + const missing = await collectClaude(io(null)); + assert.equal(missing.limits.status, 'missing-auth'); + assert.equal(missing.history, history); + const expired = await collectClaude(io({claudeAiOauth: {accessToken: 'token', expiresAt: 1}})); + assert.equal(expired.limits.status, 'missing-auth'); + assert.equal(requests, 0); + const absent = await collectClaude({...io(null), hasCommand: () => false}); + assert.equal(absent.limits.status, 'unsupported'); +}); + +test('Claude endpoint authentication failure remains independent from history', async () => { + const result = await collectClaude({scan: () => ({status: 'ready', days: [], models: []}), + credentials: () => ({claudeAiOauth: {accessToken: 'token'}}), fingerprint: () => 'hash', + http: async () => ({status: 401, data: {}})}); + assert.equal(result.capabilities.models, true); + assert.equal(result.limits.status, 'missing-auth'); + assert.equal(result.history.status, 'ready'); +}); diff --git a/tests/unit/codex.test.js b/tests/unit/codex.test.js new file mode 100644 index 0000000..2b596a5 --- /dev/null +++ b/tests/unit/codex.test.js @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {codexLimits, collectCodex, parseCodexEvent} from '../../src/providers/codex.js'; + +const tokenEvent = (input, output, cache, total) => ({timestamp: '2026-09-06T12:00:00Z', type: 'event_msg', payload: {type: 'token_count', + info: {total_token_usage: {input_tokens: input, output_tokens: output, cached_input_tokens: cache, total_tokens: total}}}}); + +test('reads all quota buckets and derives labels from real window durations', () => { + const result = codexLimits({rateLimitsByLimitId: {codex: {primary: {usedPercent: 10, windowDurationMins: 300, resetsAt: 1800000000}, + secondary: {usedPercent: 100, windowDurationMins: 10080}}}}, 100); + assert.equal(result.windows.length, 2); + assert.equal(result.windows[0].resetsAt, 1800000000000); + assert.equal(result.windows[0].durationMinutes, 300); + assert.equal(result.windows[1].label, 'Weekly'); + assert.equal(result.windows[1].usedPercent, 100); + assert.equal(result.status, 'ready'); +}); + +test('empty Codex response is unavailable, not zero usage', () => { + assert.equal(codexLimits({}).status, 'unavailable'); + assert.equal(codexLimits({rateLimits: {primary: {}}}).windows.length, 0); +}); + +test('cumulative token deltas and cached input are counted once', () => { + const state = {session: 'session-1', model: 'model-a'}; + const first = parseCodexEvent(tokenEvent(100, 20, 40, 120), state); + assert.equal(first.input, 60); + assert.equal(first.cacheRead, 40); + assert.equal(parseCodexEvent(tokenEvent(100, 20, 40, 120), state), null); + const second = parseCodexEvent(tokenEvent(180, 35, 60, 215), state); + assert.equal(second.input + second.output + second.cacheRead, 95); + const reset = parseCodexEvent(tokenEvent(20, 5, 8, 25), state); + assert.equal(reset.input + reset.output + reset.cacheRead, 25); + assert.equal(state.cumulativeEpoch, 1); + assert.deepEqual(Object.keys(state.cumulative).sort(), [ + 'cache_write_input_tokens', 'cached_input_tokens', 'input_tokens', 'output_tokens', 'total_tokens', + ]); +}); + +test('multi-bucket response falls back to the compatible quota view when empty', () => { + const result = codexLimits({rateLimitsByLimitId: {}, rateLimits: { + primary: {usedPercent: 12, windowDurationMins: 300}, + }}, 100); + assert.equal(result.status, 'ready'); + assert.equal(result.windows[0].label, 'Session · 5 hours'); +}); + +test('primary Codex limits remain first when the app server reorders buckets', () => { + const result = codexLimits({rateLimitsByLimitId: { + reserve: {limitName: 'Reserve', primary: {usedPercent: 1, windowDurationMins: 10080}}, + codex: {primary: {usedPercent: 2, windowDurationMins: 300}}, + }}); + assert.equal(result.windows[0].id, 'codex:primary'); + assert.equal(result.windows[1].label, 'Reserve · Weekly'); +}); + +test('record parsing tracks model and stable session identity', () => { + const state = {session: 'file-hash'}; + parseCodexEvent({type: 'session_meta', payload: {id: 'native-session'}}, state); + parseCodexEvent({type: 'turn_context', payload: {model: 'model-b'}}, state); + const event = parseCodexEvent(tokenEvent(10, 2, 0, 12), state); + assert.equal(event.model, 'model-b'); + assert.equal(event.session, 'native-session'); +}); + +test('local history survives missing account authentication and closes RPC', async () => { + let closed = false; + const history = {status: 'ready', days: [], models: []}; + const result = await collectCodex({scan: () => history, + codexClient: () => ({request: async method => method === 'account/read' ? {account: null} : {}, + notify: () => {}, close: () => { closed = true; }})}); + assert.equal(result.limits.status, 'missing-auth'); + assert.equal(result.history, history); + assert.equal(closed, true); +}); diff --git a/tests/unit/layout.test.js b/tests/unit/layout.test.js new file mode 100644 index 0000000..d87bce8 --- /dev/null +++ b/tests/unit/layout.test.js @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {initialLayout, nextLayout, pageSlice} from '../../src/ui/layoutPolicy.js'; + +test('adaptive layout is finite and preserves readable text rather than scaling it down', () => { + for (const expanded of [false, true]) { + let layout = initialLayout(); + let attempts = 0; + while (layout) { + assert.ok(layout.limits >= 1 && layout.models >= 1); + assert.ok(++attempts <= 10, 'Layout must converge without allocation feedback'); + layout = nextLayout(layout, expanded); + } + } +}); + +test('pagination reaches every item and clamps pages after a data change', () => { + const items = Array.from({length: 32}, (_, index) => index); + const seen = []; + for (let page = 0; page < 11; page++) + seen.push(...pageSlice(items, page, 3).items); + assert.deepEqual(seen, items); + assert.deepEqual(pageSlice([1], 10, 3), {items: [1], page: 0, pages: 1}); + assert.deepEqual(pageSlice([], -1, 3), {items: [], page: 0, pages: 1}); +}); diff --git a/tests/unit/presentation.test.js b/tests/unit/presentation.test.js new file mode 100644 index 0000000..23d187e --- /dev/null +++ b/tests/unit/presentation.test.js @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {chartBarGeometry, historyOverview, latestUpdate, panelQuota, periodDays, providerStatus, + quotaName, quotaPresentation} from '../../src/ui/presentation.js'; +import {notificationMilestones, quotaSeverity, QUOTA_THRESHOLDS} from '../../src/core/thresholds.js'; + +test('provider status distinguishes live, local, cached and setup data', () => { + const value = {limits: {status: 'ready'}, history: {status: 'ready'}}; + assert.equal(providerStatus(value), 'LIVE'); + value.limits.status = 'unavailable'; + assert.equal(providerStatus(value), 'LOCAL'); + value.history.status = 'stale'; + assert.equal(providerStatus(value), 'CACHED'); + value.history.status = 'unsupported'; + assert.equal(providerStatus(value), 'SETUP'); + assert.equal(providerStatus(value, true), 'SYNC'); + assert.equal(providerStatus(null, true), 'SYNC'); + value.limits.status = 'ready'; + assert.equal(providerStatus(value, true), 'LIVE'); +}); + +test('presentation helpers derive bounded period and freshness labels', () => { + assert.equal(latestUpdate({limits: {updatedAt: 10}, history: {updatedAt: 20}}), 20); + assert.equal(latestUpdate(null), null); + assert.equal(periodDays({start: '2026-08-31', end: '2026-09-06'}), 7); + assert.equal(periodDays({start: 'invalid', end: '2026-09-06'}), null); +}); + +test('history overview summarizes daily activity without double-counting models', () => { + const history = { + scope: 'local', + period: {start: '2026-08-31', end: '2026-09-06'}, + days: [{total: 10}, {total: 20}], + models: [{total: 30}, {total: 40}], + }; + assert.deepEqual(historyOverview(history), {days: 7, scope: 'local', total: 30}); + assert.deepEqual(historyOverview({...history, scope: 'account', days: []}), + {days: 7, scope: 'account', total: 70}); + assert.equal(historyOverview({...history, days: [], models: []}), null); + assert.equal(historyOverview(null), null); +}); + +test('quota presentation keeps names concise and reset descriptions explicit', () => { + const now = 1_000_000; + const reserve = {id: 'gpt-reserve:primary', label: 'Reserve · Weekly', + durationMinutes: 10080, usedPercent: 18.2, resetsAt: now + 4 * 86400000 + 22 * 3600000}; + assert.equal(quotaName(reserve), 'Weekly Reserve'); + assert.deepEqual(quotaPresentation(reserve, now), { + name: 'Weekly Reserve', value: '18%', reset: 'Resets in 4d 22h', + }); + assert.equal(quotaName({id: 'five-hour', label: 'Session · 5 hours', durationMinutes: 300}), + '5H Session'); + assert.deepEqual(quotaPresentation({id: 'credits', label: 'Credits', unlimited: true}, now), { + name: 'Credits', value: 'Unlimited', reset: null, + }); + assert.equal(quotaPresentation({id: 'broken', label: 'Broken'}, now), null); + assert.equal(quotaPresentation({...reserve, resetsAt: now - 1}, now).reset, + 'Reset due · awaiting update'); + assert.equal(quotaPresentation({...reserve, resetsAt: null}, now).reset, + 'Reset time unavailable'); +}); + +test('notification milestones follow quota semantics without duplicate levels', () => { + assert.deepEqual(notificationMilestones(80), [80, 90, 100]); + assert.deepEqual(notificationMilestones(90), [90, 100]); + assert.deepEqual(notificationMilestones(95), [95, 100]); + assert.deepEqual(notificationMilestones(NaN), [90, 100]); +}); + +test('panel quota prefers the shortest current quota window at every usage level', () => { + const now = 1_000_000; + const record = {limits: {status: 'ready', windows: [ + {id: 'weekly', usedPercent: 96, durationMinutes: 10080, + resetsAt: now + 15 * 3600000 + 59 * 60000}, + {id: 'five-hour', usedPercent: 15, durationMinutes: 300, + resetsAt: now + 3600000}, + {usedPercent: null, unlimited: true}, + ]}}; + assert.deepEqual(panelQuota(record, now), {percent: 15, reset: '1h 0m'}); + record.limits.windows[1].usedPercent = 100; + assert.deepEqual(panelQuota(record, now), {percent: 100, reset: '1h 0m'}); + record.limits.windows = [ + {id: 'provider-first', usedPercent: 20}, + {id: 'provider-second', usedPercent: 90}, + ]; + assert.deepEqual(panelQuota(record, now), {percent: 20, reset: null}); + assert.equal(panelQuota({limits: {...record.limits, status: 'stale'}}, now), null); + assert.equal(panelQuota(null, now), null); +}); + +test('quota severity uses explicit caution, warning and danger thresholds', () => { + assert.deepEqual(QUOTA_THRESHOLDS, {caution: 80, warning: 90, danger: 100}); + for (const value of [null, undefined, NaN, 0, 79.9]) + assert.equal(quotaSeverity(value), null); + assert.equal(quotaSeverity(80), 'caution'); + assert.equal(quotaSeverity(89.9), 'caution'); + assert.equal(quotaSeverity(90), 'warning'); + assert.equal(quotaSeverity(99.9), 'warning'); + assert.equal(quotaSeverity(100), 'danger'); + assert.equal(quotaSeverity(125), 'danger'); +}); + +test('chart geometry gives every non-zero day visible width and bottom alignment', () => { + assert.deepEqual(chartBarGeometry(70, 70, 50, 64), {x: 4, y: 0, width: 42, height: 64}); + assert.deepEqual(chartBarGeometry(21.7, 70, 50, 64), {x: 4, y: 44, width: 42, height: 20}); + assert.deepEqual(chartBarGeometry(0, 70, 50, 64), {x: 4, y: 64, width: 42, height: 0}); +}); diff --git a/tests/unit/usage.test.js b/tests/unit/usage.test.js new file mode 100644 index 0000000..83ce845 --- /dev/null +++ b/tests/unit/usage.test.js @@ -0,0 +1,176 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {aggregateEvents, mergeRecord, number, recentDates, record, validTime, validateRecord, windowUsage} from '../../src/core/usage.js'; +import {compactTokens, modelName, resetCountdown, resetTime, tokens} from '../../src/core/format.js'; +import {notificationBody, ThresholdTracker} from '../../src/core/notifications.js'; + +test('unknown metrics are not coerced to zero', () => { + for (const value of [null, undefined, '', ' ', false, -1, Infinity, 'bad', [1], {value: 1}]) + assert.equal(number(value), null); + assert.equal(number(0), 0); + assert.equal(windowUsage({id: 'plan', label: 'Plan', used: 0, limit: 0}), null); +}); + +test('unknown, exhausted and unlimited windows remain distinct', () => { + const exhausted = windowUsage({id: 'x', label: 'X', used: 12, limit: 10}); + assert.equal(exhausted.usedPercent, 120); + assert.equal(exhausted.state, 'exhausted'); + const unlimited = windowUsage({id: 'x', label: 'X', unlimited: true}); + assert.equal(unlimited.usedPercent, null); + assert.equal(unlimited.unlimited, true); + assert.equal(unlimited.state, 'unlimited'); +}); + +test('contract rejects invalid provider IDs and quota values', () => { + assert.throws(() => record('toString'), /Unsupported provider/); + assert.throws(() => record('cursor'), /Unsupported provider/); + assert.throws(() => validateRecord(record('codex'), 'claude')); + const value = record('codex'); + value.limits.windows.push({id: 'x', label: 'X', state: 'active', usedPercent: null}); + assert.throws(() => validateRecord(value, 'codex')); +}); + +test('contract accepts complete records and rejects unsafe cached shapes', () => { + const value = record('codex'); + value.accountKey = 'a'.repeat(64); + value.capabilities = {limits: true, history: true, models: true}; + value.limits = {status: 'ready', message: '', updatedAt: 100, scope: 'account', source: 'synthetic limits', + windows: [windowUsage({id: 'weekly', label: 'Weekly', usedPercent: 20})]}; + value.history = {status: 'ready', message: '', updatedAt: 100, scope: 'local', + source: 'synthetic history', period: {start: '2026-09-05', end: '2026-09-06'}, + days: [{date: '2026-09-05', total: 0, sessions: 0, events: 0}, + {date: '2026-09-06', total: 10, sessions: 1, events: 1}], + models: [{model: 'test-model', total: 10, input: 6, output: 4, cacheRead: 0, cacheWrite: 0}]}; + assert.equal(validateRecord(value, 'codex'), value); + + const invalidDate = structuredClone(value); + invalidDate.history.days[1].date = '2026-99-99'; + assert.throws(() => validateRecord(invalidDate, 'codex'), /daily date/); + const invalidTotal = structuredClone(value); + invalidTotal.history.models[0].total = 11; + assert.throws(() => validateRecord(invalidTotal, 'codex'), /model totals/); + const rawAccount = structuredClone(value); + rawAccount.accountKey = 'user@example.com'; + assert.throws(() => validateRecord(rawAccount, 'codex'), /account key/); + const missingSource = structuredClone(value); + missingSource.limits.source = null; + assert.throws(() => validateRecord(missingSource, 'codex'), /limits source/); + const missingFreshness = structuredClone(value); + missingFreshness.history.updatedAt = 0; + assert.throws(() => validateRecord(missingFreshness, 'codex'), /history freshness/); + const unknownField = structuredClone(value); + unknownField.rawResponse = 'must not persist'; + assert.throws(() => validateRecord(unknownField, 'codex'), /record shape/); + const duplicateQuota = structuredClone(value); + duplicateQuota.limits.windows.push(structuredClone(duplicateQuota.limits.windows[0])); + assert.throws(() => validateRecord(duplicateQuota, 'codex'), /duplicate quota/); +}); + +test('failure preserves last successful values as stale, account change discards them', () => { + const old = record('codex'); + old.accountKey = 'one'; + old.limits = {status: 'ready', updatedAt: 100, windows: [{usedPercent: 50}]}; + const next = record('codex'); + next.limits.status = 'unavailable'; + next.limits.message = 'Disconnected'; + const merged = mergeRecord(old, next); + assert.equal(merged.limits.status, 'stale'); + assert.equal(merged.limits.updatedAt, 100); + assert.equal(merged.limits.windows[0].usedPercent, 50); + next.accountKey = 'two'; + assert.equal(mergeRecord(old, next).limits.windows.length, 0); + next.accountKey = null; + next.limits.status = 'missing-auth'; + assert.equal(mergeRecord(old, next).limits.windows.length, 0); + assert.equal(mergeRecord(old, next).accountKey, null); +}); + +test('stale quota windows are discarded after their reset', () => { + const old = record('claude'); + old.limits = {status: 'ready', updatedAt: 100, windows: [ + {id: 'expired', resetsAt: 1}, + {id: 'open', resetsAt: 2000}, + ]}; + const next = record('claude'); + next.limits.status = 'unavailable'; + const result = mergeRecord(old, next, 1000); + assert.equal(result.limits.status, 'stale'); + assert.deepEqual(result.limits.windows.map(window => window.id), ['open']); +}); + +test('daily and model totals use the same period and deduplicate events', () => { + const event = {id: 'one', session: 'a', model: 'model-a', date: '2026-09-06', input: 20, output: 5, cacheRead: 10, cacheWrite: 0}; + const result = aggregateEvents([event, event, {...event, id: 'older', date: '2026-08-01'}], new Date('2026-09-06T12:00:00').getTime()); + assert.equal(result.days.length, 7); + assert.equal(result.days.reduce((sum, d) => sum + d.total, 0), 35); + assert.equal(result.models[0].total, 35); + assert.equal(result.days.at(-1).sessions, 1); + assert.equal(result.period.start, '2026-08-31'); +}); + +test('calendar buckets are consecutive across month boundaries', () => { + assert.deepEqual(recentDates(new Date('2026-03-02T12:00:00').getTime(), 3), ['2026-02-28', '2026-03-01', '2026-03-02']); + assert.equal(validTime('1800000000'), 1800000000000); +}); + +test('notifications emit every configured milestone once per quota period', () => { + const tracker = new ThresholdTracker(); + const value = record('codex'); + value.limits = {status: 'ready', windows: [{id: 'weekly', label: 'Weekly', usedPercent: 79, resetsAt: 500}]}; + assert.deepEqual(tracker.update(value, 80), []); + value.limits.windows[0].usedPercent = 80; + assert.equal(tracker.update(value, 80)[0].threshold, 80); + assert.deepEqual(tracker.update(value, 80), []); + value.limits.windows[0].usedPercent = 90; + assert.equal(tracker.update(value, 80)[0].threshold, 90); + assert.deepEqual(tracker.update(value, 80), []); + value.limits.windows[0].usedPercent = 100; + assert.equal(tracker.update(value, 80)[0].threshold, 100); + assert.deepEqual(tracker.update(value, 80), []); + value.limits.windows[0].usedPercent = 50; + assert.deepEqual(tracker.update(value, 80), []); + value.limits.windows[0].usedPercent = 100; + assert.deepEqual(tracker.update(value, 80), []); + value.limits.status = 'unavailable'; + assert.deepEqual(tracker.update(value, 80), []); + value.limits.status = 'ready'; + value.limits.windows[0].resetsAt = 1000; + assert.deepEqual(tracker.update(value, 80), []); +}); + +test('simultaneous and repeated alerts produce one concise notification body', () => { + assert.equal(notificationBody([ + {provider: 'Codex', label: 'Weekly', threshold: 90}, + {provider: 'Codex', label: 'Weekly', threshold: 90}, + {provider: 'Codex', label: '5H Session', threshold: 100}, + ]), 'Codex: Weekly reached 90%.\nCodex: 5H Session limit reached.'); + assert.equal(notificationBody([]), ''); +}); + +test('notification eviction retains recently refreshed windows and accepts fresh partial data', () => { + const tracker = new ThresholdTracker(); + const current = (id, percent) => ({id: 'codex', name: 'Codex', limits: {status: 'partial', windows: [ + {id, label: id, usedPercent: percent, resetsAt: 1000}, + ]}}); + tracker.update(current('active', 79), 80); + assert.equal(tracker.update(current('active', 90), 80).length, 1); + for (let id = 0; id < 500; id++) { + tracker.update(current(String(id), 0), 80); + assert.deepEqual(tracker.update(current('active', 90), 80), []); + } + assert.equal(tracker.previous.size, 200); +}); + +test('formatting preserves unknown/reset-due states', () => { + assert.equal(tokens(null), '—'); + assert.equal(tokens(23000000), '23.0M'); + assert.equal(compactTokens(186000000), '186M'); + assert.equal(compactTokens(56300000), '56.3M'); + assert.equal(resetTime(null), 'Reset time unavailable'); + assert.match(resetTime(100, 200), /awaiting update/); + assert.equal(resetCountdown(null), null); + assert.equal(resetCountdown(100, 200), 'due'); + assert.equal(resetCountdown(100 + 4 * 86400000 + 22 * 3600000, 100), '4d 22h'); + assert.equal(modelName('gpt-5.6-sol'), 'GPT 5.6 Sol'); + assert.equal(modelName('codex_auto-review'), 'Codex Auto Review'); +}); diff --git a/tools/check.js b/tools/check.js new file mode 100644 index 0000000..e0d92b9 --- /dev/null +++ b/tools/check.js @@ -0,0 +1,87 @@ +import {readFileSync, readdirSync, existsSync} from 'node:fs'; +import {resolve, dirname, join, relative} from 'node:path'; +import {execFileSync} from 'node:child_process'; + +function walk(directory) { + return readdirSync(directory, {withFileTypes: true}).flatMap(entry => entry.isDirectory() + ? walk(join(directory, entry.name)) : [join(directory, entry.name)]); +} +const files = ['extension.js', 'prefs.js', ...walk('src'), ...walk('tests'), ...walk('tools')].filter(file => file.endsWith('.js')); +for (const file of walk('src')) { + if (!file.endsWith('.js')) + throw new Error(`Unexpected non-runtime file under src/: ${file}`); +} +const allowedRuntimeImports = { + core: new Set(['core']), + providers: new Set(['core', 'providers']), + services: new Set(['core', 'services']), + collector: new Set(['core', 'providers', 'services', 'collector']), + ui: new Set(['core', 'ui']), +}; +const runtimeLayer = file => file.startsWith('src/') ? file.split('/')[1] : null; +for (const file of files) { + const text = readFileSync(file, 'utf8'); + execFileSync(process.execPath, ['--check', file], {stdio: 'pipe'}); + if (!text.endsWith('\n') || /[ \t]+$/m.test(text)) + throw new Error(`${file}: trailing whitespace or missing final newline`); + for (const [, target] of text.matchAll(/from ['"]([.][^'"]+)['"]/g)) { + const absolute = resolve(dirname(file), target); + if (!existsSync(absolute)) + throw new Error(`${file}: missing import ${target}`); + const source = runtimeLayer(file); + const destination = runtimeLayer(relative('.', absolute)); + if (source && destination && !allowedRuntimeImports[source]?.has(destination)) + throw new Error(`${file}: ${source} modules cannot import the ${destination} layer`); + } +} +for (const entrypoint of ['extension.js', 'prefs.js']) { + if (readFileSync(entrypoint, 'utf8').split('\n').length > 120) + throw new Error(`${entrypoint}: GNOME entry points must remain thin`); +} +const metadata = JSON.parse(readFileSync('metadata.json', 'utf8')); +if (metadata.uuid !== 'usagebeam@oo7kc.github.io') + throw new Error('Extension identity changed without a migration'); +if (metadata.name !== 'UsageBeam' || metadata['settings-schema'] !== 'org.gnome.shell.extensions.usagebeam') + throw new Error('UsageBeam product metadata is inconsistent'); +const version = JSON.parse(readFileSync('package.json', 'utf8')).version; +if (!readFileSync('meson.build', 'utf8').includes(`version: '${version}'`)) + throw new Error('Meson and package versions differ'); + +const required = ['README.md', 'CHANGELOG.md', + 'docs/providers/codex.md', 'docs/providers/claude.md']; +for (const file of required) { + if (!existsSync(file)) + throw new Error(`Missing product documentation: ${file}`); +} +const schemas = walk('schemas').filter(file => file.endsWith('.xml')); +if (schemas.length !== 1 || schemas[0] !== 'schemas/org.gnome.shell.extensions.usagebeam.gschema.xml') + throw new Error('Unexpected extension schema input'); +const icons = walk('icons'); +if (icons.length !== 2 || !icons.includes('icons/claude.svg') || + !icons.includes('icons/codex-symbolic.svg')) + throw new Error('Unexpected runtime icon input'); +const providerFiles = walk('src/providers').filter(file => file.endsWith('.js')).sort(); +if (providerFiles.join(',') !== 'src/providers/claude.js,src/providers/codex.js') + throw new Error(`Unverified provider adapters must not ship: ${providerFiles.join(', ')}`); +const obsolete = ['plan.md', 'indicator.js', 'src/providers/legacy.js', 'scripts/freeby.sh', + 'src/providers/cursor.js', 'src/providers/copilot.js', 'scripts/copilot-setup.sh', + 'tests/freeby.bats', 'docs/README.md', 'docs/assets', 'docs/providers/README.md', + 'docs/s1.png', 'docs/s2.png', 'CONTRIBUTING.md']; +for (const file of obsolete) { + if (existsSync(file)) + throw new Error(`Obsolete repository path returned: ${file}`); +} +const markdown = ['README.md', 'CHANGELOG.md', ...walk('docs')] + .filter(file => file.endsWith('.md')); +for (const file of markdown) { + const text = readFileSync(file, 'utf8'); + for (const [, target] of text.matchAll(/\[[^\]]+\]\(([^)]+)\)/g)) { + if (/^(?:https?:|#|mailto:)/.test(target)) + continue; + const path = target.split('#')[0]; + if (path && !existsSync(resolve(dirname(file), path))) + throw new Error(`${file}: broken relative link ${target}`); + } +} +execFileSync('glib-compile-schemas', ['--strict', '--dry-run', 'schemas']); +console.log(`Syntax, imports, formatting, repository layout, links, release metadata and schema checks passed (${files.length} JavaScript files).`); diff --git a/tools/package.py b/tools/package.py new file mode 100644 index 0000000..81388c8 --- /dev/null +++ b/tools/package.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Create a deterministic GNOME extension archive from an explicit allowlist.""" + +import argparse +import json +import re +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +def checked_file(source, path): + """Return a regular, repository-owned packaging input.""" + if path.is_symlink() or not path.is_file(): + raise RuntimeError(f"Invalid packaging input: {path}") + try: + path.resolve().relative_to(source) + except ValueError as error: + raise RuntimeError( + f"Packaging input escapes the source tree: {path}" + ) from error + return path + + +def package(source, output): + source = source.resolve() + metadata_file = checked_file(source, source / "metadata.json") + package_file = checked_file(source, source / "package.json") + metadata = json.loads(metadata_file.read_text()) + version = json.loads(package_file.read_text())["version"] + uuid = metadata.get("uuid") + if not isinstance(uuid, str) or not re.fullmatch( + r"[A-Za-z0-9._-]+@[A-Za-z0-9._-]+", uuid + ): + raise RuntimeError("metadata.json contains an invalid extension UUID") + if not isinstance(version, str) or not re.fullmatch( + r"[0-9A-Za-z][0-9A-Za-z.+-]*", version + ): + raise RuntimeError("package.json contains an invalid release version") + files = [ + source / name + for name in ( + "extension.js", + "prefs.js", + "metadata.json", + "stylesheet.css", + "LICENSE", + ) + ] + files.extend(sorted((source / "src").rglob("*.js"))) + files.extend(sorted((source / "icons").glob("*.svg"))) + schema = metadata.get("settings-schema") + if schema != "org.gnome.shell.extensions.usagebeam": + raise RuntimeError("metadata.json contains an unexpected settings schema") + files.append(source / "schemas" / f"{schema}.gschema.xml") + files = [checked_file(source, file) for file in files] + output.mkdir(parents=True, exist_ok=True) + archive = output / f"{uuid}-{version}.zip" + if archive.is_symlink(): + raise RuntimeError(f"Refusing to overwrite archive symlink: {archive}") + with tempfile.TemporaryDirectory(prefix="usagebeam-schemas-") as scratch: + subprocess.run( + [ + "glib-compile-schemas", + "--strict", + "--targetdir", + scratch, + str(source / "schemas"), + ], + check=True, + ) + payloads = [ + (file.relative_to(source).as_posix(), file.read_bytes()) for file in files + ] + payloads.append( + ( + "schemas/gschemas.compiled", + (Path(scratch) / "gschemas.compiled").read_bytes(), + ) + ) + expected = {name for name, _payload in payloads} + if len(expected) != len(payloads): + raise RuntimeError("Duplicate paths in extension package") + with zipfile.ZipFile( + archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9 + ) as target: + for name, payload in sorted(payloads): + info = zipfile.ZipInfo(name, date_time=(2020, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o100644 << 16 + target.writestr(info, payload) + with zipfile.ZipFile(archive) as check: + names = check.namelist() + if check.testzip() is not None: + raise RuntimeError("Extension archive failed its integrity check") + if set(names) != expected: + raise RuntimeError( + "Extension archive contents differ from the runtime allowlist" + ) + if any( + name.startswith((".AGENTS/", ".github/", "docs/", "tests/", "tools/")) + for name in names + ): + raise RuntimeError( + "Repository-only content entered the extension archive" + ) + print(archive.resolve()) + return archive + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source", type=Path, default=Path(__file__).resolve().parent.parent + ) + parser.add_argument("--output", type=Path, default=Path("dist")) + args = parser.parse_args() + package(args.source.resolve(), args.output.resolve()) diff --git a/tools/smoke-shell.py b/tools/smoke-shell.py new file mode 100644 index 0000000..cbe0aaf --- /dev/null +++ b/tools/smoke-shell.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Exercise the packaged extension in a private headless GNOME session.""" + +import argparse +import json +import os +import shutil +import signal +import subprocess +import tempfile +import time +import zipfile +from datetime import date, timedelta +from pathlib import Path +from xml.sax.saxutils import escape + +UUID = "usagebeam@oo7kc.github.io" +SCHEMA = "org.gnome.shell.extensions.usagebeam" +PRODUCT_DIRECTORY = "usagebeam" +TEST_UUID = "usagebeam-ui-test@tests.invalid" + + +def run(command, env, **options): + return subprocess.run( + command, env=env, text=True, capture_output=True, timeout=15, **options + ) + + +def install(source, archive, prefix, destination): + extension = prefix / "share" / "gnome-shell" / "extensions" / UUID + if archive: + with zipfile.ZipFile(archive) as payload: + for entry in payload.infolist(): + parts = Path(entry.filename).parts + if entry.is_dir(): + continue + if Path(entry.filename).is_absolute() or ".." in parts: + raise RuntimeError(f"Unsafe archive entry: {entry.filename}") + target = extension.joinpath(*parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(payload.read(entry)) + return + build = destination / "build" + subprocess.run( + ["meson", "setup", str(build), str(source), f"--prefix={prefix}"], + check=True, + stdout=subprocess.DEVNULL, + ) + subprocess.run( + ["meson", "install", "-C", str(build)], check=True, stdout=subprocess.DEVNULL + ) + + +def seed_usage(destination): + """Populate non-personal records so every primary popup widget is constructed.""" + now = int(time.time() * 1000) + dates = [ + (date.today() - timedelta(days=offset)).isoformat() + for offset in range(6, -1, -1) + ] + + def record(provider, name, plan, percentages, models): + days = [ + { + "date": day, + "total": [0, 0, 0, 0, 0, 70_700_000, 38_500_000][index], + "sessions": index + 1, + "events": index + 2, + } + for index, day in enumerate(dates) + ] + return { + "schemaVersion": 2, + "id": provider, + "name": name, + "plan": plan, + "accountKey": "0" * 64, + "capabilities": {"limits": True, "history": True, "models": True}, + "limits": { + "status": "ready", + "message": "", + "updatedAt": now, + "scope": "account", + "source": "Synthetic smoke fixture", + "windows": [ + { + "id": f"{provider}:session", + "label": "Session · 5 hours", + "usedPercent": percentages[0], + "used": None, + "limit": None, + "unit": "percent", + "unlimited": False, + "state": "exhausted" if percentages[0] >= 100 else "active", + "durationMinutes": 300, + "resetsAt": now + 7_200_000, + }, + { + "id": f"{provider}:weekly", + "label": "Weekly", + "usedPercent": percentages[1], + "used": None, + "limit": None, + "unit": "percent", + "unlimited": False, + "state": "exhausted" if percentages[1] >= 100 else "active", + "durationMinutes": 10_080, + "resetsAt": now + 172_800_000, + }, + { + "id": f"{provider}:reserve", + "label": "Weekly reserve", + "usedPercent": percentages[2], + "used": None, + "limit": None, + "unit": "percent", + "unlimited": False, + "state": "exhausted" if percentages[2] >= 100 else "active", + "durationMinutes": 10_080, + "resetsAt": now + 604_800_000, + }, + ], + }, + "history": { + "status": "ready", + "message": "", + "updatedAt": now, + "scope": "local", + "period": {"start": dates[0], "end": dates[-1]}, + "days": days, + "models": models, + "source": "Synthetic smoke fixture", + }, + } + + models = [ + { + "model": "gpt-5.6-sol", + "total": 77_200_000, + "input": 190_000, + "output": 68_000, + "cacheRead": 76_942_000, + "cacheWrite": 0, + }, + { + "model": "codex-auto-review", + "total": 24_100_000, + "input": 97_000, + "output": 8_000, + "cacheRead": 23_995_000, + "cacheWrite": 0, + }, + { + "model": "gpt-6-astra", + "total": 7_900_000, + "input": 42_000, + "output": 62_000, + "cacheRead": 7_796_000, + "cacheWrite": 0, + }, + ] + target = destination / "state" / PRODUCT_DIRECTORY + target.mkdir(parents=True, exist_ok=True) + records = { + "codex": record("codex", "Codex", "Plus", (100, 94, 83), models), + "claude": record("claude", "Claude Code", "Pro", (46, 7, 0), models), + } + for provider, data in records.items(): + (target / f"{provider}.json").write_text(json.dumps(data)) + # Separate fixtures survive asynchronous collector updates to the cache. + (destination / "ui-fixtures.json").write_text(json.dumps(records)) + + +def smoke(source, archive, destination, scale=1, text_scale=1.0, *, + width=1280, height=1024, system_fonts=False, stress=False): + destination.mkdir(parents=True, exist_ok=True) + prefix = destination / "install" + install(source, archive, prefix, destination) + shutil.copytree( + source / "tests/shell", prefix / "share/gnome-shell/extensions" / TEST_UUID + ) + isolated_home = destination / "home" + isolated_home.mkdir(parents=True, exist_ok=True) + runtime = destination / "runtime" + runtime.mkdir(mode=0o700) + seed_usage(destination) + env = { + **os.environ, + "HOME": str(isolated_home), + "PATH": "/usr/bin:/bin", + "XDG_CONFIG_HOME": str(destination / "config"), + "XDG_DATA_HOME": str(prefix / "share"), + "XDG_CACHE_HOME": str(destination / "cache"), + "XDG_STATE_HOME": str(destination / "state"), + "GSETTINGS_BACKEND": "keyfile", + "XDG_RUNTIME_DIR": str(runtime), + "GSETTINGS_SCHEMA_DIR": str( + prefix / "share/gnome-shell/extensions" / UUID / "schemas" + ), + "LIBGL_ALWAYS_SOFTWARE": "1", + "USAGEBEAM_TEST_OUTPUT": str(destination), + "USAGEBEAM_TEST_SCALE": str(scale), + "USAGEBEAM_STRESS": "1" if stress else "0", + } + # Never let inherited provider paths expose the real account to a test shell. + for name in ("CODEX_HOME", "CLAUDE_CONFIG_DIR"): + env.pop(name, None) + # Use locally installed fonts without copying or distributing licensed files. + font = run( + ["fc-match", "-f", "%{file}", "SF Pro Text"], os.environ, check=True + ).stdout + font_config = destination / "fonts.conf" + extra_fonts = "" if system_fonts else f"{escape(str(Path(font).parent))}" + font_config.write_text( + "/etc/fonts/fonts.conf" + f"{extra_fonts}" + ) + env["FONTCONFIG_FILE"] = str(font_config) + resolved_font = run(["fc-match", "-f", "%{family}", "SF Pro Text"], env, check=True).stdout + if system_fonts and "SF Pro" in resolved_font: + raise RuntimeError("System-font case still resolves SF Pro; font fallback was not isolated") + run( + [ + "gsettings", + "set", + "org.gnome.desktop.interface", + "scaling-factor", + str(scale), + ], + env, + check=True, + ) + run( + [ + "gsettings", + "set", + "org.gnome.desktop.interface", + "text-scaling-factor", + str(text_scale), + ], + env, + check=True, + ) + run( + ["gsettings", "set", SCHEMA, "enabled-providers", "['codex', 'claude']"], + env, + check=True, + ) + run( + ["gsettings", "set", SCHEMA, "panel-position", "'left-of-calendar'"], + env, + check=True, + ) + run( + [ + "gsettings", + "set", + "org.gnome.shell", + "enabled-extensions", + f"['{UUID}', '{TEST_UUID}']", + ], + env, + check=True, + ) + run( + [ + "gsettings", + "set", + "org.gnome.shell", + "welcome-dialog-last-shown-version", + "999", + ], + env, + ) + bus = subprocess.Popen( + ["dbus-daemon", "--session", "--nofork", "--print-address=1"], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip() + log_path = destination / "shell.log" + shell = None + try: + with log_path.open("w") as log: + shell = subprocess.Popen( + [ + "gnome-shell", + "--headless", + "--no-x11", + "--virtual-monitor", + f"{width * scale}x{height * scale}", + "--wayland-display", + "usagebeam-test", + ], + env=env, + stdout=log, + stderr=log, + start_new_session=True, + ) + command = [ + "gdbus", + "call", + "--session", + "--dest", + "org.gnome.Shell.Extensions", + "--object-path", + "/org/gnome/Shell/Extensions", + "--method", + "org.gnome.Shell.Extensions.GetExtensionInfo", + UUID, + ] + deadline = time.monotonic() + 30 + info = "" + while time.monotonic() < deadline and shell.poll() is None: + result = run(command, env) + info = result.stdout + if "'state': <1.0>" in info: + break + if "'state': <3.0>" in info: + raise RuntimeError(f"Extension failed: {info}") + time.sleep(0.4) + else: + raise RuntimeError(f"Extension did not become active: {info}") + print("PASS: packaged extension loads in a private GNOME Shell session") + results_path = destination / "ui-results.json" + deadline = time.monotonic() + (90 if stress else 25) + while ( + time.monotonic() < deadline + and not results_path.exists() + and shell.poll() is None + ): + time.sleep(0.2) + if not results_path.exists(): + raise RuntimeError(f"UI tests did not complete; inspect {log_path}") + results = json.loads(results_path.read_text()) + if not results["ok"] and not stress: + raise RuntimeError(f"UI check failed: {results['error']}") + if results["ok"]: + print("PASS: private-shell UI assertions") + print(f"UI geometry and screenshots: {destination}") + run(["gnome-extensions", "disable", TEST_UUID], env, check=True) + cycles = 10 if stress else 1 + for cycle in range(cycles): + run(["gnome-extensions", "disable", UUID], env, check=True) + disabled = run(command, env, check=True).stdout + if "'state': <2.0>" not in disabled: + raise RuntimeError(f"Disable cycle {cycle + 1} did not reach INACTIVE") + run(["gnome-extensions", "enable", UUID], env, check=True) + enabled = run(command, env, check=True).stdout + if "'state': <1.0>" not in enabled: + raise RuntimeError(f"Enable cycle {cycle + 1} did not reach ACTIVE") + print(f"PASS: {cycles} verified disable/re-enable cycles") + # Leave no collector running while the private shell itself shuts down. + run(["gnome-extensions", "disable", UUID], env, check=True) + finally: + if shell and shell.poll() is None: + os.killpg(shell.pid, signal.SIGTERM) + try: + shell.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(shell.pid, signal.SIGKILL) + shell.wait() + bus.terminate() + bus.wait(timeout=5) + print(f"GNOME log: {log_path}") + contents = log_path.read_text(errors="replace") + if "Gjs-CRITICAL" in contents and UUID in contents: + raise RuntimeError(f"Extension emitted a GJS critical; inspect {log_path}") + if ( + "St-WARNING" in contents + or "UsageBeam could not" in contents + or "JS ERROR" in contents + ): + raise RuntimeError(f"UI, stylesheet, or JavaScript warning; inspect {log_path}") + results.update(resolvedFont=resolved_font, lifecycleCycles=cycles) + (destination / "ui-results.json").write_text(json.dumps(results, indent=2)) + if not results["ok"]: + raise RuntimeError(f"UI stress failures; inspect {destination / 'ui-results.json'}") + + +def matrix(source, archive, destination, selected=None): + cases = [ + ("desktop-sf", 1280, 1024, 1, 1.0, False), + ("laptop-fallback", 1366, 768, 1, 1.0, True), + ("small-fallback", 1024, 600, 1, 1.0, True), + ("large-text", 1920, 1080, 1, 1.5, True), + ("hidpi-fallback", 1920, 1080, 2, 1.0, True), + ("hidpi-large-text", 1280, 1024, 2, 1.25, False), + ] + outcomes = [] + for name, width, height, scale, text_scale, fallback in cases: + if selected and name != selected: + continue + print(f"CASE: {name} ({width}x{height} logical, scale {scale}, text {text_scale})", flush=True) + try: + smoke(source, archive, destination / name, scale, text_scale, + width=width, height=height, system_fonts=fallback, stress=True) + outcomes.append({"case": name, "ok": True}) + except (RuntimeError, subprocess.SubprocessError) as error: + outcomes.append({"case": name, "ok": False, "error": str(error)}) + print(f"FAIL: {name}: {error}", flush=True) + (destination / "matrix-results.json").write_text(json.dumps(outcomes, indent=2)) + return all(result["ok"] for result in outcomes) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--source", type=Path, default=Path(__file__).resolve().parent.parent + ) + parser.add_argument( + "--archive", + type=Path, + help="Test an already-built release archive instead of a Meson install", + ) + parser.add_argument("--output", type=Path) + parser.add_argument("--matrix", action="store_true", help="Run isolated portability stress cases") + parser.add_argument("--case", choices=["desktop-sf", "laptop-fallback", "small-fallback", + "large-text", "hidpi-fallback", "hidpi-large-text"], help="Select one matrix case") + parser.add_argument( + "--scale", + type=int, + choices=[1, 2], + default=1, + help="Private virtual-monitor scale", + ) + parser.add_argument( + "--text-scale", + type=float, + choices=[1.0, 1.25], + default=1.0, + help="Accessibility text scale", + ) + args = parser.parse_args() + if args.case and not args.matrix: + parser.error("--case requires --matrix") + if args.matrix: + output = args.output.resolve() if args.output else Path(tempfile.mkdtemp(prefix="usagebeam-stress-")) + raise SystemExit(0 if matrix(args.source.resolve(), args.archive.resolve() if args.archive else None, output, args.case) else 1) + smoke( + args.source.resolve(), + args.archive.resolve() if args.archive else None, + args.output.resolve() + if args.output + else Path(tempfile.mkdtemp(prefix="usagebeam-shell-")), + args.scale, + args.text_scale, + )