From e78fc591cae2804624ac0578ecee14fd6b75fbdf Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 11:52:10 +0200 Subject: [PATCH 1/8] Migrate source from TypeScript to JavaScript --- CONTRIBUTING.md | 10 +- Makefile | 4 +- package-lock.json | 713 ------------------ package.json | 5 +- src/ambient.d.ts | 9 - src/app/{scheduler.ts => scheduler.js} | 74 +- src/app/{settings.ts => settings.js} | 96 +-- src/app/{usage.ts => usage.js} | 73 +- src/domain/{prediction.ts => prediction.js} | 114 +-- src/domain/refresh-failure.js | 13 + src/domain/refresh-failure.ts | 22 - src/domain/types.js | 56 ++ src/domain/usage.js | 49 ++ src/domain/usage.ts | 70 -- src/{extension.ts => extension.js} | 6 +- src/infra/api_client.js | 93 +++ src/infra/api_client.ts | 202 ----- src/infra/{config.ts => config.js} | 1 - src/infra/{filesystem.ts => filesystem.js} | 78 +- src/infra/providers/auth.js | 50 ++ src/infra/providers/auth.ts | 97 --- src/infra/providers/{codex.ts => codex.js} | 18 +- .../{codex_auth.ts => codex_auth.js} | 34 +- src/infra/providers/codex_auth_parser.js | 29 + src/infra/providers/codex_auth_parser.ts | 29 - ...onse.ts => codex_banked_reset_response.js} | 97 ++- ...anked_resets.ts => codex_banked_resets.js} | 111 +-- ...ge_response.ts => codex_usage_response.js} | 110 ++- src/infra/providers/index.js | 15 + src/infra/providers/index.ts | 24 - src/infra/providers/types.d.ts | 20 - ...t-snapshot.ts => banked-reset-snapshot.js} | 42 +- src/infra/storage/{history.ts => history.js} | 152 ++-- .../{snapshot-cache.ts => snapshot-cache.js} | 54 +- src/{prefs.ts => prefs.js} | 9 +- src/ui/{button.ts => button.js} | 29 +- ...{panel-indicator.ts => panel-indicator.js} | 441 ++++------- src/ui/{popup-menu.ts => popup-menu.js} | 147 +--- src/ui/preferences-pages.js | 295 ++++++++ src/ui/preferences-pages.ts | 439 ----------- ...or-message.ts => refresh-error-message.js} | 4 +- src/ui/{usage-bar.ts => usage-bar.js} | 68 +- src/ui/{view-model.ts => view-model.js} | 540 ++++--------- tsconfig.json | 12 +- 44 files changed, 1387 insertions(+), 3167 deletions(-) delete mode 100644 src/ambient.d.ts rename src/app/{scheduler.ts => scheduler.js} (51%) rename src/app/{settings.ts => settings.js} (53%) rename src/app/{usage.ts => usage.js} (55%) rename src/domain/{prediction.ts => prediction.js} (58%) create mode 100644 src/domain/refresh-failure.js delete mode 100644 src/domain/refresh-failure.ts create mode 100644 src/domain/types.js create mode 100644 src/domain/usage.js delete mode 100644 src/domain/usage.ts rename src/{extension.ts => extension.js} (95%) create mode 100644 src/infra/api_client.js delete mode 100644 src/infra/api_client.ts rename src/infra/{config.ts => config.js} (99%) rename src/infra/{filesystem.ts => filesystem.js} (50%) create mode 100644 src/infra/providers/auth.js delete mode 100644 src/infra/providers/auth.ts rename src/infra/providers/{codex.ts => codex.js} (72%) rename src/infra/providers/{codex_auth.ts => codex_auth.js} (61%) create mode 100644 src/infra/providers/codex_auth_parser.js delete mode 100644 src/infra/providers/codex_auth_parser.ts rename src/infra/providers/{codex_banked_reset_response.ts => codex_banked_reset_response.js} (58%) rename src/infra/providers/{codex_banked_resets.ts => codex_banked_resets.js} (57%) rename src/infra/providers/{codex_usage_response.ts => codex_usage_response.js} (61%) create mode 100644 src/infra/providers/index.js delete mode 100644 src/infra/providers/index.ts delete mode 100644 src/infra/providers/types.d.ts rename src/infra/storage/{banked-reset-snapshot.ts => banked-reset-snapshot.js} (61%) rename src/infra/storage/{history.ts => history.js} (57%) rename src/infra/storage/{snapshot-cache.ts => snapshot-cache.js} (51%) rename src/{prefs.ts => prefs.js} (86%) rename src/ui/{button.ts => button.js} (64%) rename src/ui/{panel-indicator.ts => panel-indicator.js} (75%) rename src/ui/{popup-menu.ts => popup-menu.js} (91%) create mode 100644 src/ui/preferences-pages.js delete mode 100644 src/ui/preferences-pages.ts rename src/ui/{refresh-error-message.ts => refresh-error-message.js} (76%) rename src/ui/{usage-bar.ts => usage-bar.js} (69%) rename src/ui/{view-model.ts => view-model.js} (53%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f4023e..9163d2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,8 +6,7 @@ For local development, run the following commands to try out changes: ```sh make clean install -gnome-extensions disable codex-meter@slobbe.github.io -gnome-extensions enable codex-meter@slobbe.github.io +make reload ``` You may need to log out and back in to see changes. @@ -22,12 +21,17 @@ make clean pack ## Checks -Run the test suite before submitting changes: +Run the checked-JavaScript validation and test suite before submitting changes: ```sh +npm run check npm test ``` +Source files run directly as JavaScript. Critical pure modules opt into TypeScript's +no-output checker with `// @ts-check` and JSDoc annotations; dynamic GJS UI modules +are covered by runtime tests and manual extension testing. + ## Guidelines - Keep changes small and focused. diff --git a/Makefile b/Makefile index 227c637..56be66a 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ DOMAIN=slobbe.github.io VERSION=$(shell node -p "require('./package.json').version") TAG_VERSION=v$(VERSION) ZIP=$(NAME)@$(DOMAIN)-$(TAG_VERSION).zip -TS_SOURCES=$(shell find . -path ./dist -prune -o -path ./node_modules -prune -o -name "*.ts" -print) +SOURCES=$(shell find src -type f -print) .PHONY: all pack install reload clean @@ -12,7 +12,7 @@ all: dist/extension.js node_modules/.package-lock.json: package.json npm install -dist/extension.js dist/prefs.js &: node_modules/.package-lock.json $(TS_SOURCES) +dist/extension.js dist/prefs.js &: node_modules/.package-lock.json $(SOURCES) rm -rf dist npm run build diff --git a/package-lock.json b/package-lock.json index 4c0b88f..bc9c945 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,722 +9,9 @@ "version": "0.9.0", "license": "GPL-3.0-or-later", "devDependencies": { - "@girs/gjs": "^4.0.0-rc.9", - "@girs/gnome-shell": "^50.0.0", "typescript": "^6.0.3" } }, - "node_modules/@girs/accountsservice-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/accountsservice-1.0/-/accountsservice-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-8duXs1yABr8POmqhnJjnU8pIrOmSdzRMdFjHhHgJeqZslU9oNB5qY47k0tVeIhzGREQ3kTWM6Fx57mzPVn8Stw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/adw-1": { - "version": "1.10.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/adw-1/-/adw-1-1.10.0-4.0.0-rc.9.tgz", - "integrity": "sha512-4B5U3QeEHnFUMs6YqKCq3SoCAamjtialC1VgCcMcL6J+aUaLb0cLKad0Vpld77FMyF7FuHmqBoTJGXudKnDDSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/gsk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gtk-4.0": "4.23.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/pangocairo-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/atk-1.0": { - "version": "2.60.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/atk-1.0/-/atk-1.0-2.60.0-4.0.0-rc.9.tgz", - "integrity": "sha512-HkLEKh/4jdY078iYQFILumcDXgtrpVwxWD4Db4HfFcMJMppy2/Rcw1Ng+teRPPkMAmtNNu/LsoyMMtJFFhUccg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/cairo-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/cairo-1.0/-/cairo-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-zeY4NcJvQL8UTkyvWQY0ZRe8MgVu3ldartR+ML8oPH/gGys8l2TK1yCO5RvGZbBHt+jeJy7ZRSOC4IwL3yWaAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/clutter-18": { - "version": "18.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/clutter-18/-/clutter-18-18.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-7Cc8eTIJjjZgBNpyxJL9bjttjzmK4aemHOPdWapepB8h36HGgniavmihlEmLLIq1MG2CBm9/yF/ZxLSeqfjEzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/atk-1.0": "2.60.0-4.0.0-rc.9", - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/cogl-18": "18.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/gl-1.0": "1.0.0-4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/mtk-18": "18.0.0-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9" - } - }, - "node_modules/@girs/cogl-18": { - "version": "18.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/cogl-18/-/cogl-18-18.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-BWhNUyNl31T7kItXyHeCHomhlasrhk+PJ1rfJmnah8b6qeMqg7MFn5Q0zlN4finhPUjpuq2iwwNNgElDYO2ECw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gl-1.0": "1.0.0-4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/mtk-18": "18.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/cogl-2.0": { - "version": "2.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/cogl-2.0/-/cogl-2.0-2.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-FPJJyAFsZIdikS/yzRkSTitBqh0da1U4iWZEUwV1+eHgoSsGbYa+03cL5n2n63RzdqobhqrykYGen2J/ONuNIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gl-1.0": "1.0.0-4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/freetype2-2.0": { - "version": "2.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/freetype2-2.0/-/freetype2-2.0-2.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-9KJmu8I8i7GzWuIkrb+HydeHmzt5QcLk6yJ2AkGleEAippv9+Tmj5BdPlZ4E2H9MMxPEJKI/KWpJeM9AwFodPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gck-2": { - "version": "4.4.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gck-2/-/gck-2-4.4.0-4.0.0-rc.9.tgz", - "integrity": "sha512-lvAZfNfe7vqTs+djPKGEJ8Nn/KhEyM+8dDbvvMM1sd7ODntOYG4iN9SyGEHWWI6a+UsMBqWHA46THMU84+J3QA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gcr-4": { - "version": "4.4.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gcr-4/-/gcr-4-4.4.0-4.0.0-rc.9.tgz", - "integrity": "sha512-yLyJE+QciI+t+jQ4A2kP2T4WQAxXIjCIPLZF2PxPCwsNt20ZQC6WppMg4FMbolVtnHnVZyb6+2Qx9ubTU6kQLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gck-2": "4.4.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gdesktopenums-3.0": { - "version": "3.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gdesktopenums-3.0/-/gdesktopenums-3.0-3.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-0QVmQvhPoYhwGFIT1exQEDxRV9Ej5AnSdRjJD4T4dEPmiAYTkvhpLMmT7Vutyr1iRWYLYO3M2AXF0z2X6fVpDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gdk-4.0": { - "version": "4.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gdk-4.0/-/gdk-4.0-4.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-tE5/URxYzmIx0xaj24YNkWMrFfWW7guMhsE6e8jJo7VF1tn6DNakdRZb0vTzIvWg6MxG4d8TPFKn/z/t1poQLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/pangocairo-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gdkpixbuf-2.0": { - "version": "2.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gdkpixbuf-2.0/-/gdkpixbuf-2.0-2.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-QSYPmHzE8zKBlu2sTHHUFf+GHFwR31txcE2B2mgVt/LYPQJQUPO5/mdDwnfUlGFgsMw3pDepbeyU81daJIPL+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gdm-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gdm-1.0/-/gdm-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-0+Sr4mZGHI/b3J6XdVNrpW3M4AnqufBkdztHqb8PVdXI/4aPTBqxFnmHT11JljgoMSHd5NJtzsc9HNl7DJYpKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gio-2.0": { - "version": "2.88.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gio-2.0/-/gio-2.0-2.88.0-4.0.0-rc.9.tgz", - "integrity": "sha512-ESoyk4ybL1/T09B7J/vgzF00GTI9KWQhDMtwTH8fMEBrubfzQD+Z9LL5v9du8+Flt3yR5RquajCkmg3aXk9jWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/giounix-2.0": { - "version": "2.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/giounix-2.0/-/giounix-2.0-2.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-kxZrKi0Gp7jSAYuNLF9ioWVfHXSB2j/kDPwUk7UJP9aJWYqZ5OFOD7qVw3gf+XryX0s4/vyRIscjdbEdbW/pUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gjs": { - "version": "4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gjs/-/gjs-4.0.0-rc.9.tgz", - "integrity": "sha512-lTEkBK4CZzYTGw57LJFWkXzdFRzpaqGPFD54tuXJHPJt2lMYtQxMzS9qqRB7ywvY2EWLwrbpcaK6oBlMhU6JgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gl-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gl-1.0/-/gl-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-3dLvDGURVM6nSV/WyvuSWCtDwDoZCROAObt8vLSY8erIr7tJL0/ejjXYEM/Fg08iBVUizH5yr51ewEwrHFEyYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/glib-2.0": { - "version": "2.88.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/glib-2.0/-/glib-2.0-2.88.0-4.0.0-rc.9.tgz", - "integrity": "sha512-7cclBcQ6sgs/OlyO25GOEutw1959ManLxDMcXQVIYJzOd+rxGkYNULcqkHBfHvxaufog1TDWVQ70e489CB002g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gmodule-2.0": { - "version": "2.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gmodule-2.0/-/gmodule-2.0-2.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-V8PQ0VXVOAStLKheCuWYJRUO5GUpMYmUdSvHuvktQIqJHSehwWOAkuzDBJcgKbMrIpoGjswXKji65Im9UYX1rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gnome-shell": { - "version": "50.0.0", - "resolved": "https://registry.npmjs.org/@girs/gnome-shell/-/gnome-shell-50.0.0.tgz", - "integrity": "sha512-ki3VBO/WUvinGRB8lSPa6Rn0qJwEs3cQOvZa/P8MzM/68mujLPy453qVT6fwyMPqHVusAv3JZuj9HnUewx1GeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/accountsservice-1.0": "^1.0.0-4.0.0-rc.9", - "@girs/adw-1": "^1.10.0-4.0.0-rc.9", - "@girs/atk-1.0": "^2.60.0-4.0.0-rc.9", - "@girs/clutter-18": "^18.0.0-4.0.0-rc.9", - "@girs/cogl-2.0": "^2.0.0-4.0.0-rc.9", - "@girs/gcr-4": "^4.4.0-4.0.0-rc.9", - "@girs/gdm-1.0": "^1.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "^2.88.0-4.0.0-rc.9", - "@girs/giounix-2.0": "^2.0.0-4.0.0-rc.9", - "@girs/gjs": "^4.0.0-rc.9", - "@girs/glib-2.0": "^2.88.0-4.0.0-rc.9", - "@girs/gnomebg-4.0": "^4.0.0-4.0.0-rc.9", - "@girs/gnomebluetooth-3.0": "^3.0.0-4.0.0-rc.9", - "@girs/gnomedesktop-4.0": "^4.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "^2.88.0-4.0.0-rc.9", - "@girs/gtk-4.0": "^4.23.0-4.0.0-rc.9", - "@girs/gvc-1.0": "^1.0.0-4.0.0-rc.9", - "@girs/meta-18": "^18.0.0-4.0.0-rc.9", - "@girs/mtk-18": "^18.0.0-4.0.0-rc.9", - "@girs/polkit-1.0": "^1.0.0-4.0.0-rc.9", - "@girs/shell-18": "^18.0.0-4.0.0-rc.9", - "@girs/shew-0": "^0.0.0-4.0.0-rc.9", - "@girs/st-18": "^18.0.0-4.0.0-rc.9", - "@girs/upowerglib-1.0": "^1.91.1-4.0.0-rc.9" - } - }, - "node_modules/@girs/gnomebg-4.0": { - "version": "4.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gnomebg-4.0/-/gnomebg-4.0-4.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-JskLEUkDU4EFiiUcmJuVag0HZG8JrqLmWp7g+tThjsVwTqtN6NnfEzynY2OhaEuqzbzyWtorYTElpvcjgGTHhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdesktopenums-3.0": "3.0.0-4.0.0-rc.9", - "@girs/gdk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gnomedesktop-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/pangocairo-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gnomebluetooth-3.0": { - "version": "3.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gnomebluetooth-3.0/-/gnomebluetooth-3.0-3.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-9o2G+V8/HhxXP/E74Y2MBw+ocENGtKzVzLR3A+EmJZgfncWgJddQvDN3udasKxhriPbWPa8unsElI3mNg4f6vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gnomedesktop-4.0": { - "version": "4.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gnomedesktop-4.0/-/gnomedesktop-4.0-4.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-2vCY79lNR+IcrxT6g0u8vG7g/wLVsWUloryOBmk0BtwabABhfVPJ2xcecHCpmhQwGmutAlhDfxjAxRWgN219mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gdesktopenums-3.0": "3.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gobject-2.0": { - "version": "2.88.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gobject-2.0/-/gobject-2.0-2.88.0-4.0.0-rc.9.tgz", - "integrity": "sha512-RdmPZYbFIUDuxMAmRphz6vnOTmigN0pLHx9ALFrYOtR1J7zkpOWlXDDIVgU79xnt+lTwU5q2ptSNcjldWZ217w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/graphene-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/graphene-1.0/-/graphene-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-hVKntJZNi6Aw1BBgomGSWVVPG2CZF2CKPCVWcDdHA3Iry6CXTkLLOC/2dA8m/BOVPoWb9bXxPdeQhDFVkOu/nQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gsk-4.0": { - "version": "4.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gsk-4.0/-/gsk-4.0-4.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-F+fkMmbIswI5aQ4vhsmAnbBqa1goOng6LBFn8JRcFaNswTzHkx8JP8JXPEcsOgvUas12bMYed8niwUyegpOHcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/pangocairo-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gtk-4.0": { - "version": "4.23.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gtk-4.0/-/gtk-4.0-4.23.0-4.0.0-rc.9.tgz", - "integrity": "sha512-nh9NHm0RUR7xKrvS47G5r88Wy13L4e+ppxtZIVYEbJyGBmJ8fkn9H/lrDvRVg7/IkXHmVw+Zogo4llDoll/SNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/gsk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/pangocairo-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/gvc-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/gvc-1.0/-/gvc-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-irlujv9x1vqLMQJ5zA60ktky6JW/+f8RoG4yUE2Olbnraqfw4wZRB84isvMYmqeSOdjRgIQIRLPAdr0Qc8kC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/harfbuzz-0.0": { - "version": "13.2.1-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/harfbuzz-0.0/-/harfbuzz-0.0-13.2.1-4.0.0-rc.9.tgz", - "integrity": "sha512-RNJdg/YlkSmk9F8DY78wJqVtTfo9GRF5LQCpdeLY6Yd9GzMH99P1Be6663wf/XaQyRNh5GGgXSPZSJ7rKlr3uw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/meta-18": { - "version": "18.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/meta-18/-/meta-18-18.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-B1tH6ddFAFJCUd9dtgshBuwe6SlL6z1cptgvR0ndGSmjPFifoBx/+JzVYi/SBd3r72kfMu2pVAWhB9NLkMPciA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/atk-1.0": "2.60.0-4.0.0-rc.9", - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/clutter-18": "18.0.0-4.0.0-rc.9", - "@girs/cogl-18": "18.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdesktopenums-3.0": "3.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/gl-1.0": "1.0.0-4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/mtk-18": "18.0.0-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/xfixes-4.0": "4.0.0-4.0.0-rc.9", - "@girs/xlib-2.0": "2.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/mtk-18": { - "version": "18.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/mtk-18/-/mtk-18-18.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-RZNogW9fXniYULnehd1L90oC6mauFZwI8Kzvc0kNAfddmYu7wTWW0Nk5Ksa99F5zlAcLEps8zAVsjv/Z6b4l9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/nm-1.0": { - "version": "1.56.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/nm-1.0/-/nm-1.0-1.56.0-4.0.0-rc.9.tgz", - "integrity": "sha512-DL3u2q9P76fD+rwb73tPNczaCd3NtvOpOQyW2y8z+PzlqPWGRcmFX3UM6pYI5oStFutzRkp4Wj/Fb9uA+bOJ6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/pango-1.0": { - "version": "1.57.1-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/pango-1.0/-/pango-1.0-1.57.1-4.0.0-rc.9.tgz", - "integrity": "sha512-qIPTkYK6RvCzolkkl6Kqdq/1wmAjCjj9VvFjGAlf/rRC8sjk006E/t3vtfQsQdvFE+/cFxBmpKBt6gjP/ebRYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9" - } - }, - "node_modules/@girs/pangocairo-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/pangocairo-1.0/-/pangocairo-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-zvQiX6YGg8Lg8C9nnW2dXqqqiey4DljPwfE/WhZPAeU/IGuBxQK+2Xs2Ym0dEEVML5Rkf7cuRWjg1TFAOVh+ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9" - } - }, - "node_modules/@girs/polkit-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/polkit-1.0/-/polkit-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-pI033V7ALtm4Xu5vZLVsekvP+iCFgbNlhErmmgTZoEnNjsGBS1TFlR1V6ty633MzPGKjoohBiCJCIVmWXr6hiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/polkitagent-1.0": { - "version": "1.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/polkitagent-1.0/-/polkitagent-1.0-1.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-FxMTRAaHtDeKM+wnIBm/33NVmul+kCTpfP3p4eShMllGL+vSS5Sz9y5/PKzD0lE9dCUJ9JYOUshgQXvTE/bDWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/polkit-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/shell-18": { - "version": "18.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/shell-18/-/shell-18-18.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-xss5m61UlvhZfz2r/ORnzmQzo33T48Wqbq+TsW1/IRV1i7BRK9mZVbNpKHvrdSfH8B5Vd8Awsqx4grxn6su5Lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/atk-1.0": "2.60.0-4.0.0-rc.9", - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/clutter-18": "18.0.0-4.0.0-rc.9", - "@girs/cogl-18": "18.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gck-2": "4.4.0-4.0.0-rc.9", - "@girs/gcr-4": "4.4.0-4.0.0-rc.9", - "@girs/gdesktopenums-3.0": "3.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/giounix-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/gl-1.0": "1.0.0-4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/gvc-1.0": "1.0.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/meta-18": "18.0.0-4.0.0-rc.9", - "@girs/mtk-18": "18.0.0-4.0.0-rc.9", - "@girs/nm-1.0": "1.56.0-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/polkit-1.0": "1.0.0-4.0.0-rc.9", - "@girs/polkitagent-1.0": "1.0.0-4.0.0-rc.9", - "@girs/st-18": "18.0.0-4.0.0-rc.9", - "@girs/xfixes-4.0": "4.0.0-4.0.0-rc.9", - "@girs/xlib-2.0": "2.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/shew-0": { - "version": "0.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/shew-0/-/shew-0-0.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-uxRFPv5JQ7jXZQv0y0xc29fzli3YjWZXQYf6KKGzr41cWbMkrXRRj3w0C8iPpSvTm+11DYozte8bkT2C2t2vTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/gsk-4.0": "4.0.0-4.0.0-rc.9", - "@girs/gtk-4.0": "4.23.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/pangocairo-1.0": "1.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/st-18": { - "version": "18.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/st-18/-/st-18-18.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-ixhU9uzWxFuKWsTVWxxgLFdSg467mSOnpCv4xOCM0+rvEUvJ2okx5uhWB7bM5fkIf1ovVIYPuP0x5v0wmEaZRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/atk-1.0": "2.60.0-4.0.0-rc.9", - "@girs/cairo-1.0": "1.0.0-4.0.0-rc.9", - "@girs/clutter-18": "18.0.0-4.0.0-rc.9", - "@girs/cogl-18": "18.0.0-4.0.0-rc.9", - "@girs/freetype2-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gdesktopenums-3.0": "3.0.0-4.0.0-rc.9", - "@girs/gdkpixbuf-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/gl-1.0": "1.0.0-4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9", - "@girs/graphene-1.0": "1.0.0-4.0.0-rc.9", - "@girs/harfbuzz-0.0": "13.2.1-4.0.0-rc.9", - "@girs/meta-18": "18.0.0-4.0.0-rc.9", - "@girs/mtk-18": "18.0.0-4.0.0-rc.9", - "@girs/pango-1.0": "1.57.1-4.0.0-rc.9", - "@girs/xfixes-4.0": "4.0.0-4.0.0-rc.9", - "@girs/xlib-2.0": "2.0.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/upowerglib-1.0": { - "version": "1.91.1-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/upowerglib-1.0/-/upowerglib-1.0-1.91.1-4.0.0-rc.9.tgz", - "integrity": "sha512-CBH7QfDMZ4eSJLbdobaKgF6qYPRs0AZf0AVPI387FI+xVdv/cfv2v0e34e88ZlDdDYSX4Q+ZrZnnxuO3nfjVkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gio-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gjs": "4.0.0-rc.9", - "@girs/glib-2.0": "2.88.0-4.0.0-rc.9", - "@girs/gmodule-2.0": "2.0.0-4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/xfixes-4.0": { - "version": "4.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/xfixes-4.0/-/xfixes-4.0-4.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-16G3hIkM8ermzg5wqA3Hn4dpB07MFFp3NkNmAaQW6y8isTezk+N5rYA+MXUVwKfFFngSwwY8zEspZs7I+r71aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, - "node_modules/@girs/xlib-2.0": { - "version": "2.0.0-4.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@girs/xlib-2.0/-/xlib-2.0-2.0.0-4.0.0-rc.9.tgz", - "integrity": "sha512-DeZWf8K5YShAVAvwTRMcBv9TXfdTps06kohAFZlc3jj4JFCOGASIQOHhUapLab5d0YBDvRBN/n21M8Jictu6Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@girs/gjs": "4.0.0-rc.9", - "@girs/gobject-2.0": "2.88.0-4.0.0-rc.9" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index 094c821..6c3f411 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,11 @@ "homepage": "https://github.com/slobbe/codex-meter", "sideEffects": false, "scripts": { - "build": "tsc", + "check": "tsc", + "build": "npm run check && rm -rf dist && cp -r src dist && find dist -name '*.d.ts' -delete", "test": "npm run build && node --test test/prediction.test.js test/usage-trend.test.js test/view-model.test.js test/refresh-failure-format.test.js test/codex-provider.test.js test/codex-auth.test.js test/codex-banked-resets.test.js && gjs -m test/history-storage.test.js" }, "devDependencies": { - "@girs/gjs": "^4.0.0-rc.9", - "@girs/gnome-shell": "^50.0.0", "typescript": "^6.0.3" } } diff --git a/src/ambient.d.ts b/src/ambient.d.ts deleted file mode 100644 index a4edd12..0000000 --- a/src/ambient.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import "@girs/gjs"; -import "@girs/gjs/dom"; -import "@girs/gnome-shell/ambient"; -import "@girs/gnome-shell/extensions/global"; - -declare module "gi://Soup?version=3.0" { - const Soup: any; - export default Soup; -} diff --git a/src/app/scheduler.ts b/src/app/scheduler.js similarity index 51% rename from src/app/scheduler.ts rename to src/app/scheduler.js index 5d673d1..5c87215 100644 --- a/src/app/scheduler.ts +++ b/src/app/scheduler.js @@ -1,89 +1,71 @@ import GLib from "gi://GLib"; - export class Scheduler { - private timeoutId: number | null = null; - private running = false; - private started = false; - - constructor( - private intervalSeconds: number, - private readonly task: () => Promise, - ) {} - - start({ runImmediately = false }: { runImmediately?: boolean } = {}): void { + intervalSeconds; + task; + timeoutId = null; + running = false; + started = false; + constructor(intervalSeconds, task) { + this.intervalSeconds = intervalSeconds; + this.task = task; + } + start({ runImmediately = false } = {}) { if (this.started) { return; } - this.started = true; this.schedule(); - if (runImmediately) { void this.run(); } } - - stop(): void { + stop() { this.started = false; this.removeTimeout(); } - - setIntervalSeconds(intervalSeconds: number): void { + setIntervalSeconds(intervalSeconds) { this.intervalSeconds = intervalSeconds; - if (!this.started) { return; } - this.removeTimeout(); this.schedule(); } - - runNow(): void { + runNow() { void this.run(); } - - private schedule(): void { + schedule() { if (this.timeoutId !== null || this.intervalSeconds <= 0) { return; } - - this.timeoutId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, - this.intervalSeconds, - () => { - if (!this.started) { - this.timeoutId = null; - return GLib.SOURCE_REMOVE; - } - - void this.run(); - return GLib.SOURCE_CONTINUE; - }, - ); + this.timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this.intervalSeconds, () => { + if (!this.started) { + this.timeoutId = null; + return GLib.SOURCE_REMOVE; + } + void this.run(); + return GLib.SOURCE_CONTINUE; + }); } - - private removeTimeout(): void { + removeTimeout() { if (this.timeoutId === null) { return; } - GLib.source_remove(this.timeoutId); this.timeoutId = null; } - - private async run(): Promise { + async run() { if (this.running) { return; } - this.running = true; - try { await this.task(); - } catch (err) { + } + catch (err) { console.error("Scheduler task failed", err); - } finally { + } + finally { this.running = false; } } diff --git a/src/app/settings.ts b/src/app/settings.js similarity index 53% rename from src/app/settings.ts rename to src/app/settings.js index 833f5b7..77cc5aa 100644 --- a/src/app/settings.ts +++ b/src/app/settings.js @@ -1,43 +1,19 @@ -import Gio from "gi://Gio"; - -import { type ProviderId } from "../infra/providers/index.js"; - export const SETTINGS_USAGE_PROVIDER = "usage-provider"; export const SETTINGS_SHOW_PRIMARY = "show-primary"; export const SETTINGS_SHOW_SECONDARY = "show-secondary"; export const SETTINGS_TOP_PANEL_DISPLAY_MODE = "top-panel-display-mode"; export const SETTINGS_TOP_PANEL_INDICATOR_ICON = "top-panel-indicator-icon"; export const SETTINGS_PERCENT_DISPLAY_MODE = "percent-display-mode"; -export const SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES = - "background-refresh-interval-minutes"; +export const SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES = "background-refresh-interval-minutes"; export const SETTINGS_AUTO_APPLY_BANKED_RESET = "auto-apply-banked-reset"; - export const MIN_REFRESH_INTERVAL_MINUTES = 0; - - -export type TopPanelDisplayMode = "percentages" | "bars"; -export type TopPanelIndicatorIcon = "text" | "codex" | "openai"; -export type PercentDisplayMode = "used" | "left"; - -export type ExtensionSettings = { - usageProvider: ProviderId; - showPrimary: boolean; - showSecondary: boolean; - topPanelDisplayMode: TopPanelDisplayMode; - topPanelIndicatorIcon: TopPanelIndicatorIcon; - percentDisplayMode: PercentDisplayMode; - backgroundRefreshIntervalMinutes: number; - backgroundRefreshIntervalSeconds: number; - autoApplyBankedReset: boolean; -}; - export class SettingsService { - constructor(private readonly settings: Gio.Settings) {} - - getAll(): ExtensionSettings { - const backgroundRefreshIntervalMinutes = - this.getBackgroundRefreshIntervalMinutes(); - + settings; + constructor(settings) { + this.settings = settings; + } + getAll() { + const backgroundRefreshIntervalMinutes = this.getBackgroundRefreshIntervalMinutes(); return { usageProvider: this.getUsageProvider(), showPrimary: this.getShowPrimary(), @@ -46,91 +22,63 @@ export class SettingsService { topPanelIndicatorIcon: this.getTopPanelIndicatorIcon(), percentDisplayMode: this.getPercentDisplayMode(), backgroundRefreshIntervalMinutes, - backgroundRefreshIntervalSeconds: - backgroundRefreshIntervalMinutes * 60, + backgroundRefreshIntervalSeconds: backgroundRefreshIntervalMinutes * 60, autoApplyBankedReset: this.getAutoApplyBankedReset(), }; } - - getUsageProvider(): ProviderId { + getUsageProvider() { const value = this.settings.get_string(SETTINGS_USAGE_PROVIDER); - if (value === "codex") { return value; } - return "codex"; } - - getShowPrimary(): boolean { + getShowPrimary() { return this.settings.get_boolean(SETTINGS_SHOW_PRIMARY); } - - getShowSecondary(): boolean { + getShowSecondary() { return this.settings.get_boolean(SETTINGS_SHOW_SECONDARY); } - - getTopPanelDisplayMode(): TopPanelDisplayMode { + getTopPanelDisplayMode() { const value = this.settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE); - if (value === "bars") { return value; } - return "percentages"; } - - getTopPanelIndicatorIcon(): TopPanelIndicatorIcon { + getTopPanelIndicatorIcon() { const value = this.settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON); - if (value === "codex" || value === "openai") { return value; } - if (value === "icon") { return "codex"; } - return "text"; } - - getPercentDisplayMode(): PercentDisplayMode { + getPercentDisplayMode() { const value = this.settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE); - if (value === "left") { return value; } - return "used"; } - - getBackgroundRefreshIntervalMinutes(): number { - return Math.max( - MIN_REFRESH_INTERVAL_MINUTES, - this.settings.get_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES), - ); + getBackgroundRefreshIntervalMinutes() { + return Math.max(MIN_REFRESH_INTERVAL_MINUTES, this.settings.get_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES)); } - - getBackgroundRefreshIntervalSeconds(): number { + getBackgroundRefreshIntervalSeconds() { return this.getBackgroundRefreshIntervalMinutes() * 60; } - - getAutoApplyBankedReset(): boolean { + getAutoApplyBankedReset() { return this.settings.get_boolean(SETTINGS_AUTO_APPLY_BANKED_RESET); } - - connectChanged(callback: () => void): number { + connectChanged(callback) { return this.settings.connect("changed", callback); } - - connectBackgroundRefreshIntervalChanged(callback: () => void): number { - return this.settings.connect( - `changed::${SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES}`, - callback, - ); + connectBackgroundRefreshIntervalChanged(callback) { + return this.settings.connect(`changed::${SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES}`, callback); } - - disconnect(signalId: number): void { + disconnect(signalId) { this.settings.disconnect(signalId); } } diff --git a/src/app/usage.ts b/src/app/usage.js similarity index 55% rename from src/app/usage.ts rename to src/app/usage.js index 2620765..5e2512e 100644 --- a/src/app/usage.ts +++ b/src/app/usage.js @@ -1,91 +1,64 @@ -import Gio from "gi://Gio"; - -import { predict, UsagePrediction } from "../domain/prediction.js"; -import { HistoryEntry, UsageSnapshot, toHistoryEntry } from "../domain/usage.js"; -import { getUsageProvider, UsageProvider } from "../infra/providers/index.js"; -import { - appendHistoryRow, - hasSameQuotaValues, - MAX_HISTORY_ENTRIES, - normalizeHistoryEntries, - normalizeHistoryEntry, - readHistory, - rewriteHistory, -} from "../infra/storage/history.js"; +import { predict } from "../domain/prediction.js"; +import { toHistoryEntry } from "../domain/usage.js"; +import { getUsageProvider } from "../infra/providers/index.js"; +import { appendHistoryRow, hasSameQuotaValues, MAX_HISTORY_ENTRIES, normalizeHistoryEntries, normalizeHistoryEntry, readHistory, rewriteHistory, } from "../infra/storage/history.js"; import { readSnapshot, writeSnapshot } from "../infra/storage/snapshot-cache.js"; - -export type RefreshOptions = { - cancellable?: Gio.Cancellable | null; -}; - export class UsageService { - private readonly provider: UsageProvider; - private history: HistoryEntry[] | null = null; - - constructor(provider: UsageProvider = getUsageProvider()) { + provider; + history = null; + constructor(provider = getUsageProvider()) { this.provider = provider; } - - async readCachedSnapshot(): Promise { + async readCachedSnapshot() { return await readSnapshot(this.provider.info.id); } - - async refresh(options: RefreshOptions = {}): Promise { + async refresh(options = {}) { const snapshot = await this.provider.refreshUsage({ cancellable: options.cancellable ?? null, }); - try { await writeSnapshot(this.provider.info.id, snapshot); - } catch (error) { + } + catch (error) { console.error(`Unable to write ${this.provider.info.displayName} usage snapshot cache`, error); } - try { await this.appendSnapshotToHistory(snapshot); - } catch (error) { + } + catch (error) { console.error(`Unable to append ${this.provider.info.displayName} usage history`, error); } - return snapshot; } - - async readHistory(): Promise { + async readHistory() { return [...await this.loadHistory()]; } - - private async loadHistory(): Promise { - if (this.history) return this.history; - + async loadHistory() { + if (this.history) + return this.history; this.history = await readHistory(this.provider.info.id); return this.history; } - - private async appendSnapshotToHistory(snapshot: UsageSnapshot): Promise { + async appendSnapshotToHistory(snapshot) { const history = await this.loadHistory(); const row = normalizeHistoryEntry(toHistoryEntry(snapshot)); - - if (!row) return; - + if (!row) + return; const lastRow = history.at(-1); - if (lastRow && hasSameQuotaValues(lastRow, row)) return; - + if (lastRow && hasSameQuotaValues(lastRow, row)) + return; await appendHistoryRow(this.provider.info.id, row); history.push(row); - if (history.length > MAX_HISTORY_ENTRIES) { this.history = normalizeHistoryEntries(history); await rewriteHistory(this.provider.info.id, this.history); } } - - async predict(snapshot?: UsageSnapshot): Promise { + async predict(snapshot) { const currentSnapshot = snapshot ?? await readSnapshot(this.provider.info.id); - if (!currentSnapshot) { throw new Error("No snapshot available"); } - const history = await this.readHistory(); return predict(history, currentSnapshot); } diff --git a/src/domain/prediction.ts b/src/domain/prediction.js similarity index 58% rename from src/domain/prediction.ts rename to src/domain/prediction.js index 820145f..836072c 100644 --- a/src/domain/prediction.ts +++ b/src/domain/prediction.js @@ -1,42 +1,43 @@ -import { UsageSnapshot, HistoryEntry, UsageQuota } from "./usage.js"; - -type Trend = "safe" | "unsafe" | "limit reached" | "unknown"; - -export type WindowPrediction = { - estimatedLimitAt: number | null; - trend: Trend; -}; - -export type UsagePrediction = { - quotas: Record; - primary: WindowPrediction; - secondary: WindowPrediction; -}; - -type HistoryEntrySlice = { - timestamp: number; // UNIX in seconds - usedPercent: number; -}; - -export function createUnknownUsagePrediction(snapshot?: UsageSnapshot | null): UsagePrediction { - const quotas: Record = {}; +// @ts-check + +/** @typedef {import("./types.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("./types.js").UsageQuota} UsageQuota */ +/** @typedef {import("./types.js").HistoryEntry} HistoryEntry */ +/** @typedef {import("./types.js").WindowPrediction} WindowPrediction */ +/** @typedef {import("./types.js").UsagePrediction} UsagePrediction */ +/** @typedef {UsageQuota & {limitWindowSeconds: number, resetAfterSeconds: number, resetAt: number}} PredictionReadyQuota */ +/** @typedef {{timestamp: number, usedPercent: number}} HistoryEntrySlice */ + +/** + * @param {UsageSnapshot | null} [snapshot] + * @returns {UsagePrediction} + */ +export function createUnknownUsagePrediction(snapshot) { + /** @type {Record} */ + const quotas = {}; for (const quota of snapshot?.quotas ?? []) { quotas[quota.id] = unknownPrediction(); } + const primaryId = snapshot?.quotas[0]?.id; + const secondaryId = snapshot?.quotas[1]?.id; + return { quotas, - primary: quotas[snapshot?.quotas[0]?.id] ?? unknownPrediction(), - secondary: quotas[snapshot?.quotas[1]?.id] ?? unknownPrediction(), + primary: primaryId ? quotas[primaryId] ?? unknownPrediction() : unknownPrediction(), + secondary: secondaryId ? quotas[secondaryId] ?? unknownPrediction() : unknownPrediction(), }; } -export function predict( - history: HistoryEntry[], - snapshot: UsageSnapshot, -): UsagePrediction { - const quotas: Record = {}; +/** + * @param {HistoryEntry[]} history + * @param {UsageSnapshot} snapshot + * @returns {UsagePrediction} + */ +export function predict(history, snapshot) { + /** @type {Record} */ + const quotas = {}; for (const quota of snapshot.quotas) { quotas[quota.id] = predictQuota(history, snapshot, quota); @@ -49,11 +50,13 @@ export function predict( }; } -function predictQuota( - history: HistoryEntry[], - snapshot: UsageSnapshot, - quota: UsageQuota, -): WindowPrediction { +/** + * @param {HistoryEntry[]} history + * @param {UsageSnapshot} snapshot + * @param {UsageQuota} quota + * @returns {WindowPrediction} + */ +function predictQuota(history, snapshot, quota) { if (!hasPredictionMetadata(quota)) { return unknownPrediction(); } @@ -80,7 +83,7 @@ function predictQuota( .filter((entry) => Number.isFinite(entry.usedPercent)) .map((entry) => ({ timestamp: entry.timestamp, - usedPercent: entry.usedPercent as number, + usedPercent: Number(entry.usedPercent), })) .toSorted((a, b) => b.timestamp - a.timestamp) .filter((entry) => entry.timestamp >= startedAt); @@ -88,35 +91,39 @@ function predictQuota( return predictWindow(quotaHistory, startedAt, quota.resetAt); } -function hasPredictionMetadata(quota: UsageQuota): quota is UsageQuota & { - limitWindowSeconds: number; - resetAfterSeconds: number; - resetAt: number; -} { +/** + * @param {UsageQuota} quota + * @returns {quota is PredictionReadyQuota} + */ +function hasPredictionMetadata(quota) { return Number.isFinite(quota.limitWindowSeconds) && Number.isFinite(quota.resetAfterSeconds) && Number.isFinite(quota.resetAt) && (quota.limitWindowSeconds ?? 0) > 0; } -function unknownPrediction(): WindowPrediction { +/** @returns {WindowPrediction} */ +function unknownPrediction() { return { estimatedLimitAt: null, trend: "unknown", }; } -function predictWindow( - windowHistory: HistoryEntrySlice[], - windowStartedAt: number, - resetAt: number, -): WindowPrediction { +/** + * @param {HistoryEntrySlice[]} windowHistory + * @param {number} windowStartedAt + * @param {number} resetAt + * @returns {WindowPrediction} + */ +function predictWindow(windowHistory, windowStartedAt, resetAt) { if (windowHistory.length < 1) { return { estimatedLimitAt: null, trend: "unknown", }; } + const history = windowHistory.toSorted((a, b) => a.timestamp - b.timestamp); if (history[0].usedPercent > 0 && history[0].timestamp > windowStartedAt) { @@ -151,10 +158,9 @@ function predictWindow( } const fit = calculateFit( - history.map((h) => h.timestamp), - history.map((h) => h.usedPercent), + history.map((entry) => entry.timestamp), + history.map((entry) => entry.usedPercent), ); - const limitAt = fit(100); if (!Number.isFinite(limitAt)) { @@ -170,10 +176,14 @@ function predictWindow( }; } -function calculateFit(x: number[], y: number[]) { +/** + * @param {number[]} x + * @param {number[]} y + * @returns {(value: number) => number} + */ +function calculateFit(x, y) { const n = x.length; - const secondsPerPercent = (x[n - 1] - x[0]) / (y[n - 1] - y[0]); - return (t: number) => x[n - 1] + (t - y[n - 1]) * secondsPerPercent; + return (value) => x[n - 1] + (value - y[n - 1]) * secondsPerPercent; } diff --git a/src/domain/refresh-failure.js b/src/domain/refresh-failure.js new file mode 100644 index 0000000..b8e244a --- /dev/null +++ b/src/domain/refresh-failure.js @@ -0,0 +1,13 @@ +export class RefreshFailureError extends Error { + kind; + technicalMessage; + constructor(kind, message, technicalMessage = message) { + super(message); + this.name = "RefreshFailureError"; + this.kind = kind; + this.technicalMessage = technicalMessage; + } +} +export function isRefreshFailureError(error) { + return error instanceof RefreshFailureError; +} diff --git a/src/domain/refresh-failure.ts b/src/domain/refresh-failure.ts deleted file mode 100644 index 33f4463..0000000 --- a/src/domain/refresh-failure.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type RefreshFailureKind = - | "missing-auth" - | "unauthorized" - | "network" - | "malformed-response" - | "unexpected-response"; - -export class RefreshFailureError extends Error { - kind: RefreshFailureKind; - technicalMessage: string; - - constructor(kind: RefreshFailureKind, message: string, technicalMessage = message) { - super(message); - this.name = "RefreshFailureError"; - this.kind = kind; - this.technicalMessage = technicalMessage; - } -} - -export function isRefreshFailureError(error: unknown): error is RefreshFailureError { - return error instanceof RefreshFailureError; -} diff --git a/src/domain/types.js b/src/domain/types.js new file mode 100644 index 0000000..fee4851 --- /dev/null +++ b/src/domain/types.js @@ -0,0 +1,56 @@ +/** + * Shared domain types for checked JavaScript modules. + * This module intentionally has no runtime behavior. + * + * @typedef {object} UsageSnapshot + * @property {number} fetchedAt UNIX timestamp in seconds + * @property {string} [providerId] + * @property {string} planType + * @property {UsageCredits} [credits] + * @property {UsageQuota[]} quotas + * + * @typedef {object} UsageCredits + * @property {string | null} balance + * @property {boolean} [hasCredits] + * @property {boolean} [unlimited] + * @property {boolean} [overageLimitReached] + * + * @typedef {object} UsageQuota + * @property {string} id + * @property {string} label + * @property {number} usedPercent + * @property {number | null} [used] + * @property {number | null} [limit] + * @property {number | null} [remaining] + * @property {number | null} [limitWindowSeconds] + * @property {number | null} [resetAfterSeconds] + * @property {number | null} [resetAt] UNIX timestamp in seconds + * @property {boolean} [limitReached] + * @property {string | null} [resetDescription] + * + * @typedef {object} HistoryEntry + * @property {string} timestamp ISO timestamp + * @property {HistoryQuotaEntry[]} quotas + * + * @typedef {object} HistoryQuotaEntry + * @property {string} id + * @property {number} usedPercent + * @property {number | null} [used] + * @property {number | null} [limit] + * @property {number | null} [remaining] + * @property {number | null} [resetAt] + * @property {boolean} [limitReached] + * + * @typedef {"safe" | "unsafe" | "limit reached" | "unknown"} Trend + * + * @typedef {object} WindowPrediction + * @property {number | null} estimatedLimitAt + * @property {Trend} trend + * + * @typedef {object} UsagePrediction + * @property {Record} quotas + * @property {WindowPrediction} primary + * @property {WindowPrediction} secondary + */ + +export {}; diff --git a/src/domain/usage.js b/src/domain/usage.js new file mode 100644 index 0000000..0078f2a --- /dev/null +++ b/src/domain/usage.js @@ -0,0 +1,49 @@ +// @ts-check + +/** @typedef {import("./types.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("./types.js").UsageQuota} UsageQuota */ +/** @typedef {import("./types.js").HistoryEntry} HistoryEntry */ + +/** + * @param {UsageSnapshot} snapshot + * @returns {HistoryEntry} + */ +export function toHistoryEntry(snapshot) { + return { + timestamp: new Date(snapshot.fetchedAt * 1000).toISOString(), + quotas: snapshot.quotas.map((quota) => ({ + id: quota.id, + usedPercent: quota.usedPercent, + used: quota.used, + limit: quota.limit, + remaining: quota.remaining, + resetAt: quota.resetAt, + limitReached: quota.limitReached, + })), + }; +} + +/** + * @param {UsageSnapshot | null | undefined} snapshot + * @param {string} quotaId + * @returns {UsageQuota | null} + */ +export function getQuota(snapshot, quotaId) { + return snapshot?.quotas.find((quota) => quota.id === quotaId) ?? null; +} + +/** + * @param {UsageSnapshot | null | undefined} snapshot + * @returns {UsageQuota | null} + */ +export function getPrimaryQuota(snapshot) { + return snapshot?.quotas[0] ?? null; +} + +/** + * @param {UsageSnapshot | null | undefined} snapshot + * @returns {UsageQuota | null} + */ +export function getSecondaryQuota(snapshot) { + return snapshot?.quotas[1] ?? null; +} diff --git a/src/domain/usage.ts b/src/domain/usage.ts deleted file mode 100644 index f34fd70..0000000 --- a/src/domain/usage.ts +++ /dev/null @@ -1,70 +0,0 @@ -export type UsageSnapshot = { - fetchedAt: number; // UNIX in seconds - providerId?: string; - planType: "free" | "plus" | "pro" | string; - credits?: UsageCredits; - quotas: UsageQuota[]; -}; - -export type UsageCredits = { - balance: string | null; - hasCredits?: boolean; - unlimited?: boolean; - overageLimitReached?: boolean; -}; - -export type UsageQuota = { - id: string; - label: string; - usedPercent: number; - used?: number | null; - limit?: number | null; - remaining?: number | null; - limitWindowSeconds?: number | null; - resetAfterSeconds?: number | null; - resetAt?: number | null; // UNIX in seconds - limitReached?: boolean; - resetDescription?: string | null; -}; - -export type HistoryEntry = { - timestamp: string; // ISO format - quotas: HistoryQuotaEntry[]; -}; - -export type HistoryQuotaEntry = { - id: string; - usedPercent: number; - used?: number | null; - limit?: number | null; - remaining?: number | null; - resetAt?: number | null; - limitReached?: boolean; -}; - -export function toHistoryEntry(snapshot: UsageSnapshot): HistoryEntry { - return { - timestamp: new Date(snapshot.fetchedAt * 1000).toISOString(), - quotas: snapshot.quotas.map((quota) => ({ - id: quota.id, - usedPercent: quota.usedPercent, - used: quota.used, - limit: quota.limit, - remaining: quota.remaining, - resetAt: quota.resetAt, - limitReached: quota.limitReached, - })), - }; -} - -export function getQuota(snapshot: UsageSnapshot | null | undefined, quotaId: string): UsageQuota | null { - return snapshot?.quotas.find((quota) => quota.id === quotaId) ?? null; -} - -export function getPrimaryQuota(snapshot: UsageSnapshot | null | undefined): UsageQuota | null { - return snapshot?.quotas[0] ?? null; -} - -export function getSecondaryQuota(snapshot: UsageSnapshot | null | undefined): UsageQuota | null { - return snapshot?.quotas[1] ?? null; -} diff --git a/src/extension.ts b/src/extension.js similarity index 95% rename from src/extension.ts rename to src/extension.js index c637f7b..4155fbf 100644 --- a/src/extension.ts +++ b/src/extension.js @@ -17,18 +17,14 @@ */ import * as Main from "resource:///org/gnome/shell/ui/main.js"; import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; - import { CodexMeterIndicator } from "./ui/panel-indicator.js"; - export default class CodexMeterExtension extends Extension { - _indicator: CodexMeterIndicator | null = null; - + _indicator = null; enable() { this._indicator = new CodexMeterIndicator(this); Main.panel.addToStatusArea(this.uuid, this._indicator); this._indicator.start(); } - disable() { this._indicator?.destroy(); this._indicator = null; diff --git a/src/infra/api_client.js b/src/infra/api_client.js new file mode 100644 index 0000000..69520ef --- /dev/null +++ b/src/infra/api_client.js @@ -0,0 +1,93 @@ +import Gio from "gi://Gio"; +import GLib from "gi://GLib"; +import { RefreshFailureError } from "../domain/refresh-failure.js"; +// The GIRS package set used by this project does not ship Soup typings. +// @ts-ignore +import Soup from "gi://Soup?version=3.0"; +const DEFAULT_TIMEOUT_SECONDS = 15; +const MAX_ERROR_BODY_LENGTH = 512; +const MAX_RESPONSE_BYTES = 1024 * 1024; +Gio._promisify(Soup.Session.prototype, "send_and_read_async"); +function formatErrorBody(text) { + const normalized = text.trim().replace(/\s+/g, " "); + if (normalized.length <= MAX_ERROR_BODY_LENGTH) { + return normalized; + } + return `${normalized.slice(0, MAX_ERROR_BODY_LENGTH)}...`; +} +function parseApiResponse(text, url, config, allowArrayResponse = false) { + let parsed; + try { + parsed = JSON.parse(text); + } + catch (error) { + const message = error instanceof Error ? error.message : "Unknown JSON parse error"; + throw new RefreshFailureError("malformed-response", config.messages.malformedResponse, `Failed to parse JSON response from ${url}: ${message}`); + } + const isObject = typeof parsed === "object" && parsed !== null; + const isArray = Array.isArray(parsed); + if (!isObject || (isArray && !allowArrayResponse)) { + throw new RefreshFailureError("malformed-response", config.messages.unexpectedResponseFormat, `Expected JSON ${allowArrayResponse ? "object or array" : "object"} response from ${url}`); + } + return parsed; +} +export async function fetchJson(url, config, options = {}) { + const session = new Soup.Session(); + const message = Soup.Message.new(options.method ?? "GET", url); + const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; + session.timeout = timeoutSeconds; + const headers = { + Accept: "application/json", + ...options.headers, + }; + for (const [key, value] of Object.entries(headers)) { + message.request_headers.append(key, value); + } + if (options.body !== undefined && options.body !== null) { + const contentType = options.bodyContentType ?? "application/json"; + const bytes = new GLib.Bytes(new TextEncoder().encode(options.body)); + // The GIRS package set currently lacks this Soup binding in typings. + // @ts-ignore + message.set_request_body_from_bytes(contentType, bytes); + } + let bytes; + try { + bytes = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, options.cancellable ?? null); + } + catch (error) { + if (isCancellationError(error)) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + throw new RefreshFailureError("network", config.messages.networkUnavailable, `Request to ${url} failed before receiving a response: ${message}`); + } + if (bytes.get_size() > MAX_RESPONSE_BYTES) { + throw new RefreshFailureError("malformed-response", config.messages.responseTooLarge, `Response from ${url} exceeded ${MAX_RESPONSE_BYTES} bytes`); + } + const data = bytes.get_data(); + const text = data ? new TextDecoder("utf-8").decode(data) : ""; + if (message.statusCode < 200 || message.statusCode >= 300) { + const errorBody = formatErrorBody(text); + const technicalMessage = `Request to ${url} failed with HTTP ${message.statusCode}: ${errorBody}`; + if (message.statusCode === 401 || message.statusCode === 403) { + throw new RefreshFailureError("unauthorized", config.messages.unauthorized, technicalMessage); + } + throw new RefreshFailureError("network", config.messages.refreshFailed, technicalMessage); + } + if (!data) { + throw new RefreshFailureError("malformed-response", config.messages.emptyResponse, `Received empty response body from ${url}`); + } + return parseApiResponse(text, url, config, options.allowArrayResponse ?? false); +} +export async function fetchProviderUsage(accessToken, config, options = {}, url = config.usageUrl) { + return await fetchJson(url, config, { + ...options, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); +} +function isCancellationError(error) { + return error instanceof GLib.Error && + error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); +} diff --git a/src/infra/api_client.ts b/src/infra/api_client.ts deleted file mode 100644 index 021c18b..0000000 --- a/src/infra/api_client.ts +++ /dev/null @@ -1,202 +0,0 @@ -import Gio from "gi://Gio"; -import GLib from "gi://GLib"; - -import { RefreshFailureError } from "../domain/refresh-failure.js"; -// The GIRS package set used by this project does not ship Soup typings. -// @ts-ignore -import Soup from "gi://Soup?version=3.0"; - -const DEFAULT_TIMEOUT_SECONDS = 15; -const MAX_ERROR_BODY_LENGTH = 512; -const MAX_RESPONSE_BYTES = 1024 * 1024; - -Gio._promisify(Soup.Session.prototype, "send_and_read_async"); - -export type JsonObject = Record; -export type JsonValue = JsonObject | unknown[]; - -export type UsageApiClientMessages = { - malformedResponse: string; - unexpectedResponseFormat: string; - networkUnavailable: string; - responseTooLarge: string; - unauthorized: string; - refreshFailed: string; - emptyResponse: string; -}; - -export type UsageApiClientConfig = { - providerName: string; - usageUrl: string; - messages: UsageApiClientMessages; -}; - -export type FetchUsageOptions = { - cancellable?: Gio.Cancellable | null; - timeoutSeconds?: number; -}; - -export type FetchJsonOptions = FetchUsageOptions & { - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - headers?: Record; - body?: string | null; - bodyContentType?: string; - allowArrayResponse?: boolean; -}; - -function formatErrorBody(text: string): string { - const normalized = text.trim().replace(/\s+/g, " "); - - if (normalized.length <= MAX_ERROR_BODY_LENGTH) { - return normalized; - } - - return `${normalized.slice(0, MAX_ERROR_BODY_LENGTH)}...`; -} - -function parseApiResponse( - text: string, - url: string, - config: UsageApiClientConfig, - allowArrayResponse = false, -): JsonValue { - let parsed: unknown; - - try { - parsed = JSON.parse(text); - } catch (error) { - const message = - error instanceof Error ? error.message : "Unknown JSON parse error"; - - throw new RefreshFailureError( - "malformed-response", - config.messages.malformedResponse, - `Failed to parse JSON response from ${url}: ${message}`, - ); - } - - const isObject = typeof parsed === "object" && parsed !== null; - const isArray = Array.isArray(parsed); - - if (!isObject || (isArray && !allowArrayResponse)) { - throw new RefreshFailureError( - "malformed-response", - config.messages.unexpectedResponseFormat, - `Expected JSON ${allowArrayResponse ? "object or array" : "object"} response from ${url}`, - ); - } - - return parsed as JsonValue; -} - -export async function fetchJson( - url: string, - config: UsageApiClientConfig, - options: FetchJsonOptions = {}, -): Promise { - const session = new Soup.Session(); - const message = Soup.Message.new(options.method ?? "GET", url); - const timeoutSeconds = options.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS; - - session.timeout = timeoutSeconds; - - const headers = { - Accept: "application/json", - ...options.headers, - }; - - for (const [key, value] of Object.entries(headers)) { - message.request_headers.append(key, value); - } - - if (options.body !== undefined && options.body !== null) { - const contentType = options.bodyContentType ?? "application/json"; - const bytes = new GLib.Bytes(new TextEncoder().encode(options.body)); - // The GIRS package set currently lacks this Soup binding in typings. - // @ts-ignore - message.set_request_body_from_bytes(contentType, bytes); - } - - let bytes; - - try { - bytes = await session.send_and_read_async( - message, - GLib.PRIORITY_DEFAULT, - options.cancellable ?? null, - ); - } catch (error) { - if (isCancellationError(error)) { - throw error; - } - - const message = - error instanceof Error ? error.message : String(error); - - throw new RefreshFailureError( - "network", - config.messages.networkUnavailable, - `Request to ${url} failed before receiving a response: ${message}`, - ); - } - - if (bytes.get_size() > MAX_RESPONSE_BYTES) { - throw new RefreshFailureError( - "malformed-response", - config.messages.responseTooLarge, - `Response from ${url} exceeded ${MAX_RESPONSE_BYTES} bytes`, - ); - } - - const data = bytes.get_data(); - const text = data ? new TextDecoder("utf-8").decode(data) : ""; - - if (message.statusCode < 200 || message.statusCode >= 300) { - const errorBody = formatErrorBody(text); - const technicalMessage = - `Request to ${url} failed with HTTP ${message.statusCode}: ${errorBody}`; - - if (message.statusCode === 401 || message.statusCode === 403) { - throw new RefreshFailureError( - "unauthorized", - config.messages.unauthorized, - technicalMessage, - ); - } - - throw new RefreshFailureError( - "network", - config.messages.refreshFailed, - technicalMessage, - ); - } - - if (!data) { - throw new RefreshFailureError( - "malformed-response", - config.messages.emptyResponse, - `Received empty response body from ${url}`, - ); - } - - return parseApiResponse(text, url, config, options.allowArrayResponse ?? false); -} - -export async function fetchProviderUsage( - accessToken: string, - config: UsageApiClientConfig, - options: FetchUsageOptions = {}, - url: string = config.usageUrl, -): Promise { - return await fetchJson(url, config, { - ...options, - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) as JsonObject; -} - -function isCancellationError(error: unknown): boolean { - return error instanceof GLib.Error && - error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); -} diff --git a/src/infra/config.ts b/src/infra/config.js similarity index 99% rename from src/infra/config.ts rename to src/infra/config.js index a591123..0bbcd5f 100644 --- a/src/infra/config.ts +++ b/src/infra/config.js @@ -1,5 +1,4 @@ import GLib from "gi://GLib"; - export const CONFIG_DIR = GLib.build_filenamev([ GLib.get_user_config_dir(), "codex-meter", diff --git a/src/infra/filesystem.ts b/src/infra/filesystem.js similarity index 50% rename from src/infra/filesystem.ts rename to src/infra/filesystem.js index 435a7e9..265bcf9 100644 --- a/src/infra/filesystem.ts +++ b/src/infra/filesystem.js @@ -1,103 +1,73 @@ import Gio from "gi://Gio"; import GLib from "gi://GLib"; - Gio._promisify(Gio.File.prototype, "load_contents_async"); Gio._promisify(Gio.File.prototype, "replace_contents_async"); Gio._promisify(Gio.File.prototype, "append_to_async"); Gio._promisify(Gio.OutputStream.prototype, "write_bytes_async"); Gio._promisify(Gio.OutputStream.prototype, "close_async"); - const PRIVATE_DIR_MODE = 0o700; const PRIVATE_FILE_MODE = 0o600; - -function ensureParentDir(path: string) { +function ensureParentDir(path) { const dir = GLib.path_get_dirname(path); GLib.mkdir_with_parents(dir, PRIVATE_DIR_MODE); GLib.chmod(dir, PRIVATE_DIR_MODE); } - -export async function readFile(path: string): Promise { +export async function readFile(path) { try { const file = Gio.File.new_for_path(path); const [contents] = await file.load_contents_async(null); - return new TextDecoder("utf-8").decode(contents); - } catch (error) { - throw new Error( - `Failed to read file "${path}": ${error instanceof Error ? error.message : String(error)}`, - ); + } + catch (error) { + throw new Error(`Failed to read file "${path}": ${error instanceof Error ? error.message : String(error)}`); } } - -export async function writeFile(path: string, text: string): Promise { +export async function writeFile(path, text) { try { ensureParentDir(path); - const file = Gio.File.new_for_path(path); const bytes = new TextEncoder().encode(text); - - await file.replace_contents_async( - bytes, - null, - false, - Gio.FileCreateFlags.REPLACE_DESTINATION, - null, - ); + await file.replace_contents_async(bytes, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); GLib.chmod(path, PRIVATE_FILE_MODE); - } catch (error) { - throw new Error( - `Failed to write file "${path}": ${error instanceof Error ? error.message : String(error)}`, - ); + } + catch (error) { + throw new Error(`Failed to write file "${path}": ${error instanceof Error ? error.message : String(error)}`); } } - -export async function appendFile(path: string, line: string): Promise { +export async function appendFile(path, line) { try { ensureParentDir(path); - const file = Gio.File.new_for_path(path); const text = line.endsWith("\n") ? line : `${line}\n`; const bytes = new GLib.Bytes(new TextEncoder().encode(text)); - - const stream = await file.append_to_async( - Gio.FileCreateFlags.NONE, - GLib.PRIORITY_DEFAULT, - null, - ); + const stream = await file.append_to_async(Gio.FileCreateFlags.NONE, GLib.PRIORITY_DEFAULT, null); await stream.write_bytes_async(bytes, GLib.PRIORITY_DEFAULT, null); await stream.close_async(GLib.PRIORITY_DEFAULT, null); GLib.chmod(path, PRIVATE_FILE_MODE); - } catch (error) { - throw new Error( - `Failed to append file "${path}": ${error instanceof Error ? error.message : String(error)}`, - ); + } + catch (error) { + throw new Error(`Failed to append file "${path}": ${error instanceof Error ? error.message : String(error)}`); } } - // JSON -export async function readJsonFile(path: string): Promise { +export async function readJsonFile(path) { try { const raw = await readFile(path); - return JSON.parse(raw) as T; - } catch (error) { - throw new Error( - `Failed to read JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`, - ); + return JSON.parse(raw); + } + catch (error) { + throw new Error(`Failed to read JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`); } } - -export async function writeJsonFile(path: string, data: unknown): Promise { +export async function writeJsonFile(path, data) { try { const json = JSON.stringify(data, null, 2); - if (json === undefined) { throw new Error("JSON.stringify returned undefined"); } - await writeFile(path, `${json}\n`); - } catch (error) { - throw new Error( - `Failed to write JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`, - ); + } + catch (error) { + throw new Error(`Failed to write JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`); } } diff --git a/src/infra/providers/auth.js b/src/infra/providers/auth.js new file mode 100644 index 0000000..8a2a4d2 --- /dev/null +++ b/src/infra/providers/auth.js @@ -0,0 +1,50 @@ +import GLib from "gi://GLib"; +import { RefreshFailureError } from "../../domain/refresh-failure.js"; +import { readJsonFile } from "../filesystem.js"; +export async function getLocalAccessToken(config) { + if (!isNonEmptyString(config.authPath)) { + throw new RefreshFailureError("missing-auth", `${config.providerName} auth is unavailable. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth path must be a non-empty string`); + } + const auth = await readLocalAuth(config.authPath, config); + const token = config.parseAccessToken(auth); + if (!isUsableAccessToken(token)) { + throw new RefreshFailureError("missing-auth", `${config.providerName} access token is missing. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth data does not contain a valid access token`); + } + return token.trim(); +} +export async function readLocalAuth(path, config) { + if (!GLib.file_test(path, GLib.FileTest.EXISTS)) { + throw new RefreshFailureError("missing-auth", `${config.providerName} auth file is missing. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth file does not exist at "${path}"`); + } + try { + const raw = await readJsonFile(path); + if (!isObject(raw)) { + throw new RefreshFailureError("missing-auth", `${config.providerName} auth file is malformed. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth file at "${path}" does not contain a valid JSON object`); + } + return raw; + } + catch (err) { + if (err instanceof RefreshFailureError) { + throw err; + } + throw new RefreshFailureError("missing-auth", `${config.providerName} auth file could not be read. Run \`${config.loginCommand}\` and try again.`, `Failed to read ${config.providerName} auth file at "${path}": ${formatError(err)}`); + } +} +function isObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function isNonEmptyString(value) { + return typeof value === "string" && value.trim().length > 0; +} +function isUsableAccessToken(value) { + return isNonEmptyString(value) && !/\s/.test(value.trim()); +} +function formatError(err) { + if (err instanceof Error && err.message) { + return err.message; + } + if (typeof err === "string" && err.length > 0) { + return err; + } + return "Unknown error"; +} diff --git a/src/infra/providers/auth.ts b/src/infra/providers/auth.ts deleted file mode 100644 index f1fb0fa..0000000 --- a/src/infra/providers/auth.ts +++ /dev/null @@ -1,97 +0,0 @@ -import GLib from "gi://GLib"; - -import { RefreshFailureError } from "../../domain/refresh-failure.js"; -import { readJsonFile } from "../filesystem.js"; - -export type LocalTokenAuthConfig = { - providerName: string; - authPath: string; - loginCommand: string; - parseAccessToken(auth: TAuth): string | null; -}; - -export async function getLocalAccessToken( - config: LocalTokenAuthConfig, -): Promise { - if (!isNonEmptyString(config.authPath)) { - throw new RefreshFailureError( - "missing-auth", - `${config.providerName} auth is unavailable. Run \`${config.loginCommand}\` and try again.`, - `${config.providerName} auth path must be a non-empty string`, - ); - } - - const auth = await readLocalAuth(config.authPath, config); - const token = config.parseAccessToken(auth); - - if (!isUsableAccessToken(token)) { - throw new RefreshFailureError( - "missing-auth", - `${config.providerName} access token is missing. Run \`${config.loginCommand}\` and try again.`, - `${config.providerName} auth data does not contain a valid access token`, - ); - } - - return token.trim(); -} - -export async function readLocalAuth( - path: string, - config: LocalTokenAuthConfig, -): Promise { - if (!GLib.file_test(path, GLib.FileTest.EXISTS)) { - throw new RefreshFailureError( - "missing-auth", - `${config.providerName} auth file is missing. Run \`${config.loginCommand}\` and try again.`, - `${config.providerName} auth file does not exist at "${path}"`, - ); - } - - try { - const raw = await readJsonFile(path); - - if (!isObject(raw)) { - throw new RefreshFailureError( - "missing-auth", - `${config.providerName} auth file is malformed. Run \`${config.loginCommand}\` and try again.`, - `${config.providerName} auth file at "${path}" does not contain a valid JSON object`, - ); - } - - return raw as TAuth; - } catch (err) { - if (err instanceof RefreshFailureError) { - throw err; - } - - throw new RefreshFailureError( - "missing-auth", - `${config.providerName} auth file could not be read. Run \`${config.loginCommand}\` and try again.`, - `Failed to read ${config.providerName} auth file at "${path}": ${formatError(err)}`, - ); - } -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isNonEmptyString(value: unknown): value is string { - return typeof value === "string" && value.trim().length > 0; -} - -function isUsableAccessToken(value: unknown): value is string { - return isNonEmptyString(value) && !/\s/.test(value.trim()); -} - -function formatError(err: unknown): string { - if (err instanceof Error && err.message) { - return err.message; - } - - if (typeof err === "string" && err.length > 0) { - return err; - } - - return "Unknown error"; -} diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.js similarity index 72% rename from src/infra/providers/codex.ts rename to src/infra/providers/codex.js index c29bcd6..e0fd4d0 100644 --- a/src/infra/providers/codex.ts +++ b/src/infra/providers/codex.js @@ -1,12 +1,8 @@ -import { fetchProviderUsage, UsageApiClientConfig } from "../api_client.js"; +import { fetchProviderUsage } from "../api_client.js"; import { getCodexAccessToken } from "./codex_auth.js"; import { toUsageSnapshot } from "./codex_usage_response.js"; -import type { UsageProvider, UsageProviderRefreshOptions } from "./types.js"; - const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; - - -const CODEX_API_CONFIG: UsageApiClientConfig = { +const CODEX_API_CONFIG = { providerName: "Codex", usageUrl: CODEX_USAGE_URL, messages: { @@ -19,17 +15,13 @@ const CODEX_API_CONFIG: UsageApiClientConfig = { emptyResponse: "Codex returned an empty response.", }, }; - -export class CodexUsageProvider implements UsageProvider { - readonly info = { id: "codex", displayName: "Codex" } as const; - - async refreshUsage(options: UsageProviderRefreshOptions = {}) { +export class CodexUsageProvider { + info = { id: "codex", displayName: "Codex" }; + async refreshUsage(options = {}) { const token = await getCodexAccessToken(); const apiResponse = await fetchProviderUsage(token, CODEX_API_CONFIG, { cancellable: options.cancellable ?? null, }); - return toUsageSnapshot(apiResponse); } } - diff --git a/src/infra/providers/codex_auth.ts b/src/infra/providers/codex_auth.js similarity index 61% rename from src/infra/providers/codex_auth.ts rename to src/infra/providers/codex_auth.js index df09ade..e36033f 100644 --- a/src/infra/providers/codex_auth.ts +++ b/src/infra/providers/codex_auth.js @@ -1,52 +1,28 @@ import GLib from "gi://GLib"; - -import { - getLocalAccessToken, - LocalTokenAuthConfig, - readLocalAuth, -} from "./auth.js"; -import { - CodexAuth, - parseCodexAccessToken, - parseCodexAccountId, -} from "./codex_auth_parser.js"; - -export type { CodexAuth } from "./codex_auth_parser.js"; +import { getLocalAccessToken, readLocalAuth, } from "./auth.js"; +import { parseCodexAccessToken, parseCodexAccountId, } from "./codex_auth_parser.js"; export { parseCodexAccessToken, parseCodexAccountId } from "./codex_auth_parser.js"; - -export type CodexCredentials = { - accessToken: string; - accountId: string | null; -}; - export function getCodexAuthPath() { return GLib.build_filenamev([GLib.get_home_dir(), ".codex", "auth.json"]); } - - - -export const CODEX_AUTH_CONFIG: LocalTokenAuthConfig = { +export const CODEX_AUTH_CONFIG = { providerName: "Codex", authPath: getCodexAuthPath(), loginCommand: "codex login", parseAccessToken: parseCodexAccessToken, }; - -export async function getCodexAccessToken(): Promise { +export async function getCodexAccessToken() { return await getLocalAccessToken(CODEX_AUTH_CONFIG); } - -export async function getCodexCredentials(): Promise { +export async function getCodexCredentials() { const auth = await readLocalAuth(CODEX_AUTH_CONFIG.authPath, CODEX_AUTH_CONFIG); const accessToken = parseCodexAccessToken(auth); - if (!accessToken || accessToken.trim().length === 0 || /\s/.test(accessToken.trim())) { return { accessToken: await getCodexAccessToken(), accountId: parseCodexAccountId(auth), }; } - return { accessToken: accessToken.trim(), accountId: parseCodexAccountId(auth), diff --git a/src/infra/providers/codex_auth_parser.js b/src/infra/providers/codex_auth_parser.js new file mode 100644 index 0000000..481319d --- /dev/null +++ b/src/infra/providers/codex_auth_parser.js @@ -0,0 +1,29 @@ +// @ts-check + +/** @typedef {{tokens?: {access_token?: string | null, account_id?: string | null}}} CodexAuth */ + +/** + * @param {CodexAuth} auth + * @returns {string | null} + */ +export function parseCodexAccessToken(auth) { + if (!auth.tokens || typeof auth.tokens !== "object") { + return null; + } + + return auth.tokens.access_token ?? null; +} + +/** + * @param {CodexAuth} auth + * @returns {string | null} + */ +export function parseCodexAccountId(auth) { + const accountId = auth.tokens?.account_id; + + if (typeof accountId !== "string" || accountId.trim().length === 0) { + return null; + } + + return accountId.trim(); +} diff --git a/src/infra/providers/codex_auth_parser.ts b/src/infra/providers/codex_auth_parser.ts deleted file mode 100644 index c46e95f..0000000 --- a/src/infra/providers/codex_auth_parser.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type CodexAuth = { - auth_mode?: string; - OPENAI_API_KEY?: string | null; - tokens?: { - id_token?: string; - access_token?: string; - refresh_token?: string; - account_id?: string; - }; - last_refresh?: string; -}; - -export function parseCodexAccessToken(auth: CodexAuth): string | null { - if (!auth.tokens || typeof auth.tokens !== "object") { - return null; - } - - return auth.tokens.access_token ?? null; -} - -export function parseCodexAccountId(auth: CodexAuth): string | null { - const accountId = auth.tokens?.account_id; - - if (typeof accountId !== "string" || accountId.trim().length === 0) { - return null; - } - - return accountId.trim(); -} diff --git a/src/infra/providers/codex_banked_reset_response.ts b/src/infra/providers/codex_banked_reset_response.js similarity index 58% rename from src/infra/providers/codex_banked_reset_response.ts rename to src/infra/providers/codex_banked_reset_response.js index 1f21170..c71f5d2 100644 --- a/src/infra/providers/codex_banked_reset_response.ts +++ b/src/infra/providers/codex_banked_reset_response.js @@ -1,31 +1,37 @@ +// @ts-check + import { RefreshFailureError } from "../../domain/refresh-failure.js"; -export type CodexBankedResetCredit = { - id: string; - reset_type?: string | null; - status: string; - granted_at?: string | null; - expires_at?: string | null; - profile_image_url?: string | null; - profile_user_id?: string | null; - title?: string | null; - description?: string | null; -}; - -export type CodexBankedResetListResponse = { - available_count: number; - credits: CodexBankedResetCredit[]; -}; - -type JsonObject = Record; - -export function toListResponse(response: JsonObject): CodexBankedResetListResponse { +/** + * @typedef {object} CodexBankedResetCredit + * @property {string} id + * @property {string} status + * @property {string | null} [reset_type] + * @property {string | null} [granted_at] + * @property {string | null} [expires_at] + * @property {string | null} [profile_image_url] + * @property {string | null} [profile_user_id] + * @property {string | null} [title] + * @property {string | null} [description] + * + * @typedef {object} CodexBankedResetListResponse + * @property {number} available_count + * @property {CodexBankedResetCredit[]} credits + * + * @typedef {Record} JsonObject + */ + +/** + * @param {JsonObject} response + * @returns {CodexBankedResetListResponse} + */ +export function toListResponse(response) { assertListResponseShape(response); - - return response as CodexBankedResetListResponse; + return /** @type {CodexBankedResetListResponse} */ (response); } -function assertListResponseShape(response: JsonObject): void { +/** @param {JsonObject} response */ +function assertListResponseShape(response) { if (!Number.isFinite(response.available_count)) { throwUnexpected("available_count is missing or not a finite number"); } @@ -67,7 +73,11 @@ function assertListResponseShape(response: JsonObject): void { } } -export function selectCreditToRedeem(credits: CodexBankedResetCredit[]): CodexBankedResetCredit | null { +/** + * @param {CodexBankedResetCredit[]} credits + * @returns {CodexBankedResetCredit | null} + */ +export function selectCreditToRedeem(credits) { const available = credits.filter((credit) => credit.status === "available"); if (available.length === 0) { @@ -81,11 +91,13 @@ export function selectCreditToRedeem(credits: CodexBankedResetCredit[]): CodexBa return sortByExpiry(available)[0]; } -export function selectCreditExpiringWithin( - credits: CodexBankedResetCredit[], - nowMs: number, - windowMs: number, -): CodexBankedResetCredit | null { +/** + * @param {CodexBankedResetCredit[]} credits + * @param {number} nowMs + * @param {number} windowMs + * @returns {CodexBankedResetCredit | null} + */ +export function selectCreditExpiringWithin(credits, nowMs, windowMs) { const expiresBy = nowMs + windowMs; const expiring = credits.filter((credit) => { if (credit.status !== "available" || !hasValidExpiresAt(credit)) return false; @@ -97,21 +109,38 @@ export function selectCreditExpiringWithin( return sortByExpiry(expiring)[0] ?? null; } -function sortByExpiry(credits: CodexBankedResetCredit[]): CodexBankedResetCredit[] { +/** + * @param {CodexBankedResetCredit[]} credits + * @returns {CodexBankedResetCredit[]} + */ +function sortByExpiry(credits) { return [...credits].sort((left, right) => { return Date.parse(left.expires_at ?? "") - Date.parse(right.expires_at ?? ""); }); } -function hasValidExpiresAt(credit: CodexBankedResetCredit): boolean { - return typeof credit.expires_at === "string" && Number.isFinite(Date.parse(credit.expires_at)); +/** + * @param {CodexBankedResetCredit} credit + * @returns {boolean} + */ +function hasValidExpiresAt(credit) { + return typeof credit.expires_at === "string" && + Number.isFinite(Date.parse(credit.expires_at)); } -function isObject(value: unknown): value is Record { +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -function throwUnexpected(message: string): never { +/** + * @param {string} message + * @returns {never} + */ +function throwUnexpected(message) { throw new RefreshFailureError( "unexpected-response", "Codex returned banked reset data this extension does not understand.", diff --git a/src/infra/providers/codex_banked_resets.ts b/src/infra/providers/codex_banked_resets.js similarity index 57% rename from src/infra/providers/codex_banked_resets.ts rename to src/infra/providers/codex_banked_resets.js index 464f5c7..a189fd7 100644 --- a/src/infra/providers/codex_banked_resets.ts +++ b/src/infra/providers/codex_banked_resets.js @@ -1,43 +1,12 @@ -import Gio from "gi://Gio"; import GLib from "gi://GLib"; - -import { fetchJson, JsonObject, UsageApiClientConfig } from "../api_client.js"; +import { fetchJson } from "../api_client.js"; import { RefreshFailureError } from "../../domain/refresh-failure.js"; -import { - readBankedResetSnapshot, - writeBankedResetSnapshot, -} from "../storage/banked-reset-snapshot.js"; +import { readBankedResetSnapshot, writeBankedResetSnapshot, } from "../storage/banked-reset-snapshot.js"; import { getCodexCredentials } from "./codex_auth.js"; -import { - CodexBankedResetCredit, - CodexBankedResetListResponse, - selectCreditToRedeem, - toListResponse, -} from "./codex_banked_reset_response.js"; - -export type { - CodexBankedResetCredit, - CodexBankedResetListResponse, -} from "./codex_banked_reset_response.js"; - -const CODEX_BANKED_RESETS_URL = - "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; +import { selectCreditToRedeem, toListResponse, } from "./codex_banked_reset_response.js"; +const CODEX_BANKED_RESETS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; const CODEX_REDEEM_BANKED_RESET_URL = `${CODEX_BANKED_RESETS_URL}/consume`; - - - -export type CodexRedeemBankedResetRequest = { - credit_id: string; - redeem_request_id: string; -}; - -export type CodexRedeemBankedResetResponse = JsonObject; - -export type CodexBankedResetRequestOptions = { - cancellable?: Gio.Cancellable | null; -}; - -const CODEX_BANKED_RESETS_API_CONFIG: UsageApiClientConfig = { +const CODEX_BANKED_RESETS_API_CONFIG = { providerName: "Codex", usageUrl: CODEX_BANKED_RESETS_URL, messages: { @@ -50,94 +19,66 @@ const CODEX_BANKED_RESETS_API_CONFIG: UsageApiClientConfig = { emptyResponse: "Codex returned an empty banked reset response.", }, }; - -export async function listCodexBankedResets( - options: CodexBankedResetRequestOptions = {}, -): Promise { +export async function listCodexBankedResets(options = {}) { const credentials = await getCodexCredentials(); const response = await fetchJson(CODEX_BANKED_RESETS_URL, CODEX_BANKED_RESETS_API_CONFIG, { headers: createCodexBankedResetHeaders(credentials.accessToken, credentials.accountId), cancellable: options.cancellable ?? null, }); - const listResponse = toListResponse(response as JsonObject); - + const listResponse = toListResponse(response); try { await writeBankedResetSnapshot(listResponse); - } catch (error) { + } + catch (error) { console.error("Unable to write Codex banked reset snapshot cache", error); } - return listResponse; } - export async function readCachedCodexBankedResets() { return await readBankedResetSnapshot(); } - -export async function redeemNextCodexBankedReset( - options: CodexBankedResetRequestOptions = {}, -): Promise { +export async function redeemNextCodexBankedReset(options = {}) { const list = await listCodexBankedResets(options); const credit = selectCreditToRedeem(list.credits); - if (!credit) { - throw new RefreshFailureError( - "unexpected-response", - "No banked Codex resets are available to redeem.", - "Codex banked reset redemption was requested with zero available credits.", - ); + throw new RefreshFailureError("unexpected-response", "No banked Codex resets are available to redeem.", "Codex banked reset redemption was requested with zero available credits."); } - await redeemCodexBankedReset(credit.id, options); - return credit; } - -export async function redeemCodexBankedReset( - creditId: string, - options: CodexBankedResetRequestOptions = {}, -): Promise { +export async function redeemCodexBankedReset(creditId, options = {}) { const response = await consumeCodexBankedReset(creditId, options); - try { await markCachedBankedResetRedeemed(creditId); - } catch (error) { + } + catch (error) { console.error("Unable to update Codex banked reset snapshot cache", error); } - return response; } - -async function markCachedBankedResetRedeemed(creditId: string): Promise { +async function markCachedBankedResetRedeemed(creditId) { const snapshot = await readBankedResetSnapshot(); - - if (!snapshot) return; - + if (!snapshot) + return; const credits = snapshot.credits.map((credit) => { - if (credit.id !== creditId) return credit; - + if (credit.id !== creditId) + return credit; return { ...credit, status: "redeemed", }; }); - await writeBankedResetSnapshot({ available_count: credits.filter((credit) => credit.status === "available").length, credits, }); } - -async function consumeCodexBankedReset( - creditId: string, - options: CodexBankedResetRequestOptions = {}, -): Promise { +async function consumeCodexBankedReset(creditId, options = {}) { const credentials = await getCodexCredentials(); - const request: CodexRedeemBankedResetRequest = { + const request = { credit_id: creditId, redeem_request_id: GLib.uuid_string_random(), }; - return await fetchJson(CODEX_REDEEM_BANKED_RESET_URL, CODEX_BANKED_RESETS_API_CONFIG, { method: "POST", headers: { @@ -147,19 +88,15 @@ async function consumeCodexBankedReset( body: JSON.stringify(request), bodyContentType: "application/json", cancellable: options.cancellable ?? null, - }) as JsonObject; + }); } - -function createCodexBankedResetHeaders(accessToken: string, accountId: string | null): Record { - const headers: Record = { +function createCodexBankedResetHeaders(accessToken, accountId) { + const headers = { Authorization: `Bearer ${accessToken}`, originator: "Codex Desktop", }; - if (accountId) { headers["ChatGPT-Account-ID"] = accountId; } - return headers; } - diff --git a/src/infra/providers/codex_usage_response.ts b/src/infra/providers/codex_usage_response.js similarity index 61% rename from src/infra/providers/codex_usage_response.ts rename to src/infra/providers/codex_usage_response.js index fc6cc9d..45c3e5b 100644 --- a/src/infra/providers/codex_usage_response.ts +++ b/src/infra/providers/codex_usage_response.js @@ -1,25 +1,27 @@ -import { RefreshFailureError } from "../../domain/refresh-failure.js"; -import { UsageCredits, UsageSnapshot } from "../../domain/usage.js"; - -type JsonObject = Record; +// @ts-check -type CodexApiResponse = { - plan_type: string; - rate_limit: { - limit_reached: boolean; - primary_window: CodexRateLimitWindow | null; - secondary_window: CodexRateLimitWindow | null; - }; -}; - -type CodexRateLimitWindow = { - used_percent: number; - limit_window_seconds: number; - reset_after_seconds: number; - reset_at: number; -}; +import { RefreshFailureError } from "../../domain/refresh-failure.js"; -export function toUsageSnapshot(api: JsonObject): UsageSnapshot { +/** @typedef {import("../../domain/types.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../domain/types.js").UsageCredits} UsageCredits */ +/** @typedef {Record} JsonObject */ +/** + * @typedef {object} CodexRateLimitWindow + * @property {number} used_percent + * @property {number} limit_window_seconds + * @property {number} reset_after_seconds + * @property {number} reset_at + * + * @typedef {object} CodexApiResponse + * @property {string} plan_type + * @property {{limit_reached: boolean, primary_window: CodexRateLimitWindow | null, secondary_window: CodexRateLimitWindow | null}} rate_limit + */ + +/** + * @param {JsonObject} api + * @returns {UsageSnapshot} + */ +export function toUsageSnapshot(api) { const codexApi = toCodexApiResponse(api); return { @@ -31,12 +33,17 @@ export function toUsageSnapshot(api: JsonObject): UsageSnapshot { }; } -function toCodexApiResponse(api: JsonObject): CodexApiResponse { +/** + * @param {JsonObject} api + * @returns {CodexApiResponse} + */ +function toCodexApiResponse(api) { assertApiResponseShape(api); - return api as CodexApiResponse; + return /** @type {CodexApiResponse} */ (/** @type {unknown} */ (api)); } -function assertApiResponseShape(api: JsonObject): void { +/** @param {JsonObject} api */ +function assertApiResponseShape(api) { if (typeof api.plan_type !== "string") { throwUnexpected("plan_type is missing or not a string"); } @@ -57,7 +64,11 @@ function assertApiResponseShape(api: JsonObject): void { } } -function toUsageQuotas(api: CodexApiResponse): UsageSnapshot["quotas"] { +/** + * @param {CodexApiResponse} api + * @returns {UsageSnapshot["quotas"]} + */ +function toUsageQuotas(api) { const { primary_window: primary, secondary_window: secondary } = api.rate_limit; if (primary && secondary) { @@ -75,12 +86,14 @@ function toUsageQuotas(api: CodexApiResponse): UsageSnapshot["quotas"] { return [toUsageQuota("weekly", "Week", weeklyWindow, api.rate_limit.limit_reached)]; } -function toUsageQuota( - id: string, - label: string, - window: CodexRateLimitWindow, - limitReached: boolean, -): UsageSnapshot["quotas"][number] { +/** + * @param {string} id + * @param {string} label + * @param {CodexRateLimitWindow} window + * @param {boolean} limitReached + * @returns {UsageSnapshot["quotas"][number]} + */ +function toUsageQuota(id, label, window, limitReached) { return { id, label, @@ -92,12 +105,20 @@ function toUsageQuota( }; } -function assertOptionalWindow(value: unknown, path: string): void { +/** + * @param {unknown} value + * @param {string} path + */ +function assertOptionalWindow(value, path) { if (value === null || value === undefined) return; assertWindow(value, path); } -function toUsageCredits(value: unknown): UsageCredits | undefined { +/** + * @param {unknown} value + * @returns {UsageCredits | undefined} + */ +function toUsageCredits(value) { if (!isObject(value)) return undefined; const balance = typeof value.balance === "string" && value.balance.trim().length > 0 @@ -114,23 +135,40 @@ function toUsageCredits(value: unknown): UsageCredits | undefined { }; } -function assertWindow(value: unknown, path: string): void { +/** + * @param {unknown} value + * @param {string} path + */ +function assertWindow(value, path) { if (!isObject(value)) { throwUnexpected(`${path} is missing or not an object`); } - for (const key of ["used_percent", "limit_window_seconds", "reset_after_seconds", "reset_at"]) { + for (const key of [ + "used_percent", + "limit_window_seconds", + "reset_after_seconds", + "reset_at", + ]) { if (!Number.isFinite(value[key])) { throwUnexpected(`${path}.${key} is missing or not a finite number`); } } } -function isObject(value: unknown): value is Record { +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -function throwUnexpected(message: string): never { +/** + * @param {string} message + * @returns {never} + */ +function throwUnexpected(message) { throw new RefreshFailureError( "unexpected-response", "Codex returned data this extension does not understand.", diff --git a/src/infra/providers/index.js b/src/infra/providers/index.js new file mode 100644 index 0000000..37e2a31 --- /dev/null +++ b/src/infra/providers/index.js @@ -0,0 +1,15 @@ +import { CodexUsageProvider } from "./codex.js"; +export const DEFAULT_PROVIDER_ID = "codex"; +const providers = { + codex: new CodexUsageProvider(), +}; +export function getUsageProvider(id = DEFAULT_PROVIDER_ID) { + const provider = providers[id]; + if (!provider) { + throw new Error(`Unsupported usage provider: ${id}`); + } + return provider; +} +export function listUsageProviders() { + return Object.values(providers); +} diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts deleted file mode 100644 index d99b714..0000000 --- a/src/infra/providers/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { CodexUsageProvider } from "./codex.js"; -import type { ProviderId, UsageProvider } from "./types.js"; - -export const DEFAULT_PROVIDER_ID: ProviderId = "codex"; - -const providers = { - codex: new CodexUsageProvider(), -} satisfies Partial>; - -export function getUsageProvider(id: ProviderId = DEFAULT_PROVIDER_ID): UsageProvider { - const provider = providers[id]; - - if (!provider) { - throw new Error(`Unsupported usage provider: ${id}`); - } - - return provider; -} - -export function listUsageProviders(): UsageProvider[] { - return Object.values(providers); -} - -export type { ProviderId, UsageProvider } from "./types.js"; diff --git a/src/infra/providers/types.d.ts b/src/infra/providers/types.d.ts deleted file mode 100644 index 5837738..0000000 --- a/src/infra/providers/types.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Gio from "gi://Gio"; - -import type { UsageSnapshot } from "../../domain/usage.js"; - -export type ProviderId = "codex"; - -export type UsageProviderInfo = { - id: ProviderId; - displayName: string; -}; - -export type UsageProviderRefreshOptions = { - cancellable?: Gio.Cancellable | null; -}; - -export interface UsageProvider { - readonly info: UsageProviderInfo; - - refreshUsage(options?: UsageProviderRefreshOptions): Promise; -} diff --git a/src/infra/storage/banked-reset-snapshot.ts b/src/infra/storage/banked-reset-snapshot.js similarity index 61% rename from src/infra/storage/banked-reset-snapshot.ts rename to src/infra/storage/banked-reset-snapshot.js index bca11b7..5dfac21 100644 --- a/src/infra/storage/banked-reset-snapshot.ts +++ b/src/infra/storage/banked-reset-snapshot.js @@ -1,63 +1,43 @@ import GLib from "gi://GLib"; - -import { - type CodexBankedResetCredit, - type CodexBankedResetListResponse, -} from "../providers/codex_banked_resets.js"; import { CACHE_DIR } from "../config.js"; import { readJsonFile, writeJsonFile } from "../filesystem.js"; - -export type BankedResetSnapshot = CodexBankedResetListResponse & { - fetchedAt: number; -}; - function getBankedResetSnapshotPath() { return GLib.build_filenamev([CACHE_DIR, "codex", "banked-reset-snapshot.json"]); } - -export async function writeBankedResetSnapshot( - response: CodexBankedResetListResponse, -): Promise { +export async function writeBankedResetSnapshot(response) { await writeJsonFile(getBankedResetSnapshotPath(), { ...response, fetchedAt: Math.floor(Date.now() / 1000), }); } - -export async function readBankedResetSnapshot(): Promise { +export async function readBankedResetSnapshot() { try { - const raw = await readJsonFile(getBankedResetSnapshotPath()); - + const raw = await readJsonFile(getBankedResetSnapshotPath()); return toBankedResetSnapshot(raw); - } catch { + } + catch { return null; } } - -function toBankedResetSnapshot(value: unknown): BankedResetSnapshot | null { +function toBankedResetSnapshot(value) { if (!isBankedResetSnapshot(value)) { return null; } - return value; } - -function isBankedResetSnapshot(value: unknown): value is BankedResetSnapshot { +function isBankedResetSnapshot(value) { if (!isObject(value)) { return false; } - return Number.isFinite(value.fetchedAt) && Number.isFinite(value.available_count) && Array.isArray(value.credits) && value.credits.every(isBankedResetCredit); } - -function isBankedResetCredit(value: unknown): value is CodexBankedResetCredit { +function isBankedResetCredit(value) { if (!isObject(value)) { return false; } - return typeof value.id === "string" && value.id.trim().length > 0 && typeof value.status === "string" && @@ -71,11 +51,9 @@ function isBankedResetCredit(value: unknown): value is CodexBankedResetCredit { "description", ].every((key) => isOptionalString(value[key])); } - -function isOptionalString(value: unknown): boolean { +function isOptionalString(value) { return value === undefined || value === null || typeof value === "string"; } - -function isObject(value: unknown): value is Record { +function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/infra/storage/history.ts b/src/infra/storage/history.js similarity index 57% rename from src/infra/storage/history.ts rename to src/infra/storage/history.js index f131248..88fd434 100644 --- a/src/infra/storage/history.ts +++ b/src/infra/storage/history.js @@ -1,151 +1,108 @@ import GLib from "gi://GLib"; - -import { HistoryEntry, HistoryQuotaEntry } from "../../domain/usage.js"; import { STATE_DIR } from "../config.js"; import { appendFile, readFile, writeFile } from "../filesystem.js"; -import type { ProviderId } from "../providers/types.js"; - export const MAX_HISTORY_ENTRIES = 25_000; const MAX_HISTORY_AGE_SECONDS = 21 * 24 * 60 * 60; - -function getHistoryPath(providerId: ProviderId) { +function getHistoryPath(providerId) { return GLib.build_filenamev([STATE_DIR, providerId, "usage-history.jsonl"]); } - -export async function readHistory(providerId: ProviderId): Promise { +export async function readHistory(providerId) { return readHistoryFromPath(getHistoryPath(providerId)); } - -export async function readHistoryFromPath(path: string): Promise { - if (!fileExists(path)) return []; - +export async function readHistoryFromPath(path) { + if (!fileExists(path)) + return []; try { return normalizeHistoryEntries(parseJsonlHistory(await readFile(path))); - } catch (error) { + } + catch (error) { console.error("Unable to read usage history", error); return []; } } - - - -export async function appendHistory( - providerId: ProviderId, - row: HistoryEntry, -): Promise { +export async function appendHistory(providerId, row) { return appendHistoryToPath(getHistoryPath(providerId), row); } - /** * Append a history row verbatim without reading, deduping, or normalizing existing history. * Callers must pre-normalize rows and decide whether appending is appropriate. */ -export async function appendHistoryRow( - providerId: ProviderId, - row: HistoryEntry, -): Promise { +export async function appendHistoryRow(providerId, row) { return appendHistoryRowToPath(getHistoryPath(providerId), row); } - -export async function appendHistoryRowToPath( - path: string, - row: HistoryEntry, -): Promise { +export async function appendHistoryRowToPath(path, row) { await appendFile(path, JSON.stringify(row)); } - -export async function rewriteHistory( - providerId: ProviderId, - rows: HistoryEntry[], -): Promise { +export async function rewriteHistory(providerId, rows) { await rewriteHistoryToPath(getHistoryPath(providerId), normalizeHistoryEntries(rows)); } - -export async function appendHistoryToPath( - path: string, - row: HistoryEntry, - existingRows: HistoryEntry[] | null = null, -): Promise { +export async function appendHistoryToPath(path, row, existingRows = null) { const rows = existingRows ?? await readHistoryFromPath(path); const normalizedRows = normalizeHistoryEntries(rows); const normalizedRow = normalizeHistoryEntry(row); - - if (!normalizedRow) return; - + if (!normalizedRow) + return; const lastRow = normalizedRows.at(-1); const shouldAppend = !lastRow || !hasSameQuotaValues(lastRow, normalizedRow); const nextRows = shouldAppend ? normalizeHistoryEntries([...normalizedRows, normalizedRow]) : normalizedRows; - if (!fileExists(path) && nextRows.length > 0) { await rewriteHistoryToPath(path, nextRows); return; } - - if (!shouldAppend) return; - + if (!shouldAppend) + return; await appendFile(path, JSON.stringify(normalizedRow)); - if (nextRows.length !== normalizedRows.length + 1) { await rewriteHistoryToPath(path, nextRows); } } - -function fileExists(path: string): boolean { +function fileExists(path) { return GLib.file_test(path, GLib.FileTest.EXISTS); } - -function parseJsonlHistory(text: string): HistoryEntry[] { +function parseJsonlHistory(text) { return text .replace(/^\uFEFF/, "") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .map((line) => { - try { - return JSON.parse(line); - } catch (_error) { - return null; - } - }) - .filter((row): row is HistoryEntry => row !== null); -} - -export function normalizeHistoryEntries(rows: HistoryEntry[]): HistoryEntry[] { + try { + return JSON.parse(line); + } + catch (_error) { + return null; + } + }) + .filter((row) => row !== null); +} +export function normalizeHistoryEntries(rows) { const minTimestamp = Date.now() - (MAX_HISTORY_AGE_SECONDS * 1000); - return rows .map(normalizeHistoryEntry) - .filter((row): row is HistoryEntry => - row !== null && new Date(row.timestamp).getTime() >= minTimestamp - ) + .filter((row) => row !== null && new Date(row.timestamp).getTime() >= minTimestamp) .slice(-MAX_HISTORY_ENTRIES); } - -export function normalizeHistoryEntry(row: HistoryEntry): HistoryEntry | null { +export function normalizeHistoryEntry(row) { if (!row || !isValidTimestamp(row.timestamp) || !Array.isArray(row.quotas)) { return null; } - const quotas = row.quotas .map(normalizeHistoryQuota) - .filter((quota): quota is HistoryQuotaEntry => quota !== null); - - if (quotas.length === 0) return null; - + .filter((quota) => quota !== null); + if (quotas.length === 0) + return null; return { timestamp: row.timestamp, quotas, }; } - -function normalizeHistoryQuota(quota): HistoryQuotaEntry | null { +function normalizeHistoryQuota(quota) { const id = `${quota?.id ?? ""}`; const usedPercent = Number(quota?.usedPercent); - - if (id.length === 0 || !Number.isFinite(usedPercent)) return null; - + if (id.length === 0 || !Number.isFinite(usedPercent)) + return null; return omitUndefined({ id, usedPercent, @@ -158,40 +115,27 @@ function normalizeHistoryQuota(quota): HistoryQuotaEntry | null { : undefined, }); } - -function finiteOrNull(value): number | null | undefined { - if (value === null) return null; - +function finiteOrNull(value) { + if (value === null) + return null; const number = Number(value); - return Number.isFinite(number) ? number : undefined; } - -function omitUndefined>(value: T): T { - return Object.fromEntries( - Object.entries(value).filter(([, item]) => item !== undefined), - ) as T; +function omitUndefined(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); } - -function isValidTimestamp(value: string): boolean { +function isValidTimestamp(value) { return value.length > 0 && Number.isFinite(new Date(value).getTime()); } - -async function rewriteHistoryToPath(path: string, rows: HistoryEntry[]): Promise { - await writeFile( - path, - rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""), - ); -} - -export function hasSameQuotaValues(left: HistoryEntry, right: HistoryEntry): boolean { - if (left.quotas.length !== right.quotas.length) return false; - +async function rewriteHistoryToPath(path, rows) { + await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : "")); +} +export function hasSameQuotaValues(left, right) { + if (left.quotas.length !== right.quotas.length) + return false; const leftById = new Map(left.quotas.map((quota) => [quota.id, quota])); - return right.quotas.every((rightQuota) => { const leftQuota = leftById.get(rightQuota.id); - return Boolean(leftQuota) && leftQuota?.usedPercent === rightQuota.usedPercent && leftQuota?.used === rightQuota.used && diff --git a/src/infra/storage/snapshot-cache.ts b/src/infra/storage/snapshot-cache.js similarity index 51% rename from src/infra/storage/snapshot-cache.ts rename to src/infra/storage/snapshot-cache.js index cb60fa1..8cd3d36 100644 --- a/src/infra/storage/snapshot-cache.ts +++ b/src/infra/storage/snapshot-cache.js @@ -1,74 +1,52 @@ import GLib from "gi://GLib"; - -import { UsageSnapshot } from "../../domain/usage.js"; import { CACHE_DIR } from "../config.js"; import { readJsonFile, writeJsonFile } from "../filesystem.js"; -import type { ProviderId } from "../providers/types.js"; - -function getSnapshotPath(providerId: ProviderId) { +function getSnapshotPath(providerId) { return GLib.build_filenamev([CACHE_DIR, providerId, "snapshot.json"]); } - -export async function writeSnapshot( - providerId: ProviderId, - snapshot: UsageSnapshot, -): Promise { +export async function writeSnapshot(providerId, snapshot) { await writeJsonFile(getSnapshotPath(providerId), snapshot); } - -export async function readSnapshot(providerId: ProviderId): Promise { +export async function readSnapshot(providerId) { try { - const raw = await readJsonFile(getSnapshotPath(providerId)); - + const raw = await readJsonFile(getSnapshotPath(providerId)); if (!isUsageSnapshot(raw)) { return null; } - return raw; - } catch { + } + catch { return null; } } - -function isUsageSnapshot(value: unknown): value is UsageSnapshot { +function isUsageSnapshot(value) { if (!value || typeof value !== "object") { return false; } - - const snapshot = value as Record; - - return ( - isFiniteNumber(snapshot.fetchedAt) && + const snapshot = value; + return (isFiniteNumber(snapshot.fetchedAt) && typeof snapshot.planType === "string" && Array.isArray(snapshot.quotas) && snapshot.quotas.length > 0 && - snapshot.quotas.every(isUsageQuota) - ); + snapshot.quotas.every(isUsageQuota)); } - -function isUsageQuota(value: unknown): boolean { +function isUsageQuota(value) { if (!value || typeof value !== "object") { return false; } - - const quota = value as Record; - - return ( - typeof quota.id === "string" && + const quota = value; + return (typeof quota.id === "string" && quota.id.length > 0 && typeof quota.label === "string" && quota.label.length > 0 && isFiniteNumber(quota.usedPercent) && isOptionalFiniteNumber(quota.limitWindowSeconds) && isOptionalFiniteNumber(quota.resetAfterSeconds) && - isOptionalFiniteNumber(quota.resetAt) - ); + isOptionalFiniteNumber(quota.resetAt)); } - -function isFiniteNumber(value: unknown): value is number { +function isFiniteNumber(value) { return typeof value === "number" && Number.isFinite(value); } - -function isOptionalFiniteNumber(value: unknown): boolean { +function isOptionalFiniteNumber(value) { return value === undefined || value === null || isFiniteNumber(value); } diff --git a/src/prefs.ts b/src/prefs.js similarity index 86% rename from src/prefs.ts rename to src/prefs.js index ae26275..72c9306 100644 --- a/src/prefs.ts +++ b/src/prefs.js @@ -1,24 +1,17 @@ import Gdk from "gi://Gdk"; import Gtk from "gi://Gtk"; import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js"; - import { readCachedCodexBankedResets } from "./infra/providers/codex_banked_resets.js"; import { PreferencesPage } from "./ui/preferences-pages.js"; - export default class CodexMeterPreferences extends ExtensionPreferences { async fillPreferencesWindow(window) { const settings = this.getSettings(); const display = Gdk.Display.get_default(); - if (display) { Gtk.IconTheme.get_for_display(display).add_search_path(`${this.path}/icons`); } - const bankedResetSnapshot = await readCachedCodexBankedResets(); - - window.add( - new (PreferencesPage as any)(settings, bankedResetSnapshot, this.metadata), - ); + window.add(new PreferencesPage(settings, bankedResetSnapshot, this.metadata)); window.set_default_size(640, 720); } } diff --git a/src/ui/button.ts b/src/ui/button.js similarity index 64% rename from src/ui/button.ts rename to src/ui/button.js index 5394ad9..7a01f72 100644 --- a/src/ui/button.ts +++ b/src/ui/button.js @@ -1,21 +1,6 @@ import Clutter from "gi://Clutter"; import St from "gi://St"; - -type ButtonOptions = { - styleClass: string; - onClick: (button: St.Button) => void; -}; - -type TextButtonOptions = ButtonOptions & { - text: string; -}; - -type IconButtonOptions = ButtonOptions & { - iconName: string; - accessibleName: string; -}; - -export function createTextButton({ text, styleClass, onClick }: TextButtonOptions) { +export function createTextButton({ text, styleClass, onClick }) { const label = new St.Label({ text, y_align: Clutter.ActorAlign.CENTER, @@ -26,18 +11,10 @@ export function createTextButton({ text, styleClass, onClick }: TextButtonOption can_focus: true, y_align: Clutter.ActorAlign.CENTER, }); - button.connect("clicked", () => onClick(button)); - return { button, label }; } - -export function createIconButton({ - iconName, - accessibleName, - styleClass, - onClick, -}: IconButtonOptions) { +export function createIconButton({ iconName, accessibleName, styleClass, onClick, }) { const icon = new St.Icon({ icon_name: iconName, style_class: "popup-menu-icon", @@ -50,8 +27,6 @@ export function createIconButton({ x_align: Clutter.ActorAlign.END, y_align: Clutter.ActorAlign.CENTER, }); - button.connect("clicked", () => onClick(button)); - return { button, icon }; } diff --git a/src/ui/panel-indicator.ts b/src/ui/panel-indicator.js similarity index 75% rename from src/ui/panel-indicator.ts rename to src/ui/panel-indicator.js index 638e0c0..71f6f1b 100644 --- a/src/ui/panel-indicator.ts +++ b/src/ui/panel-indicator.js @@ -3,56 +3,34 @@ import Gio from "gi://Gio"; import GLib from "gi://GLib"; import GObject from "gi://GObject"; import St from "gi://St"; - import * as Main from "resource:///org/gnome/shell/ui/main.js"; import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; - import { SettingsService } from "../app/settings.js"; import { Scheduler } from "../app/scheduler.js"; import { UsageService } from "../app/usage.js"; -import { DEFAULT_PROVIDER_ID, getUsageProvider, type ProviderId } from "../infra/providers/index.js"; +import { DEFAULT_PROVIDER_ID, getUsageProvider } from "../infra/providers/index.js"; import { isRefreshFailureError } from "../domain/refresh-failure.js"; -import { - listCodexBankedResets, - readCachedCodexBankedResets, - redeemCodexBankedReset, - redeemNextCodexBankedReset, -} from "../infra/providers/codex_banked_resets.js"; +import { listCodexBankedResets, readCachedCodexBankedResets, redeemCodexBankedReset, redeemNextCodexBankedReset, } from "../infra/providers/codex_banked_resets.js"; import { selectCreditExpiringWithin } from "../infra/providers/codex_banked_reset_response.js"; import { CodexMeterPopupMenu } from "./popup-menu.js"; import { formatRefreshFailure } from "./refresh-error-message.js"; import { UsageBar } from "./usage-bar.js"; -import { - createMenuViewModel, - createPanelBarViewModel, -} from "./view-model.js"; - +import { createMenuViewModel, createPanelBarViewModel, } from "./view-model.js"; const BANKED_RESET_BACKGROUND_REFRESH_SECONDS = 20 * 60; const AUTO_APPLY_BANKED_RESET_WINDOW_MS = 60 * 60 * 1000; - export class CodexMeterIndicator extends PanelMenu.Button { - [key: string]: any; - static { GObject.registerClass(this); } - constructor(extension) { super(0.0, "CodexMeter"); - this._extension = extension; this._settings = new SettingsService(extension.getSettings()); this._providerId = this._settings.getUsageProvider(); this._autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); this._usageService = createUsageService(this._providerId); - this._scheduler = new Scheduler( - this._settings.getBackgroundRefreshIntervalSeconds(), - () => this._refreshUsage(), - ); - this._bankedResetScheduler = new Scheduler( - BANKED_RESET_BACKGROUND_REFRESH_SECONDS, - () => this._refreshBankedResetsIfStale(), - ); + this._scheduler = new Scheduler(this._settings.getBackgroundRefreshIntervalSeconds(), () => this._refreshUsage()); + this._bankedResetScheduler = new Scheduler(BANKED_RESET_BACKGROUND_REFRESH_SECONDS, () => this._refreshBankedResetsIfStale()); this._refreshSpinId = 0; this._menuSyncId = 0; this._refreshPromise = null; @@ -69,12 +47,10 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._redeemingBankedReset = false; this._redeemBankedResetCancellable = null; this._destroyed = false; - this._panelBox = new St.BoxLayout({ y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-box", }); - this._prefixLabel = new St.Label({ text: "CX", y_align: Clutter.ActorAlign.CENTER, @@ -82,31 +58,24 @@ export class CodexMeterIndicator extends PanelMenu.Button { }); this._codexIcon = new St.Icon({ gicon: new Gio.FileIcon({ - file: Gio.File.new_for_path( - `${this._extension.path}/icons/codex-symbolic.svg`, - ), + file: Gio.File.new_for_path(`${this._extension.path}/icons/codex-symbolic.svg`), }), y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-icon", }); this._openAiIcon = new St.Icon({ gicon: new Gio.FileIcon({ - file: Gio.File.new_for_path( - `${this._extension.path}/icons/openai-symbolic.svg`, - ), + file: Gio.File.new_for_path(`${this._extension.path}/icons/openai-symbolic.svg`), }), y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-icon", }); - this._label = new St.Label({ text: "--", y_align: Clutter.ActorAlign.CENTER, style_class: "cx-usage-label", }); - this._panelBars = this._createPanelBars(); - this._panelBox.add_child(this._prefixLabel); this._panelBox.add_child(this._codexIcon); this._panelBox.add_child(this._openAiIcon); @@ -116,21 +85,19 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._buildMenu(); this._connectSignals(); } - start() { - if (this._destroyed) return; - + if (this._destroyed) + return; this._syncLabel(); this._syncMenu(); void this._loadCachedBankedResets(); this._bankedResetScheduler.start(); void this._loadCachedSnapshot().finally(() => { - if (this._destroyed) return; - + if (this._destroyed) + return; this._scheduler.start({ runImmediately: true }); }); } - destroy() { this._destroyed = true; this._scheduler.stop(); @@ -138,35 +105,28 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._cancelRefresh(); this._cancelBankedResetRefresh(); this._cancelBankedResetRedemption(); - if (this._refreshSpinId) { GLib.source_remove(this._refreshSpinId); this._refreshSpinId = 0; } - if (this._menuSyncId) { GLib.source_remove(this._menuSyncId); this._menuSyncId = 0; } - if (this._menuOpenChangedId) { this.menu.disconnect(this._menuOpenChangedId); this._menuOpenChangedId = 0; } - if (this._settingsChangedId) { this._settings.disconnect(this._settingsChangedId); this._settingsChangedId = 0; } - if (this._refreshIntervalChangedId) { this._settings.disconnect(this._refreshIntervalChangedId); this._refreshIntervalChangedId = 0; } - super.destroy(); } - _buildMenu() { this._popupMenu = new CodexMeterPopupMenu({ onRefresh: () => { @@ -180,47 +140,35 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._extension.openPreferences(); }, }); - this._headerItem = this._popupMenu.headerItem; this._primaryItem = this._popupMenu.primaryItem; this._secondaryItem = this._popupMenu.secondaryItem; this._footerItem = this._popupMenu.footerItem; - this._popupMenu.addToMenu(this.menu); - (this.menu as any).setSourceAlignment(0.0); + this.menu.setSourceAlignment(0.0); } - _createPanelBars() { const box = new St.BoxLayout({ vertical: true, y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-bars", }); - this._panelPrimaryBar = new UsageBar("panel"); this._panelSecondaryBar = new UsageBar("panel"); - box.add_child(this._panelPrimaryBar.actor); box.add_child(this._panelSecondaryBar.actor); - return box; } - _connectSignals() { - this._menuOpenChangedId = (this.menu as any).connect( - "open-state-changed", - (_menu, isOpen) => { - if (isOpen) { - this._queueMenuBarSync(); - void this._refreshBankedResets(); - } - }, - ); - + this._menuOpenChangedId = this.menu.connect("open-state-changed", (_menu, isOpen) => { + if (isOpen) { + this._queueMenuBarSync(); + void this._refreshBankedResets(); + } + }); this._settingsChangedId = this._settings.connectChanged(() => { const providerId = this._settings.getUsageProvider(); const autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); - if (providerId !== this._providerId) { this._providerId = providerId; this._usageService = createUsageService(providerId); @@ -234,380 +182,331 @@ export class CodexMeterIndicator extends PanelMenu.Button { void this._loadCachedBankedResets(); void this._loadCachedSnapshot(); } - if (autoApplyBankedReset && !this._autoApplyBankedReset) { void this._refreshBankedResets(); } this._autoApplyBankedReset = autoApplyBankedReset; - this._syncLabel(); this._syncMenu(); }); - this._refreshIntervalChangedId = this._settings.connectBackgroundRefreshIntervalChanged(() => { - this._scheduler.setIntervalSeconds( - this._settings.getBackgroundRefreshIntervalSeconds(), - ); + this._scheduler.setIntervalSeconds(this._settings.getBackgroundRefreshIntervalSeconds()); }); } - async _refreshUsage({ manual = false } = {}) { - if (this._destroyed) return; - - if (manual) this._startRefreshSpin(); - + if (this._destroyed) + return; + if (manual) + this._startRefreshSpin(); if (this._refreshPromise) { try { await this._refreshPromise; - } finally { - if (manual && !this._destroyed) this._stopRefreshSpin(); + } + finally { + if (manual && !this._destroyed) + this._stopRefreshSpin(); } return; } - this._refreshCancellable = new Gio.Cancellable(); this._refreshPromise = this._refreshUsageOnce(this._refreshCancellable); - try { await this._refreshPromise; - } finally { + } + finally { this._refreshPromise = null; this._refreshCancellable = null; - if (manual && !this._destroyed) this._stopRefreshSpin(); + if (manual && !this._destroyed) + this._stopRefreshSpin(); } } - _cancelRefresh() { - if (!this._refreshCancellable) return; - + if (!this._refreshCancellable) + return; this._refreshCancellable.cancel(); this._refreshCancellable = null; } - _cancelBankedResetRefresh() { - if (!this._bankedResetRefreshCancellable) return; - + if (!this._bankedResetRefreshCancellable) + return; this._bankedResetRefreshCancellable.cancel(); this._bankedResetRefreshCancellable = null; } - _cancelBankedResetRedemption() { - if (!this._redeemBankedResetCancellable) return; - + if (!this._redeemBankedResetCancellable) + return; this._redeemBankedResetCancellable.cancel(); this._redeemBankedResetCancellable = null; } - async _refreshUsageOnce(cancellable) { try { - if (this._destroyed) return; - + if (this._destroyed) + return; this._snapshot = await this._usageService.refresh({ cancellable }); - - if (this._destroyed) return; - + if (this._destroyed) + return; await this._loadHistory(); - this._errorMessage = null; this._cachedFailureMessage = null; - try { - this._prediction = await this._usageService.predict( - this._snapshot, - ); - } catch (error) { + this._prediction = await this._usageService.predict(this._snapshot); + } + catch (error) { this._prediction = null; } - } catch (error) { - if (this._destroyed && isCancellationError(error)) return; - + } + catch (error) { + if (this._destroyed && isCancellationError(error)) + return; const failureMessage = formatRefreshFailure(error); - if (this._snapshot) { this._errorMessage = null; this._cachedFailureMessage = failureMessage; - } else { + } + else { const loadedCachedSnapshot = !this._destroyed && await this._loadCachedSnapshotAfterFailure(); - if (loadedCachedSnapshot) { this._errorMessage = null; this._cachedFailureMessage = failureMessage; - } else { + } + else { this._errorMessage = failureMessage; this._cachedFailureMessage = null; } } - } finally { - if (this._destroyed) return; - + } + finally { + if (this._destroyed) + return; this._syncLabel(); this._syncMenu(); } } - async _loadCachedSnapshot() { - if (this._destroyed || this._snapshot || this._errorMessage) return; - + if (this._destroyed || this._snapshot || this._errorMessage) + return; try { const snapshot = await this._usageService.readCachedSnapshot(); - - if ( - this._destroyed || + if (this._destroyed || !snapshot || this._snapshot || - this._errorMessage - ) { + this._errorMessage) { return; } - this._snapshot = snapshot; - await this._loadHistory(); - try { this._prediction = await this._usageService.predict(snapshot); - } catch (error) { + } + catch (error) { this._prediction = null; } - - if (this._destroyed) return; - + if (this._destroyed) + return; this._syncLabel(); this._syncMenu(); - } catch (error) {} + } + catch (error) { } } - async _loadCachedBankedResets() { - if (this._destroyed || this._providerId !== "codex") return; - + if (this._destroyed || this._providerId !== "codex") + return; try { const snapshot = await readCachedCodexBankedResets(); - - if (this._destroyed || !snapshot || this._providerId !== "codex") return; - + if (this._destroyed || !snapshot || this._providerId !== "codex") + return; this._bankedResetCount = Math.max(0, snapshot.available_count); this._lastBankedResetRefreshAt = snapshot.fetchedAt; this._syncMenu(); - } catch (error) {} + } + catch (error) { } } - async _loadCachedSnapshotAfterFailure() { - if (this._destroyed) return false; - + if (this._destroyed) + return false; try { const snapshot = await this._usageService.readCachedSnapshot(); - - if (this._destroyed || !snapshot) return false; - if (this._snapshot) return true; - + if (this._destroyed || !snapshot) + return false; + if (this._snapshot) + return true; this._snapshot = snapshot; - await this._loadHistory(); - try { this._prediction = await this._usageService.predict(snapshot); - } catch (error) { + } + catch (error) { this._prediction = null; } - return true; - } catch (error) { + } + catch (error) { return false; } } - async _loadHistory() { try { this._history = await this._usageService.readHistory(); - } catch (error) { + } + catch (error) { this._history = []; } } - async _refreshBankedResets(cancellable = null) { if (this._providerId !== "codex") { this._bankedResetCount = null; this._lastBankedResetRefreshAt = null; return; } - if (this._bankedResetRefreshPromise) { await this._bankedResetRefreshPromise; return; } - const refreshCancellable = cancellable ?? new Gio.Cancellable(); - if (!cancellable) this._bankedResetRefreshCancellable = refreshCancellable; - + if (!cancellable) + this._bankedResetRefreshCancellable = refreshCancellable; this._bankedResetRefreshPromise = this._refreshBankedResetsOnce(refreshCancellable); - try { await this._bankedResetRefreshPromise; - } finally { + } + finally { this._bankedResetRefreshPromise = null; if (this._bankedResetRefreshCancellable === refreshCancellable) { this._bankedResetRefreshCancellable = null; } } } - async _refreshBankedResetsIfStale() { - if (!this._shouldRefreshBankedResets()) return; - + if (!this._shouldRefreshBankedResets()) + return; await this._refreshBankedResets(); } - _shouldRefreshBankedResets() { - if (this._destroyed || this._providerId !== "codex") return false; - if (this._bankedResetCount === null || !this._lastBankedResetRefreshAt) return true; - + if (this._destroyed || this._providerId !== "codex") + return false; + if (this._bankedResetCount === null || !this._lastBankedResetRefreshAt) + return true; const nowSeconds = Math.floor(Date.now() / 1000); return nowSeconds - this._lastBankedResetRefreshAt >= BANKED_RESET_BACKGROUND_REFRESH_SECONDS; } - async _refreshBankedResetsOnce(cancellable = null) { try { const response = await listCodexBankedResets({ cancellable }); - - if (this._destroyed || this._providerId !== "codex") return; - + if (this._destroyed || this._providerId !== "codex") + return; this._bankedResetCount = Math.max(0, response.available_count); this._lastBankedResetRefreshAt = Math.floor(Date.now() / 1000); await this._autoApplyExpiringBankedReset(response.credits, cancellable); this._syncMenu(); - } catch (error) { - if (isCancellationError(error)) return; - if (this._destroyed) return; - + } + catch (error) { + if (isCancellationError(error)) + return; + if (this._destroyed) + return; console.warn("Unable to refresh Codex banked reset count", error); this._bankedResetCount = null; this._lastBankedResetRefreshAt = null; this._syncMenu(); } } - async _autoApplyExpiringBankedReset(credits, cancellable) { - if ( - this._destroyed || + if (this._destroyed || this._redeemingBankedReset || - !this._settings.getAutoApplyBankedReset() - ) { + !this._settings.getAutoApplyBankedReset()) { return; } - - const credit = selectCreditExpiringWithin( - credits, - Date.now(), - AUTO_APPLY_BANKED_RESET_WINDOW_MS, - ); - if (!credit) return; - + const credit = selectCreditExpiringWithin(credits, Date.now(), AUTO_APPLY_BANKED_RESET_WINDOW_MS); + if (!credit) + return; this._redeemingBankedReset = true; this._syncMenu(); - try { await redeemCodexBankedReset(credit.id, { cancellable }); - - if (this._destroyed) return; - + if (this._destroyed) + return; this._bankedResetCount = Math.max(0, (this._bankedResetCount ?? 1) - 1); notify("Codex Meter", "Expiring banked Codex reset applied automatically."); await this._refreshUsage(); - } catch (error) { - if (this._destroyed && isCancellationError(error)) return; - + } + catch (error) { + if (this._destroyed && isCancellationError(error)) + return; console.warn("Unable to auto-apply expiring Codex banked reset", error); - } finally { + } + finally { if (!this._destroyed) { this._redeemingBankedReset = false; this._syncMenu(); } } } - async _redeemBankedReset() { - if (this._destroyed || this._redeemingBankedReset) return; - + if (this._destroyed || this._redeemingBankedReset) + return; if (this._providerId !== "codex") { notify("Codex Meter", "Banked resets are only available for Codex usage."); return; } - if (this._bankedResetCount !== null && this._bankedResetCount <= 0) { notify("Codex Meter", "No banked Codex resets are available to redeem."); return; } - const cancellable = new Gio.Cancellable(); this._redeemingBankedReset = true; this._redeemBankedResetCancellable = cancellable; this._syncMenu(); - try { await redeemNextCodexBankedReset({ cancellable }); - - if (this._destroyed) return; - + if (this._destroyed) + return; notify("Codex Meter", "Banked Codex reset redeemed."); await this._refreshBankedResets(cancellable); await this._refreshUsage({ manual: true }); - } catch (error) { - if (this._destroyed && isCancellationError(error)) return; - + } + catch (error) { + if (this._destroyed && isCancellationError(error)) + return; notify("Codex Meter", formatBankedResetFailure(error)); - } finally { + } + finally { if (this._redeemBankedResetCancellable === cancellable) { this._redeemBankedResetCancellable = null; } - - if (this._destroyed) return; - + if (this._destroyed) + return; this._redeemingBankedReset = false; this._syncMenu(); } } - _startRefreshSpin() { - if (!this._headerItem?.refreshIcon || this._refreshSpinId) return; - + if (!this._headerItem?.refreshIcon || this._refreshSpinId) + return; this._headerItem.refreshButton.reactive = false; this._headerItem.refreshButton.can_focus = false; - - this._refreshSpinId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT, - 30, - () => { - if (this._destroyed) return GLib.SOURCE_REMOVE; - - this._headerItem.refreshIcon.rotation_angle_z = - (this._headerItem.refreshIcon.rotation_angle_z + 18) % 360; - return GLib.SOURCE_CONTINUE; - }, - ); + this._refreshSpinId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { + if (this._destroyed) + return GLib.SOURCE_REMOVE; + this._headerItem.refreshIcon.rotation_angle_z = + (this._headerItem.refreshIcon.rotation_angle_z + 18) % 360; + return GLib.SOURCE_CONTINUE; + }); } - _stopRefreshSpin() { if (this._refreshSpinId) { GLib.source_remove(this._refreshSpinId); this._refreshSpinId = 0; } - - if (!this._headerItem?.refreshIcon) return; - + if (!this._headerItem?.refreshIcon) + return; this._headerItem.refreshIcon.rotation_angle_z = 0; this._headerItem.refreshButton.reactive = true; this._headerItem.refreshButton.can_focus = true; } - _syncLabel() { const settings = this._settings.getAll(); - const viewModel = createPanelBarViewModel( - settings, - this._snapshot, - this._errorMessage, - ); - + const viewModel = createPanelBarViewModel(settings, this._snapshot, this._errorMessage); this._panelPrimaryBar.actor.visible = viewModel.primaryVisible; this._panelSecondaryBar.actor.visible = viewModel.secondaryVisible; this._panelPrimaryBar.update({ @@ -621,32 +520,24 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._panelBars.visible = viewModel.showBars; if (viewModel.primaryVisible && viewModel.secondaryVisible) { this._panelBars.add_style_class_name("cx-panel-bars-stacked"); - } else { + } + else { this._panelBars.remove_style_class_name("cx-panel-bars-stacked"); } this._label.visible = viewModel.showLabel && viewModel.label !== ""; this._label.text = viewModel.label; if (viewModel.label.includes("/")) { this._label.add_style_class_name("cx-usage-label-wide"); - } else { + } + else { this._label.remove_style_class_name("cx-usage-label-wide"); } this._prefixLabel.visible = settings.topPanelIndicatorIcon === "text"; this._codexIcon.visible = settings.topPanelIndicatorIcon === "codex"; this._openAiIcon.visible = settings.topPanelIndicatorIcon === "openai"; - } - _syncMenu() { - const viewModel = createMenuViewModel( - this._settings.getAll(), - this._snapshot, - this._prediction, - this._history, - this._errorMessage, - this._cachedFailureMessage, - ); - + const viewModel = createMenuViewModel(this._settings.getAll(), this._snapshot, this._prediction, this._history, this._errorMessage, this._cachedFailureMessage); this._headerItem.datetimeLabel.text = viewModel.updatedAt; this._headerItem.datetimeLabel.visible = false; this._popupMenu.setError(viewModel.errorMessage); @@ -665,55 +556,41 @@ export class CodexMeterIndicator extends PanelMenu.Button { }); this._footerItem.planLabel.text = viewModel.plan; } - _queueMenuBarSync() { - if (this._menuSyncId) return; - + if (this._menuSyncId) + return; this._menuSyncId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { this._menuSyncId = 0; this._popupMenu.syncBars(); return GLib.SOURCE_REMOVE; }); } - _setUsageItem(item, viewModel) { this._popupMenu.setUsageItem(item, viewModel); } - } - -function createUsageService(providerId: ProviderId): UsageService { +function createUsageService(providerId) { try { return new UsageService(getUsageProvider(providerId)); - } catch (error) { - console.warn( - `Usage provider "${providerId}" is not supported yet; falling back to ${DEFAULT_PROVIDER_ID}.`, - error, - ); + } + catch (error) { + console.warn(`Usage provider "${providerId}" is not supported yet; falling back to ${DEFAULT_PROVIDER_ID}.`, error); return new UsageService(getUsageProvider(DEFAULT_PROVIDER_ID)); } } - -function formatBankedResetFailure(error: unknown): string { +function formatBankedResetFailure(error) { if (isRefreshFailureError(error)) { return error.message; } - - const message = - error instanceof Error && error.message - ? error.message - : "Unknown banked reset failure"; - + const message = error instanceof Error && error.message + ? error.message + : "Unknown banked reset failure"; return `Banked Codex reset redemption failed: ${message}`; } - -function notify(title: string, message: string): void { - (Main as any).notify(title, message); +function notify(title, message) { + Main.notify(title, message); } - -function isCancellationError(error: unknown): boolean { - return ( - error instanceof GLib.Error && - error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) - ); +function isCancellationError(error) { + return (error instanceof GLib.Error && + error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)); } diff --git a/src/ui/popup-menu.ts b/src/ui/popup-menu.js similarity index 91% rename from src/ui/popup-menu.ts rename to src/ui/popup-menu.js index 33b8703..37d4003 100644 --- a/src/ui/popup-menu.ts +++ b/src/ui/popup-menu.js @@ -1,8 +1,6 @@ import Clutter from "gi://Clutter"; import St from "gi://St"; - import * as PopupMenu from "resource:///org/gnome/shell/ui/popupMenu.js"; - import { createIconButton, createTextButton } from "./button.js"; import { UsageBar } from "./usage-bar.js"; const POPUP_CONTENT_WIDTH = 285; @@ -11,23 +9,20 @@ const TREND_BAR_MAX_HEIGHT = 28; const TREND_BAR_MIN_HEIGHT = 3; const TREND_BAR_SPACING = 2; export class CodexMeterPopupMenu { - headerItem: any; - errorItem: any; - statusItem: any; - primaryItem: any; - secondaryItem: any; - trendItem: any; - footerItem: any; - - private _onRefresh: () => void; - private _onRedeemBankedReset: () => void; - private _onOpenPreferences: () => void; - + headerItem; + errorItem; + statusItem; + primaryItem; + secondaryItem; + trendItem; + footerItem; + _onRefresh; + _onRedeemBankedReset; + _onOpenPreferences; constructor({ onRefresh, onRedeemBankedReset, onOpenPreferences }) { this._onRefresh = onRefresh; this._onRedeemBankedReset = onRedeemBankedReset; this._onOpenPreferences = onOpenPreferences; - this.headerItem = this._createHeaderItem(); this.errorItem = this._createErrorItem(); this.statusItem = this._createStatusItem(); @@ -36,7 +31,6 @@ export class CodexMeterPopupMenu { this.trendItem = this._createTrendItem(); this.footerItem = this._createFooterItem(); } - addToMenu(menu) { menu.addMenuItem(this.headerItem); menu.addMenuItem(this.errorItem); @@ -47,34 +41,29 @@ export class CodexMeterPopupMenu { menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); menu.addMenuItem(this.footerItem); } - syncBars() { this.primaryItem.usageBar.sync(); this.secondaryItem.usageBar.sync(); } - setTrend(viewModel) { this.trendItem.visible = viewModel.visible; this.trendItem.titleLabel.text = viewModel.title; this._updateTrendBarWidths(); - for (let index = 0; index < this.trendItem.bars.length; index += 1) { const bar = this.trendItem.bars[index]; const value = viewModel.bars[index] ?? 0; - bar.visible = index < viewModel.bars.length; bar.height = value > 0 ? Math.round(TREND_BAR_MAX_HEIGHT * (value / 100)) : TREND_BAR_MIN_HEIGHT; - if (value > 0) { bar.remove_style_class_name("cx-trend-bar-empty"); - } else { + } + else { bar.add_style_class_name("cx-trend-bar-empty"); } } } - setUsageItem(item, viewModel) { item.visible = viewModel.visible; item.titleLabel.text = viewModel.title; @@ -89,10 +78,8 @@ export class CodexMeterPopupMenu { displayBaselinePercentValue: viewModel.displayBaselinePercentValue, }); } - setError(message) { const hasError = Boolean(message); - this.errorItem.visible = hasError; this.primaryItem.visible = !hasError; this.secondaryItem.visible = !hasError; @@ -100,16 +87,13 @@ export class CodexMeterPopupMenu { this.errorItem.message = message ?? ""; this.errorItem.messageLabel.text = message ?? ""; } - setStatus({ title, message, visible }) { this.statusItem.visible = Boolean(visible); this.statusItem.titleLabel.text = title ?? ""; this.statusItem.messageLabel.text = message ?? ""; } - setBankedResets({ count, visible, redeeming }) { const canRedeem = !redeeming && count !== null && count > 0; - this.headerItem.redeemButton.visible = visible && count !== null; this.headerItem.redeemButton.reactive = canRedeem; this.headerItem.redeemButton.can_focus = canRedeem; @@ -118,39 +102,32 @@ export class CodexMeterPopupMenu { ? "Resetting…" : `Reset limits (${count ?? 0})`; } - - private _createUsageItem(title) { + _createUsageItem(title) { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false, - }) as any; - + }); const box = new St.BoxLayout({ vertical: true, x_expand: true, style_class: "cx-usage-menu-item", }); - const headingBox = new St.BoxLayout({ x_expand: true, style_class: "cx-usage-heading-row", }); - const titleLabel = new St.Label({ text: title, style_class: "cx-usage-heading cx-usage-title", x_expand: true, y_align: Clutter.ActorAlign.CENTER, }); - const valueLabel = new St.Label({ text: "-- used", style_class: "cx-usage-heading cx-usage-value", y_align: Clutter.ActorAlign.CENTER, }); - const usageBar = new UsageBar("menu"); - const detailBox = new St.BoxLayout({ x_expand: true, style_class: "cx-usage-detail", @@ -167,13 +144,10 @@ export class CodexMeterPopupMenu { y_align: Clutter.ActorAlign.CENTER, style_class: "cx-usage-detail-muted", }); - detailBox.add_child(predictionLabel); detailBox.add_child(resetLabel); - headingBox.add_child(titleLabel); headingBox.add_child(valueLabel); - box.add_child(headingBox); box.add_child(usageBar.actor); box.add_child(detailBox); @@ -183,29 +157,24 @@ export class CodexMeterPopupMenu { item.usageBar = usageBar; item.predictionLabel = predictionLabel; item.resetLabel = resetLabel; - return item; } - - private _createTrendItem() { + _createTrendItem() { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false, - }) as any; - + }); const box = new St.BoxLayout({ vertical: true, x_expand: true, style_class: "cx-trend-row", }); - const titleLabel = new St.Label({ text: "Weekly activity", x_expand: true, y_align: Clutter.ActorAlign.CENTER, style_class: "cx-usage-heading cx-trend-title", }); - const sparklineBox = new St.BoxLayout({ x_expand: true, y_align: Clutter.ActorAlign.END, @@ -215,19 +184,16 @@ export class CodexMeterPopupMenu { this._updateTrendBarWidths(); }); const bars = []; - for (let index = 0; index < TREND_BAR_COUNT; index += 1) { const bar = new St.Widget({ y_align: Clutter.ActorAlign.END, style_class: "cx-trend-bar", }); - bar.height = 0; bar.visible = false; bars.push(bar); sparklineBox.add_child(bar); } - box.add_child(titleLabel); box.add_child(sparklineBox); item.add_child(box); @@ -235,11 +201,9 @@ export class CodexMeterPopupMenu { item.sparklineBox = sparklineBox; item.bars = bars; item.visible = false; - return item; } - - private _createErrorItem() { + _createErrorItem() { const item = this._createBannerItem({ iconName: "dialog-error-symbolic", title: "Unable to load usage", @@ -247,11 +211,9 @@ export class CodexMeterPopupMenu { messageStyleClass: "cx-error-message-danger", }); item.message = ""; - return item; } - - private _createStatusItem() { + _createStatusItem() { return this._createBannerItem({ iconName: "dialog-warning-symbolic", title: "", @@ -259,38 +221,32 @@ export class CodexMeterPopupMenu { messageStyleClass: "cx-error-message-warning", }); } - - private _createBannerItem({ iconName, title, colorStyleClass, messageStyleClass }) { + _createBannerItem({ iconName, title, colorStyleClass, messageStyleClass }) { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false, - }) as any; - + }); const box = new St.BoxLayout({ vertical: true, style_class: "cx-error-menu-item", }); box.width = POPUP_CONTENT_WIDTH; - const headingBox = new St.BoxLayout({ x_expand: true, y_align: Clutter.ActorAlign.CENTER, style_class: "cx-error-heading-row", }); - const icon = new St.Icon({ icon_name: iconName, style_class: `popup-menu-icon ${colorStyleClass}`, y_align: Clutter.ActorAlign.CENTER, }); - const titleLabel = new St.Label({ text: title, x_expand: true, y_align: Clutter.ActorAlign.CENTER, style_class: `cx-error-title ${colorStyleClass}`, }); - const messageLabel = new St.Label({ text: "", x_expand: true, @@ -298,7 +254,6 @@ export class CodexMeterPopupMenu { }); messageLabel.clutter_text.line_wrap = true; messageLabel.clutter_text.ellipsize = 0; - headingBox.add_child(icon); headingBox.add_child(titleLabel); box.add_child(headingBox); @@ -307,52 +262,39 @@ export class CodexMeterPopupMenu { item.visible = false; item.titleLabel = titleLabel; item.messageLabel = messageLabel; - return item; } - - private _createHeaderItem() { + _createHeaderItem() { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false, - }) as any; - + }); const box = new St.BoxLayout({ vertical: true, x_expand: true, style_class: "cx-header-item", }); - const topRow = new St.BoxLayout({ x_expand: true, y_align: Clutter.ActorAlign.CENTER, style_class: "cx-header-row", }); - - const { - button: redeemButton, - label: redeemButtonLabel, - } = createTextButton({ + const { button: redeemButton, label: redeemButtonLabel, } = createTextButton({ text: "Reset limits (0)", styleClass: "cx-reset-button", onClick: button => { - if (!button.reactive) return; - + if (!button.reactive) + return; this._onRedeemBankedReset(); }, }); - - const { - button: refreshButton, - icon: refreshIcon, - } = createIconButton({ + const { button: refreshButton, icon: refreshIcon, } = createIconButton({ iconName: "view-refresh-symbolic", accessibleName: "Refresh usage", styleClass: "cx-footer-button", onClick: () => this._onRefresh(), }); refreshIcon.set_pivot_point(0.5, 0.5); - const datetimeLabel = new St.Label({ text: "--", x_expand: true, @@ -360,7 +302,6 @@ export class CodexMeterPopupMenu { y_align: Clutter.ActorAlign.CENTER, style_class: "cx-header-detail", }); - topRow.add_child(redeemButton); topRow.add_child(new St.Widget({ x_expand: true })); topRow.add_child(datetimeLabel); @@ -372,77 +313,63 @@ export class CodexMeterPopupMenu { item.redeemButtonLabel = redeemButtonLabel; item.refreshIcon = refreshIcon; item.refreshButton = refreshButton; - return item; } - - - private _createFooterItem() { + _createFooterItem() { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, can_focus: false, - }) as any; - + }); const box = new St.BoxLayout({ x_expand: true, y_align: Clutter.ActorAlign.CENTER, style_class: "cx-footer-row", }); - const planLabel = new St.Label({ text: "--", x_expand: true, y_align: Clutter.ActorAlign.CENTER, style_class: "cx-footer-label", }); - const { button: settingsButton } = createIconButton({ iconName: "preferences-system-symbolic", accessibleName: "Open preferences", styleClass: "cx-footer-button", onClick: () => this._onOpenPreferences(), }); - box.add_child(planLabel); box.add_child(settingsButton); item.add_child(box); item.planLabel = planLabel; item.settingsButton = settingsButton; - return item; } - - private _updateTrendBarWidths() { - if (!this.trendItem?.sparklineBox || !this.trendItem?.bars) return; - + _updateTrendBarWidths() { + if (!this.trendItem?.sparklineBox || !this.trendItem?.bars) + return; const visibleBars = this.trendItem.bars.length; - if (visibleBars <= 0 || this.trendItem.sparklineBox.width <= 0) return; - + if (visibleBars <= 0 || this.trendItem.sparklineBox.width <= 0) + return; const totalSpacing = TREND_BAR_SPACING * Math.max(0, visibleBars - 1); const availableWidth = Math.max(visibleBars, this.trendItem.sparklineBox.width - totalSpacing); const baseBarWidth = Math.max(1, Math.floor(availableWidth / visibleBars)); const extraPixels = Math.max(0, availableWidth - (baseBarWidth * visibleBars)); - for (let index = 0; index < this.trendItem.bars.length; index += 1) { this.trendItem.bars[index].width = baseBarWidth + (index < extraPixels ? 1 : 0); } } - } - function setPredictionStyleClass(label, style) { removeColorStyleClasses(label); - label.add_style_class_name(getPredictionColorStyleClass(style)); } - function getPredictionColorStyleClass(style) { - if (style === "safe") return "cx-color-green"; - if (style === "muted") return "cx-muted"; - + if (style === "safe") + return "cx-color-green"; + if (style === "muted") + return "cx-muted"; return `cx-color-${style}`; } - function removeColorStyleClasses(actor) { actor.remove_style_class_name("cx-color-green"); actor.remove_style_class_name("cx-color-warning"); diff --git a/src/ui/preferences-pages.js b/src/ui/preferences-pages.js new file mode 100644 index 0000000..a9290d7 --- /dev/null +++ b/src/ui/preferences-pages.js @@ -0,0 +1,295 @@ +import Adw from "gi://Adw"; +import Gio from "gi://Gio"; +import GObject from "gi://GObject"; +import Gtk from "gi://Gtk"; +import { MIN_REFRESH_INTERVAL_MINUTES, SETTINGS_AUTO_APPLY_BANKED_RESET, SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, SETTINGS_PERCENT_DISPLAY_MODE, SETTINGS_SHOW_PRIMARY, SETTINGS_SHOW_SECONDARY, SETTINGS_TOP_PANEL_DISPLAY_MODE, SETTINGS_TOP_PANEL_INDICATOR_ICON, } from "../app/settings.js"; +export const PreferencesPage = GObject.registerClass(class PreferencesPage extends Adw.PreferencesPage { + _init(settings, snapshot, metadata) { + super._init({ + title: "Codex Meter", + icon_name: "codex-symbolic", + }); + this.add(createBehaviorGroup(settings)); + this.add(createTopPanelGroup(settings)); + this.add(createBankedResetsGroup(settings, snapshot)); + this.add(createFooterGroup(metadata)); + } +}); +function createBankedResetsGroup(settings, snapshot) { + const group = new Adw.PreferencesGroup({ title: "Banked resets" }); + group.add(createBoundSwitchRow({ + settings, + key: SETTINGS_AUTO_APPLY_BANKED_RESET, + title: "Auto-apply expiring resets", + subtitle: "Apply a banked reset automatically within 1 hour of expiry", + })); + if (!snapshot) { + group.add(new Adw.ActionRow({ title: getNoCodexCreditSnapshotMessage() })); + return group; + } + if (snapshot.credits.length === 0) { + group.add(new Adw.ActionRow({ + title: "No banked Codex resets are available.", + })); + return group; + } + for (const credit of snapshot.credits) { + group.add(createCreditRow(credit)); + } + return group; +} +function createCreditRow(credit) { + const row = new Adw.PreferencesRow(); + const box = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + spacing: 6, + margin_top: 12, + margin_bottom: 12, + margin_start: 12, + margin_end: 12, + }); + const titleRow = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 12, + }); + const title = new Gtk.Label({ + label: createCreditTitle(credit), + xalign: 0, + wrap: true, + hexpand: true, + halign: Gtk.Align.FILL, + }); + const description = new Gtk.Label({ + label: credit.description ?? "", + xalign: 0, + wrap: true, + hexpand: true, + halign: Gtk.Align.FILL, + }); + const status = new Gtk.Label({ + label: formatCreditStatus(credit), + valign: Gtk.Align.CENTER, + halign: Gtk.Align.END, + }); + const dates = new Gtk.Box({ + orientation: Gtk.Orientation.HORIZONTAL, + spacing: 12, + }); + const granted = new Gtk.Label({ + label: `Granted ${formatCreditDate(credit.granted_at)}`, + xalign: 0, + hexpand: true, + halign: Gtk.Align.FILL, + }); + const expires = new Gtk.Label({ + label: `Expires ${formatCreditDate(credit.expires_at)}`, + xalign: 1, + halign: Gtk.Align.END, + }); + title.add_css_class("heading"); + description.add_css_class("dim-label"); + granted.add_css_class("dim-label"); + expires.add_css_class("dim-label"); + status.add_css_class("dim-label"); + titleRow.append(title); + titleRow.append(status); + dates.append(granted); + dates.append(expires); + box.append(titleRow); + box.append(description); + box.append(dates); + row.set_child(box); + return row; +} +function getNoCodexCreditSnapshotMessage() { + return "No Codex credit snapshot is available yet. It will appear after the next successful panel refresh."; +} +function createCreditTitle(credit) { + const title = credit.title || "Codex rate limit reset"; + return credit.profile_user_id ? `${title} from ${credit.profile_user_id}` : title; +} +function formatCreditDate(value) { + if (!value) + return "Unknown"; + const date = new Date(value); + if (!Number.isFinite(date.getTime())) + return "Unknown"; + return date.toLocaleString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} +function formatCreditStatus(credit) { + return credit.status === "available" ? "Available" : credit.status; +} +function createTopPanelGroup(settings) { + const group = new Adw.PreferencesGroup({ + title: "Top panel indicator", + }); + group.add(createTopPanelIndicatorIconRow(settings)); + group.add(createTopPanelStyleRow(settings)); + group.add(createBoundSwitchRow({ + settings, + key: SETTINGS_SHOW_PRIMARY, + title: "Display 5-hour session usage", + })); + group.add(createBoundSwitchRow({ + settings, + key: SETTINGS_SHOW_SECONDARY, + title: "Display weekly usage", + })); + return group; +} +function createBehaviorGroup(settings) { + const group = new Adw.PreferencesGroup({ + title: "Behavior", + }); + group.add(createPercentDisplayModeRow(settings)); + group.add(createRefreshIntervalRow(settings)); + return group; +} +function createTopPanelStyleRow(settings) { + const row = new Adw.ComboRow({ + title: "Show percentages as", + model: Gtk.StringList.new([ + "Progress bars", + "Raw percentages", + ]), + selected: getTopPanelDisplayModeIndex(settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE)), + }); + row.connect("notify::selected", () => { + settings.set_string(SETTINGS_TOP_PANEL_DISPLAY_MODE, getTopPanelDisplayModeValue(row.selected)); + }); + settings.connect(`changed::${SETTINGS_TOP_PANEL_DISPLAY_MODE}`, () => { + row.selected = getTopPanelDisplayModeIndex(settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE)); + }); + return row; +} +function createPercentDisplayModeRow(settings) { + const row = new Adw.ComboRow({ + title: "Display mode", + model: Gtk.StringList.new(["Percent used", "Percent left"]), + selected: getPercentDisplayModeIndex(settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE)), + }); + row.connect("notify::selected", () => { + settings.set_string(SETTINGS_PERCENT_DISPLAY_MODE, getPercentDisplayModeValue(row.selected)); + }); + settings.connect(`changed::${SETTINGS_PERCENT_DISPLAY_MODE}`, () => { + row.selected = getPercentDisplayModeIndex(settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE)); + }); + return row; +} +function createTopPanelIndicatorIconRow(settings) { + const row = new Adw.ComboRow({ + title: "Icon", + model: Gtk.StringList.new(["Codex icon", "OpenAI icon", "CX shortcode"]), + selected: getTopPanelIndicatorIconIndex(settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON)), + }); + row.connect("notify::selected", () => { + settings.set_string(SETTINGS_TOP_PANEL_INDICATOR_ICON, getTopPanelIndicatorIconValue(row.selected)); + }); + settings.connect(`changed::${SETTINGS_TOP_PANEL_INDICATOR_ICON}`, () => { + row.selected = getTopPanelIndicatorIconIndex(settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON)); + }); + return row; +} +function createBoundSwitchRow({ settings, key, title, subtitle, }) { + const rowOptions = { + title, + active: settings.get_boolean(key), + }; + if (subtitle !== undefined) { + rowOptions.subtitle = subtitle; + } + const row = new Adw.SwitchRow(rowOptions); + settings.bind(key, row, "active", Gio.SettingsBindFlags.DEFAULT); + return row; +} +function createRefreshIntervalRow(settings) { + const row = new Adw.SpinRow({ + title: "Background refresh interval (minutes)", + subtitle: "Set to 0 to refresh manually", + adjustment: new Gtk.Adjustment({ + lower: MIN_REFRESH_INTERVAL_MINUTES, + upper: 60, + step_increment: 1, + page_increment: 5, + value: settings.get_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES), + }), + climb_rate: 1, + digits: 0, + }); + row.connect("notify::value", () => { + settings.set_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, Math.max(MIN_REFRESH_INTERVAL_MINUTES, Math.round(row.value))); + }); + settings.connect(`changed::${SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES}`, () => { + row.value = settings.get_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES); + }); + return row; +} +function createFooterGroup(metadata) { + const footerGroup = new Adw.PreferencesGroup(); + const footerBox = new Gtk.Box({ + orientation: Gtk.Orientation.VERTICAL, + spacing: 4, + margin_top: 12, + margin_bottom: 12, + }); + footerBox.append(new Gtk.Label({ + label: `${metadata.name} v${metadata["version-name"]}`, + css_classes: ["caption", "dim-label"], + })); + footerBox.append(new Gtk.Label({ + label: "Not affiliated with or endorsed by OpenAI.", + wrap: true, + justify: Gtk.Justification.CENTER, + css_classes: ["caption", "dim-label"], + })); + const links = new Gtk.Label({ + css_classes: ["caption", "dim-label"], + }); + links.set_markup(`Source code · Report a bug`); + footerBox.append(links); + footerGroup.add(footerBox); + return footerGroup; +} +function getTopPanelDisplayModeIndex(value) { + switch (value) { + case "percentages": + return 1; + default: + return 0; + } +} +function getTopPanelDisplayModeValue(selected) { + switch (selected) { + case 1: + return "percentages"; + default: + return "bars"; + } +} +function getTopPanelIndicatorIconIndex(value) { + if (value === "openai") + return 1; + if (value === "text") + return 2; + return 0; +} +function getTopPanelIndicatorIconValue(selected) { + if (selected === 1) + return "openai"; + if (selected === 2) + return "text"; + return "codex"; +} +function getPercentDisplayModeIndex(value) { + return value === "left" ? 1 : 0; +} +function getPercentDisplayModeValue(selected) { + return selected === 1 ? "left" : "used"; +} diff --git a/src/ui/preferences-pages.ts b/src/ui/preferences-pages.ts deleted file mode 100644 index cccfd79..0000000 --- a/src/ui/preferences-pages.ts +++ /dev/null @@ -1,439 +0,0 @@ -import Adw from "gi://Adw"; -import Gio from "gi://Gio"; -import GObject from "gi://GObject"; -import Gtk from "gi://Gtk"; - -import { - MIN_REFRESH_INTERVAL_MINUTES, - SETTINGS_AUTO_APPLY_BANKED_RESET, - SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, - SETTINGS_PERCENT_DISPLAY_MODE, - SETTINGS_SHOW_PRIMARY, - SETTINGS_SHOW_SECONDARY, - SETTINGS_TOP_PANEL_DISPLAY_MODE, - SETTINGS_TOP_PANEL_INDICATOR_ICON, - type PercentDisplayMode, - type TopPanelIndicatorIcon, - type TopPanelDisplayMode, -} from "../app/settings.js"; -import { - type CodexBankedResetCredit, - type CodexBankedResetListResponse, -} from "../infra/providers/codex_banked_resets.js"; - - -type Metadata = Record; - -export const PreferencesPage = GObject.registerClass( - class PreferencesPage extends Adw.PreferencesPage { - _init( - settings: Gio.Settings, - snapshot: CodexBankedResetListResponse | null, - metadata: Metadata, - ) { - super._init({ - title: "Codex Meter", - icon_name: "codex-symbolic", - }); - - this.add(createBehaviorGroup(settings)); - this.add(createTopPanelGroup(settings)); - this.add(createBankedResetsGroup(settings, snapshot)); - this.add(createFooterGroup(metadata)); - } - }, -); - -function createBankedResetsGroup( - settings: Gio.Settings, - snapshot: CodexBankedResetListResponse | null, -): Adw.PreferencesGroup { - const group = new Adw.PreferencesGroup({ title: "Banked resets" }); - group.add( - createBoundSwitchRow({ - settings, - key: SETTINGS_AUTO_APPLY_BANKED_RESET, - title: "Auto-apply expiring resets", - subtitle: "Apply a banked reset automatically within 1 hour of expiry", - }), - ); - - if (!snapshot) { - group.add(new Adw.ActionRow({ title: getNoCodexCreditSnapshotMessage() })); - return group; - } - - if (snapshot.credits.length === 0) { - group.add(new Adw.ActionRow({ - title: "No banked Codex resets are available.", - })); - return group; - } - - for (const credit of snapshot.credits) { - group.add(createCreditRow(credit)); - } - - return group; -} - -function createCreditRow(credit: CodexBankedResetCredit): Adw.PreferencesRow { - const row = new Adw.PreferencesRow(); - const box = new Gtk.Box({ - orientation: Gtk.Orientation.VERTICAL, - spacing: 6, - margin_top: 12, - margin_bottom: 12, - margin_start: 12, - margin_end: 12, - }); - const titleRow = new Gtk.Box({ - orientation: Gtk.Orientation.HORIZONTAL, - spacing: 12, - }); - const title = new Gtk.Label({ - label: createCreditTitle(credit), - xalign: 0, - wrap: true, - hexpand: true, - halign: Gtk.Align.FILL, - }); - const description = new Gtk.Label({ - label: credit.description ?? "", - xalign: 0, - wrap: true, - hexpand: true, - halign: Gtk.Align.FILL, - }); - const status = new Gtk.Label({ - label: formatCreditStatus(credit), - valign: Gtk.Align.CENTER, - halign: Gtk.Align.END, - }); - const dates = new Gtk.Box({ - orientation: Gtk.Orientation.HORIZONTAL, - spacing: 12, - }); - const granted = new Gtk.Label({ - label: `Granted ${formatCreditDate(credit.granted_at)}`, - xalign: 0, - hexpand: true, - halign: Gtk.Align.FILL, - }); - const expires = new Gtk.Label({ - label: `Expires ${formatCreditDate(credit.expires_at)}`, - xalign: 1, - halign: Gtk.Align.END, - }); - - title.add_css_class("heading"); - description.add_css_class("dim-label"); - granted.add_css_class("dim-label"); - expires.add_css_class("dim-label"); - status.add_css_class("dim-label"); - - titleRow.append(title); - titleRow.append(status); - dates.append(granted); - dates.append(expires); - box.append(titleRow); - box.append(description); - box.append(dates); - row.set_child(box); - - return row; -} - -function getNoCodexCreditSnapshotMessage(): string { - return "No Codex credit snapshot is available yet. It will appear after the next successful panel refresh."; -} - -function createCreditTitle(credit: CodexBankedResetCredit): string { - const title = credit.title || "Codex rate limit reset"; - - return credit.profile_user_id ? `${title} from ${credit.profile_user_id}` : title; -} - -function formatCreditDate(value?: string | null): string { - if (!value) return "Unknown"; - - const date = new Date(value); - if (!Number.isFinite(date.getTime())) return "Unknown"; - - return date.toLocaleString("en-GB", { - day: "2-digit", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); -} - -function formatCreditStatus(credit: CodexBankedResetCredit): string { - return credit.status === "available" ? "Available" : credit.status; -} - - -function createTopPanelGroup(settings: Gio.Settings) { - const group = new Adw.PreferencesGroup({ - title: "Top panel indicator", - }); - - group.add(createTopPanelIndicatorIconRow(settings)); - group.add(createTopPanelStyleRow(settings)); - group.add( - createBoundSwitchRow({ - settings, - key: SETTINGS_SHOW_PRIMARY, - title: "Display 5-hour session usage", - }), - ); - group.add( - createBoundSwitchRow({ - settings, - key: SETTINGS_SHOW_SECONDARY, - title: "Display weekly usage", - }), - ); - - return group; -} - -function createBehaviorGroup(settings: Gio.Settings) { - const group = new Adw.PreferencesGroup({ - title: "Behavior", - }); - - group.add(createPercentDisplayModeRow(settings)); - group.add(createRefreshIntervalRow(settings)); - - return group; -} - - - -function createTopPanelStyleRow(settings: Gio.Settings) { - const row = new Adw.ComboRow({ - title: "Show percentages as", - model: Gtk.StringList.new([ - "Progress bars", - "Raw percentages", - ]), - selected: getTopPanelDisplayModeIndex( - settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE), - ), - }); - - row.connect("notify::selected", () => { - settings.set_string( - SETTINGS_TOP_PANEL_DISPLAY_MODE, - getTopPanelDisplayModeValue(row.selected), - ); - }); - - settings.connect(`changed::${SETTINGS_TOP_PANEL_DISPLAY_MODE}`, () => { - row.selected = getTopPanelDisplayModeIndex( - settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE), - ); - }); - - return row; -} - -function createPercentDisplayModeRow(settings: Gio.Settings) { - const row = new Adw.ComboRow({ - title: "Display mode", - model: Gtk.StringList.new(["Percent used", "Percent left"]), - selected: getPercentDisplayModeIndex( - settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE), - ), - }); - - row.connect("notify::selected", () => { - settings.set_string( - SETTINGS_PERCENT_DISPLAY_MODE, - getPercentDisplayModeValue(row.selected), - ); - }); - - settings.connect(`changed::${SETTINGS_PERCENT_DISPLAY_MODE}`, () => { - row.selected = getPercentDisplayModeIndex( - settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE), - ); - }); - - return row; -} - -function createTopPanelIndicatorIconRow(settings: Gio.Settings) { - const row = new Adw.ComboRow({ - title: "Icon", - model: Gtk.StringList.new(["Codex icon", "OpenAI icon", "CX shortcode"]), - selected: getTopPanelIndicatorIconIndex( - settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON), - ), - }); - - row.connect("notify::selected", () => { - settings.set_string( - SETTINGS_TOP_PANEL_INDICATOR_ICON, - getTopPanelIndicatorIconValue(row.selected), - ); - }); - - settings.connect(`changed::${SETTINGS_TOP_PANEL_INDICATOR_ICON}`, () => { - row.selected = getTopPanelIndicatorIconIndex( - settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON), - ); - }); - - return row; -} - -function createBoundSwitchRow({ - settings, - key, - title, - subtitle, -}: { - settings: Gio.Settings; - key: string; - title: string; - subtitle?: string; -}) { - const rowOptions: { - title: string; - active: boolean; - subtitle?: string; - } = { - title, - active: settings.get_boolean(key), - }; - - if (subtitle !== undefined) { - rowOptions.subtitle = subtitle; - } - - const row = new Adw.SwitchRow(rowOptions); - - settings.bind(key, row, "active", Gio.SettingsBindFlags.DEFAULT); - - return row; -} - -function createRefreshIntervalRow(settings: Gio.Settings) { - const row = new Adw.SpinRow({ - title: "Background refresh interval (minutes)", - subtitle: "Set to 0 to refresh manually", - adjustment: new Gtk.Adjustment({ - lower: MIN_REFRESH_INTERVAL_MINUTES, - upper: 60, - step_increment: 1, - page_increment: 5, - value: settings.get_uint( - SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, - ), - }), - climb_rate: 1, - digits: 0, - }); - - row.connect("notify::value", () => { - settings.set_uint( - SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, - Math.max(MIN_REFRESH_INTERVAL_MINUTES, Math.round(row.value)), - ); - }); - - settings.connect( - `changed::${SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES}`, - () => { - row.value = settings.get_uint( - SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, - ); - }, - ); - - return row; -} - -function createFooterGroup(metadata: Metadata): Adw.PreferencesGroup { - const footerGroup = new Adw.PreferencesGroup(); - const footerBox = new Gtk.Box({ - orientation: Gtk.Orientation.VERTICAL, - spacing: 4, - margin_top: 12, - margin_bottom: 12, - }); - - footerBox.append( - new Gtk.Label({ - label: `${metadata.name} v${metadata["version-name"]}`, - css_classes: ["caption", "dim-label"], - }), - ); - - footerBox.append( - new Gtk.Label({ - label: "Not affiliated with or endorsed by OpenAI.", - wrap: true, - justify: Gtk.Justification.CENTER, - css_classes: ["caption", "dim-label"], - }), - ); - - const links = new Gtk.Label({ - css_classes: ["caption", "dim-label"], - }); - links.set_markup( - `Source code · Report a bug`, - ); - footerBox.append(links); - - footerGroup.add(footerBox); - return footerGroup; -} - - - - -function getTopPanelDisplayModeIndex(value: string) { - switch (value as TopPanelDisplayMode) { - case "percentages": - return 1; - default: - return 0; - } -} - -function getTopPanelDisplayModeValue(selected: number): TopPanelDisplayMode { - switch (selected) { - case 1: - return "percentages"; - default: - return "bars"; - } -} - -function getTopPanelIndicatorIconIndex(value: string) { - if (value === "openai") return 1; - if (value === "text") return 2; - - return 0; -} - -function getTopPanelIndicatorIconValue(selected: number): TopPanelIndicatorIcon { - if (selected === 1) return "openai"; - if (selected === 2) return "text"; - - return "codex"; -} - -function getPercentDisplayModeIndex(value: string) { - return value === "left" ? 1 : 0; -} - -function getPercentDisplayModeValue(selected: number): PercentDisplayMode { - return selected === 1 ? "left" : "used"; -} diff --git a/src/ui/refresh-error-message.ts b/src/ui/refresh-error-message.js similarity index 76% rename from src/ui/refresh-error-message.ts rename to src/ui/refresh-error-message.js index db754ab..96aad03 100644 --- a/src/ui/refresh-error-message.ts +++ b/src/ui/refresh-error-message.js @@ -1,9 +1,7 @@ import { isRefreshFailureError } from "../domain/refresh-failure.js"; - -export function formatRefreshFailure(error: unknown): string { +export function formatRefreshFailure(error) { if (isRefreshFailureError(error)) { return error.message; } - return "Codex usage refresh failed. Try again later."; } diff --git a/src/ui/usage-bar.ts b/src/ui/usage-bar.js similarity index 69% rename from src/ui/usage-bar.ts rename to src/ui/usage-bar.js index 57c4cf7..352939c 100644 --- a/src/ui/usage-bar.ts +++ b/src/ui/usage-bar.js @@ -1,12 +1,6 @@ import Clutter from "gi://Clutter"; import St from "gi://St"; - -import { - calculateBarFillWidth, - calculateBarMarkerPosition, - getUsageBarColorStyleClass, -} from "./view-model.js"; - +import { calculateBarFillWidth, calculateBarMarkerPosition, getUsageBarColorStyleClass, } from "./view-model.js"; const BASELINE_MARKER_WIDTH = 3; const COLOR_STYLE_CLASSES = [ "cx-color-green", @@ -14,26 +8,14 @@ const COLOR_STYLE_CLASSES = [ "cx-color-danger", "cx-muted", ]; - -type UsageBarVariant = "menu" | "panel"; - -type UsageBarState = { - percentValue: number; - displayPercentValue: number; - baselinePercentValue?: number | null; - displayBaselinePercentValue?: number | null; -}; - export class UsageBar { - readonly actor: St.Widget; - - private readonly _variant: UsageBarVariant; - private readonly _barTrack: St.BoxLayout; - private readonly _barFill: St.Widget; - private readonly _barMarker: St.Widget | null; - private _state: UsageBarState; - - constructor(variant: UsageBarVariant) { + actor; + _variant; + _barTrack; + _barFill; + _barMarker; + _state; + constructor(variant) { this._variant = variant; this._state = { percentValue: 0, @@ -56,7 +38,6 @@ export class UsageBar { }); this._barTrack.add_child(this._barFill); this._barTrack.add_child(new St.Widget({ x_expand: true })); - if (variant === "menu") { const overlay = new St.Widget({ x_expand: true, @@ -77,52 +58,37 @@ export class UsageBar { overlay.connect("notify::width", () => this.sync()); overlay.connect("notify::height", () => this.sync()); this.actor = overlay; - } else { + } + else { this._barMarker = null; this._barTrack.connect("notify::width", () => this.sync()); this.actor = this._barTrack; } } - - update(state: UsageBarState) { + update(state) { this._state = state; this._syncColor(); this.sync(); } - sync() { const width = this.actor.width; - if (this._variant === "menu") { this._barTrack.width = width; this._barTrack.height = this.actor.height; } - - this._barFill.width = calculateBarFillWidth( - width, - this._state.displayPercentValue, - ); - - if (!this._barMarker) return; - + this._barFill.width = calculateBarFillWidth(width, this._state.displayPercentValue); + if (!this._barMarker) + return; this._barMarker.width = BASELINE_MARKER_WIDTH; this._barMarker.height = this.actor.height; this._barMarker.visible = Number.isFinite(this._state.baselinePercentValue); - this._barMarker.x = calculateBarMarkerPosition( - width, - this._barMarker.width, - this._state.displayBaselinePercentValue, - ); + this._barMarker.x = calculateBarMarkerPosition(width, this._barMarker.width, this._state.displayBaselinePercentValue); this._barMarker.y = 1; } - - private _syncColor() { + _syncColor() { for (const styleClass of COLOR_STYLE_CLASSES) { this._barFill.remove_style_class_name(styleClass); } - - this._barFill.add_style_class_name( - getUsageBarColorStyleClass(this._state.percentValue), - ); + this._barFill.add_style_class_name(getUsageBarColorStyleClass(this._state.percentValue)); } } diff --git a/src/ui/view-model.ts b/src/ui/view-model.js similarity index 53% rename from src/ui/view-model.ts rename to src/ui/view-model.js index b9e924d..0b2804a 100644 --- a/src/ui/view-model.ts +++ b/src/ui/view-model.js @@ -1,55 +1,4 @@ -import { UsagePrediction, WindowPrediction } from "../domain/prediction.js"; -import { getPrimaryQuota, getQuota, getSecondaryQuota, HistoryEntry, UsageQuota, UsageSnapshot } from "../domain/usage.js"; -import { type PercentDisplayMode } from "../app/settings.js"; - -export type UsageWindowType = "primary" | "secondary"; - -export type PredictionStyle = "safe" | "warning" | "danger" | "muted"; - -export type UsageItemViewModel = { - visible: boolean; - title: string; - value: string; - prediction: string; - reset: string; - percentValue: number; - displayPercentValue: number; - baselinePercentValue: number | null; - displayBaselinePercentValue: number | null; - percentLabel: PercentDisplayMode; - predictionStyle: PredictionStyle; -}; - -export type PanelBarViewModel = { - primaryVisible: boolean; - secondaryVisible: boolean; - primaryPercent: number; - secondaryPercent: number; - primaryDisplayPercent: number; - secondaryDisplayPercent: number; - showBars: boolean; - showLabel: boolean; - label: string; -}; - -export type UsageTrendViewModel = { - visible: boolean; - title: string; - bars: number[]; -}; - -export type MenuViewModel = { - updatedAt: string; - primary: UsageItemViewModel; - secondary: UsageItemViewModel; - plan: string; - trend: UsageTrendViewModel; - errorMessage: string | null; - statusTitle: string | null; - statusMessage: string | null; - hasError: boolean; -}; - +import { getQuota, getSecondaryQuota } from "../domain/usage.js"; export function createPanelBarViewModel(settings, snapshot, errorMessage) { const sessionQuota = getQuota(snapshot, "session"); const weeklyQuota = getQuota(snapshot, "weekly"); @@ -59,8 +8,7 @@ export function createPanelBarViewModel(settings, snapshot, errorMessage) { const hasTopPanelUsage = showPrimary || showSecondary; const showSplitBars = displayMode === "bars" && hasTopPanelUsage; const percentDisplayMode = settings.percentDisplayMode; - - const viewModel: PanelBarViewModel = { + const viewModel = { primaryVisible: showPrimary, secondaryVisible: showSecondary, primaryPercent: 0, @@ -71,64 +19,39 @@ export function createPanelBarViewModel(settings, snapshot, errorMessage) { showLabel: !showSplitBars, label: "", }; - if (errorMessage) { viewModel.primaryVisible = false; viewModel.secondaryVisible = false; viewModel.showBars = false; viewModel.showLabel = false; - return viewModel; } - if (snapshot) { viewModel.primaryPercent = normalizePercent(sessionQuota?.usedPercent); viewModel.secondaryPercent = normalizePercent(weeklyQuota?.usedPercent); - - viewModel.primaryDisplayPercent = convertPercentForDisplay( - viewModel.primaryPercent, - percentDisplayMode, - ); - viewModel.secondaryDisplayPercent = convertPercentForDisplay( - viewModel.secondaryPercent, - percentDisplayMode, - ); + viewModel.primaryDisplayPercent = convertPercentForDisplay(viewModel.primaryPercent, percentDisplayMode); + viewModel.secondaryDisplayPercent = convertPercentForDisplay(viewModel.secondaryPercent, percentDisplayMode); } - - if (!viewModel.showLabel) return viewModel; - - if (!hasTopPanelUsage) return viewModel; - + if (!viewModel.showLabel) + return viewModel; + if (!hasTopPanelUsage) + return viewModel; if (!snapshot) { viewModel.label = errorMessage ? "!" : "--"; return viewModel; } - const parts = []; - if (showPrimary) { parts.push(formatPercentForDisplay(sessionQuota?.usedPercent, percentDisplayMode)); } - if (showSecondary) { parts.push(formatPercentForDisplay(weeklyQuota?.usedPercent, percentDisplayMode)); } - viewModel.label = parts.join("/"); - return viewModel; } - -export function createMenuViewModel( - settings, - snapshot: UsageSnapshot, - prediction: UsagePrediction, - history: HistoryEntry[] = [], - errorMessage = null, - cachedFailureMessage = null, -) { +export function createMenuViewModel(settings, snapshot, prediction, history = [], errorMessage = null, cachedFailureMessage = null) { const percentDisplayMode = settings.percentDisplayMode; - if (errorMessage) { return { updatedAt: "--", @@ -152,7 +75,6 @@ export function createMenuViewModel( trend: createUsageTrendViewModel(null, []), }; } - if (!snapshot) { return { updatedAt: "--", @@ -174,10 +96,8 @@ export function createMenuViewModel( trend: createUsageTrendViewModel(null, []), }; } - const primaryQuota = getQuota(snapshot, "session"); const secondaryQuota = getQuota(snapshot, "weekly"); - return { updatedAt: "", errorMessage: null, @@ -187,64 +107,34 @@ export function createMenuViewModel( primary: createUsageItemViewModel({ visible: Boolean(primaryQuota), title: primaryQuota?.label ?? "Primary", - value: formatPercentForDisplay( - primaryQuota?.usedPercent, - percentDisplayMode, - ), - prediction: formatLimitPrediction( - primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary, - primaryQuota, - "primary", - ), + value: formatPercentForDisplay(primaryQuota?.usedPercent, percentDisplayMode), + prediction: formatLimitPrediction(primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary, primaryQuota, "primary"), reset: formatReset(primaryQuota, "primary"), percentValue: primaryQuota?.usedPercent, baselinePercentValue: calculateBaselinePercent(primaryQuota), percentDisplayMode, - predictionStyle: getPredictionStyleClass( - primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary, - ), + predictionStyle: getPredictionStyleClass(primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary), }), secondary: createUsageItemViewModel({ visible: Boolean(secondaryQuota), title: secondaryQuota?.label ?? "Secondary", - value: formatPercentForDisplay( - secondaryQuota?.usedPercent, - percentDisplayMode, - ), - prediction: formatLimitPrediction( - secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary, - secondaryQuota, - "secondary", - ), + value: formatPercentForDisplay(secondaryQuota?.usedPercent, percentDisplayMode), + prediction: formatLimitPrediction(secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary, secondaryQuota, "secondary"), reset: formatReset(secondaryQuota, "secondary"), percentValue: secondaryQuota?.usedPercent, baselinePercentValue: calculateBaselinePercent(secondaryQuota), percentDisplayMode, - predictionStyle: getPredictionStyleClass( - secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary, - ), + predictionStyle: getPredictionStyleClass(secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary), }), plan: formatFooter(snapshot), trend: createUsageTrendViewModel(snapshot, history), }; } - -export function createUsageItemViewModel({ - visible = true, - title, - value, - prediction = "", - reset = "resets in --", - percentValue = null, - baselinePercentValue = null, - percentDisplayMode = "used" as PercentDisplayMode, - predictionStyle = "muted" as PredictionStyle, -}): UsageItemViewModel { +export function createUsageItemViewModel({ visible = true, title, value, prediction = "", reset = "resets in --", percentValue = null, baselinePercentValue = null, percentDisplayMode = "used", predictionStyle = "muted", }) { const normalizedPercentValue = normalizePercent(percentValue); const normalizedBaselinePercentValue = Number.isFinite(baselinePercentValue) ? normalizePercent(baselinePercentValue) : null; - return { visible, title, @@ -252,53 +142,33 @@ export function createUsageItemViewModel({ prediction, reset, percentValue: normalizedPercentValue, - displayPercentValue: convertPercentForDisplay( - normalizedPercentValue, - percentDisplayMode, - ), + displayPercentValue: convertPercentForDisplay(normalizedPercentValue, percentDisplayMode), baselinePercentValue: normalizedBaselinePercentValue, displayBaselinePercentValue: normalizedBaselinePercentValue === null ? null - : convertPercentForDisplay( - normalizedBaselinePercentValue, - percentDisplayMode, - ), + : convertPercentForDisplay(normalizedBaselinePercentValue, percentDisplayMode), percentLabel: percentDisplayMode, predictionStyle, }; } - const TREND_TITLE = "Weekly activity"; const TREND_LOOKBACK_SECONDS = 7 * 24 * 60 * 60; const TREND_BUCKET_COUNT = 56; const TREND_MIN_BAR_PERCENT = 12; const MIN_BURN_RATE_OBSERVED_SECONDS = 6 * 60 * 60; - -type UsageTrendSample = { - timestamp: number; - usedPercent: number; -}; - -export function createUsageTrendViewModel( - snapshot: UsageSnapshot | null, - history: HistoryEntry[] = [], - nowSeconds = Date.now() / 1000, -): UsageTrendViewModel { +export function createUsageTrendViewModel(snapshot, history = [], nowSeconds = Date.now() / 1000) { const minTimestamp = nowSeconds - TREND_LOOKBACK_SECONDS; const samples = getUsageTrendSamples(snapshot, history, nowSeconds); - - if (samples.length < 2) return createHiddenUsageTrendViewModel(); - + if (samples.length < 2) + return createHiddenUsageTrendViewModel(); const bucketSize = TREND_LOOKBACK_SECONDS / TREND_BUCKET_COUNT; const bucketValues = Array(TREND_BUCKET_COUNT).fill(0); - for (let index = 1; index < samples.length; index += 1) { const previous = samples[index - 1]; const current = samples[index]; const delta = current.usedPercent - previous.usedPercent; - - if (delta <= 0 || delta > 100) continue; - + if (delta <= 0 || delta > 100) + continue; addTrendDeltaToBuckets({ bucketValues, delta, @@ -308,336 +178,234 @@ export function createUsageTrendViewModel( bucketSize, }); } - const maxValue = Math.max(...bucketValues); - - if (maxValue <= 0) return createHiddenUsageTrendViewModel(); - + if (maxValue <= 0) + return createHiddenUsageTrendViewModel(); return { visible: true, title: TREND_TITLE, bars: bucketValues.map((value) => { - if (value <= 0) return 0; - - return Math.max( - TREND_MIN_BAR_PERCENT, - Math.round((value / maxValue) * 100), - ); + if (value <= 0) + return 0; + return Math.max(TREND_MIN_BAR_PERCENT, Math.round((value / maxValue) * 100)); }), }; } - -function createHiddenUsageTrendViewModel(): UsageTrendViewModel { +function createHiddenUsageTrendViewModel() { return { visible: false, title: TREND_TITLE, bars: [], }; } - -export function getUsageTrendSamples( - snapshot: UsageSnapshot | null, - history: HistoryEntry[] = [], - nowSeconds = Date.now() / 1000, -): UsageTrendSample[] { +export function getUsageTrendSamples(snapshot, history = [], nowSeconds = Date.now() / 1000) { const quotaId = getSecondaryQuota(snapshot)?.id ?? "weekly"; const minTimestamp = nowSeconds - TREND_LOOKBACK_SECONDS; - return history .map((entry) => { - const timestamp = new Date(entry.timestamp).getTime() / 1000; - const quota = entry.quotas.find((item) => item.id === quotaId) ?? - entry.quotas.find((item) => item.id === "weekly"); - - return { - timestamp, - usedPercent: quota?.usedPercent, - }; - }) - .filter((entry): entry is UsageTrendSample => - Number.isFinite(entry.timestamp) && - entry.timestamp >= minTimestamp && - Number.isFinite(entry.usedPercent) - ) + const timestamp = new Date(entry.timestamp).getTime() / 1000; + const quota = entry.quotas.find((item) => item.id === quotaId) ?? + entry.quotas.find((item) => item.id === "weekly"); + return { + timestamp, + usedPercent: quota?.usedPercent, + }; + }) + .filter((entry) => Number.isFinite(entry.timestamp) && + entry.timestamp >= minTimestamp && + Number.isFinite(entry.usedPercent)) .toSorted((a, b) => a.timestamp - b.timestamp); } - -export function calculateRecentPositiveDelta(samples: UsageTrendSample[]): number { +export function calculateRecentPositiveDelta(samples) { let total = 0; - for (let index = 1; index < samples.length; index += 1) { const delta = samples[index].usedPercent - samples[index - 1].usedPercent; - - if (delta > 0 && delta <= 100) total += delta; + if (delta > 0 && delta <= 100) + total += delta; } - return total; } - -export function calculateAverageBurnRatePercentPerDay(samples: UsageTrendSample[]): number | null { - if (samples.length < 2) return null; - +export function calculateAverageBurnRatePercentPerDay(samples) { + if (samples.length < 2) + return null; const positiveDelta = calculateRecentPositiveDelta(samples); - if (positiveDelta <= 0) return null; - + if (positiveDelta <= 0) + return null; const observedSeconds = samples.at(-1).timestamp - samples[0].timestamp; - if (!Number.isFinite(observedSeconds) || observedSeconds <= 0) return null; - - const observedDays = Math.max( - observedSeconds, - MIN_BURN_RATE_OBSERVED_SECONDS, - ) / 86400; - + if (!Number.isFinite(observedSeconds) || observedSeconds <= 0) + return null; + const observedDays = Math.max(observedSeconds, MIN_BURN_RATE_OBSERVED_SECONDS) / 86400; return positiveDelta / observedDays; } - -function addTrendDeltaToBuckets({ - bucketValues, - delta, - fromTimestamp, - toTimestamp, - minTimestamp, - bucketSize, -}: { - bucketValues: number[]; - delta: number; - fromTimestamp: number; - toTimestamp: number; - minTimestamp: number; - bucketSize: number; -}) { +function addTrendDeltaToBuckets({ bucketValues, delta, fromTimestamp, toTimestamp, minTimestamp, bucketSize, }) { const start = Math.max(minTimestamp, fromTimestamp); const end = Math.max(start, toTimestamp); - if (end <= start) { const bucketIndex = getTrendBucketIndex(toTimestamp, minTimestamp, bucketSize); bucketValues[bucketIndex] += delta; return; } - const duration = end - start; const firstBucketIndex = getTrendBucketIndex(start, minTimestamp, bucketSize); const lastBucketIndex = getTrendBucketIndex(end, minTimestamp, bucketSize); - for (let index = firstBucketIndex; index <= lastBucketIndex; index += 1) { const bucketStart = minTimestamp + index * bucketSize; const bucketEnd = bucketStart + bucketSize; - const overlap = Math.max( - 0, - Math.min(end, bucketEnd) - Math.max(start, bucketStart), - ); - + const overlap = Math.max(0, Math.min(end, bucketEnd) - Math.max(start, bucketStart)); if (overlap > 0) { bucketValues[index] += delta * (overlap / duration); } } } - -function getTrendBucketIndex( - timestamp: number, - minTimestamp: number, - bucketSize: number, -): number { - return Math.min( - TREND_BUCKET_COUNT - 1, - Math.max(0, Math.floor((timestamp - minTimestamp) / bucketSize)), - ); +function getTrendBucketIndex(timestamp, minTimestamp, bucketSize) { + return Math.min(TREND_BUCKET_COUNT - 1, Math.max(0, Math.floor((timestamp - minTimestamp) / bucketSize))); } - -export function calculateBarFillWidth(trackWidth: number, percentValue: number): number { - if (trackWidth <= 0) return 0; - +export function calculateBarFillWidth(trackWidth, percentValue) { + if (trackWidth <= 0) + return 0; return Math.round(trackWidth * (normalizePercent(percentValue) / 100)); } - -export function calculateBarMarkerPosition( - trackWidth: number, - markerWidth: number, - percentValue: number | null, -): number { - if (trackWidth <= 0 || !Number.isFinite(percentValue)) return 0; - +export function calculateBarMarkerPosition(trackWidth, markerWidth, percentValue) { + if (trackWidth <= 0 || !Number.isFinite(percentValue)) + return 0; const usableWidth = Math.max(0, trackWidth - Math.max(0, markerWidth)); - return Math.round(usableWidth * (normalizePercent(percentValue) / 100)); } - -export function calculateBaselinePercent(quota: UsageQuota | null): number | null { - if (!quota) return null; - +export function calculateBaselinePercent(quota) { + if (!quota) + return null; const { limitWindowSeconds, resetAfterSeconds } = quota; - - if ( - !Number.isFinite(limitWindowSeconds) || + if (!Number.isFinite(limitWindowSeconds) || !Number.isFinite(resetAfterSeconds) || - limitWindowSeconds <= 0 - ) { + limitWindowSeconds <= 0) { return null; } - - return normalizePercent( - ((limitWindowSeconds - resetAfterSeconds) / limitWindowSeconds) * 100, - ); + return normalizePercent(((limitWindowSeconds - resetAfterSeconds) / limitWindowSeconds) * 100); } - -export function getUsageBarColorStyleClass(percentValue: number): string { +export function getUsageBarColorStyleClass(percentValue) { const percent = normalizePercent(percentValue); - - if (percent >= 100) return "cx-color-danger"; - - if (percent > 75) return "cx-color-warning"; - + if (percent >= 100) + return "cx-color-danger"; + if (percent > 75) + return "cx-color-warning"; return "cx-color-green"; } - -export function formatPercent(value: number): string { +export function formatPercent(value) { return Number.isFinite(value) ? `${value}%` : "--"; } - -export function formatPercentForDisplay( - value: number, - displayMode: PercentDisplayMode, -): string { - if (!Number.isFinite(value)) return "--"; - +export function formatPercentForDisplay(value, displayMode) { + if (!Number.isFinite(value)) + return "--"; return formatPercent(convertPercentForDisplay(value, displayMode)); } - -export function convertPercentForDisplay( - value: number, - displayMode: PercentDisplayMode, -): number { +export function convertPercentForDisplay(value, displayMode) { const percent = normalizePercent(value); - return displayMode === "left" ? 100 - percent : percent; } - -export function formatReset(quota: UsageQuota | null, windowType: UsageWindowType) { - if (!quota) return "resets in --"; - - if (quota.resetDescription) return quota.resetDescription; - +export function formatReset(quota, windowType) { + if (!quota) + return "resets in --"; + if (quota.resetDescription) + return quota.resetDescription; const useDate = windowType === "secondary" ? true : false; - const relative = formatDuration(quota.resetAfterSeconds, windowType); const absolute = formatUnixTimestamp(quota.resetAt, useDate); - - if (relative === "--" && absolute === "--") return "resets in --"; - - if (relative === "--") return `resets in -- (${absolute})`; - - if (absolute === "--") return `resets in ${relative}`; - + if (relative === "--" && absolute === "--") + return "resets in --"; + if (relative === "--") + return `resets in -- (${absolute})`; + if (absolute === "--") + return `resets in ${relative}`; return `resets in ${relative} (${absolute})`; } - -export function formatDuration(totalSeconds: number, windowType?: UsageWindowType) { - if (!Number.isFinite(totalSeconds)) return "--"; - +export function formatDuration(totalSeconds, windowType) { + if (!Number.isFinite(totalSeconds)) + return "--"; let remaining = Math.max(0, Math.floor(totalSeconds)); const days = Math.floor(remaining / 86400); remaining %= 86400; const hours = Math.floor(remaining / 3600); remaining %= 3600; const minutes = Math.floor(remaining / 60); - - if (windowType === "primary") return `${hours}h ${minutes}m`; - - if (windowType === "secondary") return `${days}d ${hours}h`; - + if (windowType === "primary") + return `${hours}h ${minutes}m`; + if (windowType === "secondary") + return `${days}d ${hours}h`; const parts = []; - - if (days) parts.push(`${days}d`); - - if (hours) parts.push(`${hours}h`); - - if (minutes || parts.length === 0) parts.push(`${minutes}m`); - + if (days) + parts.push(`${days}d`); + if (hours) + parts.push(`${hours}h`); + if (minutes || parts.length === 0) + parts.push(`${minutes}m`); return parts.join(" "); } - /** * Format a UNIX timestamp given in seconds into time or datetime string - * + * * @param value UNIX timestamp in seconds * @param date whether to format a datetime or time only * @returns formatted string */ -export function formatUnixTimestamp(value: number, date: boolean = true): string { - if (!Number.isFinite(value)) return "--"; - +export function formatUnixTimestamp(value, date = true) { + if (!Number.isFinite(value)) + return "--"; return formatTimestamp(new Date(value * 1000).toISOString(), date); } - /** * Format a ISO string timestamp into time or datetime string - * + * * @param value ISO string timestamp * @param date whether to format a datetime or time only * @returns formatted string */ -export function formatTimestamp(value: string, date: boolean = true): string { - if (!value) return "--"; - +export function formatTimestamp(value, date = true) { + if (!value) + return "--"; const datetimeFormat = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", hour: "numeric", minute: "2-digit", }); - const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit", }); - const format = date ? datetimeFormat : timeFormat; - try { return format.format(new Date(value)); - } catch (_error) { + } + catch (_error) { return "--"; } } - /** * Calculate seconds from now until timestamp - * + * * @param unixTimestamp UNIX timestamp in seconds - * @returns + * @returns */ -export function secondsUntil(unixTimestamp: number): number { - if (!Number.isFinite(unixTimestamp)) return null; - +export function secondsUntil(unixTimestamp) { + if (!Number.isFinite(unixTimestamp)) + return null; return Math.max(0, Math.round(unixTimestamp - Date.now() / 1000)); } - -export function formatLimitPrediction( - prediction: WindowPrediction, - quota: UsageQuota | null, - windowType: UsageWindowType, -) { - if (prediction?.trend === "limit reached") return "Limit reached"; - - if (prediction?.trend === "safe") return "Safe at current pace"; - +export function formatLimitPrediction(prediction, quota, windowType) { + if (prediction?.trend === "limit reached") + return "Limit reached"; + if (prediction?.trend === "safe") + return "Safe at current pace"; if (prediction?.trend !== "unsafe" || !Number.isFinite(prediction.estimatedLimitAt)) { return ""; } - if (Number.isFinite(quota?.resetAt)) { - const secondsBeforeReset = Math.max(0, Math.round((quota?.resetAt as number) - prediction.estimatedLimitAt)); - + const secondsBeforeReset = Math.max(0, Math.round(quota?.resetAt - prediction.estimatedLimitAt)); return `Will hit limit ~${formatCompactDuration(secondsBeforeReset, windowType)} before reset`; } - - return `Will hit limit in ~${formatCompactDuration( - secondsUntil(prediction.estimatedLimitAt), - windowType, - )}`; + return `Will hit limit in ~${formatCompactDuration(secondsUntil(prediction.estimatedLimitAt), windowType)}`; } - -export function getPredictionStyleClass(prediction: WindowPrediction): PredictionStyle { +export function getPredictionStyleClass(prediction) { switch (prediction?.trend) { case "limit reached": return "danger"; @@ -649,39 +417,31 @@ export function getPredictionStyleClass(prediction: WindowPrediction): Predictio return "muted"; } } - -export function formatFooter(snapshot: UsageSnapshot | null): string { - if (!snapshot) return "--"; - +export function formatFooter(snapshot) { + if (!snapshot) + return "--"; const parts = [formatPlan(snapshot.planType)]; const credits = formatCredits(snapshot.credits); - - if (credits) parts.push(credits); - + if (credits) + parts.push(credits); parts.push(`Updated ${formatUnixTimestamp(snapshot.fetchedAt, false)}`); - return parts.filter((part) => part && part !== "--").join(" · ") || "--"; } - -function formatCredits(credits): string { - if (!credits || credits.unlimited) return ""; - +function formatCredits(credits) { + if (!credits || credits.unlimited) + return ""; const balance = typeof credits.balance === "string" ? credits.balance.trim() : ""; - - if (!balance || isZeroCreditBalance(balance)) return ""; - + if (!balance || isZeroCreditBalance(balance)) + return ""; return `Credits ${balance}`; } - -function isZeroCreditBalance(value: string): boolean { +function isZeroCreditBalance(value) { const numeric = Number(value.replace(/[^0-9.-]/g, "")); - return Number.isFinite(numeric) && numeric === 0; } - export function formatPlan(value) { - if (!value) return "--"; - + if (!value) + return "--"; return value .toString() .split(/[_-]/) @@ -689,28 +449,24 @@ export function formatPlan(value) { .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) .join(" "); } - -function formatCompactDuration(totalSeconds: number, windowType?: UsageWindowType): string { - if (!Number.isFinite(totalSeconds)) return "--"; - +function formatCompactDuration(totalSeconds, windowType) { + if (!Number.isFinite(totalSeconds)) + return "--"; const seconds = Math.max(0, Math.round(totalSeconds)); const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); - if (windowType === "secondary" && days > 0) { return hours >= 12 ? `${days + 1}d` : `${days}d`; } - - if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; - - if (hours > 0) return minutes >= 30 ? `${hours + 1}h` : `${hours}h`; - + if (days > 0) + return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + if (hours > 0) + return minutes >= 30 ? `${hours + 1}h` : `${hours}h`; return `${Math.max(1, minutes)}m`; } - -export function normalizePercent(value: number): number { - if (!Number.isFinite(value)) return 0; - +export function normalizePercent(value) { + if (!Number.isFinite(value)) + return 0; return Math.max(0, Math.min(100, value)); } diff --git a/tsconfig.json b/tsconfig.json index cbb74de..40253b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,15 @@ { "compilerOptions": { + "allowJs": true, + "checkJs": false, + "noEmit": true, + "strict": true, + "skipLibCheck": true, "module": "NodeNext", "moduleResolution": "NodeNext", - "outDir": "./dist", - "rootDir": "./src", - "sourceMap": false, - "strict": false, - "skipLibCheck": true, "target": "ES2023", "lib": ["ES2023"] }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.js", "src/**/*.d.ts"], "exclude": ["dist", "node_modules"] } From 155e028b424de074429e8614c6e444205bf6f4f8 Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 11:59:46 +0200 Subject: [PATCH 2/8] Run checks directly from source tree --- .gitignore | 3 -- Makefile | 29 ++++++------------ assets/logo.png | Bin 632292 -> 0 bytes package.json | 3 +- src/domain/prediction.js | 10 +++--- src/domain/usage.js | 6 ++-- {icons => src/icons}/codex-symbolic.svg | 0 {icons => src/icons}/openai-symbolic.svg | 0 src/infra/providers/codex_usage_response.js | 4 +-- metadata.json => src/metadata.json | 0 ...e.shell.extensions.codex-meter.gschema.xml | 0 test/codex-auth.test.js | 2 +- test/codex-banked-resets.test.js | 2 +- test/codex-provider.test.js | 2 +- test/history-storage.test.js | 2 +- test/prediction.test.js | 2 +- test/refresh-failure-format.test.js | 4 +-- test/usage-trend.test.js | 2 +- test/view-model.test.js | 2 +- tsconfig.json | 4 +-- src/domain/types.js => types/domain.js | 0 21 files changed, 31 insertions(+), 46 deletions(-) delete mode 100644 assets/logo.png rename {icons => src/icons}/codex-symbolic.svg (100%) rename {icons => src/icons}/openai-symbolic.svg (100%) rename metadata.json => src/metadata.json (100%) rename {schemas => src/schemas}/org.gnome.shell.extensions.codex-meter.gschema.xml (100%) rename src/domain/types.js => types/domain.js (100%) diff --git a/.gitignore b/.gitignore index 9fbf78a..1105c99 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,6 @@ *.compiled *.po *.pot -dist *.zip node_modules - -/plans/ diff --git a/Makefile b/Makefile index 56be66a..41d54ed 100644 --- a/Makefile +++ b/Makefile @@ -3,32 +3,21 @@ DOMAIN=slobbe.github.io VERSION=$(shell node -p "require('./package.json').version") TAG_VERSION=v$(VERSION) ZIP=$(NAME)@$(DOMAIN)-$(TAG_VERSION).zip -SOURCES=$(shell find src -type f -print) +SOURCES=$(shell find src types -type f -print) -.PHONY: all pack install reload clean +.PHONY: all check pack install reload clean -all: dist/extension.js +all: check node_modules/.package-lock.json: package.json npm install -dist/extension.js dist/prefs.js &: node_modules/.package-lock.json $(SOURCES) - rm -rf dist - npm run build - -schemas/gschemas.compiled: schemas/org.gnome.shell.extensions.$(NAME).gschema.xml - glib-compile-schemas schemas - -$(ZIP): dist/extension.js dist/prefs.js metadata.json src/stylesheet.css icons/codex-symbolic.svg icons/openai-symbolic.svg assets/logo.png - @mkdir -p dist/schemas - @cp schemas/org.gnome.shell.extensions.$(NAME).gschema.xml dist/schemas/ - @rm -f dist/schemas/gschemas.compiled - @cp -r icons dist/ - @cp -r assets dist/ - @cp metadata.json dist/ - @cp src/stylesheet.css dist/stylesheet.css +check: node_modules/.package-lock.json + npm run check + +$(ZIP): check $(SOURCES) @rm -f $(ZIP) - @(cd dist && zip ../$(ZIP) -9r .) + @(cd src && zip ../$(ZIP) -9r . -x '*.compiled') pack: $(ZIP) @@ -40,4 +29,4 @@ reload: gnome-extensions enable codex-meter@slobbe.github.io clean: - @rm -rf dist node_modules $(ZIP) + @rm -rf node_modules $(ZIP) diff --git a/assets/logo.png b/assets/logo.png deleted file mode 100644 index c39a2e59a042a72e0ef67f9aaf722960cfb64092..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 632292 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 Ve-Fv{-hb&&fA|0JUC;i={|8f1Qgi?S diff --git a/package.json b/package.json index 6c3f411..53815d1 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,7 @@ "sideEffects": false, "scripts": { "check": "tsc", - "build": "npm run check && rm -rf dist && cp -r src dist && find dist -name '*.d.ts' -delete", - "test": "npm run build && node --test test/prediction.test.js test/usage-trend.test.js test/view-model.test.js test/refresh-failure-format.test.js test/codex-provider.test.js test/codex-auth.test.js test/codex-banked-resets.test.js && gjs -m test/history-storage.test.js" + "test": "npm run check && node --test test/prediction.test.js test/usage-trend.test.js test/view-model.test.js test/refresh-failure-format.test.js test/codex-provider.test.js test/codex-auth.test.js test/codex-banked-resets.test.js && gjs -m test/history-storage.test.js" }, "devDependencies": { "typescript": "^6.0.3" diff --git a/src/domain/prediction.js b/src/domain/prediction.js index 836072c..5840786 100644 --- a/src/domain/prediction.js +++ b/src/domain/prediction.js @@ -1,10 +1,10 @@ // @ts-check -/** @typedef {import("./types.js").UsageSnapshot} UsageSnapshot */ -/** @typedef {import("./types.js").UsageQuota} UsageQuota */ -/** @typedef {import("./types.js").HistoryEntry} HistoryEntry */ -/** @typedef {import("./types.js").WindowPrediction} WindowPrediction */ -/** @typedef {import("./types.js").UsagePrediction} UsagePrediction */ +/** @typedef {import("../../types/domain.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../types/domain.js").UsageQuota} UsageQuota */ +/** @typedef {import("../../types/domain.js").HistoryEntry} HistoryEntry */ +/** @typedef {import("../../types/domain.js").WindowPrediction} WindowPrediction */ +/** @typedef {import("../../types/domain.js").UsagePrediction} UsagePrediction */ /** @typedef {UsageQuota & {limitWindowSeconds: number, resetAfterSeconds: number, resetAt: number}} PredictionReadyQuota */ /** @typedef {{timestamp: number, usedPercent: number}} HistoryEntrySlice */ diff --git a/src/domain/usage.js b/src/domain/usage.js index 0078f2a..6934863 100644 --- a/src/domain/usage.js +++ b/src/domain/usage.js @@ -1,8 +1,8 @@ // @ts-check -/** @typedef {import("./types.js").UsageSnapshot} UsageSnapshot */ -/** @typedef {import("./types.js").UsageQuota} UsageQuota */ -/** @typedef {import("./types.js").HistoryEntry} HistoryEntry */ +/** @typedef {import("../../types/domain.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../types/domain.js").UsageQuota} UsageQuota */ +/** @typedef {import("../../types/domain.js").HistoryEntry} HistoryEntry */ /** * @param {UsageSnapshot} snapshot diff --git a/icons/codex-symbolic.svg b/src/icons/codex-symbolic.svg similarity index 100% rename from icons/codex-symbolic.svg rename to src/icons/codex-symbolic.svg diff --git a/icons/openai-symbolic.svg b/src/icons/openai-symbolic.svg similarity index 100% rename from icons/openai-symbolic.svg rename to src/icons/openai-symbolic.svg diff --git a/src/infra/providers/codex_usage_response.js b/src/infra/providers/codex_usage_response.js index 45c3e5b..6882ae0 100644 --- a/src/infra/providers/codex_usage_response.js +++ b/src/infra/providers/codex_usage_response.js @@ -2,8 +2,8 @@ import { RefreshFailureError } from "../../domain/refresh-failure.js"; -/** @typedef {import("../../domain/types.js").UsageSnapshot} UsageSnapshot */ -/** @typedef {import("../../domain/types.js").UsageCredits} UsageCredits */ +/** @typedef {import("../../../types/domain.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../../types/domain.js").UsageCredits} UsageCredits */ /** @typedef {Record} JsonObject */ /** * @typedef {object} CodexRateLimitWindow diff --git a/metadata.json b/src/metadata.json similarity index 100% rename from metadata.json rename to src/metadata.json diff --git a/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml b/src/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml similarity index 100% rename from schemas/org.gnome.shell.extensions.codex-meter.gschema.xml rename to src/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml diff --git a/test/codex-auth.test.js b/test/codex-auth.test.js index daab599..75aaca5 100644 --- a/test/codex-auth.test.js +++ b/test/codex-auth.test.js @@ -4,7 +4,7 @@ import test from "node:test"; import { parseCodexAccessToken, parseCodexAccountId, -} from "../dist/infra/providers/codex_auth_parser.js"; +} from "../src/infra/providers/codex_auth_parser.js"; test("returns access token from tokens.access_token", () => { assert.equal(parseCodexAccessToken({ tokens: { access_token: "fake-access-token" } }), "fake-access-token"); diff --git a/test/codex-banked-resets.test.js b/test/codex-banked-resets.test.js index d2f0a25..e5f7bd8 100644 --- a/test/codex-banked-resets.test.js +++ b/test/codex-banked-resets.test.js @@ -5,7 +5,7 @@ import { selectCreditExpiringWithin, selectCreditToRedeem, toListResponse, -} from "../dist/infra/providers/codex_banked_reset_response.js"; +} from "../src/infra/providers/codex_banked_reset_response.js"; function assertUnexpectedResponse(fn) { assert.throws(fn, (error) => { diff --git a/test/codex-provider.test.js b/test/codex-provider.test.js index 3bd96cd..b879e02 100644 --- a/test/codex-provider.test.js +++ b/test/codex-provider.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { toUsageSnapshot } from "../dist/infra/providers/codex_usage_response.js"; +import { toUsageSnapshot } from "../src/infra/providers/codex_usage_response.js"; function validUsageResponse(overrides = {}) { return { diff --git a/test/history-storage.test.js b/test/history-storage.test.js index 020a35a..bd4c402 100644 --- a/test/history-storage.test.js +++ b/test/history-storage.test.js @@ -5,7 +5,7 @@ import { appendHistoryRowToPath, appendHistoryToPath, readHistoryFromPath, -} from "../dist/infra/storage/history.js"; +} from "../src/infra/storage/history.js"; Gio._promisify(Gio.File.prototype, "load_contents_async"); diff --git a/test/prediction.test.js b/test/prediction.test.js index d40be39..6188906 100644 --- a/test/prediction.test.js +++ b/test/prediction.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { predict } from "../dist/domain/prediction.js"; +import { predict } from "../src/domain/prediction.js"; const fetchedAt = 1_700_001_800; diff --git a/test/refresh-failure-format.test.js b/test/refresh-failure-format.test.js index 6bb1628..39973a1 100644 --- a/test/refresh-failure-format.test.js +++ b/test/refresh-failure-format.test.js @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { RefreshFailureError } from "../dist/domain/refresh-failure.js"; -import { formatRefreshFailure } from "../dist/ui/refresh-error-message.js"; +import { RefreshFailureError } from "../src/domain/refresh-failure.js"; +import { formatRefreshFailure } from "../src/ui/refresh-error-message.js"; test("refresh failure UI shows only the user-facing message", () => { const message = "Codex authentication expired. Please run `codex login` again."; diff --git a/test/usage-trend.test.js b/test/usage-trend.test.js index 78825dd..76432b1 100644 --- a/test/usage-trend.test.js +++ b/test/usage-trend.test.js @@ -5,7 +5,7 @@ import { calculateAverageBurnRatePercentPerDay, calculateRecentPositiveDelta, createUsageTrendViewModel, -} from "../dist/ui/view-model.js"; +} from "../src/ui/view-model.js"; const now = 1_700_000_000; diff --git a/test/view-model.test.js b/test/view-model.test.js index 282c8ac..959082b 100644 --- a/test/view-model.test.js +++ b/test/view-model.test.js @@ -6,7 +6,7 @@ import { createPanelBarViewModel, formatFooter, formatLimitPrediction, -} from "../dist/ui/view-model.js"; +} from "../src/ui/view-model.js"; const sessionQuota = { id: "session", diff --git a/tsconfig.json b/tsconfig.json index 40253b5..fffac26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,6 @@ "target": "ES2023", "lib": ["ES2023"] }, - "include": ["src/**/*.js", "src/**/*.d.ts"], - "exclude": ["dist", "node_modules"] + "include": ["src/**/*.js", "types/**/*.js"], + "exclude": ["node_modules"] } diff --git a/src/domain/types.js b/types/domain.js similarity index 100% rename from src/domain/types.js rename to types/domain.js From 9c777e1b958325e58b4f2542182ffca4112ba74e Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 12:15:22 +0200 Subject: [PATCH 3/8] Reorganize source modules by domain and layer --- .../api.js} | 10 +++++----- .../response.js} | 2 +- .../store.js} | 4 ++-- .../providers/auth.js => codex/auth-file.js} | 4 ++-- .../auth-parser.js} | 0 .../providers/codex_auth.js => codex/auth.js} | 6 +++--- .../codex.js => codex/usage-provider.js} | 6 +++--- .../usage-response.js} | 6 +++--- src/{infra/config.js => config/paths.js} | 0 src/{app => config}/settings.js | 0 src/extension.js | 2 +- src/{infra/filesystem.js => io/files.js} | 0 src/{infra/api_client.js => io/http.js} | 2 +- src/{ui => panel}/button.js | 0 .../panel-indicator.js => panel/indicator.js} | 16 ++++++++-------- src/{ui/popup-menu.js => panel/menu.js} | 0 src/{ui => panel}/refresh-error-message.js | 2 +- src/{ui => panel}/usage-bar.js | 0 src/{ui => panel}/view-model.js | 2 +- .../preferences-pages.js => preferences/page.js} | 2 +- src/prefs.js | 4 ++-- .../refresh-failure.js => refresh/error.js} | 0 src/{app => refresh}/scheduler.js | 0 .../history.js => usage/history-store.js} | 4 ++-- src/{domain/usage.js => usage/model.js} | 6 +++--- src/{domain => usage}/prediction.js | 10 +++++----- .../providers/index.js => usage/providers.js} | 2 +- src/{app/usage.js => usage/service.js} | 10 +++++----- .../snapshot-store.js} | 4 ++-- test/codex-auth.test.js | 2 +- test/codex-banked-resets.test.js | 2 +- test/codex-provider.test.js | 2 +- test/history-storage.test.js | 2 +- test/prediction.test.js | 2 +- test/refresh-failure-format.test.js | 4 ++-- test/usage-trend.test.js | 2 +- test/view-model.test.js | 2 +- types/{domain.js => index.js} | 0 38 files changed, 61 insertions(+), 61 deletions(-) rename src/{infra/providers/codex_banked_resets.js => banked-resets/api.js} (93%) rename src/{infra/providers/codex_banked_reset_response.js => banked-resets/response.js} (98%) rename src/{infra/storage/banked-reset-snapshot.js => banked-resets/store.js} (93%) rename src/{infra/providers/auth.js => codex/auth-file.js} (95%) rename src/{infra/providers/codex_auth_parser.js => codex/auth-parser.js} (100%) rename src/{infra/providers/codex_auth.js => codex/auth.js} (80%) rename src/{infra/providers/codex.js => codex/usage-provider.js} (86%) rename src/{infra/providers/codex_usage_response.js => codex/usage-response.js} (95%) rename src/{infra/config.js => config/paths.js} (100%) rename src/{app => config}/settings.js (100%) rename src/{infra/filesystem.js => io/files.js} (100%) rename src/{infra/api_client.js => io/http.js} (98%) rename src/{ui => panel}/button.js (100%) rename src/{ui/panel-indicator.js => panel/indicator.js} (97%) rename src/{ui/popup-menu.js => panel/menu.js} (100%) rename src/{ui => panel}/refresh-error-message.js (72%) rename src/{ui => panel}/usage-bar.js (100%) rename src/{ui => panel}/view-model.js (99%) rename src/{ui/preferences-pages.js => preferences/page.js} (99%) rename src/{domain/refresh-failure.js => refresh/error.js} (100%) rename src/{app => refresh}/scheduler.js (100%) rename src/{infra/storage/history.js => usage/history-store.js} (97%) rename src/{domain/usage.js => usage/model.js} (83%) rename src/{domain => usage}/prediction.js (92%) rename src/{infra/providers/index.js => usage/providers.js} (85%) rename src/{app/usage.js => usage/service.js} (87%) rename src/{infra/storage/snapshot-cache.js => usage/snapshot-store.js} (93%) rename types/{domain.js => index.js} (100%) diff --git a/src/infra/providers/codex_banked_resets.js b/src/banked-resets/api.js similarity index 93% rename from src/infra/providers/codex_banked_resets.js rename to src/banked-resets/api.js index a189fd7..f2a35f1 100644 --- a/src/infra/providers/codex_banked_resets.js +++ b/src/banked-resets/api.js @@ -1,9 +1,9 @@ import GLib from "gi://GLib"; -import { fetchJson } from "../api_client.js"; -import { RefreshFailureError } from "../../domain/refresh-failure.js"; -import { readBankedResetSnapshot, writeBankedResetSnapshot, } from "../storage/banked-reset-snapshot.js"; -import { getCodexCredentials } from "./codex_auth.js"; -import { selectCreditToRedeem, toListResponse, } from "./codex_banked_reset_response.js"; +import { fetchJson } from "../io/http.js"; +import { RefreshFailureError } from "../refresh/error.js"; +import { readBankedResetSnapshot, writeBankedResetSnapshot, } from "./store.js"; +import { getCodexCredentials } from "../codex/auth.js"; +import { selectCreditToRedeem, toListResponse, } from "./response.js"; const CODEX_BANKED_RESETS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; const CODEX_REDEEM_BANKED_RESET_URL = `${CODEX_BANKED_RESETS_URL}/consume`; const CODEX_BANKED_RESETS_API_CONFIG = { diff --git a/src/infra/providers/codex_banked_reset_response.js b/src/banked-resets/response.js similarity index 98% rename from src/infra/providers/codex_banked_reset_response.js rename to src/banked-resets/response.js index c71f5d2..fc97436 100644 --- a/src/infra/providers/codex_banked_reset_response.js +++ b/src/banked-resets/response.js @@ -1,6 +1,6 @@ // @ts-check -import { RefreshFailureError } from "../../domain/refresh-failure.js"; +import { RefreshFailureError } from "../refresh/error.js"; /** * @typedef {object} CodexBankedResetCredit diff --git a/src/infra/storage/banked-reset-snapshot.js b/src/banked-resets/store.js similarity index 93% rename from src/infra/storage/banked-reset-snapshot.js rename to src/banked-resets/store.js index 5dfac21..2480f4e 100644 --- a/src/infra/storage/banked-reset-snapshot.js +++ b/src/banked-resets/store.js @@ -1,6 +1,6 @@ import GLib from "gi://GLib"; -import { CACHE_DIR } from "../config.js"; -import { readJsonFile, writeJsonFile } from "../filesystem.js"; +import { CACHE_DIR } from "../config/paths.js"; +import { readJsonFile, writeJsonFile } from "../io/files.js"; function getBankedResetSnapshotPath() { return GLib.build_filenamev([CACHE_DIR, "codex", "banked-reset-snapshot.json"]); } diff --git a/src/infra/providers/auth.js b/src/codex/auth-file.js similarity index 95% rename from src/infra/providers/auth.js rename to src/codex/auth-file.js index 8a2a4d2..b257775 100644 --- a/src/infra/providers/auth.js +++ b/src/codex/auth-file.js @@ -1,6 +1,6 @@ import GLib from "gi://GLib"; -import { RefreshFailureError } from "../../domain/refresh-failure.js"; -import { readJsonFile } from "../filesystem.js"; +import { RefreshFailureError } from "../refresh/error.js"; +import { readJsonFile } from "../io/files.js"; export async function getLocalAccessToken(config) { if (!isNonEmptyString(config.authPath)) { throw new RefreshFailureError("missing-auth", `${config.providerName} auth is unavailable. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth path must be a non-empty string`); diff --git a/src/infra/providers/codex_auth_parser.js b/src/codex/auth-parser.js similarity index 100% rename from src/infra/providers/codex_auth_parser.js rename to src/codex/auth-parser.js diff --git a/src/infra/providers/codex_auth.js b/src/codex/auth.js similarity index 80% rename from src/infra/providers/codex_auth.js rename to src/codex/auth.js index e36033f..ff44f1a 100644 --- a/src/infra/providers/codex_auth.js +++ b/src/codex/auth.js @@ -1,7 +1,7 @@ import GLib from "gi://GLib"; -import { getLocalAccessToken, readLocalAuth, } from "./auth.js"; -import { parseCodexAccessToken, parseCodexAccountId, } from "./codex_auth_parser.js"; -export { parseCodexAccessToken, parseCodexAccountId } from "./codex_auth_parser.js"; +import { getLocalAccessToken, readLocalAuth, } from "./auth-file.js"; +import { parseCodexAccessToken, parseCodexAccountId, } from "./auth-parser.js"; +export { parseCodexAccessToken, parseCodexAccountId } from "./auth-parser.js"; export function getCodexAuthPath() { return GLib.build_filenamev([GLib.get_home_dir(), ".codex", "auth.json"]); } diff --git a/src/infra/providers/codex.js b/src/codex/usage-provider.js similarity index 86% rename from src/infra/providers/codex.js rename to src/codex/usage-provider.js index e0fd4d0..ff8e020 100644 --- a/src/infra/providers/codex.js +++ b/src/codex/usage-provider.js @@ -1,6 +1,6 @@ -import { fetchProviderUsage } from "../api_client.js"; -import { getCodexAccessToken } from "./codex_auth.js"; -import { toUsageSnapshot } from "./codex_usage_response.js"; +import { fetchProviderUsage } from "../io/http.js"; +import { getCodexAccessToken } from "./auth.js"; +import { toUsageSnapshot } from "./usage-response.js"; const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; const CODEX_API_CONFIG = { providerName: "Codex", diff --git a/src/infra/providers/codex_usage_response.js b/src/codex/usage-response.js similarity index 95% rename from src/infra/providers/codex_usage_response.js rename to src/codex/usage-response.js index 6882ae0..8fcf269 100644 --- a/src/infra/providers/codex_usage_response.js +++ b/src/codex/usage-response.js @@ -1,9 +1,9 @@ // @ts-check -import { RefreshFailureError } from "../../domain/refresh-failure.js"; +import { RefreshFailureError } from "../refresh/error.js"; -/** @typedef {import("../../../types/domain.js").UsageSnapshot} UsageSnapshot */ -/** @typedef {import("../../../types/domain.js").UsageCredits} UsageCredits */ +/** @typedef {import("../../types/index.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../types/index.js").UsageCredits} UsageCredits */ /** @typedef {Record} JsonObject */ /** * @typedef {object} CodexRateLimitWindow diff --git a/src/infra/config.js b/src/config/paths.js similarity index 100% rename from src/infra/config.js rename to src/config/paths.js diff --git a/src/app/settings.js b/src/config/settings.js similarity index 100% rename from src/app/settings.js rename to src/config/settings.js diff --git a/src/extension.js b/src/extension.js index 4155fbf..2af5645 100644 --- a/src/extension.js +++ b/src/extension.js @@ -17,7 +17,7 @@ */ import * as Main from "resource:///org/gnome/shell/ui/main.js"; import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; -import { CodexMeterIndicator } from "./ui/panel-indicator.js"; +import { CodexMeterIndicator } from "./panel/indicator.js"; export default class CodexMeterExtension extends Extension { _indicator = null; enable() { diff --git a/src/infra/filesystem.js b/src/io/files.js similarity index 100% rename from src/infra/filesystem.js rename to src/io/files.js diff --git a/src/infra/api_client.js b/src/io/http.js similarity index 98% rename from src/infra/api_client.js rename to src/io/http.js index 69520ef..9487e00 100644 --- a/src/infra/api_client.js +++ b/src/io/http.js @@ -1,6 +1,6 @@ import Gio from "gi://Gio"; import GLib from "gi://GLib"; -import { RefreshFailureError } from "../domain/refresh-failure.js"; +import { RefreshFailureError } from "../refresh/error.js"; // The GIRS package set used by this project does not ship Soup typings. // @ts-ignore import Soup from "gi://Soup?version=3.0"; diff --git a/src/ui/button.js b/src/panel/button.js similarity index 100% rename from src/ui/button.js rename to src/panel/button.js diff --git a/src/ui/panel-indicator.js b/src/panel/indicator.js similarity index 97% rename from src/ui/panel-indicator.js rename to src/panel/indicator.js index 71f6f1b..d13cc54 100644 --- a/src/ui/panel-indicator.js +++ b/src/panel/indicator.js @@ -5,14 +5,14 @@ import GObject from "gi://GObject"; import St from "gi://St"; import * as Main from "resource:///org/gnome/shell/ui/main.js"; import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; -import { SettingsService } from "../app/settings.js"; -import { Scheduler } from "../app/scheduler.js"; -import { UsageService } from "../app/usage.js"; -import { DEFAULT_PROVIDER_ID, getUsageProvider } from "../infra/providers/index.js"; -import { isRefreshFailureError } from "../domain/refresh-failure.js"; -import { listCodexBankedResets, readCachedCodexBankedResets, redeemCodexBankedReset, redeemNextCodexBankedReset, } from "../infra/providers/codex_banked_resets.js"; -import { selectCreditExpiringWithin } from "../infra/providers/codex_banked_reset_response.js"; -import { CodexMeterPopupMenu } from "./popup-menu.js"; +import { SettingsService } from "../config/settings.js"; +import { Scheduler } from "../refresh/scheduler.js"; +import { UsageService } from "../usage/service.js"; +import { DEFAULT_PROVIDER_ID, getUsageProvider } from "../usage/providers.js"; +import { isRefreshFailureError } from "../refresh/error.js"; +import { listCodexBankedResets, readCachedCodexBankedResets, redeemCodexBankedReset, redeemNextCodexBankedReset, } from "../banked-resets/api.js"; +import { selectCreditExpiringWithin } from "../banked-resets/response.js"; +import { CodexMeterPopupMenu } from "./menu.js"; import { formatRefreshFailure } from "./refresh-error-message.js"; import { UsageBar } from "./usage-bar.js"; import { createMenuViewModel, createPanelBarViewModel, } from "./view-model.js"; diff --git a/src/ui/popup-menu.js b/src/panel/menu.js similarity index 100% rename from src/ui/popup-menu.js rename to src/panel/menu.js diff --git a/src/ui/refresh-error-message.js b/src/panel/refresh-error-message.js similarity index 72% rename from src/ui/refresh-error-message.js rename to src/panel/refresh-error-message.js index 96aad03..4cb9851 100644 --- a/src/ui/refresh-error-message.js +++ b/src/panel/refresh-error-message.js @@ -1,4 +1,4 @@ -import { isRefreshFailureError } from "../domain/refresh-failure.js"; +import { isRefreshFailureError } from "../refresh/error.js"; export function formatRefreshFailure(error) { if (isRefreshFailureError(error)) { return error.message; diff --git a/src/ui/usage-bar.js b/src/panel/usage-bar.js similarity index 100% rename from src/ui/usage-bar.js rename to src/panel/usage-bar.js diff --git a/src/ui/view-model.js b/src/panel/view-model.js similarity index 99% rename from src/ui/view-model.js rename to src/panel/view-model.js index 0b2804a..eabc965 100644 --- a/src/ui/view-model.js +++ b/src/panel/view-model.js @@ -1,4 +1,4 @@ -import { getQuota, getSecondaryQuota } from "../domain/usage.js"; +import { getQuota, getSecondaryQuota } from "../usage/model.js"; export function createPanelBarViewModel(settings, snapshot, errorMessage) { const sessionQuota = getQuota(snapshot, "session"); const weeklyQuota = getQuota(snapshot, "weekly"); diff --git a/src/ui/preferences-pages.js b/src/preferences/page.js similarity index 99% rename from src/ui/preferences-pages.js rename to src/preferences/page.js index a9290d7..386de64 100644 --- a/src/ui/preferences-pages.js +++ b/src/preferences/page.js @@ -2,7 +2,7 @@ import Adw from "gi://Adw"; import Gio from "gi://Gio"; import GObject from "gi://GObject"; import Gtk from "gi://Gtk"; -import { MIN_REFRESH_INTERVAL_MINUTES, SETTINGS_AUTO_APPLY_BANKED_RESET, SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, SETTINGS_PERCENT_DISPLAY_MODE, SETTINGS_SHOW_PRIMARY, SETTINGS_SHOW_SECONDARY, SETTINGS_TOP_PANEL_DISPLAY_MODE, SETTINGS_TOP_PANEL_INDICATOR_ICON, } from "../app/settings.js"; +import { MIN_REFRESH_INTERVAL_MINUTES, SETTINGS_AUTO_APPLY_BANKED_RESET, SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, SETTINGS_PERCENT_DISPLAY_MODE, SETTINGS_SHOW_PRIMARY, SETTINGS_SHOW_SECONDARY, SETTINGS_TOP_PANEL_DISPLAY_MODE, SETTINGS_TOP_PANEL_INDICATOR_ICON, } from "../config/settings.js"; export const PreferencesPage = GObject.registerClass(class PreferencesPage extends Adw.PreferencesPage { _init(settings, snapshot, metadata) { super._init({ diff --git a/src/prefs.js b/src/prefs.js index 72c9306..01b92f4 100644 --- a/src/prefs.js +++ b/src/prefs.js @@ -1,8 +1,8 @@ import Gdk from "gi://Gdk"; import Gtk from "gi://Gtk"; import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js"; -import { readCachedCodexBankedResets } from "./infra/providers/codex_banked_resets.js"; -import { PreferencesPage } from "./ui/preferences-pages.js"; +import { readCachedCodexBankedResets } from "./banked-resets/api.js"; +import { PreferencesPage } from "./preferences/page.js"; export default class CodexMeterPreferences extends ExtensionPreferences { async fillPreferencesWindow(window) { const settings = this.getSettings(); diff --git a/src/domain/refresh-failure.js b/src/refresh/error.js similarity index 100% rename from src/domain/refresh-failure.js rename to src/refresh/error.js diff --git a/src/app/scheduler.js b/src/refresh/scheduler.js similarity index 100% rename from src/app/scheduler.js rename to src/refresh/scheduler.js diff --git a/src/infra/storage/history.js b/src/usage/history-store.js similarity index 97% rename from src/infra/storage/history.js rename to src/usage/history-store.js index 88fd434..3fb7450 100644 --- a/src/infra/storage/history.js +++ b/src/usage/history-store.js @@ -1,6 +1,6 @@ import GLib from "gi://GLib"; -import { STATE_DIR } from "../config.js"; -import { appendFile, readFile, writeFile } from "../filesystem.js"; +import { STATE_DIR } from "../config/paths.js"; +import { appendFile, readFile, writeFile } from "../io/files.js"; export const MAX_HISTORY_ENTRIES = 25_000; const MAX_HISTORY_AGE_SECONDS = 21 * 24 * 60 * 60; function getHistoryPath(providerId) { diff --git a/src/domain/usage.js b/src/usage/model.js similarity index 83% rename from src/domain/usage.js rename to src/usage/model.js index 6934863..115936f 100644 --- a/src/domain/usage.js +++ b/src/usage/model.js @@ -1,8 +1,8 @@ // @ts-check -/** @typedef {import("../../types/domain.js").UsageSnapshot} UsageSnapshot */ -/** @typedef {import("../../types/domain.js").UsageQuota} UsageQuota */ -/** @typedef {import("../../types/domain.js").HistoryEntry} HistoryEntry */ +/** @typedef {import("../../types/index.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../types/index.js").UsageQuota} UsageQuota */ +/** @typedef {import("../../types/index.js").HistoryEntry} HistoryEntry */ /** * @param {UsageSnapshot} snapshot diff --git a/src/domain/prediction.js b/src/usage/prediction.js similarity index 92% rename from src/domain/prediction.js rename to src/usage/prediction.js index 5840786..7f19d17 100644 --- a/src/domain/prediction.js +++ b/src/usage/prediction.js @@ -1,10 +1,10 @@ // @ts-check -/** @typedef {import("../../types/domain.js").UsageSnapshot} UsageSnapshot */ -/** @typedef {import("../../types/domain.js").UsageQuota} UsageQuota */ -/** @typedef {import("../../types/domain.js").HistoryEntry} HistoryEntry */ -/** @typedef {import("../../types/domain.js").WindowPrediction} WindowPrediction */ -/** @typedef {import("../../types/domain.js").UsagePrediction} UsagePrediction */ +/** @typedef {import("../../types/index.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../types/index.js").UsageQuota} UsageQuota */ +/** @typedef {import("../../types/index.js").HistoryEntry} HistoryEntry */ +/** @typedef {import("../../types/index.js").WindowPrediction} WindowPrediction */ +/** @typedef {import("../../types/index.js").UsagePrediction} UsagePrediction */ /** @typedef {UsageQuota & {limitWindowSeconds: number, resetAfterSeconds: number, resetAt: number}} PredictionReadyQuota */ /** @typedef {{timestamp: number, usedPercent: number}} HistoryEntrySlice */ diff --git a/src/infra/providers/index.js b/src/usage/providers.js similarity index 85% rename from src/infra/providers/index.js rename to src/usage/providers.js index 37e2a31..b238161 100644 --- a/src/infra/providers/index.js +++ b/src/usage/providers.js @@ -1,4 +1,4 @@ -import { CodexUsageProvider } from "./codex.js"; +import { CodexUsageProvider } from "../codex/usage-provider.js"; export const DEFAULT_PROVIDER_ID = "codex"; const providers = { codex: new CodexUsageProvider(), diff --git a/src/app/usage.js b/src/usage/service.js similarity index 87% rename from src/app/usage.js rename to src/usage/service.js index 5e2512e..e61ad52 100644 --- a/src/app/usage.js +++ b/src/usage/service.js @@ -1,8 +1,8 @@ -import { predict } from "../domain/prediction.js"; -import { toHistoryEntry } from "../domain/usage.js"; -import { getUsageProvider } from "../infra/providers/index.js"; -import { appendHistoryRow, hasSameQuotaValues, MAX_HISTORY_ENTRIES, normalizeHistoryEntries, normalizeHistoryEntry, readHistory, rewriteHistory, } from "../infra/storage/history.js"; -import { readSnapshot, writeSnapshot } from "../infra/storage/snapshot-cache.js"; +import { predict } from "./prediction.js"; +import { toHistoryEntry } from "./model.js"; +import { getUsageProvider } from "./providers.js"; +import { appendHistoryRow, hasSameQuotaValues, MAX_HISTORY_ENTRIES, normalizeHistoryEntries, normalizeHistoryEntry, readHistory, rewriteHistory, } from "./history-store.js"; +import { readSnapshot, writeSnapshot } from "./snapshot-store.js"; export class UsageService { provider; history = null; diff --git a/src/infra/storage/snapshot-cache.js b/src/usage/snapshot-store.js similarity index 93% rename from src/infra/storage/snapshot-cache.js rename to src/usage/snapshot-store.js index 8cd3d36..0106544 100644 --- a/src/infra/storage/snapshot-cache.js +++ b/src/usage/snapshot-store.js @@ -1,6 +1,6 @@ import GLib from "gi://GLib"; -import { CACHE_DIR } from "../config.js"; -import { readJsonFile, writeJsonFile } from "../filesystem.js"; +import { CACHE_DIR } from "../config/paths.js"; +import { readJsonFile, writeJsonFile } from "../io/files.js"; function getSnapshotPath(providerId) { return GLib.build_filenamev([CACHE_DIR, providerId, "snapshot.json"]); } diff --git a/test/codex-auth.test.js b/test/codex-auth.test.js index 75aaca5..3f772e4 100644 --- a/test/codex-auth.test.js +++ b/test/codex-auth.test.js @@ -4,7 +4,7 @@ import test from "node:test"; import { parseCodexAccessToken, parseCodexAccountId, -} from "../src/infra/providers/codex_auth_parser.js"; +} from "../src/codex/auth-parser.js"; test("returns access token from tokens.access_token", () => { assert.equal(parseCodexAccessToken({ tokens: { access_token: "fake-access-token" } }), "fake-access-token"); diff --git a/test/codex-banked-resets.test.js b/test/codex-banked-resets.test.js index e5f7bd8..946502a 100644 --- a/test/codex-banked-resets.test.js +++ b/test/codex-banked-resets.test.js @@ -5,7 +5,7 @@ import { selectCreditExpiringWithin, selectCreditToRedeem, toListResponse, -} from "../src/infra/providers/codex_banked_reset_response.js"; +} from "../src/banked-resets/response.js"; function assertUnexpectedResponse(fn) { assert.throws(fn, (error) => { diff --git a/test/codex-provider.test.js b/test/codex-provider.test.js index b879e02..42cea38 100644 --- a/test/codex-provider.test.js +++ b/test/codex-provider.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { toUsageSnapshot } from "../src/infra/providers/codex_usage_response.js"; +import { toUsageSnapshot } from "../src/codex/usage-response.js"; function validUsageResponse(overrides = {}) { return { diff --git a/test/history-storage.test.js b/test/history-storage.test.js index bd4c402..da1e675 100644 --- a/test/history-storage.test.js +++ b/test/history-storage.test.js @@ -5,7 +5,7 @@ import { appendHistoryRowToPath, appendHistoryToPath, readHistoryFromPath, -} from "../src/infra/storage/history.js"; +} from "../src/usage/history-store.js"; Gio._promisify(Gio.File.prototype, "load_contents_async"); diff --git a/test/prediction.test.js b/test/prediction.test.js index 6188906..83b8793 100644 --- a/test/prediction.test.js +++ b/test/prediction.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { predict } from "../src/domain/prediction.js"; +import { predict } from "../src/usage/prediction.js"; const fetchedAt = 1_700_001_800; diff --git a/test/refresh-failure-format.test.js b/test/refresh-failure-format.test.js index 39973a1..ff317f8 100644 --- a/test/refresh-failure-format.test.js +++ b/test/refresh-failure-format.test.js @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { RefreshFailureError } from "../src/domain/refresh-failure.js"; -import { formatRefreshFailure } from "../src/ui/refresh-error-message.js"; +import { RefreshFailureError } from "../src/refresh/error.js"; +import { formatRefreshFailure } from "../src/panel/refresh-error-message.js"; test("refresh failure UI shows only the user-facing message", () => { const message = "Codex authentication expired. Please run `codex login` again."; diff --git a/test/usage-trend.test.js b/test/usage-trend.test.js index 76432b1..4560ab1 100644 --- a/test/usage-trend.test.js +++ b/test/usage-trend.test.js @@ -5,7 +5,7 @@ import { calculateAverageBurnRatePercentPerDay, calculateRecentPositiveDelta, createUsageTrendViewModel, -} from "../src/ui/view-model.js"; +} from "../src/panel/view-model.js"; const now = 1_700_000_000; diff --git a/test/view-model.test.js b/test/view-model.test.js index 959082b..a22656d 100644 --- a/test/view-model.test.js +++ b/test/view-model.test.js @@ -6,7 +6,7 @@ import { createPanelBarViewModel, formatFooter, formatLimitPrediction, -} from "../src/ui/view-model.js"; +} from "../src/panel/view-model.js"; const sessionQuota = { id: "session", diff --git a/types/domain.js b/types/index.js similarity index 100% rename from types/domain.js rename to types/index.js From 8be7079b8a4ecc880cd89cca22b5377ac416d8ee Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 12:19:16 +0200 Subject: [PATCH 4/8] Reorganize configuration and I/O modules --- src/banked-resets/store.js | 2 +- src/{config => io}/paths.js | 0 src/panel/indicator.js | 333 +++++++++++++----------- src/preferences/page.js | 2 +- src/{config => preferences}/settings.js | 0 src/usage/history-store.js | 2 +- src/usage/snapshot-store.js | 2 +- 7 files changed, 180 insertions(+), 161 deletions(-) rename src/{config => io}/paths.js (100%) rename src/{config => preferences}/settings.js (100%) diff --git a/src/banked-resets/store.js b/src/banked-resets/store.js index 2480f4e..cbe7b26 100644 --- a/src/banked-resets/store.js +++ b/src/banked-resets/store.js @@ -1,5 +1,5 @@ import GLib from "gi://GLib"; -import { CACHE_DIR } from "../config/paths.js"; +import { CACHE_DIR } from "../io/paths.js"; import { readJsonFile, writeJsonFile } from "../io/files.js"; function getBankedResetSnapshotPath() { return GLib.build_filenamev([CACHE_DIR, "codex", "banked-reset-snapshot.json"]); diff --git a/src/config/paths.js b/src/io/paths.js similarity index 100% rename from src/config/paths.js rename to src/io/paths.js diff --git a/src/panel/indicator.js b/src/panel/indicator.js index d13cc54..7065352 100644 --- a/src/panel/indicator.js +++ b/src/panel/indicator.js @@ -5,17 +5,22 @@ import GObject from "gi://GObject"; import St from "gi://St"; import * as Main from "resource:///org/gnome/shell/ui/main.js"; import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; -import { SettingsService } from "../config/settings.js"; +import { SettingsService } from "../preferences/settings.js"; import { Scheduler } from "../refresh/scheduler.js"; import { UsageService } from "../usage/service.js"; import { DEFAULT_PROVIDER_ID, getUsageProvider } from "../usage/providers.js"; import { isRefreshFailureError } from "../refresh/error.js"; -import { listCodexBankedResets, readCachedCodexBankedResets, redeemCodexBankedReset, redeemNextCodexBankedReset, } from "../banked-resets/api.js"; +import { + listCodexBankedResets, + readCachedCodexBankedResets, + redeemCodexBankedReset, + redeemNextCodexBankedReset, +} from "../banked-resets/api.js"; import { selectCreditExpiringWithin } from "../banked-resets/response.js"; import { CodexMeterPopupMenu } from "./menu.js"; import { formatRefreshFailure } from "./refresh-error-message.js"; import { UsageBar } from "./usage-bar.js"; -import { createMenuViewModel, createPanelBarViewModel, } from "./view-model.js"; +import { createMenuViewModel, createPanelBarViewModel } from "./view-model.js"; const BANKED_RESET_BACKGROUND_REFRESH_SECONDS = 20 * 60; const AUTO_APPLY_BANKED_RESET_WINDOW_MS = 60 * 60 * 1000; export class CodexMeterIndicator extends PanelMenu.Button { @@ -29,8 +34,14 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._providerId = this._settings.getUsageProvider(); this._autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); this._usageService = createUsageService(this._providerId); - this._scheduler = new Scheduler(this._settings.getBackgroundRefreshIntervalSeconds(), () => this._refreshUsage()); - this._bankedResetScheduler = new Scheduler(BANKED_RESET_BACKGROUND_REFRESH_SECONDS, () => this._refreshBankedResetsIfStale()); + this._scheduler = new Scheduler( + this._settings.getBackgroundRefreshIntervalSeconds(), + () => this._refreshUsage(), + ); + this._bankedResetScheduler = new Scheduler( + BANKED_RESET_BACKGROUND_REFRESH_SECONDS, + () => this._refreshBankedResetsIfStale(), + ); this._refreshSpinId = 0; this._menuSyncId = 0; this._refreshPromise = null; @@ -58,14 +69,18 @@ export class CodexMeterIndicator extends PanelMenu.Button { }); this._codexIcon = new St.Icon({ gicon: new Gio.FileIcon({ - file: Gio.File.new_for_path(`${this._extension.path}/icons/codex-symbolic.svg`), + file: Gio.File.new_for_path( + `${this._extension.path}/icons/codex-symbolic.svg`, + ), }), y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-icon", }); this._openAiIcon = new St.Icon({ gicon: new Gio.FileIcon({ - file: Gio.File.new_for_path(`${this._extension.path}/icons/openai-symbolic.svg`), + file: Gio.File.new_for_path( + `${this._extension.path}/icons/openai-symbolic.svg`, + ), }), y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-icon", @@ -86,15 +101,13 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._connectSignals(); } start() { - if (this._destroyed) - return; + if (this._destroyed) return; this._syncLabel(); this._syncMenu(); void this._loadCachedBankedResets(); this._bankedResetScheduler.start(); void this._loadCachedSnapshot().finally(() => { - if (this._destroyed) - return; + if (this._destroyed) return; this._scheduler.start({ runImmediately: true }); }); } @@ -160,15 +173,19 @@ export class CodexMeterIndicator extends PanelMenu.Button { return box; } _connectSignals() { - this._menuOpenChangedId = this.menu.connect("open-state-changed", (_menu, isOpen) => { - if (isOpen) { - this._queueMenuBarSync(); - void this._refreshBankedResets(); - } - }); + this._menuOpenChangedId = this.menu.connect( + "open-state-changed", + (_menu, isOpen) => { + if (isOpen) { + this._queueMenuBarSync(); + void this._refreshBankedResets(); + } + }, + ); this._settingsChangedId = this._settings.connectChanged(() => { const providerId = this._settings.getUsageProvider(); - const autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); + const autoApplyBankedReset = + this._settings.getAutoApplyBankedReset(); if (providerId !== this._providerId) { this._providerId = providerId; this._usageService = createUsageService(providerId); @@ -191,21 +208,19 @@ export class CodexMeterIndicator extends PanelMenu.Button { }); this._refreshIntervalChangedId = this._settings.connectBackgroundRefreshIntervalChanged(() => { - this._scheduler.setIntervalSeconds(this._settings.getBackgroundRefreshIntervalSeconds()); + this._scheduler.setIntervalSeconds( + this._settings.getBackgroundRefreshIntervalSeconds(), + ); }); } async _refreshUsage({ manual = false } = {}) { - if (this._destroyed) - return; - if (manual) - this._startRefreshSpin(); + if (this._destroyed) return; + if (manual) this._startRefreshSpin(); if (this._refreshPromise) { try { await this._refreshPromise; - } - finally { - if (manual && !this._destroyed) - this._stopRefreshSpin(); + } finally { + if (manual && !this._destroyed) this._stopRefreshSpin(); } return; } @@ -213,105 +228,92 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._refreshPromise = this._refreshUsageOnce(this._refreshCancellable); try { await this._refreshPromise; - } - finally { + } finally { this._refreshPromise = null; this._refreshCancellable = null; - if (manual && !this._destroyed) - this._stopRefreshSpin(); + if (manual && !this._destroyed) this._stopRefreshSpin(); } } _cancelRefresh() { - if (!this._refreshCancellable) - return; + if (!this._refreshCancellable) return; this._refreshCancellable.cancel(); this._refreshCancellable = null; } _cancelBankedResetRefresh() { - if (!this._bankedResetRefreshCancellable) - return; + if (!this._bankedResetRefreshCancellable) return; this._bankedResetRefreshCancellable.cancel(); this._bankedResetRefreshCancellable = null; } _cancelBankedResetRedemption() { - if (!this._redeemBankedResetCancellable) - return; + if (!this._redeemBankedResetCancellable) return; this._redeemBankedResetCancellable.cancel(); this._redeemBankedResetCancellable = null; } async _refreshUsageOnce(cancellable) { try { - if (this._destroyed) - return; + if (this._destroyed) return; this._snapshot = await this._usageService.refresh({ cancellable }); - if (this._destroyed) - return; + if (this._destroyed) return; await this._loadHistory(); this._errorMessage = null; this._cachedFailureMessage = null; try { - this._prediction = await this._usageService.predict(this._snapshot); - } - catch (error) { + this._prediction = await this._usageService.predict( + this._snapshot, + ); + } catch (error) { this._prediction = null; } - } - catch (error) { - if (this._destroyed && isCancellationError(error)) - return; + } catch (error) { + if (this._destroyed && isCancellationError(error)) return; const failureMessage = formatRefreshFailure(error); if (this._snapshot) { this._errorMessage = null; this._cachedFailureMessage = failureMessage; - } - else { - const loadedCachedSnapshot = !this._destroyed && await this._loadCachedSnapshotAfterFailure(); + } else { + const loadedCachedSnapshot = + !this._destroyed && + (await this._loadCachedSnapshotAfterFailure()); if (loadedCachedSnapshot) { this._errorMessage = null; this._cachedFailureMessage = failureMessage; - } - else { + } else { this._errorMessage = failureMessage; this._cachedFailureMessage = null; } } - } - finally { - if (this._destroyed) - return; + } finally { + if (this._destroyed) return; this._syncLabel(); this._syncMenu(); } } async _loadCachedSnapshot() { - if (this._destroyed || this._snapshot || this._errorMessage) - return; + if (this._destroyed || this._snapshot || this._errorMessage) return; try { const snapshot = await this._usageService.readCachedSnapshot(); - if (this._destroyed || + if ( + this._destroyed || !snapshot || this._snapshot || - this._errorMessage) { + this._errorMessage + ) { return; } this._snapshot = snapshot; await this._loadHistory(); try { this._prediction = await this._usageService.predict(snapshot); - } - catch (error) { + } catch (error) { this._prediction = null; } - if (this._destroyed) - return; + if (this._destroyed) return; this._syncLabel(); this._syncMenu(); - } - catch (error) { } + } catch (error) {} } async _loadCachedBankedResets() { - if (this._destroyed || this._providerId !== "codex") - return; + if (this._destroyed || this._providerId !== "codex") return; try { const snapshot = await readCachedCodexBankedResets(); if (this._destroyed || !snapshot || this._providerId !== "codex") @@ -319,37 +321,30 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._bankedResetCount = Math.max(0, snapshot.available_count); this._lastBankedResetRefreshAt = snapshot.fetchedAt; this._syncMenu(); - } - catch (error) { } + } catch (error) {} } async _loadCachedSnapshotAfterFailure() { - if (this._destroyed) - return false; + if (this._destroyed) return false; try { const snapshot = await this._usageService.readCachedSnapshot(); - if (this._destroyed || !snapshot) - return false; - if (this._snapshot) - return true; + if (this._destroyed || !snapshot) return false; + if (this._snapshot) return true; this._snapshot = snapshot; await this._loadHistory(); try { this._prediction = await this._usageService.predict(snapshot); - } - catch (error) { + } catch (error) { this._prediction = null; } return true; - } - catch (error) { + } catch (error) { return false; } } async _loadHistory() { try { this._history = await this._usageService.readHistory(); - } - catch (error) { + } catch (error) { this._history = []; } } @@ -366,11 +361,11 @@ export class CodexMeterIndicator extends PanelMenu.Button { const refreshCancellable = cancellable ?? new Gio.Cancellable(); if (!cancellable) this._bankedResetRefreshCancellable = refreshCancellable; - this._bankedResetRefreshPromise = this._refreshBankedResetsOnce(refreshCancellable); + this._bankedResetRefreshPromise = + this._refreshBankedResetsOnce(refreshCancellable); try { await this._bankedResetRefreshPromise; - } - finally { + } finally { this._bankedResetRefreshPromise = null; if (this._bankedResetRefreshCancellable === refreshCancellable) { this._bankedResetRefreshCancellable = null; @@ -378,33 +373,33 @@ export class CodexMeterIndicator extends PanelMenu.Button { } } async _refreshBankedResetsIfStale() { - if (!this._shouldRefreshBankedResets()) - return; + if (!this._shouldRefreshBankedResets()) return; await this._refreshBankedResets(); } _shouldRefreshBankedResets() { - if (this._destroyed || this._providerId !== "codex") - return false; + if (this._destroyed || this._providerId !== "codex") return false; if (this._bankedResetCount === null || !this._lastBankedResetRefreshAt) return true; const nowSeconds = Math.floor(Date.now() / 1000); - return nowSeconds - this._lastBankedResetRefreshAt >= BANKED_RESET_BACKGROUND_REFRESH_SECONDS; + return ( + nowSeconds - this._lastBankedResetRefreshAt >= + BANKED_RESET_BACKGROUND_REFRESH_SECONDS + ); } async _refreshBankedResetsOnce(cancellable = null) { try { const response = await listCodexBankedResets({ cancellable }); - if (this._destroyed || this._providerId !== "codex") - return; + if (this._destroyed || this._providerId !== "codex") return; this._bankedResetCount = Math.max(0, response.available_count); this._lastBankedResetRefreshAt = Math.floor(Date.now() / 1000); - await this._autoApplyExpiringBankedReset(response.credits, cancellable); + await this._autoApplyExpiringBankedReset( + response.credits, + cancellable, + ); this._syncMenu(); - } - catch (error) { - if (isCancellationError(error)) - return; - if (this._destroyed) - return; + } catch (error) { + if (isCancellationError(error)) return; + if (this._destroyed) return; console.warn("Unable to refresh Codex banked reset count", error); this._bankedResetCount = null; this._lastBankedResetRefreshAt = null; @@ -412,30 +407,40 @@ export class CodexMeterIndicator extends PanelMenu.Button { } } async _autoApplyExpiringBankedReset(credits, cancellable) { - if (this._destroyed || + if ( + this._destroyed || this._redeemingBankedReset || - !this._settings.getAutoApplyBankedReset()) { + !this._settings.getAutoApplyBankedReset() + ) { return; } - const credit = selectCreditExpiringWithin(credits, Date.now(), AUTO_APPLY_BANKED_RESET_WINDOW_MS); - if (!credit) - return; + const credit = selectCreditExpiringWithin( + credits, + Date.now(), + AUTO_APPLY_BANKED_RESET_WINDOW_MS, + ); + if (!credit) return; this._redeemingBankedReset = true; this._syncMenu(); try { await redeemCodexBankedReset(credit.id, { cancellable }); - if (this._destroyed) - return; - this._bankedResetCount = Math.max(0, (this._bankedResetCount ?? 1) - 1); - notify("Codex Meter", "Expiring banked Codex reset applied automatically."); + if (this._destroyed) return; + this._bankedResetCount = Math.max( + 0, + (this._bankedResetCount ?? 1) - 1, + ); + notify( + "Codex Meter", + "Expiring banked Codex reset applied automatically.", + ); await this._refreshUsage(); - } - catch (error) { - if (this._destroyed && isCancellationError(error)) - return; - console.warn("Unable to auto-apply expiring Codex banked reset", error); - } - finally { + } catch (error) { + if (this._destroyed && isCancellationError(error)) return; + console.warn( + "Unable to auto-apply expiring Codex banked reset", + error, + ); + } finally { if (!this._destroyed) { this._redeemingBankedReset = false; this._syncMenu(); @@ -443,14 +448,19 @@ export class CodexMeterIndicator extends PanelMenu.Button { } } async _redeemBankedReset() { - if (this._destroyed || this._redeemingBankedReset) - return; + if (this._destroyed || this._redeemingBankedReset) return; if (this._providerId !== "codex") { - notify("Codex Meter", "Banked resets are only available for Codex usage."); + notify( + "Codex Meter", + "Banked resets are only available for Codex usage.", + ); return; } if (this._bankedResetCount !== null && this._bankedResetCount <= 0) { - notify("Codex Meter", "No banked Codex resets are available to redeem."); + notify( + "Codex Meter", + "No banked Codex resets are available to redeem.", + ); return; } const cancellable = new Gio.Cancellable(); @@ -459,54 +469,54 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._syncMenu(); try { await redeemNextCodexBankedReset({ cancellable }); - if (this._destroyed) - return; + if (this._destroyed) return; notify("Codex Meter", "Banked Codex reset redeemed."); await this._refreshBankedResets(cancellable); await this._refreshUsage({ manual: true }); - } - catch (error) { - if (this._destroyed && isCancellationError(error)) - return; + } catch (error) { + if (this._destroyed && isCancellationError(error)) return; notify("Codex Meter", formatBankedResetFailure(error)); - } - finally { + } finally { if (this._redeemBankedResetCancellable === cancellable) { this._redeemBankedResetCancellable = null; } - if (this._destroyed) - return; + if (this._destroyed) return; this._redeemingBankedReset = false; this._syncMenu(); } } _startRefreshSpin() { - if (!this._headerItem?.refreshIcon || this._refreshSpinId) - return; + if (!this._headerItem?.refreshIcon || this._refreshSpinId) return; this._headerItem.refreshButton.reactive = false; this._headerItem.refreshButton.can_focus = false; - this._refreshSpinId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { - if (this._destroyed) - return GLib.SOURCE_REMOVE; - this._headerItem.refreshIcon.rotation_angle_z = - (this._headerItem.refreshIcon.rotation_angle_z + 18) % 360; - return GLib.SOURCE_CONTINUE; - }); + this._refreshSpinId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + 30, + () => { + if (this._destroyed) return GLib.SOURCE_REMOVE; + this._headerItem.refreshIcon.rotation_angle_z = + (this._headerItem.refreshIcon.rotation_angle_z + 18) % 360; + return GLib.SOURCE_CONTINUE; + }, + ); } _stopRefreshSpin() { if (this._refreshSpinId) { GLib.source_remove(this._refreshSpinId); this._refreshSpinId = 0; } - if (!this._headerItem?.refreshIcon) - return; + if (!this._headerItem?.refreshIcon) return; this._headerItem.refreshIcon.rotation_angle_z = 0; this._headerItem.refreshButton.reactive = true; this._headerItem.refreshButton.can_focus = true; } _syncLabel() { const settings = this._settings.getAll(); - const viewModel = createPanelBarViewModel(settings, this._snapshot, this._errorMessage); + const viewModel = createPanelBarViewModel( + settings, + this._snapshot, + this._errorMessage, + ); this._panelPrimaryBar.actor.visible = viewModel.primaryVisible; this._panelSecondaryBar.actor.visible = viewModel.secondaryVisible; this._panelPrimaryBar.update({ @@ -520,16 +530,14 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._panelBars.visible = viewModel.showBars; if (viewModel.primaryVisible && viewModel.secondaryVisible) { this._panelBars.add_style_class_name("cx-panel-bars-stacked"); - } - else { + } else { this._panelBars.remove_style_class_name("cx-panel-bars-stacked"); } this._label.visible = viewModel.showLabel && viewModel.label !== ""; this._label.text = viewModel.label; if (viewModel.label.includes("/")) { this._label.add_style_class_name("cx-usage-label-wide"); - } - else { + } else { this._label.remove_style_class_name("cx-usage-label-wide"); } this._prefixLabel.visible = settings.topPanelIndicatorIcon === "text"; @@ -537,7 +545,14 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._openAiIcon.visible = settings.topPanelIndicatorIcon === "openai"; } _syncMenu() { - const viewModel = createMenuViewModel(this._settings.getAll(), this._snapshot, this._prediction, this._history, this._errorMessage, this._cachedFailureMessage); + const viewModel = createMenuViewModel( + this._settings.getAll(), + this._snapshot, + this._prediction, + this._history, + this._errorMessage, + this._cachedFailureMessage, + ); this._headerItem.datetimeLabel.text = viewModel.updatedAt; this._headerItem.datetimeLabel.visible = false; this._popupMenu.setError(viewModel.errorMessage); @@ -557,8 +572,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._footerItem.planLabel.text = viewModel.plan; } _queueMenuBarSync() { - if (this._menuSyncId) - return; + if (this._menuSyncId) return; this._menuSyncId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { this._menuSyncId = 0; this._popupMenu.syncBars(); @@ -572,9 +586,11 @@ export class CodexMeterIndicator extends PanelMenu.Button { function createUsageService(providerId) { try { return new UsageService(getUsageProvider(providerId)); - } - catch (error) { - console.warn(`Usage provider "${providerId}" is not supported yet; falling back to ${DEFAULT_PROVIDER_ID}.`, error); + } catch (error) { + console.warn( + `Usage provider "${providerId}" is not supported yet; falling back to ${DEFAULT_PROVIDER_ID}.`, + error, + ); return new UsageService(getUsageProvider(DEFAULT_PROVIDER_ID)); } } @@ -582,15 +598,18 @@ function formatBankedResetFailure(error) { if (isRefreshFailureError(error)) { return error.message; } - const message = error instanceof Error && error.message - ? error.message - : "Unknown banked reset failure"; + const message = + error instanceof Error && error.message + ? error.message + : "Unknown banked reset failure"; return `Banked Codex reset redemption failed: ${message}`; } function notify(title, message) { Main.notify(title, message); } function isCancellationError(error) { - return (error instanceof GLib.Error && - error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)); + return ( + error instanceof GLib.Error && + error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) + ); } diff --git a/src/preferences/page.js b/src/preferences/page.js index 386de64..4baf201 100644 --- a/src/preferences/page.js +++ b/src/preferences/page.js @@ -2,7 +2,7 @@ import Adw from "gi://Adw"; import Gio from "gi://Gio"; import GObject from "gi://GObject"; import Gtk from "gi://Gtk"; -import { MIN_REFRESH_INTERVAL_MINUTES, SETTINGS_AUTO_APPLY_BANKED_RESET, SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, SETTINGS_PERCENT_DISPLAY_MODE, SETTINGS_SHOW_PRIMARY, SETTINGS_SHOW_SECONDARY, SETTINGS_TOP_PANEL_DISPLAY_MODE, SETTINGS_TOP_PANEL_INDICATOR_ICON, } from "../config/settings.js"; +import { MIN_REFRESH_INTERVAL_MINUTES, SETTINGS_AUTO_APPLY_BANKED_RESET, SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, SETTINGS_PERCENT_DISPLAY_MODE, SETTINGS_SHOW_PRIMARY, SETTINGS_SHOW_SECONDARY, SETTINGS_TOP_PANEL_DISPLAY_MODE, SETTINGS_TOP_PANEL_INDICATOR_ICON, } from "./settings.js"; export const PreferencesPage = GObject.registerClass(class PreferencesPage extends Adw.PreferencesPage { _init(settings, snapshot, metadata) { super._init({ diff --git a/src/config/settings.js b/src/preferences/settings.js similarity index 100% rename from src/config/settings.js rename to src/preferences/settings.js diff --git a/src/usage/history-store.js b/src/usage/history-store.js index 3fb7450..5cfa207 100644 --- a/src/usage/history-store.js +++ b/src/usage/history-store.js @@ -1,5 +1,5 @@ import GLib from "gi://GLib"; -import { STATE_DIR } from "../config/paths.js"; +import { STATE_DIR } from "../io/paths.js"; import { appendFile, readFile, writeFile } from "../io/files.js"; export const MAX_HISTORY_ENTRIES = 25_000; const MAX_HISTORY_AGE_SECONDS = 21 * 24 * 60 * 60; diff --git a/src/usage/snapshot-store.js b/src/usage/snapshot-store.js index 0106544..ba0ca31 100644 --- a/src/usage/snapshot-store.js +++ b/src/usage/snapshot-store.js @@ -1,5 +1,5 @@ import GLib from "gi://GLib"; -import { CACHE_DIR } from "../config/paths.js"; +import { CACHE_DIR } from "../io/paths.js"; import { readJsonFile, writeJsonFile } from "../io/files.js"; function getSnapshotPath(providerId) { return GLib.build_filenamev([CACHE_DIR, providerId, "snapshot.json"]); From 57ccf9b46e9d05f09e54e7eed02d9f4396dba06d Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 12:47:17 +0200 Subject: [PATCH 5/8] Add Prettier formatting checks to CI --- .github/workflows/ci.yml | 226 ++++++++++++------------ .github/workflows/release.yml | 230 ++++++++++++------------ .prettierignore | 5 + .prettierrc.json | 7 + package-lock.json | 17 ++ package.json | 5 +- src/banked-resets/api.js | 35 ++-- src/banked-resets/response.js | 3 +- src/banked-resets/store.js | 23 ++- src/codex/auth-file.js | 39 ++++- src/codex/auth.js | 9 +- src/codex/usage-provider.js | 4 + src/codex/usage-response.js | 19 +- src/extension.js | 3 + src/io/files.js | 62 +++++-- src/io/http.js | 60 +++++-- src/io/paths.js | 18 +- src/panel/button.js | 4 +- src/panel/indicator.js | 167 ++++++++---------- src/panel/menu.js | 70 +++++--- src/panel/refresh-error-message.js | 1 + src/panel/usage-bar.js | 46 +++-- src/panel/view-model.js | 270 +++++++++++++++++------------ src/preferences/page.js | 193 +++++++++++++-------- src/preferences/settings.js | 33 +++- src/prefs.js | 1 + src/refresh/error.js | 3 + src/refresh/scheduler.js | 39 +++-- src/usage/history-store.js | 80 +++++---- src/usage/prediction.js | 13 +- src/usage/providers.js | 4 + src/usage/service.js | 48 +++-- src/usage/snapshot-store.js | 22 ++- test/codex-auth.test.js | 15 +- test/codex-banked-resets.test.js | 42 ++++- test/codex-provider.test.js | 62 ++++--- test/history-storage.test.js | 86 +++++---- test/usage-trend.test.js | 42 +++-- test/view-model.test.js | 30 ++-- 39 files changed, 1232 insertions(+), 804 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b09bed3..4737c61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,122 +1,122 @@ name: CI on: - pull_request: - push: - workflow_dispatch: + pull_request: + push: + workflow_dispatch: permissions: - contents: read + contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: - validate: - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - cache: "npm" - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - - name: Install validation dependencies - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y gjs jq libglib2.0-bin unzip zip - - python -m pip install --upgrade pip - # shexli 0.2.1 currently segfaults with tree-sitter 0.26.0. - pip install -U shexli "tree-sitter==0.25.2" - - - name: Install Node.js dependencies - run: npm ci - - - name: Validate metadata - shell: bash - run: | - set -euo pipefail - jq empty metadata.json - jq -e ' - .uuid == "codex-meter@slobbe.github.io" - and (.name | type == "string" and length > 0) - and (.description | type == "string" and length > 0) - and (."version-name" | test("^[0-9]+\\.[0-9]+\\.[0-9]+$")) - and (."shell-version" | type == "array" and length > 0) - and (."settings-schema" == "org.gnome.shell.extensions.codex-meter") - ' metadata.json - - PACKAGE_VERSION="$(node -p "require('./package.json').version")" - METADATA_VERSION="$(jq -r '."version-name"' metadata.json)" - - if [[ "${PACKAGE_VERSION}" != "${METADATA_VERSION}" ]]; then - echo "package.json version ${PACKAGE_VERSION} does not match metadata.json version-name ${METADATA_VERSION}" >&2 - exit 1 - fi - - - name: Run tests - run: npm test - - - name: Validate schemas - shell: bash - run: | - set -euo pipefail - glib-compile-schemas --strict schemas - - - name: Build extension bundle - shell: bash - run: | - set -euo pipefail - make clean pack - - - name: Validate extension bundle - shell: bash - run: | - set -euo pipefail - VERSION="$(node -p "require('./package.json').version")" - UUID="$(jq -r '.uuid' metadata.json)" - ZIP="${UUID}-v${VERSION}.zip" - - test -f "${ZIP}" - unzip -p "${ZIP}" metadata.json | jq empty - unzip -p "${ZIP}" schemas/org.gnome.shell.extensions.codex-meter.gschema.xml >/dev/null - - for file in \ - metadata.json \ - extension.js \ - prefs.js \ - stylesheet.css \ - schemas/org.gnome.shell.extensions.codex-meter.gschema.xml - do - unzip -Z1 "${ZIP}" | grep -Fx "${file}" - done - - - name: Run Shexli static analyzer - shell: bash - run: | - set -euo pipefail - VERSION="$(node -p "require('./package.json').version")" - UUID="$(jq -r '.uuid' metadata.json)" - ZIP="${UUID}-v${VERSION}.zip" - - shexli "${ZIP}" - - - name: Upload extension bundle - uses: actions/upload-artifact@v7 - with: - name: release-bundle - path: codex-meter@slobbe.github.io-*.zip - if-no-files-found: error + validate: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + cache: "npm" + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install validation dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y gjs jq libglib2.0-bin unzip zip + + python -m pip install --upgrade pip + # shexli 0.2.1 currently segfaults with tree-sitter 0.26.0. + pip install -U shexli "tree-sitter==0.25.2" + + - name: Install Node.js dependencies + run: npm ci + + - name: Validate metadata + shell: bash + run: | + set -euo pipefail + jq empty metadata.json + jq -e ' + .uuid == "codex-meter@slobbe.github.io" + and (.name | type == "string" and length > 0) + and (.description | type == "string" and length > 0) + and (."version-name" | test("^[0-9]+\\.[0-9]+\\.[0-9]+$")) + and (."shell-version" | type == "array" and length > 0) + and (."settings-schema" == "org.gnome.shell.extensions.codex-meter") + ' metadata.json + + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + METADATA_VERSION="$(jq -r '."version-name"' metadata.json)" + + if [[ "${PACKAGE_VERSION}" != "${METADATA_VERSION}" ]]; then + echo "package.json version ${PACKAGE_VERSION} does not match metadata.json version-name ${METADATA_VERSION}" >&2 + exit 1 + fi + + - name: Run tests + run: npm test + + - name: Validate schemas + shell: bash + run: | + set -euo pipefail + glib-compile-schemas --strict schemas + + - name: Build extension bundle + shell: bash + run: | + set -euo pipefail + make clean pack + + - name: Validate extension bundle + shell: bash + run: | + set -euo pipefail + VERSION="$(node -p "require('./package.json').version")" + UUID="$(jq -r '.uuid' metadata.json)" + ZIP="${UUID}-v${VERSION}.zip" + + test -f "${ZIP}" + unzip -p "${ZIP}" metadata.json | jq empty + unzip -p "${ZIP}" schemas/org.gnome.shell.extensions.codex-meter.gschema.xml >/dev/null + + for file in \ + metadata.json \ + extension.js \ + prefs.js \ + stylesheet.css \ + schemas/org.gnome.shell.extensions.codex-meter.gschema.xml + do + unzip -Z1 "${ZIP}" | grep -Fx "${file}" + done + + - name: Run Shexli static analyzer + shell: bash + run: | + set -euo pipefail + VERSION="$(node -p "require('./package.json').version")" + UUID="$(jq -r '.uuid' metadata.json)" + ZIP="${UUID}-v${VERSION}.zip" + + shexli "${ZIP}" + + - name: Upload extension bundle + uses: actions/upload-artifact@v7 + with: + name: release-bundle + path: codex-meter@slobbe.github.io-*.zip + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d2fb88..a83168a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,124 +1,124 @@ name: Release on: - workflow_run: - workflows: - - "CI" - types: - - completed + workflow_run: + workflows: + - "CI" + types: + - completed permissions: - contents: write - actions: read + contents: write + actions: read concurrency: - group: release-${{ github.event.workflow_run.head_branch || github.event.workflow_run.head_sha }} - cancel-in-progress: false + group: release-${{ github.event.workflow_run.head_branch || github.event.workflow_run.head_sha }} + cancel-in-progress: false jobs: - release: - if: > - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - startsWith(github.event.workflow_run.head_branch, 'v') - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v6 - with: - ref: ${{ github.event.workflow_run.head_sha }} - fetch-depth: 0 - - - name: Validate release source - shell: bash - run: | - set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - CI_SHA="${{ github.event.workflow_run.head_sha }}" - - if [[ ! "${CI_REF}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "CI ref is not a release tag: ${CI_REF}" >&2 - exit 1 - fi - - git fetch --force --tags - TAG_SHA="$(git rev-list -n 1 "${CI_REF}")" - - if [[ "${TAG_SHA}" != "${CI_SHA}" ]]; then - echo "Tag ${CI_REF} points to ${TAG_SHA}, but CI ran on ${CI_SHA}" >&2 - exit 1 - fi - - - name: Install release dependencies - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y jq unzip - - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "24" - - - name: Validate release metadata - shell: bash - run: | - set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - TAG_VERSION="${CI_REF#v}" - PACKAGE_VERSION="$(node -p "require('./package.json').version")" - METADATA_VERSION="$(jq -r '."version-name"' metadata.json)" - - if [[ "${PACKAGE_VERSION}" != "${TAG_VERSION}" ]]; then - echo "Tag ${CI_REF} does not match package version ${PACKAGE_VERSION}" >&2 - exit 1 - fi - - if [[ "${METADATA_VERSION}" != "${TAG_VERSION}" ]]; then - echo "Tag ${CI_REF} does not match metadata version-name ${METADATA_VERSION}" >&2 - exit 1 - fi - - - name: Download extension bundle - uses: actions/download-artifact@v8 - with: - name: release-bundle - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ github.token }} - path: release-artifacts - - - name: Validate release artifact - shell: bash - run: | - set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - TAG_VERSION="${CI_REF#v}" - UUID="$(jq -r '.uuid' metadata.json)" - ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" - - test -f "${ZIP}" - unzip -p "${ZIP}" metadata.json | jq empty - unzip -p "${ZIP}" extension.js >/dev/null - unzip -p "${ZIP}" prefs.js >/dev/null - - - name: Create GitHub release - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - TAG_VERSION="${CI_REF#v}" - UUID="$(jq -r '.uuid' metadata.json)" - ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" - - if gh release view "${CI_REF}" >/dev/null 2>&1; then - gh release upload "${CI_REF}" "${ZIP}" --clobber - else - gh release create "${CI_REF}" "${ZIP}" \ - --title "${CI_REF}" \ - --generate-notes \ - --verify-tag - fi + release: + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + startsWith(github.event.workflow_run.head_branch, 'v') + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Validate release source + shell: bash + run: | + set -euo pipefail + CI_REF="${{ github.event.workflow_run.head_branch }}" + CI_SHA="${{ github.event.workflow_run.head_sha }}" + + if [[ ! "${CI_REF}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "CI ref is not a release tag: ${CI_REF}" >&2 + exit 1 + fi + + git fetch --force --tags + TAG_SHA="$(git rev-list -n 1 "${CI_REF}")" + + if [[ "${TAG_SHA}" != "${CI_SHA}" ]]; then + echo "Tag ${CI_REF} points to ${TAG_SHA}, but CI ran on ${CI_SHA}" >&2 + exit 1 + fi + + - name: Install release dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y jq unzip + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Validate release metadata + shell: bash + run: | + set -euo pipefail + CI_REF="${{ github.event.workflow_run.head_branch }}" + TAG_VERSION="${CI_REF#v}" + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + METADATA_VERSION="$(jq -r '."version-name"' metadata.json)" + + if [[ "${PACKAGE_VERSION}" != "${TAG_VERSION}" ]]; then + echo "Tag ${CI_REF} does not match package version ${PACKAGE_VERSION}" >&2 + exit 1 + fi + + if [[ "${METADATA_VERSION}" != "${TAG_VERSION}" ]]; then + echo "Tag ${CI_REF} does not match metadata version-name ${METADATA_VERSION}" >&2 + exit 1 + fi + + - name: Download extension bundle + uses: actions/download-artifact@v8 + with: + name: release-bundle + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + path: release-artifacts + + - name: Validate release artifact + shell: bash + run: | + set -euo pipefail + CI_REF="${{ github.event.workflow_run.head_branch }}" + TAG_VERSION="${CI_REF#v}" + UUID="$(jq -r '.uuid' metadata.json)" + ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" + + test -f "${ZIP}" + unzip -p "${ZIP}" metadata.json | jq empty + unzip -p "${ZIP}" extension.js >/dev/null + unzip -p "${ZIP}" prefs.js >/dev/null + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + CI_REF="${{ github.event.workflow_run.head_branch }}" + TAG_VERSION="${CI_REF#v}" + UUID="$(jq -r '.uuid' metadata.json)" + ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" + + if gh release view "${CI_REF}" >/dev/null 2>&1; then + gh release upload "${CI_REF}" "${ZIP}" --clobber + else + gh release create "${CI_REF}" "${ZIP}" \ + --title "${CI_REF}" \ + --generate-notes \ + --verify-tag + fi diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..75eb12a --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +node_modules/ +*.zip +package-lock.json +src/icons/ +src/schemas/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..066c694 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "tabWidth": 4, + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/package-lock.json b/package-lock.json index bc9c945..d05818c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,26 @@ "version": "0.9.0", "license": "GPL-3.0-or-later", "devDependencies": { + "prettier": "^3.9.6", "typescript": "^6.0.3" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index 53815d1..2191643 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,13 @@ "homepage": "https://github.com/slobbe/codex-meter", "sideEffects": false, "scripts": { - "check": "tsc", + "check": "tsc && npm run format:check", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "npm run check && node --test test/prediction.test.js test/usage-trend.test.js test/view-model.test.js test/refresh-failure-format.test.js test/codex-provider.test.js test/codex-auth.test.js test/codex-banked-resets.test.js && gjs -m test/history-storage.test.js" }, "devDependencies": { + "prettier": "^3.9.6", "typescript": "^6.0.3" } } diff --git a/src/banked-resets/api.js b/src/banked-resets/api.js index f2a35f1..f50a10c 100644 --- a/src/banked-resets/api.js +++ b/src/banked-resets/api.js @@ -1,24 +1,29 @@ import GLib from "gi://GLib"; import { fetchJson } from "../io/http.js"; import { RefreshFailureError } from "../refresh/error.js"; -import { readBankedResetSnapshot, writeBankedResetSnapshot, } from "./store.js"; +import { readBankedResetSnapshot, writeBankedResetSnapshot } from "./store.js"; import { getCodexCredentials } from "../codex/auth.js"; -import { selectCreditToRedeem, toListResponse, } from "./response.js"; +import { selectCreditToRedeem, toListResponse } from "./response.js"; + const CODEX_BANKED_RESETS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; + const CODEX_REDEEM_BANKED_RESET_URL = `${CODEX_BANKED_RESETS_URL}/consume`; + const CODEX_BANKED_RESETS_API_CONFIG = { providerName: "Codex", usageUrl: CODEX_BANKED_RESETS_URL, messages: { malformedResponse: "Codex returned a malformed banked reset response.", unexpectedResponseFormat: "Codex returned an unexpected banked reset response format.", - networkUnavailable: "Codex banked resets could not be reached. Check your network and try again.", + networkUnavailable: + "Codex banked resets could not be reached. Check your network and try again.", responseTooLarge: "Codex returned a banked reset response that is too large.", unauthorized: "Codex authentication expired. Please run `codex login` again.", refreshFailed: "Codex banked reset request failed. Try again later.", emptyResponse: "Codex returned an empty banked reset response.", }, }; + export async function listCodexBankedResets(options = {}) { const credentials = await getCodexCredentials(); const response = await fetchJson(CODEX_BANKED_RESETS_URL, CODEX_BANKED_RESETS_API_CONFIG, { @@ -28,41 +33,45 @@ export async function listCodexBankedResets(options = {}) { const listResponse = toListResponse(response); try { await writeBankedResetSnapshot(listResponse); - } - catch (error) { + } catch (error) { console.error("Unable to write Codex banked reset snapshot cache", error); } return listResponse; } + export async function readCachedCodexBankedResets() { return await readBankedResetSnapshot(); } + export async function redeemNextCodexBankedReset(options = {}) { const list = await listCodexBankedResets(options); const credit = selectCreditToRedeem(list.credits); if (!credit) { - throw new RefreshFailureError("unexpected-response", "No banked Codex resets are available to redeem.", "Codex banked reset redemption was requested with zero available credits."); + throw new RefreshFailureError( + "unexpected-response", + "No banked Codex resets are available to redeem.", + "Codex banked reset redemption was requested with zero available credits.", + ); } await redeemCodexBankedReset(credit.id, options); return credit; } + export async function redeemCodexBankedReset(creditId, options = {}) { const response = await consumeCodexBankedReset(creditId, options); try { await markCachedBankedResetRedeemed(creditId); - } - catch (error) { + } catch (error) { console.error("Unable to update Codex banked reset snapshot cache", error); } return response; } + async function markCachedBankedResetRedeemed(creditId) { const snapshot = await readBankedResetSnapshot(); - if (!snapshot) - return; + if (!snapshot) return; const credits = snapshot.credits.map((credit) => { - if (credit.id !== creditId) - return credit; + if (credit.id !== creditId) return credit; return { ...credit, status: "redeemed", @@ -73,6 +82,7 @@ async function markCachedBankedResetRedeemed(creditId) { credits, }); } + async function consumeCodexBankedReset(creditId, options = {}) { const credentials = await getCodexCredentials(); const request = { @@ -90,6 +100,7 @@ async function consumeCodexBankedReset(creditId, options = {}) { cancellable: options.cancellable ?? null, }); } + function createCodexBankedResetHeaders(accessToken, accountId) { const headers = { Authorization: `Bearer ${accessToken}`, diff --git a/src/banked-resets/response.js b/src/banked-resets/response.js index fc97436..4075147 100644 --- a/src/banked-resets/response.js +++ b/src/banked-resets/response.js @@ -124,8 +124,7 @@ function sortByExpiry(credits) { * @returns {boolean} */ function hasValidExpiresAt(credit) { - return typeof credit.expires_at === "string" && - Number.isFinite(Date.parse(credit.expires_at)); + return typeof credit.expires_at === "string" && Number.isFinite(Date.parse(credit.expires_at)); } /** diff --git a/src/banked-resets/store.js b/src/banked-resets/store.js index cbe7b26..66c95b9 100644 --- a/src/banked-resets/store.js +++ b/src/banked-resets/store.js @@ -1,44 +1,52 @@ import GLib from "gi://GLib"; import { CACHE_DIR } from "../io/paths.js"; import { readJsonFile, writeJsonFile } from "../io/files.js"; + function getBankedResetSnapshotPath() { return GLib.build_filenamev([CACHE_DIR, "codex", "banked-reset-snapshot.json"]); } + export async function writeBankedResetSnapshot(response) { await writeJsonFile(getBankedResetSnapshotPath(), { ...response, fetchedAt: Math.floor(Date.now() / 1000), }); } + export async function readBankedResetSnapshot() { try { const raw = await readJsonFile(getBankedResetSnapshotPath()); return toBankedResetSnapshot(raw); - } - catch { + } catch { return null; } } + function toBankedResetSnapshot(value) { if (!isBankedResetSnapshot(value)) { return null; } return value; } + function isBankedResetSnapshot(value) { if (!isObject(value)) { return false; } - return Number.isFinite(value.fetchedAt) && + return ( + Number.isFinite(value.fetchedAt) && Number.isFinite(value.available_count) && Array.isArray(value.credits) && - value.credits.every(isBankedResetCredit); + value.credits.every(isBankedResetCredit) + ); } + function isBankedResetCredit(value) { if (!isObject(value)) { return false; } - return typeof value.id === "string" && + return ( + typeof value.id === "string" && value.id.trim().length > 0 && typeof value.status === "string" && [ @@ -49,11 +57,14 @@ function isBankedResetCredit(value) { "profile_user_id", "title", "description", - ].every((key) => isOptionalString(value[key])); + ].every((key) => isOptionalString(value[key])) + ); } + function isOptionalString(value) { return value === undefined || value === null || typeof value === "string"; } + function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/codex/auth-file.js b/src/codex/auth-file.js index b257775..79fdbc0 100644 --- a/src/codex/auth-file.js +++ b/src/codex/auth-file.js @@ -1,44 +1,69 @@ import GLib from "gi://GLib"; import { RefreshFailureError } from "../refresh/error.js"; import { readJsonFile } from "../io/files.js"; + export async function getLocalAccessToken(config) { if (!isNonEmptyString(config.authPath)) { - throw new RefreshFailureError("missing-auth", `${config.providerName} auth is unavailable. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth path must be a non-empty string`); + throw new RefreshFailureError( + "missing-auth", + `${config.providerName} auth is unavailable. Run \`${config.loginCommand}\` and try again.`, + `${config.providerName} auth path must be a non-empty string`, + ); } const auth = await readLocalAuth(config.authPath, config); const token = config.parseAccessToken(auth); if (!isUsableAccessToken(token)) { - throw new RefreshFailureError("missing-auth", `${config.providerName} access token is missing. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth data does not contain a valid access token`); + throw new RefreshFailureError( + "missing-auth", + `${config.providerName} access token is missing. Run \`${config.loginCommand}\` and try again.`, + `${config.providerName} auth data does not contain a valid access token`, + ); } return token.trim(); } + export async function readLocalAuth(path, config) { if (!GLib.file_test(path, GLib.FileTest.EXISTS)) { - throw new RefreshFailureError("missing-auth", `${config.providerName} auth file is missing. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth file does not exist at "${path}"`); + throw new RefreshFailureError( + "missing-auth", + `${config.providerName} auth file is missing. Run \`${config.loginCommand}\` and try again.`, + `${config.providerName} auth file does not exist at "${path}"`, + ); } try { const raw = await readJsonFile(path); if (!isObject(raw)) { - throw new RefreshFailureError("missing-auth", `${config.providerName} auth file is malformed. Run \`${config.loginCommand}\` and try again.`, `${config.providerName} auth file at "${path}" does not contain a valid JSON object`); + throw new RefreshFailureError( + "missing-auth", + `${config.providerName} auth file is malformed. Run \`${config.loginCommand}\` and try again.`, + `${config.providerName} auth file at "${path}" does not contain a valid JSON object`, + ); } return raw; - } - catch (err) { + } catch (err) { if (err instanceof RefreshFailureError) { throw err; } - throw new RefreshFailureError("missing-auth", `${config.providerName} auth file could not be read. Run \`${config.loginCommand}\` and try again.`, `Failed to read ${config.providerName} auth file at "${path}": ${formatError(err)}`); + throw new RefreshFailureError( + "missing-auth", + `${config.providerName} auth file could not be read. Run \`${config.loginCommand}\` and try again.`, + `Failed to read ${config.providerName} auth file at "${path}": ${formatError(err)}`, + ); } } + function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } + function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } + function isUsableAccessToken(value) { return isNonEmptyString(value) && !/\s/.test(value.trim()); } + function formatError(err) { if (err instanceof Error && err.message) { return err.message; diff --git a/src/codex/auth.js b/src/codex/auth.js index ff44f1a..9cd7ed1 100644 --- a/src/codex/auth.js +++ b/src/codex/auth.js @@ -1,19 +1,24 @@ import GLib from "gi://GLib"; -import { getLocalAccessToken, readLocalAuth, } from "./auth-file.js"; -import { parseCodexAccessToken, parseCodexAccountId, } from "./auth-parser.js"; +import { getLocalAccessToken, readLocalAuth } from "./auth-file.js"; +import { parseCodexAccessToken, parseCodexAccountId } from "./auth-parser.js"; + export { parseCodexAccessToken, parseCodexAccountId } from "./auth-parser.js"; + export function getCodexAuthPath() { return GLib.build_filenamev([GLib.get_home_dir(), ".codex", "auth.json"]); } + export const CODEX_AUTH_CONFIG = { providerName: "Codex", authPath: getCodexAuthPath(), loginCommand: "codex login", parseAccessToken: parseCodexAccessToken, }; + export async function getCodexAccessToken() { return await getLocalAccessToken(CODEX_AUTH_CONFIG); } + export async function getCodexCredentials() { const auth = await readLocalAuth(CODEX_AUTH_CONFIG.authPath, CODEX_AUTH_CONFIG); const accessToken = parseCodexAccessToken(auth); diff --git a/src/codex/usage-provider.js b/src/codex/usage-provider.js index ff8e020..895d53a 100644 --- a/src/codex/usage-provider.js +++ b/src/codex/usage-provider.js @@ -1,7 +1,9 @@ import { fetchProviderUsage } from "../io/http.js"; import { getCodexAccessToken } from "./auth.js"; import { toUsageSnapshot } from "./usage-response.js"; + const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"; + const CODEX_API_CONFIG = { providerName: "Codex", usageUrl: CODEX_USAGE_URL, @@ -15,8 +17,10 @@ const CODEX_API_CONFIG = { emptyResponse: "Codex returned an empty response.", }, }; + export class CodexUsageProvider { info = { id: "codex", displayName: "Codex" }; + async refreshUsage(options = {}) { const token = await getCodexAccessToken(); const apiResponse = await fetchProviderUsage(token, CODEX_API_CONFIG, { diff --git a/src/codex/usage-response.js b/src/codex/usage-response.js index 8fcf269..4d881cf 100644 --- a/src/codex/usage-response.js +++ b/src/codex/usage-response.js @@ -121,17 +121,17 @@ function assertOptionalWindow(value, path) { function toUsageCredits(value) { if (!isObject(value)) return undefined; - const balance = typeof value.balance === "string" && value.balance.trim().length > 0 - ? value.balance - : null; + const balance = + typeof value.balance === "string" && value.balance.trim().length > 0 ? value.balance : null; return { balance, hasCredits: typeof value.has_credits === "boolean" ? value.has_credits : undefined, unlimited: typeof value.unlimited === "boolean" ? value.unlimited : undefined, - overageLimitReached: typeof value.overage_limit_reached === "boolean" - ? value.overage_limit_reached - : undefined, + overageLimitReached: + typeof value.overage_limit_reached === "boolean" + ? value.overage_limit_reached + : undefined, }; } @@ -144,12 +144,7 @@ function assertWindow(value, path) { throwUnexpected(`${path} is missing or not an object`); } - for (const key of [ - "used_percent", - "limit_window_seconds", - "reset_after_seconds", - "reset_at", - ]) { + for (const key of ["used_percent", "limit_window_seconds", "reset_after_seconds", "reset_at"]) { if (!Number.isFinite(value[key])) { throwUnexpected(`${path}.${key} is missing or not a finite number`); } diff --git a/src/extension.js b/src/extension.js index 2af5645..f96a9e9 100644 --- a/src/extension.js +++ b/src/extension.js @@ -18,13 +18,16 @@ import * as Main from "resource:///org/gnome/shell/ui/main.js"; import { Extension } from "resource:///org/gnome/shell/extensions/extension.js"; import { CodexMeterIndicator } from "./panel/indicator.js"; + export default class CodexMeterExtension extends Extension { _indicator = null; + enable() { this._indicator = new CodexMeterIndicator(this); Main.panel.addToStatusArea(this.uuid, this._indicator); this._indicator.start(); } + disable() { this._indicator?.destroy(); this._indicator = null; diff --git a/src/io/files.js b/src/io/files.js index 265bcf9..3a084ed 100644 --- a/src/io/files.js +++ b/src/io/files.js @@ -1,64 +1,91 @@ import Gio from "gi://Gio"; import GLib from "gi://GLib"; + Gio._promisify(Gio.File.prototype, "load_contents_async"); + Gio._promisify(Gio.File.prototype, "replace_contents_async"); + Gio._promisify(Gio.File.prototype, "append_to_async"); + Gio._promisify(Gio.OutputStream.prototype, "write_bytes_async"); + Gio._promisify(Gio.OutputStream.prototype, "close_async"); + const PRIVATE_DIR_MODE = 0o700; + const PRIVATE_FILE_MODE = 0o600; + function ensureParentDir(path) { const dir = GLib.path_get_dirname(path); GLib.mkdir_with_parents(dir, PRIVATE_DIR_MODE); GLib.chmod(dir, PRIVATE_DIR_MODE); } + export async function readFile(path) { try { const file = Gio.File.new_for_path(path); const [contents] = await file.load_contents_async(null); return new TextDecoder("utf-8").decode(contents); - } - catch (error) { - throw new Error(`Failed to read file "${path}": ${error instanceof Error ? error.message : String(error)}`); + } catch (error) { + throw new Error( + `Failed to read file "${path}": ${error instanceof Error ? error.message : String(error)}`, + ); } } + export async function writeFile(path, text) { try { ensureParentDir(path); const file = Gio.File.new_for_path(path); const bytes = new TextEncoder().encode(text); - await file.replace_contents_async(bytes, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); + await file.replace_contents_async( + bytes, + null, + false, + Gio.FileCreateFlags.REPLACE_DESTINATION, + null, + ); GLib.chmod(path, PRIVATE_FILE_MODE); - } - catch (error) { - throw new Error(`Failed to write file "${path}": ${error instanceof Error ? error.message : String(error)}`); + } catch (error) { + throw new Error( + `Failed to write file "${path}": ${error instanceof Error ? error.message : String(error)}`, + ); } } + export async function appendFile(path, line) { try { ensureParentDir(path); const file = Gio.File.new_for_path(path); const text = line.endsWith("\n") ? line : `${line}\n`; const bytes = new GLib.Bytes(new TextEncoder().encode(text)); - const stream = await file.append_to_async(Gio.FileCreateFlags.NONE, GLib.PRIORITY_DEFAULT, null); + const stream = await file.append_to_async( + Gio.FileCreateFlags.NONE, + GLib.PRIORITY_DEFAULT, + null, + ); await stream.write_bytes_async(bytes, GLib.PRIORITY_DEFAULT, null); await stream.close_async(GLib.PRIORITY_DEFAULT, null); GLib.chmod(path, PRIVATE_FILE_MODE); - } - catch (error) { - throw new Error(`Failed to append file "${path}": ${error instanceof Error ? error.message : String(error)}`); + } catch (error) { + throw new Error( + `Failed to append file "${path}": ${error instanceof Error ? error.message : String(error)}`, + ); } } + // JSON export async function readJsonFile(path) { try { const raw = await readFile(path); return JSON.parse(raw); - } - catch (error) { - throw new Error(`Failed to read JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`); + } catch (error) { + throw new Error( + `Failed to read JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`, + ); } } + export async function writeJsonFile(path, data) { try { const json = JSON.stringify(data, null, 2); @@ -66,8 +93,9 @@ export async function writeJsonFile(path, data) { throw new Error("JSON.stringify returned undefined"); } await writeFile(path, `${json}\n`); - } - catch (error) { - throw new Error(`Failed to write JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`); + } catch (error) { + throw new Error( + `Failed to write JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`, + ); } } diff --git a/src/io/http.js b/src/io/http.js index 9487e00..d432f44 100644 --- a/src/io/http.js +++ b/src/io/http.js @@ -4,10 +4,15 @@ import { RefreshFailureError } from "../refresh/error.js"; // The GIRS package set used by this project does not ship Soup typings. // @ts-ignore import Soup from "gi://Soup?version=3.0"; + const DEFAULT_TIMEOUT_SECONDS = 15; + const MAX_ERROR_BODY_LENGTH = 512; + const MAX_RESPONSE_BYTES = 1024 * 1024; + Gio._promisify(Soup.Session.prototype, "send_and_read_async"); + function formatErrorBody(text) { const normalized = text.trim().replace(/\s+/g, " "); if (normalized.length <= MAX_ERROR_BODY_LENGTH) { @@ -15,22 +20,31 @@ function formatErrorBody(text) { } return `${normalized.slice(0, MAX_ERROR_BODY_LENGTH)}...`; } + function parseApiResponse(text, url, config, allowArrayResponse = false) { let parsed; try { parsed = JSON.parse(text); - } - catch (error) { + } catch (error) { const message = error instanceof Error ? error.message : "Unknown JSON parse error"; - throw new RefreshFailureError("malformed-response", config.messages.malformedResponse, `Failed to parse JSON response from ${url}: ${message}`); + throw new RefreshFailureError( + "malformed-response", + config.messages.malformedResponse, + `Failed to parse JSON response from ${url}: ${message}`, + ); } const isObject = typeof parsed === "object" && parsed !== null; const isArray = Array.isArray(parsed); if (!isObject || (isArray && !allowArrayResponse)) { - throw new RefreshFailureError("malformed-response", config.messages.unexpectedResponseFormat, `Expected JSON ${allowArrayResponse ? "object or array" : "object"} response from ${url}`); + throw new RefreshFailureError( + "malformed-response", + config.messages.unexpectedResponseFormat, + `Expected JSON ${allowArrayResponse ? "object or array" : "object"} response from ${url}`, + ); } return parsed; } + export async function fetchJson(url, config, options = {}) { const session = new Soup.Session(); const message = Soup.Message.new(options.method ?? "GET", url); @@ -52,17 +66,28 @@ export async function fetchJson(url, config, options = {}) { } let bytes; try { - bytes = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, options.cancellable ?? null); - } - catch (error) { + bytes = await session.send_and_read_async( + message, + GLib.PRIORITY_DEFAULT, + options.cancellable ?? null, + ); + } catch (error) { if (isCancellationError(error)) { throw error; } const message = error instanceof Error ? error.message : String(error); - throw new RefreshFailureError("network", config.messages.networkUnavailable, `Request to ${url} failed before receiving a response: ${message}`); + throw new RefreshFailureError( + "network", + config.messages.networkUnavailable, + `Request to ${url} failed before receiving a response: ${message}`, + ); } if (bytes.get_size() > MAX_RESPONSE_BYTES) { - throw new RefreshFailureError("malformed-response", config.messages.responseTooLarge, `Response from ${url} exceeded ${MAX_RESPONSE_BYTES} bytes`); + throw new RefreshFailureError( + "malformed-response", + config.messages.responseTooLarge, + `Response from ${url} exceeded ${MAX_RESPONSE_BYTES} bytes`, + ); } const data = bytes.get_data(); const text = data ? new TextDecoder("utf-8").decode(data) : ""; @@ -70,15 +95,24 @@ export async function fetchJson(url, config, options = {}) { const errorBody = formatErrorBody(text); const technicalMessage = `Request to ${url} failed with HTTP ${message.statusCode}: ${errorBody}`; if (message.statusCode === 401 || message.statusCode === 403) { - throw new RefreshFailureError("unauthorized", config.messages.unauthorized, technicalMessage); + throw new RefreshFailureError( + "unauthorized", + config.messages.unauthorized, + technicalMessage, + ); } throw new RefreshFailureError("network", config.messages.refreshFailed, technicalMessage); } if (!data) { - throw new RefreshFailureError("malformed-response", config.messages.emptyResponse, `Received empty response body from ${url}`); + throw new RefreshFailureError( + "malformed-response", + config.messages.emptyResponse, + `Received empty response body from ${url}`, + ); } return parseApiResponse(text, url, config, options.allowArrayResponse ?? false); } + export async function fetchProviderUsage(accessToken, config, options = {}, url = config.usageUrl) { return await fetchJson(url, config, { ...options, @@ -87,7 +121,7 @@ export async function fetchProviderUsage(accessToken, config, options = {}, url }, }); } + function isCancellationError(error) { - return error instanceof GLib.Error && - error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); + return error instanceof GLib.Error && error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); } diff --git a/src/io/paths.js b/src/io/paths.js index 0bbcd5f..44e27d3 100644 --- a/src/io/paths.js +++ b/src/io/paths.js @@ -1,13 +1,7 @@ import GLib from "gi://GLib"; -export const CONFIG_DIR = GLib.build_filenamev([ - GLib.get_user_config_dir(), - "codex-meter", -]); -export const CACHE_DIR = GLib.build_filenamev([ - GLib.get_user_cache_dir(), - "codex-meter", -]); -export const STATE_DIR = GLib.build_filenamev([ - GLib.get_user_state_dir(), - "codex-meter", -]); + +export const CONFIG_DIR = GLib.build_filenamev([GLib.get_user_config_dir(), "codex-meter"]); + +export const CACHE_DIR = GLib.build_filenamev([GLib.get_user_cache_dir(), "codex-meter"]); + +export const STATE_DIR = GLib.build_filenamev([GLib.get_user_state_dir(), "codex-meter"]); diff --git a/src/panel/button.js b/src/panel/button.js index 7a01f72..4907905 100644 --- a/src/panel/button.js +++ b/src/panel/button.js @@ -1,5 +1,6 @@ import Clutter from "gi://Clutter"; import St from "gi://St"; + export function createTextButton({ text, styleClass, onClick }) { const label = new St.Label({ text, @@ -14,7 +15,8 @@ export function createTextButton({ text, styleClass, onClick }) { button.connect("clicked", () => onClick(button)); return { button, label }; } -export function createIconButton({ iconName, accessibleName, styleClass, onClick, }) { + +export function createIconButton({ iconName, accessibleName, styleClass, onClick }) { const icon = new St.Icon({ icon_name: iconName, style_class: "popup-menu-icon", diff --git a/src/panel/indicator.js b/src/panel/indicator.js index 7065352..6aa3695 100644 --- a/src/panel/indicator.js +++ b/src/panel/indicator.js @@ -21,12 +21,16 @@ import { CodexMeterPopupMenu } from "./menu.js"; import { formatRefreshFailure } from "./refresh-error-message.js"; import { UsageBar } from "./usage-bar.js"; import { createMenuViewModel, createPanelBarViewModel } from "./view-model.js"; + const BANKED_RESET_BACKGROUND_REFRESH_SECONDS = 20 * 60; + const AUTO_APPLY_BANKED_RESET_WINDOW_MS = 60 * 60 * 1000; + export class CodexMeterIndicator extends PanelMenu.Button { static { GObject.registerClass(this); } + constructor(extension) { super(0.0, "CodexMeter"); this._extension = extension; @@ -34,13 +38,11 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._providerId = this._settings.getUsageProvider(); this._autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); this._usageService = createUsageService(this._providerId); - this._scheduler = new Scheduler( - this._settings.getBackgroundRefreshIntervalSeconds(), - () => this._refreshUsage(), + this._scheduler = new Scheduler(this._settings.getBackgroundRefreshIntervalSeconds(), () => + this._refreshUsage(), ); - this._bankedResetScheduler = new Scheduler( - BANKED_RESET_BACKGROUND_REFRESH_SECONDS, - () => this._refreshBankedResetsIfStale(), + this._bankedResetScheduler = new Scheduler(BANKED_RESET_BACKGROUND_REFRESH_SECONDS, () => + this._refreshBankedResetsIfStale(), ); this._refreshSpinId = 0; this._menuSyncId = 0; @@ -69,18 +71,14 @@ export class CodexMeterIndicator extends PanelMenu.Button { }); this._codexIcon = new St.Icon({ gicon: new Gio.FileIcon({ - file: Gio.File.new_for_path( - `${this._extension.path}/icons/codex-symbolic.svg`, - ), + file: Gio.File.new_for_path(`${this._extension.path}/icons/codex-symbolic.svg`), }), y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-icon", }); this._openAiIcon = new St.Icon({ gicon: new Gio.FileIcon({ - file: Gio.File.new_for_path( - `${this._extension.path}/icons/openai-symbolic.svg`, - ), + file: Gio.File.new_for_path(`${this._extension.path}/icons/openai-symbolic.svg`), }), y_align: Clutter.ActorAlign.CENTER, style_class: "cx-panel-icon", @@ -100,6 +98,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._buildMenu(); this._connectSignals(); } + start() { if (this._destroyed) return; this._syncLabel(); @@ -111,6 +110,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._scheduler.start({ runImmediately: true }); }); } + destroy() { this._destroyed = true; this._scheduler.stop(); @@ -140,6 +140,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { } super.destroy(); } + _buildMenu() { this._popupMenu = new CodexMeterPopupMenu({ onRefresh: () => { @@ -160,6 +161,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._popupMenu.addToMenu(this.menu); this.menu.setSourceAlignment(0.0); } + _createPanelBars() { const box = new St.BoxLayout({ vertical: true, @@ -172,20 +174,17 @@ export class CodexMeterIndicator extends PanelMenu.Button { box.add_child(this._panelSecondaryBar.actor); return box; } + _connectSignals() { - this._menuOpenChangedId = this.menu.connect( - "open-state-changed", - (_menu, isOpen) => { - if (isOpen) { - this._queueMenuBarSync(); - void this._refreshBankedResets(); - } - }, - ); + this._menuOpenChangedId = this.menu.connect("open-state-changed", (_menu, isOpen) => { + if (isOpen) { + this._queueMenuBarSync(); + void this._refreshBankedResets(); + } + }); this._settingsChangedId = this._settings.connectChanged(() => { const providerId = this._settings.getUsageProvider(); - const autoApplyBankedReset = - this._settings.getAutoApplyBankedReset(); + const autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); if (providerId !== this._providerId) { this._providerId = providerId; this._usageService = createUsageService(providerId); @@ -206,13 +205,15 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._syncLabel(); this._syncMenu(); }); - this._refreshIntervalChangedId = - this._settings.connectBackgroundRefreshIntervalChanged(() => { + this._refreshIntervalChangedId = this._settings.connectBackgroundRefreshIntervalChanged( + () => { this._scheduler.setIntervalSeconds( this._settings.getBackgroundRefreshIntervalSeconds(), ); - }); + }, + ); } + async _refreshUsage({ manual = false } = {}) { if (this._destroyed) return; if (manual) this._startRefreshSpin(); @@ -234,21 +235,25 @@ export class CodexMeterIndicator extends PanelMenu.Button { if (manual && !this._destroyed) this._stopRefreshSpin(); } } + _cancelRefresh() { if (!this._refreshCancellable) return; this._refreshCancellable.cancel(); this._refreshCancellable = null; } + _cancelBankedResetRefresh() { if (!this._bankedResetRefreshCancellable) return; this._bankedResetRefreshCancellable.cancel(); this._bankedResetRefreshCancellable = null; } + _cancelBankedResetRedemption() { if (!this._redeemBankedResetCancellable) return; this._redeemBankedResetCancellable.cancel(); this._redeemBankedResetCancellable = null; } + async _refreshUsageOnce(cancellable) { try { if (this._destroyed) return; @@ -258,9 +263,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._errorMessage = null; this._cachedFailureMessage = null; try { - this._prediction = await this._usageService.predict( - this._snapshot, - ); + this._prediction = await this._usageService.predict(this._snapshot); } catch (error) { this._prediction = null; } @@ -272,8 +275,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._cachedFailureMessage = failureMessage; } else { const loadedCachedSnapshot = - !this._destroyed && - (await this._loadCachedSnapshotAfterFailure()); + !this._destroyed && (await this._loadCachedSnapshotAfterFailure()); if (loadedCachedSnapshot) { this._errorMessage = null; this._cachedFailureMessage = failureMessage; @@ -288,16 +290,12 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._syncMenu(); } } + async _loadCachedSnapshot() { if (this._destroyed || this._snapshot || this._errorMessage) return; try { const snapshot = await this._usageService.readCachedSnapshot(); - if ( - this._destroyed || - !snapshot || - this._snapshot || - this._errorMessage - ) { + if (this._destroyed || !snapshot || this._snapshot || this._errorMessage) { return; } this._snapshot = snapshot; @@ -312,17 +310,18 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._syncMenu(); } catch (error) {} } + async _loadCachedBankedResets() { if (this._destroyed || this._providerId !== "codex") return; try { const snapshot = await readCachedCodexBankedResets(); - if (this._destroyed || !snapshot || this._providerId !== "codex") - return; + if (this._destroyed || !snapshot || this._providerId !== "codex") return; this._bankedResetCount = Math.max(0, snapshot.available_count); this._lastBankedResetRefreshAt = snapshot.fetchedAt; this._syncMenu(); } catch (error) {} } + async _loadCachedSnapshotAfterFailure() { if (this._destroyed) return false; try { @@ -341,6 +340,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { return false; } } + async _loadHistory() { try { this._history = await this._usageService.readHistory(); @@ -348,6 +348,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._history = []; } } + async _refreshBankedResets(cancellable = null) { if (this._providerId !== "codex") { this._bankedResetCount = null; @@ -359,10 +360,8 @@ export class CodexMeterIndicator extends PanelMenu.Button { return; } const refreshCancellable = cancellable ?? new Gio.Cancellable(); - if (!cancellable) - this._bankedResetRefreshCancellable = refreshCancellable; - this._bankedResetRefreshPromise = - this._refreshBankedResetsOnce(refreshCancellable); + if (!cancellable) this._bankedResetRefreshCancellable = refreshCancellable; + this._bankedResetRefreshPromise = this._refreshBankedResetsOnce(refreshCancellable); try { await this._bankedResetRefreshPromise; } finally { @@ -372,30 +371,28 @@ export class CodexMeterIndicator extends PanelMenu.Button { } } } + async _refreshBankedResetsIfStale() { if (!this._shouldRefreshBankedResets()) return; await this._refreshBankedResets(); } + _shouldRefreshBankedResets() { if (this._destroyed || this._providerId !== "codex") return false; - if (this._bankedResetCount === null || !this._lastBankedResetRefreshAt) - return true; + if (this._bankedResetCount === null || !this._lastBankedResetRefreshAt) return true; const nowSeconds = Math.floor(Date.now() / 1000); return ( - nowSeconds - this._lastBankedResetRefreshAt >= - BANKED_RESET_BACKGROUND_REFRESH_SECONDS + nowSeconds - this._lastBankedResetRefreshAt >= BANKED_RESET_BACKGROUND_REFRESH_SECONDS ); } + async _refreshBankedResetsOnce(cancellable = null) { try { const response = await listCodexBankedResets({ cancellable }); if (this._destroyed || this._providerId !== "codex") return; this._bankedResetCount = Math.max(0, response.available_count); this._lastBankedResetRefreshAt = Math.floor(Date.now() / 1000); - await this._autoApplyExpiringBankedReset( - response.credits, - cancellable, - ); + await this._autoApplyExpiringBankedReset(response.credits, cancellable); this._syncMenu(); } catch (error) { if (isCancellationError(error)) return; @@ -406,6 +403,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._syncMenu(); } } + async _autoApplyExpiringBankedReset(credits, cancellable) { if ( this._destroyed || @@ -425,21 +423,12 @@ export class CodexMeterIndicator extends PanelMenu.Button { try { await redeemCodexBankedReset(credit.id, { cancellable }); if (this._destroyed) return; - this._bankedResetCount = Math.max( - 0, - (this._bankedResetCount ?? 1) - 1, - ); - notify( - "Codex Meter", - "Expiring banked Codex reset applied automatically.", - ); + this._bankedResetCount = Math.max(0, (this._bankedResetCount ?? 1) - 1); + notify("Codex Meter", "Expiring banked Codex reset applied automatically."); await this._refreshUsage(); } catch (error) { if (this._destroyed && isCancellationError(error)) return; - console.warn( - "Unable to auto-apply expiring Codex banked reset", - error, - ); + console.warn("Unable to auto-apply expiring Codex banked reset", error); } finally { if (!this._destroyed) { this._redeemingBankedReset = false; @@ -447,20 +436,15 @@ export class CodexMeterIndicator extends PanelMenu.Button { } } } + async _redeemBankedReset() { if (this._destroyed || this._redeemingBankedReset) return; if (this._providerId !== "codex") { - notify( - "Codex Meter", - "Banked resets are only available for Codex usage.", - ); + notify("Codex Meter", "Banked resets are only available for Codex usage."); return; } if (this._bankedResetCount !== null && this._bankedResetCount <= 0) { - notify( - "Codex Meter", - "No banked Codex resets are available to redeem.", - ); + notify("Codex Meter", "No banked Codex resets are available to redeem."); return; } const cancellable = new Gio.Cancellable(); @@ -485,21 +469,19 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._syncMenu(); } } + _startRefreshSpin() { if (!this._headerItem?.refreshIcon || this._refreshSpinId) return; this._headerItem.refreshButton.reactive = false; this._headerItem.refreshButton.can_focus = false; - this._refreshSpinId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT, - 30, - () => { - if (this._destroyed) return GLib.SOURCE_REMOVE; - this._headerItem.refreshIcon.rotation_angle_z = - (this._headerItem.refreshIcon.rotation_angle_z + 18) % 360; - return GLib.SOURCE_CONTINUE; - }, - ); + this._refreshSpinId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { + if (this._destroyed) return GLib.SOURCE_REMOVE; + this._headerItem.refreshIcon.rotation_angle_z = + (this._headerItem.refreshIcon.rotation_angle_z + 18) % 360; + return GLib.SOURCE_CONTINUE; + }); } + _stopRefreshSpin() { if (this._refreshSpinId) { GLib.source_remove(this._refreshSpinId); @@ -510,13 +492,10 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._headerItem.refreshButton.reactive = true; this._headerItem.refreshButton.can_focus = true; } + _syncLabel() { const settings = this._settings.getAll(); - const viewModel = createPanelBarViewModel( - settings, - this._snapshot, - this._errorMessage, - ); + const viewModel = createPanelBarViewModel(settings, this._snapshot, this._errorMessage); this._panelPrimaryBar.actor.visible = viewModel.primaryVisible; this._panelSecondaryBar.actor.visible = viewModel.secondaryVisible; this._panelPrimaryBar.update({ @@ -544,6 +523,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._codexIcon.visible = settings.topPanelIndicatorIcon === "codex"; this._openAiIcon.visible = settings.topPanelIndicatorIcon === "openai"; } + _syncMenu() { const viewModel = createMenuViewModel( this._settings.getAll(), @@ -571,6 +551,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { }); this._footerItem.planLabel.text = viewModel.plan; } + _queueMenuBarSync() { if (this._menuSyncId) return; this._menuSyncId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { @@ -579,10 +560,12 @@ export class CodexMeterIndicator extends PanelMenu.Button { return GLib.SOURCE_REMOVE; }); } + _setUsageItem(item, viewModel) { this._popupMenu.setUsageItem(item, viewModel); } } + function createUsageService(providerId) { try { return new UsageService(getUsageProvider(providerId)); @@ -594,22 +577,20 @@ function createUsageService(providerId) { return new UsageService(getUsageProvider(DEFAULT_PROVIDER_ID)); } } + function formatBankedResetFailure(error) { if (isRefreshFailureError(error)) { return error.message; } const message = - error instanceof Error && error.message - ? error.message - : "Unknown banked reset failure"; + error instanceof Error && error.message ? error.message : "Unknown banked reset failure"; return `Banked Codex reset redemption failed: ${message}`; } + function notify(title, message) { Main.notify(title, message); } + function isCancellationError(error) { - return ( - error instanceof GLib.Error && - error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) - ); + return error instanceof GLib.Error && error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); } diff --git a/src/panel/menu.js b/src/panel/menu.js index 37d4003..92adc48 100644 --- a/src/panel/menu.js +++ b/src/panel/menu.js @@ -3,22 +3,38 @@ import St from "gi://St"; import * as PopupMenu from "resource:///org/gnome/shell/ui/popupMenu.js"; import { createIconButton, createTextButton } from "./button.js"; import { UsageBar } from "./usage-bar.js"; + const POPUP_CONTENT_WIDTH = 285; + const TREND_BAR_COUNT = 56; + const TREND_BAR_MAX_HEIGHT = 28; + const TREND_BAR_MIN_HEIGHT = 3; + const TREND_BAR_SPACING = 2; + export class CodexMeterPopupMenu { headerItem; + errorItem; + statusItem; + primaryItem; + secondaryItem; + trendItem; + footerItem; + _onRefresh; + _onRedeemBankedReset; + _onOpenPreferences; + constructor({ onRefresh, onRedeemBankedReset, onOpenPreferences }) { this._onRefresh = onRefresh; this._onRedeemBankedReset = onRedeemBankedReset; @@ -31,6 +47,7 @@ export class CodexMeterPopupMenu { this.trendItem = this._createTrendItem(); this.footerItem = this._createFooterItem(); } + addToMenu(menu) { menu.addMenuItem(this.headerItem); menu.addMenuItem(this.errorItem); @@ -41,10 +58,12 @@ export class CodexMeterPopupMenu { menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); menu.addMenuItem(this.footerItem); } + syncBars() { this.primaryItem.usageBar.sync(); this.secondaryItem.usageBar.sync(); } + setTrend(viewModel) { this.trendItem.visible = viewModel.visible; this.trendItem.titleLabel.text = viewModel.title; @@ -53,17 +72,16 @@ export class CodexMeterPopupMenu { const bar = this.trendItem.bars[index]; const value = viewModel.bars[index] ?? 0; bar.visible = index < viewModel.bars.length; - bar.height = value > 0 - ? Math.round(TREND_BAR_MAX_HEIGHT * (value / 100)) - : TREND_BAR_MIN_HEIGHT; + bar.height = + value > 0 ? Math.round(TREND_BAR_MAX_HEIGHT * (value / 100)) : TREND_BAR_MIN_HEIGHT; if (value > 0) { bar.remove_style_class_name("cx-trend-bar-empty"); - } - else { + } else { bar.add_style_class_name("cx-trend-bar-empty"); } } } + setUsageItem(item, viewModel) { item.visible = viewModel.visible; item.titleLabel.text = viewModel.title; @@ -78,6 +96,7 @@ export class CodexMeterPopupMenu { displayBaselinePercentValue: viewModel.displayBaselinePercentValue, }); } + setError(message) { const hasError = Boolean(message); this.errorItem.visible = hasError; @@ -87,11 +106,13 @@ export class CodexMeterPopupMenu { this.errorItem.message = message ?? ""; this.errorItem.messageLabel.text = message ?? ""; } + setStatus({ title, message, visible }) { this.statusItem.visible = Boolean(visible); this.statusItem.titleLabel.text = title ?? ""; this.statusItem.messageLabel.text = message ?? ""; } + setBankedResets({ count, visible, redeeming }) { const canRedeem = !redeeming && count !== null && count > 0; this.headerItem.redeemButton.visible = visible && count !== null; @@ -102,6 +123,7 @@ export class CodexMeterPopupMenu { ? "Resetting…" : `Reset limits (${count ?? 0})`; } + _createUsageItem(title) { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, @@ -159,6 +181,7 @@ export class CodexMeterPopupMenu { item.resetLabel = resetLabel; return item; } + _createTrendItem() { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, @@ -203,6 +226,7 @@ export class CodexMeterPopupMenu { item.visible = false; return item; } + _createErrorItem() { const item = this._createBannerItem({ iconName: "dialog-error-symbolic", @@ -213,6 +237,7 @@ export class CodexMeterPopupMenu { item.message = ""; return item; } + _createStatusItem() { return this._createBannerItem({ iconName: "dialog-warning-symbolic", @@ -221,6 +246,7 @@ export class CodexMeterPopupMenu { messageStyleClass: "cx-error-message-warning", }); } + _createBannerItem({ iconName, title, colorStyleClass, messageStyleClass }) { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, @@ -264,6 +290,7 @@ export class CodexMeterPopupMenu { item.messageLabel = messageLabel; return item; } + _createHeaderItem() { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, @@ -279,16 +306,15 @@ export class CodexMeterPopupMenu { y_align: Clutter.ActorAlign.CENTER, style_class: "cx-header-row", }); - const { button: redeemButton, label: redeemButtonLabel, } = createTextButton({ + const { button: redeemButton, label: redeemButtonLabel } = createTextButton({ text: "Reset limits (0)", styleClass: "cx-reset-button", - onClick: button => { - if (!button.reactive) - return; + onClick: (button) => { + if (!button.reactive) return; this._onRedeemBankedReset(); }, }); - const { button: refreshButton, icon: refreshIcon, } = createIconButton({ + const { button: refreshButton, icon: refreshIcon } = createIconButton({ iconName: "view-refresh-symbolic", accessibleName: "Refresh usage", styleClass: "cx-footer-button", @@ -315,6 +341,7 @@ export class CodexMeterPopupMenu { item.refreshButton = refreshButton; return item; } + _createFooterItem() { const item = new PopupMenu.PopupBaseMenuItem({ reactive: false, @@ -344,32 +371,35 @@ export class CodexMeterPopupMenu { item.settingsButton = settingsButton; return item; } + _updateTrendBarWidths() { - if (!this.trendItem?.sparklineBox || !this.trendItem?.bars) - return; + if (!this.trendItem?.sparklineBox || !this.trendItem?.bars) return; const visibleBars = this.trendItem.bars.length; - if (visibleBars <= 0 || this.trendItem.sparklineBox.width <= 0) - return; + if (visibleBars <= 0 || this.trendItem.sparklineBox.width <= 0) return; const totalSpacing = TREND_BAR_SPACING * Math.max(0, visibleBars - 1); - const availableWidth = Math.max(visibleBars, this.trendItem.sparklineBox.width - totalSpacing); + const availableWidth = Math.max( + visibleBars, + this.trendItem.sparklineBox.width - totalSpacing, + ); const baseBarWidth = Math.max(1, Math.floor(availableWidth / visibleBars)); - const extraPixels = Math.max(0, availableWidth - (baseBarWidth * visibleBars)); + const extraPixels = Math.max(0, availableWidth - baseBarWidth * visibleBars); for (let index = 0; index < this.trendItem.bars.length; index += 1) { this.trendItem.bars[index].width = baseBarWidth + (index < extraPixels ? 1 : 0); } } } + function setPredictionStyleClass(label, style) { removeColorStyleClasses(label); label.add_style_class_name(getPredictionColorStyleClass(style)); } + function getPredictionColorStyleClass(style) { - if (style === "safe") - return "cx-color-green"; - if (style === "muted") - return "cx-muted"; + if (style === "safe") return "cx-color-green"; + if (style === "muted") return "cx-muted"; return `cx-color-${style}`; } + function removeColorStyleClasses(actor) { actor.remove_style_class_name("cx-color-green"); actor.remove_style_class_name("cx-color-warning"); diff --git a/src/panel/refresh-error-message.js b/src/panel/refresh-error-message.js index 4cb9851..450a950 100644 --- a/src/panel/refresh-error-message.js +++ b/src/panel/refresh-error-message.js @@ -1,4 +1,5 @@ import { isRefreshFailureError } from "../refresh/error.js"; + export function formatRefreshFailure(error) { if (isRefreshFailureError(error)) { return error.message; diff --git a/src/panel/usage-bar.js b/src/panel/usage-bar.js index 352939c..eceaa3d 100644 --- a/src/panel/usage-bar.js +++ b/src/panel/usage-bar.js @@ -1,20 +1,28 @@ import Clutter from "gi://Clutter"; import St from "gi://St"; -import { calculateBarFillWidth, calculateBarMarkerPosition, getUsageBarColorStyleClass, } from "./view-model.js"; +import { + calculateBarFillWidth, + calculateBarMarkerPosition, + getUsageBarColorStyleClass, +} from "./view-model.js"; + const BASELINE_MARKER_WIDTH = 3; -const COLOR_STYLE_CLASSES = [ - "cx-color-green", - "cx-color-warning", - "cx-color-danger", - "cx-muted", -]; + +const COLOR_STYLE_CLASSES = ["cx-color-green", "cx-color-warning", "cx-color-danger", "cx-muted"]; + export class UsageBar { actor; + _variant; + _barTrack; + _barFill; + _barMarker; + _state; + constructor(variant) { this._variant = variant; this._state = { @@ -26,15 +34,12 @@ export class UsageBar { this._barTrack = new St.BoxLayout({ x_expand: variant === "menu", y_align: Clutter.ActorAlign.CENTER, - style_class: variant === "menu" - ? "cx-usage-bar-track" - : "cx-panel-bar-track", + style_class: variant === "menu" ? "cx-usage-bar-track" : "cx-panel-bar-track", }); this._barFill = new St.Widget({ y_expand: true, - style_class: variant === "menu" - ? "cx-usage-bar-fill" - : "cx-usage-bar-fill cx-panel-bar-fill", + style_class: + variant === "menu" ? "cx-usage-bar-fill" : "cx-usage-bar-fill cx-panel-bar-fill", }); this._barTrack.add_child(this._barFill); this._barTrack.add_child(new St.Widget({ x_expand: true })); @@ -58,18 +63,19 @@ export class UsageBar { overlay.connect("notify::width", () => this.sync()); overlay.connect("notify::height", () => this.sync()); this.actor = overlay; - } - else { + } else { this._barMarker = null; this._barTrack.connect("notify::width", () => this.sync()); this.actor = this._barTrack; } } + update(state) { this._state = state; this._syncColor(); this.sync(); } + sync() { const width = this.actor.width; if (this._variant === "menu") { @@ -77,14 +83,18 @@ export class UsageBar { this._barTrack.height = this.actor.height; } this._barFill.width = calculateBarFillWidth(width, this._state.displayPercentValue); - if (!this._barMarker) - return; + if (!this._barMarker) return; this._barMarker.width = BASELINE_MARKER_WIDTH; this._barMarker.height = this.actor.height; this._barMarker.visible = Number.isFinite(this._state.baselinePercentValue); - this._barMarker.x = calculateBarMarkerPosition(width, this._barMarker.width, this._state.displayBaselinePercentValue); + this._barMarker.x = calculateBarMarkerPosition( + width, + this._barMarker.width, + this._state.displayBaselinePercentValue, + ); this._barMarker.y = 1; } + _syncColor() { for (const styleClass of COLOR_STYLE_CLASSES) { this._barFill.remove_style_class_name(styleClass); diff --git a/src/panel/view-model.js b/src/panel/view-model.js index eabc965..927284b 100644 --- a/src/panel/view-model.js +++ b/src/panel/view-model.js @@ -1,4 +1,5 @@ import { getQuota, getSecondaryQuota } from "../usage/model.js"; + export function createPanelBarViewModel(settings, snapshot, errorMessage) { const sessionQuota = getQuota(snapshot, "session"); const weeklyQuota = getQuota(snapshot, "weekly"); @@ -29,13 +30,17 @@ export function createPanelBarViewModel(settings, snapshot, errorMessage) { if (snapshot) { viewModel.primaryPercent = normalizePercent(sessionQuota?.usedPercent); viewModel.secondaryPercent = normalizePercent(weeklyQuota?.usedPercent); - viewModel.primaryDisplayPercent = convertPercentForDisplay(viewModel.primaryPercent, percentDisplayMode); - viewModel.secondaryDisplayPercent = convertPercentForDisplay(viewModel.secondaryPercent, percentDisplayMode); + viewModel.primaryDisplayPercent = convertPercentForDisplay( + viewModel.primaryPercent, + percentDisplayMode, + ); + viewModel.secondaryDisplayPercent = convertPercentForDisplay( + viewModel.secondaryPercent, + percentDisplayMode, + ); } - if (!viewModel.showLabel) - return viewModel; - if (!hasTopPanelUsage) - return viewModel; + if (!viewModel.showLabel) return viewModel; + if (!hasTopPanelUsage) return viewModel; if (!snapshot) { viewModel.label = errorMessage ? "!" : "--"; return viewModel; @@ -50,7 +55,15 @@ export function createPanelBarViewModel(settings, snapshot, errorMessage) { viewModel.label = parts.join("/"); return viewModel; } -export function createMenuViewModel(settings, snapshot, prediction, history = [], errorMessage = null, cachedFailureMessage = null) { + +export function createMenuViewModel( + settings, + snapshot, + prediction, + history = [], + errorMessage = null, + cachedFailureMessage = null, +) { const percentDisplayMode = settings.percentDisplayMode; if (errorMessage) { return { @@ -108,29 +121,52 @@ export function createMenuViewModel(settings, snapshot, prediction, history = [] visible: Boolean(primaryQuota), title: primaryQuota?.label ?? "Primary", value: formatPercentForDisplay(primaryQuota?.usedPercent, percentDisplayMode), - prediction: formatLimitPrediction(primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary, primaryQuota, "primary"), + prediction: formatLimitPrediction( + primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary, + primaryQuota, + "primary", + ), reset: formatReset(primaryQuota, "primary"), percentValue: primaryQuota?.usedPercent, baselinePercentValue: calculateBaselinePercent(primaryQuota), percentDisplayMode, - predictionStyle: getPredictionStyleClass(primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary), + predictionStyle: getPredictionStyleClass( + primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary, + ), }), secondary: createUsageItemViewModel({ visible: Boolean(secondaryQuota), title: secondaryQuota?.label ?? "Secondary", value: formatPercentForDisplay(secondaryQuota?.usedPercent, percentDisplayMode), - prediction: formatLimitPrediction(secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary, secondaryQuota, "secondary"), + prediction: formatLimitPrediction( + secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary, + secondaryQuota, + "secondary", + ), reset: formatReset(secondaryQuota, "secondary"), percentValue: secondaryQuota?.usedPercent, baselinePercentValue: calculateBaselinePercent(secondaryQuota), percentDisplayMode, - predictionStyle: getPredictionStyleClass(secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary), + predictionStyle: getPredictionStyleClass( + secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary, + ), }), plan: formatFooter(snapshot), trend: createUsageTrendViewModel(snapshot, history), }; } -export function createUsageItemViewModel({ visible = true, title, value, prediction = "", reset = "resets in --", percentValue = null, baselinePercentValue = null, percentDisplayMode = "used", predictionStyle = "muted", }) { + +export function createUsageItemViewModel({ + visible = true, + title, + value, + prediction = "", + reset = "resets in --", + percentValue = null, + baselinePercentValue = null, + percentDisplayMode = "used", + predictionStyle = "muted", +}) { const normalizedPercentValue = normalizePercent(percentValue); const normalizedBaselinePercentValue = Number.isFinite(baselinePercentValue) ? normalizePercent(baselinePercentValue) @@ -144,31 +180,36 @@ export function createUsageItemViewModel({ visible = true, title, value, predict percentValue: normalizedPercentValue, displayPercentValue: convertPercentForDisplay(normalizedPercentValue, percentDisplayMode), baselinePercentValue: normalizedBaselinePercentValue, - displayBaselinePercentValue: normalizedBaselinePercentValue === null - ? null - : convertPercentForDisplay(normalizedBaselinePercentValue, percentDisplayMode), + displayBaselinePercentValue: + normalizedBaselinePercentValue === null + ? null + : convertPercentForDisplay(normalizedBaselinePercentValue, percentDisplayMode), percentLabel: percentDisplayMode, predictionStyle, }; } + const TREND_TITLE = "Weekly activity"; + const TREND_LOOKBACK_SECONDS = 7 * 24 * 60 * 60; + const TREND_BUCKET_COUNT = 56; + const TREND_MIN_BAR_PERCENT = 12; + const MIN_BURN_RATE_OBSERVED_SECONDS = 6 * 60 * 60; + export function createUsageTrendViewModel(snapshot, history = [], nowSeconds = Date.now() / 1000) { const minTimestamp = nowSeconds - TREND_LOOKBACK_SECONDS; const samples = getUsageTrendSamples(snapshot, history, nowSeconds); - if (samples.length < 2) - return createHiddenUsageTrendViewModel(); + if (samples.length < 2) return createHiddenUsageTrendViewModel(); const bucketSize = TREND_LOOKBACK_SECONDS / TREND_BUCKET_COUNT; const bucketValues = Array(TREND_BUCKET_COUNT).fill(0); for (let index = 1; index < samples.length; index += 1) { const previous = samples[index - 1]; const current = samples[index]; const delta = current.usedPercent - previous.usedPercent; - if (delta <= 0 || delta > 100) - continue; + if (delta <= 0 || delta > 100) continue; addTrendDeltaToBuckets({ bucketValues, delta, @@ -179,18 +220,17 @@ export function createUsageTrendViewModel(snapshot, history = [], nowSeconds = D }); } const maxValue = Math.max(...bucketValues); - if (maxValue <= 0) - return createHiddenUsageTrendViewModel(); + if (maxValue <= 0) return createHiddenUsageTrendViewModel(); return { visible: true, title: TREND_TITLE, bars: bucketValues.map((value) => { - if (value <= 0) - return 0; + if (value <= 0) return 0; return Math.max(TREND_MIN_BAR_PERCENT, Math.round((value / maxValue) * 100)); }), }; } + function createHiddenUsageTrendViewModel() { return { visible: false, @@ -198,46 +238,57 @@ function createHiddenUsageTrendViewModel() { bars: [], }; } + export function getUsageTrendSamples(snapshot, history = [], nowSeconds = Date.now() / 1000) { const quotaId = getSecondaryQuota(snapshot)?.id ?? "weekly"; const minTimestamp = nowSeconds - TREND_LOOKBACK_SECONDS; return history .map((entry) => { - const timestamp = new Date(entry.timestamp).getTime() / 1000; - const quota = entry.quotas.find((item) => item.id === quotaId) ?? - entry.quotas.find((item) => item.id === "weekly"); - return { - timestamp, - usedPercent: quota?.usedPercent, - }; - }) - .filter((entry) => Number.isFinite(entry.timestamp) && - entry.timestamp >= minTimestamp && - Number.isFinite(entry.usedPercent)) + const timestamp = new Date(entry.timestamp).getTime() / 1000; + const quota = + entry.quotas.find((item) => item.id === quotaId) ?? + entry.quotas.find((item) => item.id === "weekly"); + return { + timestamp, + usedPercent: quota?.usedPercent, + }; + }) + .filter( + (entry) => + Number.isFinite(entry.timestamp) && + entry.timestamp >= minTimestamp && + Number.isFinite(entry.usedPercent), + ) .toSorted((a, b) => a.timestamp - b.timestamp); } + export function calculateRecentPositiveDelta(samples) { let total = 0; for (let index = 1; index < samples.length; index += 1) { const delta = samples[index].usedPercent - samples[index - 1].usedPercent; - if (delta > 0 && delta <= 100) - total += delta; + if (delta > 0 && delta <= 100) total += delta; } return total; } + export function calculateAverageBurnRatePercentPerDay(samples) { - if (samples.length < 2) - return null; + if (samples.length < 2) return null; const positiveDelta = calculateRecentPositiveDelta(samples); - if (positiveDelta <= 0) - return null; + if (positiveDelta <= 0) return null; const observedSeconds = samples.at(-1).timestamp - samples[0].timestamp; - if (!Number.isFinite(observedSeconds) || observedSeconds <= 0) - return null; + if (!Number.isFinite(observedSeconds) || observedSeconds <= 0) return null; const observedDays = Math.max(observedSeconds, MIN_BURN_RATE_OBSERVED_SECONDS) / 86400; return positiveDelta / observedDays; } -function addTrendDeltaToBuckets({ bucketValues, delta, fromTimestamp, toTimestamp, minTimestamp, bucketSize, }) { + +function addTrendDeltaToBuckets({ + bucketValues, + delta, + fromTimestamp, + toTimestamp, + minTimestamp, + bucketSize, +}) { const start = Math.max(minTimestamp, fromTimestamp); const end = Math.max(start, toTimestamp); if (end <= start) { @@ -257,89 +308,88 @@ function addTrendDeltaToBuckets({ bucketValues, delta, fromTimestamp, toTimestam } } } + function getTrendBucketIndex(timestamp, minTimestamp, bucketSize) { - return Math.min(TREND_BUCKET_COUNT - 1, Math.max(0, Math.floor((timestamp - minTimestamp) / bucketSize))); + return Math.min( + TREND_BUCKET_COUNT - 1, + Math.max(0, Math.floor((timestamp - minTimestamp) / bucketSize)), + ); } + export function calculateBarFillWidth(trackWidth, percentValue) { - if (trackWidth <= 0) - return 0; + if (trackWidth <= 0) return 0; return Math.round(trackWidth * (normalizePercent(percentValue) / 100)); } + export function calculateBarMarkerPosition(trackWidth, markerWidth, percentValue) { - if (trackWidth <= 0 || !Number.isFinite(percentValue)) - return 0; + if (trackWidth <= 0 || !Number.isFinite(percentValue)) return 0; const usableWidth = Math.max(0, trackWidth - Math.max(0, markerWidth)); return Math.round(usableWidth * (normalizePercent(percentValue) / 100)); } + export function calculateBaselinePercent(quota) { - if (!quota) - return null; + if (!quota) return null; const { limitWindowSeconds, resetAfterSeconds } = quota; - if (!Number.isFinite(limitWindowSeconds) || + if ( + !Number.isFinite(limitWindowSeconds) || !Number.isFinite(resetAfterSeconds) || - limitWindowSeconds <= 0) { + limitWindowSeconds <= 0 + ) { return null; } return normalizePercent(((limitWindowSeconds - resetAfterSeconds) / limitWindowSeconds) * 100); } + export function getUsageBarColorStyleClass(percentValue) { const percent = normalizePercent(percentValue); - if (percent >= 100) - return "cx-color-danger"; - if (percent > 75) - return "cx-color-warning"; + if (percent >= 100) return "cx-color-danger"; + if (percent > 75) return "cx-color-warning"; return "cx-color-green"; } + export function formatPercent(value) { return Number.isFinite(value) ? `${value}%` : "--"; } + export function formatPercentForDisplay(value, displayMode) { - if (!Number.isFinite(value)) - return "--"; + if (!Number.isFinite(value)) return "--"; return formatPercent(convertPercentForDisplay(value, displayMode)); } + export function convertPercentForDisplay(value, displayMode) { const percent = normalizePercent(value); return displayMode === "left" ? 100 - percent : percent; } + export function formatReset(quota, windowType) { - if (!quota) - return "resets in --"; - if (quota.resetDescription) - return quota.resetDescription; + if (!quota) return "resets in --"; + if (quota.resetDescription) return quota.resetDescription; const useDate = windowType === "secondary" ? true : false; const relative = formatDuration(quota.resetAfterSeconds, windowType); const absolute = formatUnixTimestamp(quota.resetAt, useDate); - if (relative === "--" && absolute === "--") - return "resets in --"; - if (relative === "--") - return `resets in -- (${absolute})`; - if (absolute === "--") - return `resets in ${relative}`; + if (relative === "--" && absolute === "--") return "resets in --"; + if (relative === "--") return `resets in -- (${absolute})`; + if (absolute === "--") return `resets in ${relative}`; return `resets in ${relative} (${absolute})`; } + export function formatDuration(totalSeconds, windowType) { - if (!Number.isFinite(totalSeconds)) - return "--"; + if (!Number.isFinite(totalSeconds)) return "--"; let remaining = Math.max(0, Math.floor(totalSeconds)); const days = Math.floor(remaining / 86400); remaining %= 86400; const hours = Math.floor(remaining / 3600); remaining %= 3600; const minutes = Math.floor(remaining / 60); - if (windowType === "primary") - return `${hours}h ${minutes}m`; - if (windowType === "secondary") - return `${days}d ${hours}h`; + if (windowType === "primary") return `${hours}h ${minutes}m`; + if (windowType === "secondary") return `${days}d ${hours}h`; const parts = []; - if (days) - parts.push(`${days}d`); - if (hours) - parts.push(`${hours}h`); - if (minutes || parts.length === 0) - parts.push(`${minutes}m`); + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes || parts.length === 0) parts.push(`${minutes}m`); return parts.join(" "); } + /** * Format a UNIX timestamp given in seconds into time or datetime string * @@ -348,10 +398,10 @@ export function formatDuration(totalSeconds, windowType) { * @returns formatted string */ export function formatUnixTimestamp(value, date = true) { - if (!Number.isFinite(value)) - return "--"; + if (!Number.isFinite(value)) return "--"; return formatTimestamp(new Date(value * 1000).toISOString(), date); } + /** * Format a ISO string timestamp into time or datetime string * @@ -360,8 +410,7 @@ export function formatUnixTimestamp(value, date = true) { * @returns formatted string */ export function formatTimestamp(value, date = true) { - if (!value) - return "--"; + if (!value) return "--"; const datetimeFormat = new Intl.DateTimeFormat(undefined, { day: "numeric", month: "short", @@ -375,11 +424,11 @@ export function formatTimestamp(value, date = true) { const format = date ? datetimeFormat : timeFormat; try { return format.format(new Date(value)); - } - catch (_error) { + } catch (_error) { return "--"; } } + /** * Calculate seconds from now until timestamp * @@ -387,24 +436,26 @@ export function formatTimestamp(value, date = true) { * @returns */ export function secondsUntil(unixTimestamp) { - if (!Number.isFinite(unixTimestamp)) - return null; + if (!Number.isFinite(unixTimestamp)) return null; return Math.max(0, Math.round(unixTimestamp - Date.now() / 1000)); } + export function formatLimitPrediction(prediction, quota, windowType) { - if (prediction?.trend === "limit reached") - return "Limit reached"; - if (prediction?.trend === "safe") - return "Safe at current pace"; + if (prediction?.trend === "limit reached") return "Limit reached"; + if (prediction?.trend === "safe") return "Safe at current pace"; if (prediction?.trend !== "unsafe" || !Number.isFinite(prediction.estimatedLimitAt)) { return ""; } if (Number.isFinite(quota?.resetAt)) { - const secondsBeforeReset = Math.max(0, Math.round(quota?.resetAt - prediction.estimatedLimitAt)); + const secondsBeforeReset = Math.max( + 0, + Math.round(quota?.resetAt - prediction.estimatedLimitAt), + ); return `Will hit limit ~${formatCompactDuration(secondsBeforeReset, windowType)} before reset`; } return `Will hit limit in ~${formatCompactDuration(secondsUntil(prediction.estimatedLimitAt), windowType)}`; } + export function getPredictionStyleClass(prediction) { switch (prediction?.trend) { case "limit reached": @@ -417,31 +468,30 @@ export function getPredictionStyleClass(prediction) { return "muted"; } } + export function formatFooter(snapshot) { - if (!snapshot) - return "--"; + if (!snapshot) return "--"; const parts = [formatPlan(snapshot.planType)]; const credits = formatCredits(snapshot.credits); - if (credits) - parts.push(credits); + if (credits) parts.push(credits); parts.push(`Updated ${formatUnixTimestamp(snapshot.fetchedAt, false)}`); return parts.filter((part) => part && part !== "--").join(" · ") || "--"; } + function formatCredits(credits) { - if (!credits || credits.unlimited) - return ""; + if (!credits || credits.unlimited) return ""; const balance = typeof credits.balance === "string" ? credits.balance.trim() : ""; - if (!balance || isZeroCreditBalance(balance)) - return ""; + if (!balance || isZeroCreditBalance(balance)) return ""; return `Credits ${balance}`; } + function isZeroCreditBalance(value) { const numeric = Number(value.replace(/[^0-9.-]/g, "")); return Number.isFinite(numeric) && numeric === 0; } + export function formatPlan(value) { - if (!value) - return "--"; + if (!value) return "--"; return value .toString() .split(/[_-]/) @@ -449,9 +499,9 @@ export function formatPlan(value) { .map((part) => `${part[0].toUpperCase()}${part.slice(1)}`) .join(" "); } + function formatCompactDuration(totalSeconds, windowType) { - if (!Number.isFinite(totalSeconds)) - return "--"; + if (!Number.isFinite(totalSeconds)) return "--"; const seconds = Math.max(0, Math.round(totalSeconds)); const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); @@ -459,14 +509,12 @@ function formatCompactDuration(totalSeconds, windowType) { if (windowType === "secondary" && days > 0) { return hours >= 12 ? `${days + 1}d` : `${days}d`; } - if (days > 0) - return hours > 0 ? `${days}d ${hours}h` : `${days}d`; - if (hours > 0) - return minutes >= 30 ? `${hours + 1}h` : `${hours}h`; + if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`; + if (hours > 0) return minutes >= 30 ? `${hours + 1}h` : `${hours}h`; return `${Math.max(1, minutes)}m`; } + export function normalizePercent(value) { - if (!Number.isFinite(value)) - return 0; + if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, value)); } diff --git a/src/preferences/page.js b/src/preferences/page.js index 4baf201..614d12b 100644 --- a/src/preferences/page.js +++ b/src/preferences/page.js @@ -2,35 +2,52 @@ import Adw from "gi://Adw"; import Gio from "gi://Gio"; import GObject from "gi://GObject"; import Gtk from "gi://Gtk"; -import { MIN_REFRESH_INTERVAL_MINUTES, SETTINGS_AUTO_APPLY_BANKED_RESET, SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, SETTINGS_PERCENT_DISPLAY_MODE, SETTINGS_SHOW_PRIMARY, SETTINGS_SHOW_SECONDARY, SETTINGS_TOP_PANEL_DISPLAY_MODE, SETTINGS_TOP_PANEL_INDICATOR_ICON, } from "./settings.js"; -export const PreferencesPage = GObject.registerClass(class PreferencesPage extends Adw.PreferencesPage { - _init(settings, snapshot, metadata) { - super._init({ - title: "Codex Meter", - icon_name: "codex-symbolic", - }); - this.add(createBehaviorGroup(settings)); - this.add(createTopPanelGroup(settings)); - this.add(createBankedResetsGroup(settings, snapshot)); - this.add(createFooterGroup(metadata)); - } -}); +import { + MIN_REFRESH_INTERVAL_MINUTES, + SETTINGS_AUTO_APPLY_BANKED_RESET, + SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, + SETTINGS_PERCENT_DISPLAY_MODE, + SETTINGS_SHOW_PRIMARY, + SETTINGS_SHOW_SECONDARY, + SETTINGS_TOP_PANEL_DISPLAY_MODE, + SETTINGS_TOP_PANEL_INDICATOR_ICON, +} from "./settings.js"; + +export const PreferencesPage = GObject.registerClass( + class PreferencesPage extends Adw.PreferencesPage { + _init(settings, snapshot, metadata) { + super._init({ + title: "Codex Meter", + icon_name: "codex-symbolic", + }); + this.add(createBehaviorGroup(settings)); + this.add(createTopPanelGroup(settings)); + this.add(createBankedResetsGroup(settings, snapshot)); + this.add(createFooterGroup(metadata)); + } + }, +); + function createBankedResetsGroup(settings, snapshot) { const group = new Adw.PreferencesGroup({ title: "Banked resets" }); - group.add(createBoundSwitchRow({ - settings, - key: SETTINGS_AUTO_APPLY_BANKED_RESET, - title: "Auto-apply expiring resets", - subtitle: "Apply a banked reset automatically within 1 hour of expiry", - })); + group.add( + createBoundSwitchRow({ + settings, + key: SETTINGS_AUTO_APPLY_BANKED_RESET, + title: "Auto-apply expiring resets", + subtitle: "Apply a banked reset automatically within 1 hour of expiry", + }), + ); if (!snapshot) { group.add(new Adw.ActionRow({ title: getNoCodexCreditSnapshotMessage() })); return group; } if (snapshot.credits.length === 0) { - group.add(new Adw.ActionRow({ - title: "No banked Codex resets are available.", - })); + group.add( + new Adw.ActionRow({ + title: "No banked Codex resets are available.", + }), + ); return group; } for (const credit of snapshot.credits) { @@ -38,6 +55,7 @@ function createBankedResetsGroup(settings, snapshot) { } return group; } + function createCreditRow(credit) { const row = new Adw.PreferencesRow(); const box = new Gtk.Box({ @@ -101,19 +119,20 @@ function createCreditRow(credit) { row.set_child(box); return row; } + function getNoCodexCreditSnapshotMessage() { return "No Codex credit snapshot is available yet. It will appear after the next successful panel refresh."; } + function createCreditTitle(credit) { const title = credit.title || "Codex rate limit reset"; return credit.profile_user_id ? `${title} from ${credit.profile_user_id}` : title; } + function formatCreditDate(value) { - if (!value) - return "Unknown"; + if (!value) return "Unknown"; const date = new Date(value); - if (!Number.isFinite(date.getTime())) - return "Unknown"; + if (!Number.isFinite(date.getTime())) return "Unknown"; return date.toLocaleString("en-GB", { day: "2-digit", month: "short", @@ -123,27 +142,34 @@ function formatCreditDate(value) { hour12: false, }); } + function formatCreditStatus(credit) { return credit.status === "available" ? "Available" : credit.status; } + function createTopPanelGroup(settings) { const group = new Adw.PreferencesGroup({ title: "Top panel indicator", }); group.add(createTopPanelIndicatorIconRow(settings)); group.add(createTopPanelStyleRow(settings)); - group.add(createBoundSwitchRow({ - settings, - key: SETTINGS_SHOW_PRIMARY, - title: "Display 5-hour session usage", - })); - group.add(createBoundSwitchRow({ - settings, - key: SETTINGS_SHOW_SECONDARY, - title: "Display weekly usage", - })); + group.add( + createBoundSwitchRow({ + settings, + key: SETTINGS_SHOW_PRIMARY, + title: "Display 5-hour session usage", + }), + ); + group.add( + createBoundSwitchRow({ + settings, + key: SETTINGS_SHOW_SECONDARY, + title: "Display weekly usage", + }), + ); return group; } + function createBehaviorGroup(settings) { const group = new Adw.PreferencesGroup({ title: "Behavior", @@ -152,23 +178,27 @@ function createBehaviorGroup(settings) { group.add(createRefreshIntervalRow(settings)); return group; } + function createTopPanelStyleRow(settings) { const row = new Adw.ComboRow({ title: "Show percentages as", - model: Gtk.StringList.new([ - "Progress bars", - "Raw percentages", - ]), + model: Gtk.StringList.new(["Progress bars", "Raw percentages"]), selected: getTopPanelDisplayModeIndex(settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE)), }); row.connect("notify::selected", () => { - settings.set_string(SETTINGS_TOP_PANEL_DISPLAY_MODE, getTopPanelDisplayModeValue(row.selected)); + settings.set_string( + SETTINGS_TOP_PANEL_DISPLAY_MODE, + getTopPanelDisplayModeValue(row.selected), + ); }); settings.connect(`changed::${SETTINGS_TOP_PANEL_DISPLAY_MODE}`, () => { - row.selected = getTopPanelDisplayModeIndex(settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE)); + row.selected = getTopPanelDisplayModeIndex( + settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE), + ); }); return row; } + function createPercentDisplayModeRow(settings) { const row = new Adw.ComboRow({ title: "Display mode", @@ -176,28 +206,42 @@ function createPercentDisplayModeRow(settings) { selected: getPercentDisplayModeIndex(settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE)), }); row.connect("notify::selected", () => { - settings.set_string(SETTINGS_PERCENT_DISPLAY_MODE, getPercentDisplayModeValue(row.selected)); + settings.set_string( + SETTINGS_PERCENT_DISPLAY_MODE, + getPercentDisplayModeValue(row.selected), + ); }); settings.connect(`changed::${SETTINGS_PERCENT_DISPLAY_MODE}`, () => { - row.selected = getPercentDisplayModeIndex(settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE)); + row.selected = getPercentDisplayModeIndex( + settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE), + ); }); return row; } + function createTopPanelIndicatorIconRow(settings) { const row = new Adw.ComboRow({ title: "Icon", model: Gtk.StringList.new(["Codex icon", "OpenAI icon", "CX shortcode"]), - selected: getTopPanelIndicatorIconIndex(settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON)), + selected: getTopPanelIndicatorIconIndex( + settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON), + ), }); row.connect("notify::selected", () => { - settings.set_string(SETTINGS_TOP_PANEL_INDICATOR_ICON, getTopPanelIndicatorIconValue(row.selected)); + settings.set_string( + SETTINGS_TOP_PANEL_INDICATOR_ICON, + getTopPanelIndicatorIconValue(row.selected), + ); }); settings.connect(`changed::${SETTINGS_TOP_PANEL_INDICATOR_ICON}`, () => { - row.selected = getTopPanelIndicatorIconIndex(settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON)); + row.selected = getTopPanelIndicatorIconIndex( + settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON), + ); }); return row; } -function createBoundSwitchRow({ settings, key, title, subtitle, }) { + +function createBoundSwitchRow({ settings, key, title, subtitle }) { const rowOptions = { title, active: settings.get_boolean(key), @@ -209,6 +253,7 @@ function createBoundSwitchRow({ settings, key, title, subtitle, }) { settings.bind(key, row, "active", Gio.SettingsBindFlags.DEFAULT); return row; } + function createRefreshIntervalRow(settings) { const row = new Adw.SpinRow({ title: "Background refresh interval (minutes)", @@ -224,13 +269,17 @@ function createRefreshIntervalRow(settings) { digits: 0, }); row.connect("notify::value", () => { - settings.set_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, Math.max(MIN_REFRESH_INTERVAL_MINUTES, Math.round(row.value))); + settings.set_uint( + SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, + Math.max(MIN_REFRESH_INTERVAL_MINUTES, Math.round(row.value)), + ); }); settings.connect(`changed::${SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES}`, () => { row.value = settings.get_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES); }); return row; } + function createFooterGroup(metadata) { const footerGroup = new Adw.PreferencesGroup(); const footerBox = new Gtk.Box({ @@ -239,24 +288,31 @@ function createFooterGroup(metadata) { margin_top: 12, margin_bottom: 12, }); - footerBox.append(new Gtk.Label({ - label: `${metadata.name} v${metadata["version-name"]}`, - css_classes: ["caption", "dim-label"], - })); - footerBox.append(new Gtk.Label({ - label: "Not affiliated with or endorsed by OpenAI.", - wrap: true, - justify: Gtk.Justification.CENTER, - css_classes: ["caption", "dim-label"], - })); + footerBox.append( + new Gtk.Label({ + label: `${metadata.name} v${metadata["version-name"]}`, + css_classes: ["caption", "dim-label"], + }), + ); + footerBox.append( + new Gtk.Label({ + label: "Not affiliated with or endorsed by OpenAI.", + wrap: true, + justify: Gtk.Justification.CENTER, + css_classes: ["caption", "dim-label"], + }), + ); const links = new Gtk.Label({ css_classes: ["caption", "dim-label"], }); - links.set_markup(`Source code · Report a bug`); + links.set_markup( + `Source code · Report a bug`, + ); footerBox.append(links); footerGroup.add(footerBox); return footerGroup; } + function getTopPanelDisplayModeIndex(value) { switch (value) { case "percentages": @@ -265,6 +321,7 @@ function getTopPanelDisplayModeIndex(value) { return 0; } } + function getTopPanelDisplayModeValue(selected) { switch (selected) { case 1: @@ -273,23 +330,23 @@ function getTopPanelDisplayModeValue(selected) { return "bars"; } } + function getTopPanelIndicatorIconIndex(value) { - if (value === "openai") - return 1; - if (value === "text") - return 2; + if (value === "openai") return 1; + if (value === "text") return 2; return 0; } + function getTopPanelIndicatorIconValue(selected) { - if (selected === 1) - return "openai"; - if (selected === 2) - return "text"; + if (selected === 1) return "openai"; + if (selected === 2) return "text"; return "codex"; } + function getPercentDisplayModeIndex(value) { return value === "left" ? 1 : 0; } + function getPercentDisplayModeValue(selected) { return selected === 1 ? "left" : "used"; } diff --git a/src/preferences/settings.js b/src/preferences/settings.js index 77cc5aa..99fe168 100644 --- a/src/preferences/settings.js +++ b/src/preferences/settings.js @@ -1,17 +1,28 @@ export const SETTINGS_USAGE_PROVIDER = "usage-provider"; + export const SETTINGS_SHOW_PRIMARY = "show-primary"; + export const SETTINGS_SHOW_SECONDARY = "show-secondary"; + export const SETTINGS_TOP_PANEL_DISPLAY_MODE = "top-panel-display-mode"; + export const SETTINGS_TOP_PANEL_INDICATOR_ICON = "top-panel-indicator-icon"; + export const SETTINGS_PERCENT_DISPLAY_MODE = "percent-display-mode"; + export const SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES = "background-refresh-interval-minutes"; + export const SETTINGS_AUTO_APPLY_BANKED_RESET = "auto-apply-banked-reset"; + export const MIN_REFRESH_INTERVAL_MINUTES = 0; + export class SettingsService { settings; + constructor(settings) { this.settings = settings; } + getAll() { const backgroundRefreshIntervalMinutes = this.getBackgroundRefreshIntervalMinutes(); return { @@ -26,6 +37,7 @@ export class SettingsService { autoApplyBankedReset: this.getAutoApplyBankedReset(), }; } + getUsageProvider() { const value = this.settings.get_string(SETTINGS_USAGE_PROVIDER); if (value === "codex") { @@ -33,12 +45,15 @@ export class SettingsService { } return "codex"; } + getShowPrimary() { return this.settings.get_boolean(SETTINGS_SHOW_PRIMARY); } + getShowSecondary() { return this.settings.get_boolean(SETTINGS_SHOW_SECONDARY); } + getTopPanelDisplayMode() { const value = this.settings.get_string(SETTINGS_TOP_PANEL_DISPLAY_MODE); if (value === "bars") { @@ -46,6 +61,7 @@ export class SettingsService { } return "percentages"; } + getTopPanelIndicatorIcon() { const value = this.settings.get_string(SETTINGS_TOP_PANEL_INDICATOR_ICON); if (value === "codex" || value === "openai") { @@ -56,6 +72,7 @@ export class SettingsService { } return "text"; } + getPercentDisplayMode() { const value = this.settings.get_string(SETTINGS_PERCENT_DISPLAY_MODE); if (value === "left") { @@ -63,21 +80,33 @@ export class SettingsService { } return "used"; } + getBackgroundRefreshIntervalMinutes() { - return Math.max(MIN_REFRESH_INTERVAL_MINUTES, this.settings.get_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES)); + return Math.max( + MIN_REFRESH_INTERVAL_MINUTES, + this.settings.get_uint(SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES), + ); } + getBackgroundRefreshIntervalSeconds() { return this.getBackgroundRefreshIntervalMinutes() * 60; } + getAutoApplyBankedReset() { return this.settings.get_boolean(SETTINGS_AUTO_APPLY_BANKED_RESET); } + connectChanged(callback) { return this.settings.connect("changed", callback); } + connectBackgroundRefreshIntervalChanged(callback) { - return this.settings.connect(`changed::${SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES}`, callback); + return this.settings.connect( + `changed::${SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES}`, + callback, + ); } + disconnect(signalId) { this.settings.disconnect(signalId); } diff --git a/src/prefs.js b/src/prefs.js index 01b92f4..664a46f 100644 --- a/src/prefs.js +++ b/src/prefs.js @@ -3,6 +3,7 @@ import Gtk from "gi://Gtk"; import { ExtensionPreferences } from "resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js"; import { readCachedCodexBankedResets } from "./banked-resets/api.js"; import { PreferencesPage } from "./preferences/page.js"; + export default class CodexMeterPreferences extends ExtensionPreferences { async fillPreferencesWindow(window) { const settings = this.getSettings(); diff --git a/src/refresh/error.js b/src/refresh/error.js index b8e244a..9911304 100644 --- a/src/refresh/error.js +++ b/src/refresh/error.js @@ -1,6 +1,8 @@ export class RefreshFailureError extends Error { kind; + technicalMessage; + constructor(kind, message, technicalMessage = message) { super(message); this.name = "RefreshFailureError"; @@ -8,6 +10,7 @@ export class RefreshFailureError extends Error { this.technicalMessage = technicalMessage; } } + export function isRefreshFailureError(error) { return error instanceof RefreshFailureError; } diff --git a/src/refresh/scheduler.js b/src/refresh/scheduler.js index 5c87215..fc5b705 100644 --- a/src/refresh/scheduler.js +++ b/src/refresh/scheduler.js @@ -1,14 +1,21 @@ import GLib from "gi://GLib"; + export class Scheduler { intervalSeconds; + task; + timeoutId = null; + running = false; + started = false; + constructor(intervalSeconds, task) { this.intervalSeconds = intervalSeconds; this.task = task; } + start({ runImmediately = false } = {}) { if (this.started) { return; @@ -19,10 +26,12 @@ export class Scheduler { void this.run(); } } + stop() { this.started = false; this.removeTimeout(); } + setIntervalSeconds(intervalSeconds) { this.intervalSeconds = intervalSeconds; if (!this.started) { @@ -31,22 +40,29 @@ export class Scheduler { this.removeTimeout(); this.schedule(); } + runNow() { void this.run(); } + schedule() { if (this.timeoutId !== null || this.intervalSeconds <= 0) { return; } - this.timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this.intervalSeconds, () => { - if (!this.started) { - this.timeoutId = null; - return GLib.SOURCE_REMOVE; - } - void this.run(); - return GLib.SOURCE_CONTINUE; - }); + this.timeoutId = GLib.timeout_add_seconds( + GLib.PRIORITY_DEFAULT, + this.intervalSeconds, + () => { + if (!this.started) { + this.timeoutId = null; + return GLib.SOURCE_REMOVE; + } + void this.run(); + return GLib.SOURCE_CONTINUE; + }, + ); } + removeTimeout() { if (this.timeoutId === null) { return; @@ -54,6 +70,7 @@ export class Scheduler { GLib.source_remove(this.timeoutId); this.timeoutId = null; } + async run() { if (this.running) { return; @@ -61,11 +78,9 @@ export class Scheduler { this.running = true; try { await this.task(); - } - catch (err) { + } catch (err) { console.error("Scheduler task failed", err); - } - finally { + } finally { this.running = false; } } diff --git a/src/usage/history-store.js b/src/usage/history-store.js index 5cfa207..2ceb320 100644 --- a/src/usage/history-store.js +++ b/src/usage/history-store.js @@ -1,28 +1,33 @@ import GLib from "gi://GLib"; import { STATE_DIR } from "../io/paths.js"; import { appendFile, readFile, writeFile } from "../io/files.js"; + export const MAX_HISTORY_ENTRIES = 25_000; + const MAX_HISTORY_AGE_SECONDS = 21 * 24 * 60 * 60; + function getHistoryPath(providerId) { return GLib.build_filenamev([STATE_DIR, providerId, "usage-history.jsonl"]); } + export async function readHistory(providerId) { return readHistoryFromPath(getHistoryPath(providerId)); } + export async function readHistoryFromPath(path) { - if (!fileExists(path)) - return []; + if (!fileExists(path)) return []; try { return normalizeHistoryEntries(parseJsonlHistory(await readFile(path))); - } - catch (error) { + } catch (error) { console.error("Unable to read usage history", error); return []; } } + export async function appendHistory(providerId, row) { return appendHistoryToPath(getHistoryPath(providerId), row); } + /** * Append a history row verbatim without reading, deduping, or normalizing existing history. * Callers must pre-normalize rows and decide whether appending is appropriate. @@ -30,18 +35,20 @@ export async function appendHistory(providerId, row) { export async function appendHistoryRow(providerId, row) { return appendHistoryRowToPath(getHistoryPath(providerId), row); } + export async function appendHistoryRowToPath(path, row) { await appendFile(path, JSON.stringify(row)); } + export async function rewriteHistory(providerId, rows) { await rewriteHistoryToPath(getHistoryPath(providerId), normalizeHistoryEntries(rows)); } + export async function appendHistoryToPath(path, row, existingRows = null) { - const rows = existingRows ?? await readHistoryFromPath(path); + const rows = existingRows ?? (await readHistoryFromPath(path)); const normalizedRows = normalizeHistoryEntries(rows); const normalizedRow = normalizeHistoryEntry(row); - if (!normalizedRow) - return; + if (!normalizedRow) return; const lastRow = normalizedRows.at(-1); const shouldAppend = !lastRow || !hasSameQuotaValues(lastRow, normalizedRow); const nextRows = shouldAppend @@ -51,16 +58,17 @@ export async function appendHistoryToPath(path, row, existingRows = null) { await rewriteHistoryToPath(path, nextRows); return; } - if (!shouldAppend) - return; + if (!shouldAppend) return; await appendFile(path, JSON.stringify(normalizedRow)); if (nextRows.length !== normalizedRows.length + 1) { await rewriteHistoryToPath(path, nextRows); } } + function fileExists(path) { return GLib.file_test(path, GLib.FileTest.EXISTS); } + function parseJsonlHistory(text) { return text .replace(/^\uFEFF/, "") @@ -68,41 +76,39 @@ function parseJsonlHistory(text) { .map((line) => line.trim()) .filter(Boolean) .map((line) => { - try { - return JSON.parse(line); - } - catch (_error) { - return null; - } - }) + try { + return JSON.parse(line); + } catch (_error) { + return null; + } + }) .filter((row) => row !== null); } + export function normalizeHistoryEntries(rows) { - const minTimestamp = Date.now() - (MAX_HISTORY_AGE_SECONDS * 1000); + const minTimestamp = Date.now() - MAX_HISTORY_AGE_SECONDS * 1000; return rows .map(normalizeHistoryEntry) .filter((row) => row !== null && new Date(row.timestamp).getTime() >= minTimestamp) .slice(-MAX_HISTORY_ENTRIES); } + export function normalizeHistoryEntry(row) { if (!row || !isValidTimestamp(row.timestamp) || !Array.isArray(row.quotas)) { return null; } - const quotas = row.quotas - .map(normalizeHistoryQuota) - .filter((quota) => quota !== null); - if (quotas.length === 0) - return null; + const quotas = row.quotas.map(normalizeHistoryQuota).filter((quota) => quota !== null); + if (quotas.length === 0) return null; return { timestamp: row.timestamp, quotas, }; } + function normalizeHistoryQuota(quota) { const id = `${quota?.id ?? ""}`; const usedPercent = Number(quota?.usedPercent); - if (id.length === 0 || !Number.isFinite(usedPercent)) - return null; + if (id.length === 0 || !Number.isFinite(usedPercent)) return null; return omitUndefined({ id, usedPercent, @@ -110,38 +116,44 @@ function normalizeHistoryQuota(quota) { limit: finiteOrNull(quota?.limit), remaining: finiteOrNull(quota?.remaining), resetAt: finiteOrNull(quota?.resetAt), - limitReached: typeof quota?.limitReached === "boolean" - ? quota.limitReached - : undefined, + limitReached: typeof quota?.limitReached === "boolean" ? quota.limitReached : undefined, }); } + function finiteOrNull(value) { - if (value === null) - return null; + if (value === null) return null; const number = Number(value); return Number.isFinite(number) ? number : undefined; } + function omitUndefined(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); } + function isValidTimestamp(value) { return value.length > 0 && Number.isFinite(new Date(value).getTime()); } + async function rewriteHistoryToPath(path, rows) { - await writeFile(path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : "")); + await writeFile( + path, + rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""), + ); } + export function hasSameQuotaValues(left, right) { - if (left.quotas.length !== right.quotas.length) - return false; + if (left.quotas.length !== right.quotas.length) return false; const leftById = new Map(left.quotas.map((quota) => [quota.id, quota])); return right.quotas.every((rightQuota) => { const leftQuota = leftById.get(rightQuota.id); - return Boolean(leftQuota) && + return ( + Boolean(leftQuota) && leftQuota?.usedPercent === rightQuota.usedPercent && leftQuota?.used === rightQuota.used && leftQuota?.limit === rightQuota.limit && leftQuota?.remaining === rightQuota.remaining && leftQuota?.resetAt === rightQuota.resetAt && - leftQuota?.limitReached === rightQuota.limitReached; + leftQuota?.limitReached === rightQuota.limitReached + ); }); } diff --git a/src/usage/prediction.js b/src/usage/prediction.js index 7f19d17..c6423c1 100644 --- a/src/usage/prediction.js +++ b/src/usage/prediction.js @@ -25,8 +25,8 @@ export function createUnknownUsagePrediction(snapshot) { return { quotas, - primary: primaryId ? quotas[primaryId] ?? unknownPrediction() : unknownPrediction(), - secondary: secondaryId ? quotas[secondaryId] ?? unknownPrediction() : unknownPrediction(), + primary: primaryId ? (quotas[primaryId] ?? unknownPrediction()) : unknownPrediction(), + secondary: secondaryId ? (quotas[secondaryId] ?? unknownPrediction()) : unknownPrediction(), }; } @@ -61,8 +61,7 @@ function predictQuota(history, snapshot, quota) { return unknownPrediction(); } - const startedAt = snapshot.fetchedAt - - (quota.limitWindowSeconds - quota.resetAfterSeconds); + const startedAt = snapshot.fetchedAt - (quota.limitWindowSeconds - quota.resetAfterSeconds); const historyWithSnapshot = [ ...history, { @@ -96,10 +95,12 @@ function predictQuota(history, snapshot, quota) { * @returns {quota is PredictionReadyQuota} */ function hasPredictionMetadata(quota) { - return Number.isFinite(quota.limitWindowSeconds) && + return ( + Number.isFinite(quota.limitWindowSeconds) && Number.isFinite(quota.resetAfterSeconds) && Number.isFinite(quota.resetAt) && - (quota.limitWindowSeconds ?? 0) > 0; + (quota.limitWindowSeconds ?? 0) > 0 + ); } /** @returns {WindowPrediction} */ diff --git a/src/usage/providers.js b/src/usage/providers.js index b238161..7b4f00f 100644 --- a/src/usage/providers.js +++ b/src/usage/providers.js @@ -1,8 +1,11 @@ import { CodexUsageProvider } from "../codex/usage-provider.js"; + export const DEFAULT_PROVIDER_ID = "codex"; + const providers = { codex: new CodexUsageProvider(), }; + export function getUsageProvider(id = DEFAULT_PROVIDER_ID) { const provider = providers[id]; if (!provider) { @@ -10,6 +13,7 @@ export function getUsageProvider(id = DEFAULT_PROVIDER_ID) { } return provider; } + export function listUsageProviders() { return Object.values(providers); } diff --git a/src/usage/service.js b/src/usage/service.js index e61ad52..90a1f9c 100644 --- a/src/usage/service.js +++ b/src/usage/service.js @@ -1,52 +1,69 @@ import { predict } from "./prediction.js"; import { toHistoryEntry } from "./model.js"; import { getUsageProvider } from "./providers.js"; -import { appendHistoryRow, hasSameQuotaValues, MAX_HISTORY_ENTRIES, normalizeHistoryEntries, normalizeHistoryEntry, readHistory, rewriteHistory, } from "./history-store.js"; +import { + appendHistoryRow, + hasSameQuotaValues, + MAX_HISTORY_ENTRIES, + normalizeHistoryEntries, + normalizeHistoryEntry, + readHistory, + rewriteHistory, +} from "./history-store.js"; import { readSnapshot, writeSnapshot } from "./snapshot-store.js"; + export class UsageService { provider; + history = null; + constructor(provider = getUsageProvider()) { this.provider = provider; } + async readCachedSnapshot() { return await readSnapshot(this.provider.info.id); } + async refresh(options = {}) { const snapshot = await this.provider.refreshUsage({ cancellable: options.cancellable ?? null, }); try { await writeSnapshot(this.provider.info.id, snapshot); - } - catch (error) { - console.error(`Unable to write ${this.provider.info.displayName} usage snapshot cache`, error); + } catch (error) { + console.error( + `Unable to write ${this.provider.info.displayName} usage snapshot cache`, + error, + ); } try { await this.appendSnapshotToHistory(snapshot); - } - catch (error) { - console.error(`Unable to append ${this.provider.info.displayName} usage history`, error); + } catch (error) { + console.error( + `Unable to append ${this.provider.info.displayName} usage history`, + error, + ); } return snapshot; } + async readHistory() { - return [...await this.loadHistory()]; + return [...(await this.loadHistory())]; } + async loadHistory() { - if (this.history) - return this.history; + if (this.history) return this.history; this.history = await readHistory(this.provider.info.id); return this.history; } + async appendSnapshotToHistory(snapshot) { const history = await this.loadHistory(); const row = normalizeHistoryEntry(toHistoryEntry(snapshot)); - if (!row) - return; + if (!row) return; const lastRow = history.at(-1); - if (lastRow && hasSameQuotaValues(lastRow, row)) - return; + if (lastRow && hasSameQuotaValues(lastRow, row)) return; await appendHistoryRow(this.provider.info.id, row); history.push(row); if (history.length > MAX_HISTORY_ENTRIES) { @@ -54,8 +71,9 @@ export class UsageService { await rewriteHistory(this.provider.info.id, this.history); } } + async predict(snapshot) { - const currentSnapshot = snapshot ?? await readSnapshot(this.provider.info.id); + const currentSnapshot = snapshot ?? (await readSnapshot(this.provider.info.id)); if (!currentSnapshot) { throw new Error("No snapshot available"); } diff --git a/src/usage/snapshot-store.js b/src/usage/snapshot-store.js index ba0ca31..2701aae 100644 --- a/src/usage/snapshot-store.js +++ b/src/usage/snapshot-store.js @@ -1,12 +1,15 @@ import GLib from "gi://GLib"; import { CACHE_DIR } from "../io/paths.js"; import { readJsonFile, writeJsonFile } from "../io/files.js"; + function getSnapshotPath(providerId) { return GLib.build_filenamev([CACHE_DIR, providerId, "snapshot.json"]); } + export async function writeSnapshot(providerId, snapshot) { await writeJsonFile(getSnapshotPath(providerId), snapshot); } + export async function readSnapshot(providerId) { try { const raw = await readJsonFile(getSnapshotPath(providerId)); @@ -14,39 +17,46 @@ export async function readSnapshot(providerId) { return null; } return raw; - } - catch { + } catch { return null; } } + function isUsageSnapshot(value) { if (!value || typeof value !== "object") { return false; } const snapshot = value; - return (isFiniteNumber(snapshot.fetchedAt) && + return ( + isFiniteNumber(snapshot.fetchedAt) && typeof snapshot.planType === "string" && Array.isArray(snapshot.quotas) && snapshot.quotas.length > 0 && - snapshot.quotas.every(isUsageQuota)); + snapshot.quotas.every(isUsageQuota) + ); } + function isUsageQuota(value) { if (!value || typeof value !== "object") { return false; } const quota = value; - return (typeof quota.id === "string" && + return ( + typeof quota.id === "string" && quota.id.length > 0 && typeof quota.label === "string" && quota.label.length > 0 && isFiniteNumber(quota.usedPercent) && isOptionalFiniteNumber(quota.limitWindowSeconds) && isOptionalFiniteNumber(quota.resetAfterSeconds) && - isOptionalFiniteNumber(quota.resetAt)); + isOptionalFiniteNumber(quota.resetAt) + ); } + function isFiniteNumber(value) { return typeof value === "number" && Number.isFinite(value); } + function isOptionalFiniteNumber(value) { return value === undefined || value === null || isFiniteNumber(value); } diff --git a/test/codex-auth.test.js b/test/codex-auth.test.js index 3f772e4..e40a371 100644 --- a/test/codex-auth.test.js +++ b/test/codex-auth.test.js @@ -1,13 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { - parseCodexAccessToken, - parseCodexAccountId, -} from "../src/codex/auth-parser.js"; +import { parseCodexAccessToken, parseCodexAccountId } from "../src/codex/auth-parser.js"; test("returns access token from tokens.access_token", () => { - assert.equal(parseCodexAccessToken({ tokens: { access_token: "fake-access-token" } }), "fake-access-token"); + assert.equal( + parseCodexAccessToken({ tokens: { access_token: "fake-access-token" } }), + "fake-access-token", + ); }); test("returns null when tokens are absent", () => { @@ -15,7 +15,10 @@ test("returns null when tokens are absent", () => { }); test("trims account ID", () => { - assert.equal(parseCodexAccountId({ tokens: { account_id: " account-test " } }), "account-test"); + assert.equal( + parseCodexAccountId({ tokens: { account_id: " account-test " } }), + "account-test", + ); }); test("returns null for blank account ID", () => { diff --git a/test/codex-banked-resets.test.js b/test/codex-banked-resets.test.js index 946502a..64df5d5 100644 --- a/test/codex-banked-resets.test.js +++ b/test/codex-banked-resets.test.js @@ -60,9 +60,21 @@ test("selectCreditToRedeem returns first available credit when any available exp }); test("selectCreditToRedeem ignores redeemed and unavailable credits", () => { - const redeemed = { id: "credit-redeemed", status: "redeemed", expires_at: "2026-07-01T00:00:00Z" }; - const unavailable = { id: "credit-unavailable", status: "unavailable", expires_at: "2026-07-02T00:00:00Z" }; - const available = { id: "credit-available", status: "available", expires_at: "2026-07-03T00:00:00Z" }; + const redeemed = { + id: "credit-redeemed", + status: "redeemed", + expires_at: "2026-07-01T00:00:00Z", + }; + const unavailable = { + id: "credit-unavailable", + status: "unavailable", + expires_at: "2026-07-02T00:00:00Z", + }; + const available = { + id: "credit-available", + status: "available", + expires_at: "2026-07-03T00:00:00Z", + }; assert.equal(selectCreditToRedeem([redeemed, unavailable, available]), available); }); @@ -70,8 +82,16 @@ test("selectCreditToRedeem ignores redeemed and unavailable credits", () => { test("selectCreditExpiringWithin selects the earliest credit in the window", () => { const now = Date.parse("2026-07-02T00:00:00Z"); const later = { id: "credit-later", status: "available", expires_at: "2026-07-02T00:45:00Z" }; - const earlier = { id: "credit-earlier", status: "available", expires_at: "2026-07-02T00:30:00Z" }; - const outside = { id: "credit-outside", status: "available", expires_at: "2026-07-02T02:00:00Z" }; + const earlier = { + id: "credit-earlier", + status: "available", + expires_at: "2026-07-02T00:30:00Z", + }; + const outside = { + id: "credit-outside", + status: "available", + expires_at: "2026-07-02T02:00:00Z", + }; assert.equal( selectCreditExpiringWithin([later, outside, earlier], now, 60 * 60 * 1000), @@ -81,9 +101,17 @@ test("selectCreditExpiringWithin selects the earliest credit in the window", () test("selectCreditExpiringWithin ignores expired, invalid, and unavailable credits", () => { const now = Date.parse("2026-07-02T00:00:00Z"); - const expired = { id: "credit-expired", status: "available", expires_at: "2026-07-01T23:59:59Z" }; + const expired = { + id: "credit-expired", + status: "available", + expires_at: "2026-07-01T23:59:59Z", + }; const invalid = { id: "credit-invalid", status: "available", expires_at: "unknown" }; - const redeemed = { id: "credit-redeemed", status: "redeemed", expires_at: "2026-07-02T12:00:00Z" }; + const redeemed = { + id: "credit-redeemed", + status: "redeemed", + expires_at: "2026-07-02T12:00:00Z", + }; assert.equal( selectCreditExpiringWithin([expired, invalid, redeemed], now, 60 * 60 * 1000), diff --git a/test/codex-provider.test.js b/test/codex-provider.test.js index 42cea38..a3fc461 100644 --- a/test/codex-provider.test.js +++ b/test/codex-provider.test.js @@ -61,39 +61,45 @@ test("maps valid Codex usage response to usage snapshot", () => { }); test("maps a weekly-only Codex response without a session quota", () => { - const snapshot = toUsageSnapshot(validUsageResponse({ - rate_limit: { - limit_reached: false, - primary_window: { - used_percent: 0, - limit_window_seconds: 604_800, - reset_after_seconds: 604_762, - reset_at: 1_784_488_950, + const snapshot = toUsageSnapshot( + validUsageResponse({ + rate_limit: { + limit_reached: false, + primary_window: { + used_percent: 0, + limit_window_seconds: 604_800, + reset_after_seconds: 604_762, + reset_at: 1_784_488_950, + }, + secondary_window: null, }, - secondary_window: null, - }, - })); + }), + ); - assert.deepEqual(snapshot.quotas, [{ - id: "weekly", - label: "Week", - usedPercent: 0, - limitWindowSeconds: 604_800, - resetAfterSeconds: 604_762, - resetAt: 1_784_488_950, - limitReached: false, - }]); + assert.deepEqual(snapshot.quotas, [ + { + id: "weekly", + label: "Week", + usedPercent: 0, + limitWindowSeconds: 604_800, + resetAfterSeconds: 604_762, + resetAt: 1_784_488_950, + limitReached: false, + }, + ]); }); test("maps Codex credit balance when present", () => { - const snapshot = toUsageSnapshot(validUsageResponse({ - credits: { - has_credits: true, - unlimited: false, - overage_limit_reached: false, - balance: "$4.21", - }, - })); + const snapshot = toUsageSnapshot( + validUsageResponse({ + credits: { + has_credits: true, + unlimited: false, + overage_limit_reached: false, + balance: "$4.21", + }, + }), + ); assert.deepEqual(snapshot.credits, { balance: "$4.21", diff --git a/test/history-storage.test.js b/test/history-storage.test.js index da1e675..1d75680 100644 --- a/test/history-storage.test.js +++ b/test/history-storage.test.js @@ -45,8 +45,7 @@ function writeText(path, text) { function removeFile(path) { try { Gio.File.new_for_path(path).delete(null); - } catch (_error) { - } + } catch (_error) {} } async function testAppendWritesJsonl() { @@ -84,35 +83,43 @@ async function testAppendWritesJsonl() { for (const [index, line] of lines.entries()) { assertEqual( - line.startsWith("{\"timestamp\":"), + line.startsWith('{"timestamp":'), true, `history line ${index + 1} should start with a JSON object`, ); JSON.parse(line); } - assertDeepEqual(JSON.parse(lines[0]), { - timestamp: firstTimestamp, - quotas: [ - { - id: "session", - usedPercent: 14, - used: 140, - limit: 1000, - remaining: 860, - resetAt: 1_700_000_000, - limitReached: false, - }, - { id: "weekly", usedPercent: 54 }, - ], - }, "history row should preserve richer quota fields"); - assertDeepEqual(JSON.parse(lines[1]), { - timestamp: secondTimestamp, - quotas: [ - { id: "session", usedPercent: 15, used: 150 }, - { id: "weekly", usedPercent: 55 }, - ], - }, "appended history row should be valid JSON text"); + assertDeepEqual( + JSON.parse(lines[0]), + { + timestamp: firstTimestamp, + quotas: [ + { + id: "session", + usedPercent: 14, + used: 140, + limit: 1000, + remaining: 860, + resetAt: 1_700_000_000, + limitReached: false, + }, + { id: "weekly", usedPercent: 54 }, + ], + }, + "history row should preserve richer quota fields", + ); + assertDeepEqual( + JSON.parse(lines[1]), + { + timestamp: secondTimestamp, + quotas: [ + { id: "session", usedPercent: 15, used: 150 }, + { id: "weekly", usedPercent: 55 }, + ], + }, + "appended history row should be valid JSON text", + ); } finally { removeFile(path); } @@ -175,21 +182,28 @@ async function testJsonlIsReadable() { const timestamp = new Date().toISOString(); try { - writeText(path, `${JSON.stringify({ - timestamp, - quotas: [ - { id: "session", usedPercent: 11, used: 110 }, - { id: "weekly", usedPercent: 56 }, - ], - })}\n`); + writeText( + path, + `${JSON.stringify({ + timestamp, + quotas: [ + { id: "session", usedPercent: 11, used: 110 }, + { id: "weekly", usedPercent: 56 }, + ], + })}\n`, + ); const history = await readHistoryFromPath(path); assertEqual(history.length, 1, "JSONL history row should be read"); - assertDeepEqual(history[0].quotas, [ - { id: "session", usedPercent: 11, used: 110 }, - { id: "weekly", usedPercent: 56 }, - ], "JSONL usage quotas should be read"); + assertDeepEqual( + history[0].quotas, + [ + { id: "session", usedPercent: 11, used: 110 }, + { id: "weekly", usedPercent: 56 }, + ], + "JSONL usage quotas should be read", + ); } finally { removeFile(path); } diff --git a/test/usage-trend.test.js b/test/usage-trend.test.js index 4560ab1..6512a6b 100644 --- a/test/usage-trend.test.js +++ b/test/usage-trend.test.js @@ -114,11 +114,7 @@ test("uses the secondary quota id for usage trend", () => { }; const trend = createUsageTrendViewModel( snapshot, - [ - entry(600, 90, "session"), - entry(300, 1, "week"), - entry(60, 5, "week"), - ], + [entry(600, 90, "session"), entry(300, 1, "week"), entry(60, 5, "week")], now, ); @@ -127,24 +123,32 @@ test("uses the secondary quota id for usage trend", () => { }); test("calculates recent positive delta and ignores resets", () => { - assert.equal(calculateRecentPositiveDelta([ - { timestamp: now - 300, usedPercent: 80 }, - { timestamp: now - 200, usedPercent: 2 }, - { timestamp: now - 100, usedPercent: 9 }, - ]), 7); + assert.equal( + calculateRecentPositiveDelta([ + { timestamp: now - 300, usedPercent: 80 }, + { timestamp: now - 200, usedPercent: 2 }, + { timestamp: now - 100, usedPercent: 9 }, + ]), + 7, + ); }); test("calculates average burn rate over observed span", () => { - assert.equal(calculateAverageBurnRatePercentPerDay([ - { timestamp: now - 86_400, usedPercent: 1 }, - { timestamp: now, usedPercent: 2.8 }, - ]), 1.7999999999999998); + assert.equal( + calculateAverageBurnRatePercentPerDay([ + { timestamp: now - 86_400, usedPercent: 1 }, + { timestamp: now, usedPercent: 2.8 }, + ]), + 1.7999999999999998, + ); }); test("calculates average burn rate with six hour minimum observed span", () => { - assert.equal(calculateAverageBurnRatePercentPerDay([ - { timestamp: now - 3_600, usedPercent: 1 }, - { timestamp: now, usedPercent: 2 }, - ]), 4); + assert.equal( + calculateAverageBurnRatePercentPerDay([ + { timestamp: now - 3_600, usedPercent: 1 }, + { timestamp: now, usedPercent: 2 }, + ]), + 4, + ); }); - diff --git a/test/view-model.test.js b/test/view-model.test.js index a22656d..5c1637e 100644 --- a/test/view-model.test.js +++ b/test/view-model.test.js @@ -18,7 +18,11 @@ const sessionQuota = { test("formats reached limits explicitly", () => { assert.equal( - formatLimitPrediction({ trend: "limit reached", estimatedLimitAt: null }, sessionQuota, "primary"), + formatLimitPrediction( + { trend: "limit reached", estimatedLimitAt: null }, + sessionQuota, + "primary", + ), "Limit reached", ); }); @@ -32,7 +36,11 @@ test("formats safe limits actionably", () => { test("keeps unknown predictions blank", () => { assert.equal( - formatLimitPrediction({ trend: "unknown", estimatedLimitAt: null }, sessionQuota, "primary"), + formatLimitPrediction( + { trend: "unknown", estimatedLimitAt: null }, + sessionQuota, + "primary", + ), "", ); }); @@ -94,14 +102,16 @@ test("omits the session UI when only weekly usage is available", () => { const snapshot = { fetchedAt: 1_700_000_000, planType: "plus", - quotas: [{ - id: "weekly", - label: "Week", - usedPercent: 12, - limitWindowSeconds: 604_800, - resetAfterSeconds: 86_400, - resetAt: 1_700_086_400, - }], + quotas: [ + { + id: "weekly", + label: "Week", + usedPercent: 12, + limitWindowSeconds: 604_800, + resetAfterSeconds: 86_400, + resetAt: 1_700_086_400, + }, + ], }; const prediction = { quotas: { weekly: { trend: "safe", estimatedLimitAt: null } }, From 7eee5546a1108783d9f800cfa26264f3c5f09537 Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 13:57:01 +0200 Subject: [PATCH 6/8] Remove unused usage provider abstraction --- package.json | 2 +- src/codex/usage-response.js | 1 - src/codex/{usage-provider.js => usage.js} | 17 +++---- src/io/http.js | 2 +- src/panel/indicator.js | 49 +++---------------- src/preferences/settings.js | 11 ----- ...e.shell.extensions.codex-meter.gschema.xml | 8 +-- src/usage/history-store.js | 21 +++----- src/usage/providers.js | 19 ------- src/usage/service.js | 40 +++++++-------- src/usage/snapshot-store.js | 12 ++--- ...r.test.js => codex-usage-response.test.js} | 1 - types/index.js | 2 +- 13 files changed, 47 insertions(+), 138 deletions(-) rename src/codex/{usage-provider.js => usage.js} (66%) delete mode 100644 src/usage/providers.js rename test/{codex-provider.test.js => codex-usage-response.test.js} (98%) diff --git a/package.json b/package.json index 2191643..f36a305 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "check": "tsc && npm run format:check", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "npm run check && node --test test/prediction.test.js test/usage-trend.test.js test/view-model.test.js test/refresh-failure-format.test.js test/codex-provider.test.js test/codex-auth.test.js test/codex-banked-resets.test.js && gjs -m test/history-storage.test.js" + "test": "npm run check && node --test test/prediction.test.js test/usage-trend.test.js test/view-model.test.js test/refresh-failure-format.test.js test/codex-usage-response.test.js test/codex-auth.test.js test/codex-banked-resets.test.js && gjs -m test/history-storage.test.js" }, "devDependencies": { "prettier": "^3.9.6", diff --git a/src/codex/usage-response.js b/src/codex/usage-response.js index 4d881cf..5869886 100644 --- a/src/codex/usage-response.js +++ b/src/codex/usage-response.js @@ -26,7 +26,6 @@ export function toUsageSnapshot(api) { return { fetchedAt: Math.floor(Date.now() / 1000), - providerId: "codex", planType: codexApi.plan_type, credits: toUsageCredits(api.credits), quotas: toUsageQuotas(codexApi), diff --git a/src/codex/usage-provider.js b/src/codex/usage.js similarity index 66% rename from src/codex/usage-provider.js rename to src/codex/usage.js index 895d53a..32409df 100644 --- a/src/codex/usage-provider.js +++ b/src/codex/usage.js @@ -1,4 +1,4 @@ -import { fetchProviderUsage } from "../io/http.js"; +import { fetchUsage } from "../io/http.js"; import { getCodexAccessToken } from "./auth.js"; import { toUsageSnapshot } from "./usage-response.js"; @@ -18,14 +18,11 @@ const CODEX_API_CONFIG = { }, }; -export class CodexUsageProvider { - info = { id: "codex", displayName: "Codex" }; +export async function refreshCodexUsage(options = {}) { + const token = await getCodexAccessToken(); + const apiResponse = await fetchUsage(token, CODEX_API_CONFIG, { + cancellable: options.cancellable ?? null, + }); - async refreshUsage(options = {}) { - const token = await getCodexAccessToken(); - const apiResponse = await fetchProviderUsage(token, CODEX_API_CONFIG, { - cancellable: options.cancellable ?? null, - }); - return toUsageSnapshot(apiResponse); - } + return toUsageSnapshot(apiResponse); } diff --git a/src/io/http.js b/src/io/http.js index d432f44..30bfa05 100644 --- a/src/io/http.js +++ b/src/io/http.js @@ -113,7 +113,7 @@ export async function fetchJson(url, config, options = {}) { return parseApiResponse(text, url, config, options.allowArrayResponse ?? false); } -export async function fetchProviderUsage(accessToken, config, options = {}, url = config.usageUrl) { +export async function fetchUsage(accessToken, config, options = {}, url = config.usageUrl) { return await fetchJson(url, config, { ...options, headers: { diff --git a/src/panel/indicator.js b/src/panel/indicator.js index 6aa3695..b66e0bb 100644 --- a/src/panel/indicator.js +++ b/src/panel/indicator.js @@ -8,7 +8,6 @@ import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; import { SettingsService } from "../preferences/settings.js"; import { Scheduler } from "../refresh/scheduler.js"; import { UsageService } from "../usage/service.js"; -import { DEFAULT_PROVIDER_ID, getUsageProvider } from "../usage/providers.js"; import { isRefreshFailureError } from "../refresh/error.js"; import { listCodexBankedResets, @@ -35,9 +34,8 @@ export class CodexMeterIndicator extends PanelMenu.Button { super(0.0, "CodexMeter"); this._extension = extension; this._settings = new SettingsService(extension.getSettings()); - this._providerId = this._settings.getUsageProvider(); this._autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); - this._usageService = createUsageService(this._providerId); + this._usageService = new UsageService(); this._scheduler = new Scheduler(this._settings.getBackgroundRefreshIntervalSeconds(), () => this._refreshUsage(), ); @@ -183,21 +181,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { } }); this._settingsChangedId = this._settings.connectChanged(() => { - const providerId = this._settings.getUsageProvider(); const autoApplyBankedReset = this._settings.getAutoApplyBankedReset(); - if (providerId !== this._providerId) { - this._providerId = providerId; - this._usageService = createUsageService(providerId); - this._snapshot = null; - this._prediction = null; - this._history = []; - this._errorMessage = null; - this._cachedFailureMessage = null; - this._bankedResetCount = null; - this._lastBankedResetRefreshAt = null; - void this._loadCachedBankedResets(); - void this._loadCachedSnapshot(); - } if (autoApplyBankedReset && !this._autoApplyBankedReset) { void this._refreshBankedResets(); } @@ -312,10 +296,10 @@ export class CodexMeterIndicator extends PanelMenu.Button { } async _loadCachedBankedResets() { - if (this._destroyed || this._providerId !== "codex") return; + if (this._destroyed) return; try { const snapshot = await readCachedCodexBankedResets(); - if (this._destroyed || !snapshot || this._providerId !== "codex") return; + if (this._destroyed || !snapshot) return; this._bankedResetCount = Math.max(0, snapshot.available_count); this._lastBankedResetRefreshAt = snapshot.fetchedAt; this._syncMenu(); @@ -350,11 +334,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { } async _refreshBankedResets(cancellable = null) { - if (this._providerId !== "codex") { - this._bankedResetCount = null; - this._lastBankedResetRefreshAt = null; - return; - } if (this._bankedResetRefreshPromise) { await this._bankedResetRefreshPromise; return; @@ -378,7 +357,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { } _shouldRefreshBankedResets() { - if (this._destroyed || this._providerId !== "codex") return false; + if (this._destroyed) return false; if (this._bankedResetCount === null || !this._lastBankedResetRefreshAt) return true; const nowSeconds = Math.floor(Date.now() / 1000); return ( @@ -389,7 +368,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { async _refreshBankedResetsOnce(cancellable = null) { try { const response = await listCodexBankedResets({ cancellable }); - if (this._destroyed || this._providerId !== "codex") return; + if (this._destroyed) return; this._bankedResetCount = Math.max(0, response.available_count); this._lastBankedResetRefreshAt = Math.floor(Date.now() / 1000); await this._autoApplyExpiringBankedReset(response.credits, cancellable); @@ -439,10 +418,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { async _redeemBankedReset() { if (this._destroyed || this._redeemingBankedReset) return; - if (this._providerId !== "codex") { - notify("Codex Meter", "Banked resets are only available for Codex usage."); - return; - } if (this._bankedResetCount !== null && this._bankedResetCount <= 0) { notify("Codex Meter", "No banked Codex resets are available to redeem."); return; @@ -546,7 +521,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._popupMenu.setTrend(viewModel.trend); this._popupMenu.setBankedResets({ count: this._bankedResetCount, - visible: this._providerId === "codex", + visible: true, redeeming: this._redeemingBankedReset, }); this._footerItem.planLabel.text = viewModel.plan; @@ -566,18 +541,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { } } -function createUsageService(providerId) { - try { - return new UsageService(getUsageProvider(providerId)); - } catch (error) { - console.warn( - `Usage provider "${providerId}" is not supported yet; falling back to ${DEFAULT_PROVIDER_ID}.`, - error, - ); - return new UsageService(getUsageProvider(DEFAULT_PROVIDER_ID)); - } -} - function formatBankedResetFailure(error) { if (isRefreshFailureError(error)) { return error.message; diff --git a/src/preferences/settings.js b/src/preferences/settings.js index 99fe168..15cc69c 100644 --- a/src/preferences/settings.js +++ b/src/preferences/settings.js @@ -1,5 +1,3 @@ -export const SETTINGS_USAGE_PROVIDER = "usage-provider"; - export const SETTINGS_SHOW_PRIMARY = "show-primary"; export const SETTINGS_SHOW_SECONDARY = "show-secondary"; @@ -26,7 +24,6 @@ export class SettingsService { getAll() { const backgroundRefreshIntervalMinutes = this.getBackgroundRefreshIntervalMinutes(); return { - usageProvider: this.getUsageProvider(), showPrimary: this.getShowPrimary(), showSecondary: this.getShowSecondary(), topPanelDisplayMode: this.getTopPanelDisplayMode(), @@ -38,14 +35,6 @@ export class SettingsService { }; } - getUsageProvider() { - const value = this.settings.get_string(SETTINGS_USAGE_PROVIDER); - if (value === "codex") { - return value; - } - return "codex"; - } - getShowPrimary() { return this.settings.get_boolean(SETTINGS_SHOW_PRIMARY); } diff --git a/src/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml b/src/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml index ccd64ab..ed65a35 100644 --- a/src/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml +++ b/src/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml @@ -1,13 +1,7 @@ - - 'codex' - Usage provider - - - - + 'bars' Show percentages as diff --git a/src/usage/history-store.js b/src/usage/history-store.js index 2ceb320..54d07d5 100644 --- a/src/usage/history-store.js +++ b/src/usage/history-store.js @@ -5,13 +5,10 @@ import { appendFile, readFile, writeFile } from "../io/files.js"; export const MAX_HISTORY_ENTRIES = 25_000; const MAX_HISTORY_AGE_SECONDS = 21 * 24 * 60 * 60; +const HISTORY_PATH = GLib.build_filenamev([STATE_DIR, "codex", "usage-history.jsonl"]); -function getHistoryPath(providerId) { - return GLib.build_filenamev([STATE_DIR, providerId, "usage-history.jsonl"]); -} - -export async function readHistory(providerId) { - return readHistoryFromPath(getHistoryPath(providerId)); +export async function readHistory() { + return readHistoryFromPath(HISTORY_PATH); } export async function readHistoryFromPath(path) { @@ -24,24 +21,20 @@ export async function readHistoryFromPath(path) { } } -export async function appendHistory(providerId, row) { - return appendHistoryToPath(getHistoryPath(providerId), row); -} - /** * Append a history row verbatim without reading, deduping, or normalizing existing history. * Callers must pre-normalize rows and decide whether appending is appropriate. */ -export async function appendHistoryRow(providerId, row) { - return appendHistoryRowToPath(getHistoryPath(providerId), row); +export async function appendHistoryRow(row) { + return appendHistoryRowToPath(HISTORY_PATH, row); } export async function appendHistoryRowToPath(path, row) { await appendFile(path, JSON.stringify(row)); } -export async function rewriteHistory(providerId, rows) { - await rewriteHistoryToPath(getHistoryPath(providerId), normalizeHistoryEntries(rows)); +export async function rewriteHistory(rows) { + await rewriteHistoryToPath(HISTORY_PATH, normalizeHistoryEntries(rows)); } export async function appendHistoryToPath(path, row, existingRows = null) { diff --git a/src/usage/providers.js b/src/usage/providers.js deleted file mode 100644 index 7b4f00f..0000000 --- a/src/usage/providers.js +++ /dev/null @@ -1,19 +0,0 @@ -import { CodexUsageProvider } from "../codex/usage-provider.js"; - -export const DEFAULT_PROVIDER_ID = "codex"; - -const providers = { - codex: new CodexUsageProvider(), -}; - -export function getUsageProvider(id = DEFAULT_PROVIDER_ID) { - const provider = providers[id]; - if (!provider) { - throw new Error(`Unsupported usage provider: ${id}`); - } - return provider; -} - -export function listUsageProviders() { - return Object.values(providers); -} diff --git a/src/usage/service.js b/src/usage/service.js index 90a1f9c..b3e45ab 100644 --- a/src/usage/service.js +++ b/src/usage/service.js @@ -1,6 +1,6 @@ +import { refreshCodexUsage } from "../codex/usage.js"; import { predict } from "./prediction.js"; import { toHistoryEntry } from "./model.js"; -import { getUsageProvider } from "./providers.js"; import { appendHistoryRow, hasSameQuotaValues, @@ -13,38 +13,29 @@ import { import { readSnapshot, writeSnapshot } from "./snapshot-store.js"; export class UsageService { - provider; - history = null; - constructor(provider = getUsageProvider()) { - this.provider = provider; - } - async readCachedSnapshot() { - return await readSnapshot(this.provider.info.id); + return await readSnapshot(); } async refresh(options = {}) { - const snapshot = await this.provider.refreshUsage({ + const snapshot = await refreshCodexUsage({ cancellable: options.cancellable ?? null, }); + try { - await writeSnapshot(this.provider.info.id, snapshot); + await writeSnapshot(snapshot); } catch (error) { - console.error( - `Unable to write ${this.provider.info.displayName} usage snapshot cache`, - error, - ); + console.error("Unable to write Codex usage snapshot cache", error); } + try { await this.appendSnapshotToHistory(snapshot); } catch (error) { - console.error( - `Unable to append ${this.provider.info.displayName} usage history`, - error, - ); + console.error("Unable to append Codex usage history", error); } + return snapshot; } @@ -54,7 +45,8 @@ export class UsageService { async loadHistory() { if (this.history) return this.history; - this.history = await readHistory(this.provider.info.id); + + this.history = await readHistory(); return this.history; } @@ -62,21 +54,25 @@ export class UsageService { const history = await this.loadHistory(); const row = normalizeHistoryEntry(toHistoryEntry(snapshot)); if (!row) return; + const lastRow = history.at(-1); if (lastRow && hasSameQuotaValues(lastRow, row)) return; - await appendHistoryRow(this.provider.info.id, row); + + await appendHistoryRow(row); history.push(row); + if (history.length > MAX_HISTORY_ENTRIES) { this.history = normalizeHistoryEntries(history); - await rewriteHistory(this.provider.info.id, this.history); + await rewriteHistory(this.history); } } async predict(snapshot) { - const currentSnapshot = snapshot ?? (await readSnapshot(this.provider.info.id)); + const currentSnapshot = snapshot ?? (await readSnapshot()); if (!currentSnapshot) { throw new Error("No snapshot available"); } + const history = await this.readHistory(); return predict(history, currentSnapshot); } diff --git a/src/usage/snapshot-store.js b/src/usage/snapshot-store.js index 2701aae..d23463e 100644 --- a/src/usage/snapshot-store.js +++ b/src/usage/snapshot-store.js @@ -2,17 +2,15 @@ import GLib from "gi://GLib"; import { CACHE_DIR } from "../io/paths.js"; import { readJsonFile, writeJsonFile } from "../io/files.js"; -function getSnapshotPath(providerId) { - return GLib.build_filenamev([CACHE_DIR, providerId, "snapshot.json"]); -} +const SNAPSHOT_PATH = GLib.build_filenamev([CACHE_DIR, "codex", "snapshot.json"]); -export async function writeSnapshot(providerId, snapshot) { - await writeJsonFile(getSnapshotPath(providerId), snapshot); +export async function writeSnapshot(snapshot) { + await writeJsonFile(SNAPSHOT_PATH, snapshot); } -export async function readSnapshot(providerId) { +export async function readSnapshot() { try { - const raw = await readJsonFile(getSnapshotPath(providerId)); + const raw = await readJsonFile(SNAPSHOT_PATH); if (!isUsageSnapshot(raw)) { return null; } diff --git a/test/codex-provider.test.js b/test/codex-usage-response.test.js similarity index 98% rename from test/codex-provider.test.js rename to test/codex-usage-response.test.js index a3fc461..f935497 100644 --- a/test/codex-provider.test.js +++ b/test/codex-usage-response.test.js @@ -36,7 +36,6 @@ function assertUnexpectedResponse(fn) { test("maps valid Codex usage response to usage snapshot", () => { const snapshot = toUsageSnapshot(validUsageResponse()); - assert.equal(snapshot.providerId, "codex"); assert.equal(snapshot.planType, "pro"); assert.equal(snapshot.quotas.length, 2); assert.deepEqual(snapshot.quotas[0], { diff --git a/types/index.js b/types/index.js index fee4851..b10fa5a 100644 --- a/types/index.js +++ b/types/index.js @@ -4,7 +4,7 @@ * * @typedef {object} UsageSnapshot * @property {number} fetchedAt UNIX timestamp in seconds - * @property {string} [providerId] + * @property {string} planType * @property {UsageCredits} [credits] * @property {UsageQuota[]} quotas From a343eac1097140956b96e6ee02884c89830cb835 Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 14:09:28 +0200 Subject: [PATCH 7/8] Update CI actions and metadata paths --- .github/workflows/ci.yml | 18 +++++++++--------- .github/workflows/release.yml | 10 +++++----- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4737c61..b0d21c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,16 +18,16 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: "24" cache: "npm" - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" @@ -49,7 +49,7 @@ jobs: shell: bash run: | set -euo pipefail - jq empty metadata.json + jq empty src/metadata.json jq -e ' .uuid == "codex-meter@slobbe.github.io" and (.name | type == "string" and length > 0) @@ -57,10 +57,10 @@ jobs: and (."version-name" | test("^[0-9]+\\.[0-9]+\\.[0-9]+$")) and (."shell-version" | type == "array" and length > 0) and (."settings-schema" == "org.gnome.shell.extensions.codex-meter") - ' metadata.json + ' src/metadata.json PACKAGE_VERSION="$(node -p "require('./package.json').version")" - METADATA_VERSION="$(jq -r '."version-name"' metadata.json)" + METADATA_VERSION="$(jq -r '."version-name"' src/metadata.json)" if [[ "${PACKAGE_VERSION}" != "${METADATA_VERSION}" ]]; then echo "package.json version ${PACKAGE_VERSION} does not match metadata.json version-name ${METADATA_VERSION}" >&2 @@ -74,7 +74,7 @@ jobs: shell: bash run: | set -euo pipefail - glib-compile-schemas --strict schemas + glib-compile-schemas --strict src/schemas - name: Build extension bundle shell: bash @@ -87,7 +87,7 @@ jobs: run: | set -euo pipefail VERSION="$(node -p "require('./package.json').version")" - UUID="$(jq -r '.uuid' metadata.json)" + UUID="$(jq -r '.uuid' src/metadata.json)" ZIP="${UUID}-v${VERSION}.zip" test -f "${ZIP}" @@ -109,7 +109,7 @@ jobs: run: | set -euo pipefail VERSION="$(node -p "require('./package.json').version")" - UUID="$(jq -r '.uuid' metadata.json)" + UUID="$(jq -r '.uuid' src/metadata.json)" ZIP="${UUID}-v${VERSION}.zip" shexli "${ZIP}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a83168a..0918377 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 @@ -58,7 +58,7 @@ jobs: sudo apt-get install -y jq unzip - name: Set up Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: "24" @@ -69,7 +69,7 @@ jobs: CI_REF="${{ github.event.workflow_run.head_branch }}" TAG_VERSION="${CI_REF#v}" PACKAGE_VERSION="$(node -p "require('./package.json').version")" - METADATA_VERSION="$(jq -r '."version-name"' metadata.json)" + METADATA_VERSION="$(jq -r '."version-name"' src/metadata.json)" if [[ "${PACKAGE_VERSION}" != "${TAG_VERSION}" ]]; then echo "Tag ${CI_REF} does not match package version ${PACKAGE_VERSION}" >&2 @@ -95,7 +95,7 @@ jobs: set -euo pipefail CI_REF="${{ github.event.workflow_run.head_branch }}" TAG_VERSION="${CI_REF#v}" - UUID="$(jq -r '.uuid' metadata.json)" + UUID="$(jq -r '.uuid' src/metadata.json)" ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" test -f "${ZIP}" @@ -111,7 +111,7 @@ jobs: set -euo pipefail CI_REF="${{ github.event.workflow_run.head_branch }}" TAG_VERSION="${CI_REF#v}" - UUID="$(jq -r '.uuid' metadata.json)" + UUID="$(jq -r '.uuid' src/metadata.json)" ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" if gh release view "${CI_REF}" >/dev/null 2>&1; then From fa2de09a37af15c2e6dbfb1cc0fd5d3c60531eba Mon Sep 17 00:00:00 2001 From: Sebastian Lobbe Date: Fri, 21 Aug 2026 14:14:35 +0200 Subject: [PATCH 8/8] Run CI only on main and release tags --- .github/workflows/ci.yml | 5 +- .github/workflows/release.yml | 121 ++++++++++++++++++++++------------ 2 files changed, 82 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0d21c6..76e240a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,11 @@ name: CI on: pull_request: + branches: + - main push: - workflow_dispatch: + branches: + - main permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0918377..53941a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,83 +1,119 @@ name: Release on: - workflow_run: - workflows: - - "CI" - types: - - completed + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" permissions: contents: write actions: read concurrency: - group: release-${{ github.event.workflow_run.head_branch || github.event.workflow_run.head_sha }} + group: release-${{ github.ref }} cancel-in-progress: false jobs: release: - if: > - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - startsWith(github.event.workflow_run.head_branch, 'v') runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v7 with: - ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 - - name: Validate release source + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + + - name: Install release dependencies + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y jq unzip + + - name: Validate release source and CI + id: source + env: + GH_TOKEN: ${{ github.token }} shell: bash run: | set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - CI_SHA="${{ github.event.workflow_run.head_sha }}" + TAG="${GITHUB_REF_NAME}" - if [[ ! "${CI_REF}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "CI ref is not a release tag: ${CI_REF}" >&2 + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Release tag must match vMAJOR.MINOR.PATCH: ${TAG}" >&2 exit 1 fi - git fetch --force --tags - TAG_SHA="$(git rev-list -n 1 "${CI_REF}")" + git fetch origin main --no-tags + TAG_SHA="$(git rev-parse "${TAG}^{commit}")" - if [[ "${TAG_SHA}" != "${CI_SHA}" ]]; then - echo "Tag ${CI_REF} points to ${TAG_SHA}, but CI ran on ${CI_SHA}" >&2 + if ! git merge-base --is-ancestor "${TAG_SHA}" origin/main; then + echo "Tag ${TAG} does not point to a commit on main" >&2 exit 1 fi - - name: Install release dependencies - shell: bash - run: | - set -euo pipefail - sudo apt-get update - sudo apt-get install -y jq unzip + CI_RUN_ID="" + for attempt in {1..20}; do + CI_RUN="$(gh api \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${TAG_SHA}&event=push&per_page=1" \ + --jq 'if .workflow_runs[0] then (.workflow_runs[0] | [.id, .status, .conclusion, .head_branch] | @tsv) else empty end')" + + if [[ -z "${CI_RUN}" ]]; then + echo "Waiting for CI to start for ${TAG_SHA} (${attempt}/20)" + sleep 30 + continue + fi + + IFS=$'\t' read -r RUN_ID RUN_STATUS RUN_CONCLUSION RUN_BRANCH <<< "${CI_RUN}" + + if [[ "${RUN_BRANCH}" != "main" ]]; then + echo "CI run ${RUN_ID} is for ${RUN_BRANCH}, not main" >&2 + exit 1 + fi + + if [[ "${RUN_STATUS}" == "completed" ]]; then + if [[ "${RUN_CONCLUSION}" != "success" ]]; then + echo "CI run ${RUN_ID} completed with ${RUN_CONCLUSION}" >&2 + exit 1 + fi + + CI_RUN_ID="${RUN_ID}" + break + fi + + echo "Waiting for CI run ${RUN_ID} (${RUN_STATUS}, ${attempt}/20)" + sleep 30 + done + + if [[ -z "${CI_RUN_ID}" ]]; then + echo "No successful CI run found for ${TAG_SHA}" >&2 + exit 1 + fi - - name: Set up Node.js - uses: actions/setup-node@v7 - with: - node-version: "24" + echo "tag=${TAG}" >> "${GITHUB_OUTPUT}" + echo "tag_version=${TAG#v}" >> "${GITHUB_OUTPUT}" + echo "ci_run_id=${CI_RUN_ID}" >> "${GITHUB_OUTPUT}" - name: Validate release metadata shell: bash run: | set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - TAG_VERSION="${CI_REF#v}" + TAG_VERSION="${{ steps.source.outputs.tag_version }}" PACKAGE_VERSION="$(node -p "require('./package.json').version")" METADATA_VERSION="$(jq -r '."version-name"' src/metadata.json)" if [[ "${PACKAGE_VERSION}" != "${TAG_VERSION}" ]]; then - echo "Tag ${CI_REF} does not match package version ${PACKAGE_VERSION}" >&2 + echo "Tag version ${TAG_VERSION} does not match package version ${PACKAGE_VERSION}" >&2 exit 1 fi if [[ "${METADATA_VERSION}" != "${TAG_VERSION}" ]]; then - echo "Tag ${CI_REF} does not match metadata version-name ${METADATA_VERSION}" >&2 + echo "Tag version ${TAG_VERSION} does not match metadata version-name ${METADATA_VERSION}" >&2 exit 1 fi @@ -85,7 +121,7 @@ jobs: uses: actions/download-artifact@v8 with: name: release-bundle - run-id: ${{ github.event.workflow_run.id }} + run-id: ${{ steps.source.outputs.ci_run_id }} github-token: ${{ github.token }} path: release-artifacts @@ -93,8 +129,7 @@ jobs: shell: bash run: | set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - TAG_VERSION="${CI_REF#v}" + TAG_VERSION="${{ steps.source.outputs.tag_version }}" UUID="$(jq -r '.uuid' src/metadata.json)" ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" @@ -109,16 +144,16 @@ jobs: shell: bash run: | set -euo pipefail - CI_REF="${{ github.event.workflow_run.head_branch }}" - TAG_VERSION="${CI_REF#v}" + TAG="${{ steps.source.outputs.tag }}" + TAG_VERSION="${{ steps.source.outputs.tag_version }}" UUID="$(jq -r '.uuid' src/metadata.json)" ZIP="release-artifacts/${UUID}-v${TAG_VERSION}.zip" - if gh release view "${CI_REF}" >/dev/null 2>&1; then - gh release upload "${CI_REF}" "${ZIP}" --clobber + if gh release view "${TAG}" >/dev/null 2>&1; then + gh release upload "${TAG}" "${ZIP}" --clobber else - gh release create "${CI_REF}" "${ZIP}" \ - --title "${CI_REF}" \ + gh release create "${TAG}" "${ZIP}" \ + --title "${TAG}" \ --generate-notes \ --verify-tag fi