diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b09bed3..76e240a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,122 +1,125 @@ name: CI on: - pull_request: - push: - workflow_dispatch: + pull_request: + branches: + - main + push: + branches: + - main 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@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + + - name: Set up Python + uses: actions/setup-python@v7 + 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 src/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") + ' src/metadata.json + + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + 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 + exit 1 + fi + + - name: Run tests + run: npm test + + - name: Validate schemas + shell: bash + run: | + set -euo pipefail + glib-compile-schemas --strict src/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' src/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' src/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..53941a1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,124 +1,159 @@ name: Release on: - workflow_run: - workflows: - - "CI" - types: - - completed + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" 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.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@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: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - 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 + TAG="${GITHUB_REF_NAME}" + + 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 origin main --no-tags + TAG_SHA="$(git rev-parse "${TAG}^{commit}")" + + 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 + + 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 + + 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 + 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 version ${TAG_VERSION} does not match package version ${PACKAGE_VERSION}" >&2 + exit 1 + fi + + if [[ "${METADATA_VERSION}" != "${TAG_VERSION}" ]]; then + echo "Tag version ${TAG_VERSION} 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: ${{ steps.source.outputs.ci_run_id }} + github-token: ${{ github.token }} + path: release-artifacts + + - name: Validate release artifact + shell: bash + run: | + set -euo pipefail + TAG_VERSION="${{ steps.source.outputs.tag_version }}" + UUID="$(jq -r '.uuid' src/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 + 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 "${TAG}" >/dev/null 2>&1; then + gh release upload "${TAG}" "${ZIP}" --clobber + else + gh release create "${TAG}" "${ZIP}" \ + --title "${TAG}" \ + --generate-notes \ + --verify-tag + fi 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/.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/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..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 -TS_SOURCES=$(shell find . -path ./dist -prune -o -path ./node_modules -prune -o -name "*.ts" -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 $(TS_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 c39a2e5..0000000 Binary files a/assets/logo.png and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 4c0b88f..d05818c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,720 +9,24 @@ "version": "0.9.0", "license": "GPL-3.0-or-later", "devDependencies": { - "@girs/gjs": "^4.0.0-rc.9", - "@girs/gnome-shell": "^50.0.0", + "prettier": "^3.9.6", "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==", + "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", - "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" + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/typescript": { diff --git a/package.json b/package.json index 094c821..f36a305 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,13 @@ "homepage": "https://github.com/slobbe/codex-meter", "sideEffects": false, "scripts": { - "build": "tsc", - "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" + "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-usage-response.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", + "prettier": "^3.9.6", "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/usage.ts b/src/app/usage.ts deleted file mode 100644 index 2620765..0000000 --- a/src/app/usage.ts +++ /dev/null @@ -1,92 +0,0 @@ -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 { 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()) { - this.provider = provider; - } - - async readCachedSnapshot(): Promise { - return await readSnapshot(this.provider.info.id); - } - - async refresh(options: RefreshOptions = {}): Promise { - 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); - } - - try { - await this.appendSnapshotToHistory(snapshot); - } catch (error) { - console.error(`Unable to append ${this.provider.info.displayName} usage history`, error); - } - - return snapshot; - } - - async readHistory(): Promise { - return [...await this.loadHistory()]; - } - - private async loadHistory(): Promise { - if (this.history) return this.history; - - this.history = await readHistory(this.provider.info.id); - return this.history; - } - - private async appendSnapshotToHistory(snapshot: UsageSnapshot): Promise { - 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); - 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 { - 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/infra/providers/codex_banked_resets.ts b/src/banked-resets/api.js similarity index 60% rename from src/infra/providers/codex_banked_resets.ts rename to src/banked-resets/api.js index 464f5c7..f50a10c 100644 --- a/src/infra/providers/codex_banked_resets.ts +++ b/src/banked-resets/api.js @@ -1,49 +1,22 @@ -import Gio from "gi://Gio"; import GLib from "gi://GLib"; +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"; -import { fetchJson, JsonObject, UsageApiClientConfig } 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 { - CodexBankedResetCredit, - CodexBankedResetListResponse, - selectCreditToRedeem, - toListResponse, -} from "./codex_banked_reset_response.js"; +const CODEX_BANKED_RESETS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits"; -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"; 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: { 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.", @@ -51,22 +24,18 @@ const CODEX_BANKED_RESETS_API_CONFIG: UsageApiClientConfig = { }, }; -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) { console.error("Unable to write Codex banked reset snapshot cache", error); } - return listResponse; } @@ -74,12 +43,9 @@ 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", @@ -87,57 +53,42 @@ export async function redeemNextCodexBankedReset( "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) { 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; - const credits = snapshot.credits.map((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 +98,16 @@ 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_banked_reset_response.ts b/src/banked-resets/response.js similarity index 59% rename from src/infra/providers/codex_banked_reset_response.ts rename to src/banked-resets/response.js index 1f21170..4075147 100644 --- a/src/infra/providers/codex_banked_reset_response.ts +++ b/src/banked-resets/response.js @@ -1,31 +1,37 @@ -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 { +// @ts-check + +import { RefreshFailureError } from "../refresh/error.js"; + +/** + * @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,37 @@ 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 { +/** + * @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/banked-resets/store.js b/src/banked-resets/store.js new file mode 100644 index 0000000..66c95b9 --- /dev/null +++ b/src/banked-resets/store.js @@ -0,0 +1,70 @@ +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 { + 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) && + Number.isFinite(value.available_count) && + Array.isArray(value.credits) && + value.credits.every(isBankedResetCredit) + ); +} + +function isBankedResetCredit(value) { + if (!isObject(value)) { + return false; + } + return ( + typeof value.id === "string" && + value.id.trim().length > 0 && + typeof value.status === "string" && + [ + "reset_type", + "granted_at", + "expires_at", + "profile_image_url", + "profile_user_id", + "title", + "description", + ].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/infra/providers/auth.ts b/src/codex/auth-file.js similarity index 71% rename from src/infra/providers/auth.ts rename to src/codex/auth-file.js index f1fb0fa..79fdbc0 100644 --- a/src/infra/providers/auth.ts +++ b/src/codex/auth-file.js @@ -1,18 +1,8 @@ import GLib from "gi://GLib"; +import { RefreshFailureError } from "../refresh/error.js"; +import { readJsonFile } from "../io/files.js"; -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 { +export async function getLocalAccessToken(config) { if (!isNonEmptyString(config.authPath)) { throw new RefreshFailureError( "missing-auth", @@ -20,10 +10,8 @@ export async function getLocalAccessToken( `${config.providerName} auth path must be a non-empty string`, ); } - - const auth = await readLocalAuth(config.authPath, config); + const auth = await readLocalAuth(config.authPath, config); const token = config.parseAccessToken(auth); - if (!isUsableAccessToken(token)) { throw new RefreshFailureError( "missing-auth", @@ -31,14 +19,10 @@ export async function getLocalAccessToken( `${config.providerName} auth data does not contain a valid access token`, ); } - return token.trim(); } -export async function readLocalAuth( - path: string, - config: LocalTokenAuthConfig, -): Promise { +export async function readLocalAuth(path, config) { if (!GLib.file_test(path, GLib.FileTest.EXISTS)) { throw new RefreshFailureError( "missing-auth", @@ -46,10 +30,8 @@ export async function readLocalAuth( `${config.providerName} auth file does not exist at "${path}"`, ); } - try { const raw = await readJsonFile(path); - if (!isObject(raw)) { throw new RefreshFailureError( "missing-auth", @@ -57,13 +39,11 @@ export async function readLocalAuth( `${config.providerName} auth file at "${path}" does not contain a valid JSON object`, ); } - - return raw as TAuth; + 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.`, @@ -72,26 +52,24 @@ export async function readLocalAuth( } } -function isObject(value: unknown): value is Record { +function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); } -function isNonEmptyString(value: unknown): value is string { +function isNonEmptyString(value) { return typeof value === "string" && value.trim().length > 0; } -function isUsableAccessToken(value: unknown): value is string { +function isUsableAccessToken(value) { return isNonEmptyString(value) && !/\s/.test(value.trim()); } -function formatError(err: unknown): string { +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/codex/auth-parser.js b/src/codex/auth-parser.js new file mode 100644 index 0000000..481319d --- /dev/null +++ b/src/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.ts b/src/codex/auth.js similarity index 56% rename from src/infra/providers/codex_auth.ts rename to src/codex/auth.js index df09ade..9cd7ed1 100644 --- a/src/infra/providers/codex_auth.ts +++ b/src/codex/auth.js @@ -1,52 +1,33 @@ import GLib from "gi://GLib"; +import { getLocalAccessToken, readLocalAuth } from "./auth-file.js"; +import { parseCodexAccessToken, parseCodexAccountId } from "./auth-parser.js"; -import { - getLocalAccessToken, - LocalTokenAuthConfig, - readLocalAuth, -} from "./auth.js"; -import { - CodexAuth, - parseCodexAccessToken, - parseCodexAccountId, -} from "./codex_auth_parser.js"; - -export type { CodexAuth } from "./codex_auth_parser.js"; -export { parseCodexAccessToken, parseCodexAccountId } from "./codex_auth_parser.js"; - -export type CodexCredentials = { - accessToken: string; - accountId: string | null; -}; +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: 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_usage_response.ts b/src/codex/usage-response.js similarity index 56% rename from src/infra/providers/codex_usage_response.ts rename to src/codex/usage-response.js index fc6cc9d..5869886 100644 --- a/src/infra/providers/codex_usage_response.ts +++ b/src/codex/usage-response.js @@ -1,42 +1,48 @@ -import { RefreshFailureError } from "../../domain/refresh-failure.js"; -import { UsageCredits, UsageSnapshot } from "../../domain/usage.js"; - -type JsonObject = Record; - -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; -}; - -export function toUsageSnapshot(api: JsonObject): UsageSnapshot { +// @ts-check + +import { RefreshFailureError } from "../refresh/error.js"; + +/** @typedef {import("../../types/index.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../types/index.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 { fetchedAt: Math.floor(Date.now() / 1000), - providerId: "codex", planType: codexApi.plan_type, credits: toUsageCredits(api.credits), quotas: toUsageQuotas(codexApi), }; } -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 +63,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 +85,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,29 +104,41 @@ 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 - ? 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, }; } -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`); } @@ -126,11 +150,19 @@ function assertWindow(value: unknown, path: string): void { } } -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/codex/usage.js b/src/codex/usage.js new file mode 100644 index 0000000..32409df --- /dev/null +++ b/src/codex/usage.js @@ -0,0 +1,28 @@ +import { fetchUsage } 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, + messages: { + malformedResponse: "Codex returned a malformed response.", + unexpectedResponseFormat: "Codex returned an unexpected response format.", + networkUnavailable: "Codex usage could not be reached. Check your network and try again.", + responseTooLarge: "Codex returned a response that is too large.", + unauthorized: "Codex authentication expired. Please run `codex login` again.", + refreshFailed: "Codex usage refresh failed. Try again later.", + emptyResponse: "Codex returned an empty response.", + }, +}; + +export async function refreshCodexUsage(options = {}) { + const token = await getCodexAccessToken(); + const apiResponse = await fetchUsage(token, CODEX_API_CONFIG, { + cancellable: options.cancellable ?? null, + }); + + return toUsageSnapshot(apiResponse); +} 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/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 91% rename from src/extension.ts rename to src/extension.js index c637f7b..f96a9e9 100644 --- a/src/extension.ts +++ b/src/extension.js @@ -17,11 +17,10 @@ */ 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: CodexMeterIndicator | null = null; + _indicator = null; enable() { this._indicator = new CodexMeterIndicator(this); 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/config.ts b/src/infra/config.ts deleted file mode 100644 index a591123..0000000 --- a/src/infra/config.ts +++ /dev/null @@ -1,14 +0,0 @@ -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", -]); diff --git a/src/infra/providers/codex.ts b/src/infra/providers/codex.ts deleted file mode 100644 index c29bcd6..0000000 --- a/src/infra/providers/codex.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { fetchProviderUsage, UsageApiClientConfig } 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 = { - providerName: "Codex", - usageUrl: CODEX_USAGE_URL, - messages: { - malformedResponse: "Codex returned a malformed response.", - unexpectedResponseFormat: "Codex returned an unexpected response format.", - networkUnavailable: "Codex usage could not be reached. Check your network and try again.", - responseTooLarge: "Codex returned a response that is too large.", - unauthorized: "Codex authentication expired. Please run `codex login` again.", - refreshFailed: "Codex usage refresh failed. Try again later.", - emptyResponse: "Codex returned an empty response.", - }, -}; - -export class CodexUsageProvider implements UsageProvider { - readonly info = { id: "codex", displayName: "Codex" } as const; - - async refreshUsage(options: UsageProviderRefreshOptions = {}) { - 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_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/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.ts deleted file mode 100644 index bca11b7..0000000 --- a/src/infra/storage/banked-reset-snapshot.ts +++ /dev/null @@ -1,81 +0,0 @@ -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 { - await writeJsonFile(getBankedResetSnapshotPath(), { - ...response, - fetchedAt: Math.floor(Date.now() / 1000), - }); -} - -export async function readBankedResetSnapshot(): Promise { - try { - const raw = await readJsonFile(getBankedResetSnapshotPath()); - - return toBankedResetSnapshot(raw); - } catch { - return null; - } -} - -function toBankedResetSnapshot(value: unknown): BankedResetSnapshot | null { - if (!isBankedResetSnapshot(value)) { - return null; - } - - return value; -} - -function isBankedResetSnapshot(value: unknown): value is BankedResetSnapshot { - 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 { - if (!isObject(value)) { - return false; - } - - return typeof value.id === "string" && - value.id.trim().length > 0 && - typeof value.status === "string" && - [ - "reset_type", - "granted_at", - "expires_at", - "profile_image_url", - "profile_user_id", - "title", - "description", - ].every((key) => isOptionalString(value[key])); -} - -function isOptionalString(value: unknown): boolean { - return value === undefined || value === null || typeof value === "string"; -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/infra/filesystem.ts b/src/io/files.js similarity index 86% rename from src/infra/filesystem.ts rename to src/io/files.js index 435a7e9..3a084ed 100644 --- a/src/infra/filesystem.ts +++ b/src/io/files.js @@ -2,25 +2,29 @@ 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( @@ -29,13 +33,11 @@ export async function readFile(path: string): Promise { } } -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, @@ -51,14 +53,12 @@ export async function writeFile(path: string, text: string): Promise { } } -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, @@ -75,10 +75,10 @@ export async function appendFile(path: string, line: string): Promise { } // 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; + return JSON.parse(raw); } catch (error) { throw new Error( `Failed to read JSON file "${path}": ${error instanceof Error ? error.message : String(error)}`, @@ -86,14 +86,12 @@ export async function readJsonFile(path: string): Promise { } } -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( diff --git a/src/infra/api_client.ts b/src/io/http.js similarity index 63% rename from src/infra/api_client.ts rename to src/io/http.js index 021c18b..30bfa05 100644 --- a/src/infra/api_client.ts +++ b/src/io/http.js @@ -1,83 +1,40 @@ 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"; 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 { +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: string, - url: string, - config: UsageApiClientConfig, - allowArrayResponse = false, -): JsonValue { - let parsed: unknown; - +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"; - + 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", @@ -85,30 +42,21 @@ function parseApiResponse( `Expected JSON ${allowArrayResponse ? "object or array" : "object"} response from ${url}`, ); } - - return parsed as JsonValue; + return parsed; } -export async function fetchJson( - url: string, - config: UsageApiClientConfig, - options: FetchJsonOptions = {}, -): Promise { +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)); @@ -116,9 +64,7 @@ export async function fetchJson( // @ts-ignore message.set_request_body_from_bytes(contentType, bytes); } - let bytes; - try { bytes = await session.send_and_read_async( message, @@ -129,17 +75,13 @@ export async function fetchJson( if (isCancellationError(error)) { throw error; } - - const message = - error instanceof Error ? error.message : String(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", @@ -147,15 +89,11 @@ export async function fetchJson( `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}`; - + const technicalMessage = `Request to ${url} failed with HTTP ${message.statusCode}: ${errorBody}`; if (message.statusCode === 401 || message.statusCode === 403) { throw new RefreshFailureError( "unauthorized", @@ -163,14 +101,8 @@ export async function fetchJson( technicalMessage, ); } - - throw new RefreshFailureError( - "network", - config.messages.refreshFailed, - technicalMessage, - ); + throw new RefreshFailureError("network", config.messages.refreshFailed, technicalMessage); } - if (!data) { throw new RefreshFailureError( "malformed-response", @@ -178,25 +110,18 @@ export async function fetchJson( `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 { +export async function fetchUsage(accessToken, config, options = {}, url = config.usageUrl) { 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); +function isCancellationError(error) { + return error instanceof GLib.Error && error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); } diff --git a/src/io/paths.js b/src/io/paths.js new file mode 100644 index 0000000..44e27d3 --- /dev/null +++ b/src/io/paths.js @@ -0,0 +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"]); diff --git a/metadata.json b/src/metadata.json similarity index 100% rename from metadata.json rename to src/metadata.json diff --git a/src/ui/button.ts b/src/panel/button.js similarity index 64% rename from src/ui/button.ts rename to src/panel/button.js index 5394ad9..4907905 100644 --- a/src/ui/button.ts +++ b/src/panel/button.js @@ -1,21 +1,7 @@ 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 +12,11 @@ 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 +29,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/panel/indicator.js similarity index 78% rename from src/ui/panel-indicator.ts rename to src/panel/indicator.js index 638e0c0..b66e0bb 100644 --- a/src/ui/panel-indicator.ts +++ b/src/panel/indicator.js @@ -3,55 +3,44 @@ 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 { isRefreshFailureError } from "../domain/refresh-failure.js"; +import { SettingsService } from "../preferences/settings.js"; +import { Scheduler } from "../refresh/scheduler.js"; +import { UsageService } from "../usage/service.js"; +import { isRefreshFailureError } from "../refresh/error.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"; +} 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 { - [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._usageService = new UsageService(); + 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,12 +58,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 +69,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); @@ -119,14 +99,12 @@ export class CodexMeterIndicator extends PanelMenu.Button { start() { if (this._destroyed) return; - this._syncLabel(); this._syncMenu(); void this._loadCachedBankedResets(); this._bankedResetScheduler.start(); void this._loadCachedSnapshot().finally(() => { if (this._destroyed) return; - this._scheduler.start({ runImmediately: true }); }); } @@ -138,32 +116,26 @@ 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(); } @@ -180,14 +152,12 @@ 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() { @@ -196,67 +166,41 @@ export class CodexMeterIndicator extends PanelMenu.Button { 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); - 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(); } this._autoApplyBankedReset = autoApplyBankedReset; - 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(); - if (this._refreshPromise) { try { await this._refreshPromise; @@ -265,10 +209,8 @@ export class CodexMeterIndicator extends PanelMenu.Button { } return; } - this._refreshCancellable = new Gio.Cancellable(); this._refreshPromise = this._refreshUsageOnce(this._refreshCancellable); - try { await this._refreshPromise; } finally { @@ -280,21 +222,18 @@ export class CodexMeterIndicator extends PanelMenu.Button { _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; } @@ -302,34 +241,25 @@ export class CodexMeterIndicator extends PanelMenu.Button { async _refreshUsageOnce(cancellable) { try { if (this._destroyed) return; - this._snapshot = await this._usageService.refresh({ cancellable }); - if (this._destroyed) return; - await this._loadHistory(); - 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; } } 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(); - + const loadedCachedSnapshot = + !this._destroyed && (await this._loadCachedSnapshotAfterFailure()); if (loadedCachedSnapshot) { this._errorMessage = null; this._cachedFailureMessage = failureMessage; @@ -340,7 +270,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { } } finally { if (this._destroyed) return; - this._syncLabel(); this._syncMenu(); } @@ -348,44 +277,29 @@ export class CodexMeterIndicator extends PanelMenu.Button { 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; - await this._loadHistory(); - try { this._prediction = await this._usageService.predict(snapshot); } catch (error) { this._prediction = null; } - if (this._destroyed) return; - this._syncLabel(); this._syncMenu(); } catch (error) {} } 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(); @@ -394,23 +308,17 @@ export class CodexMeterIndicator extends PanelMenu.Button { async _loadCachedSnapshotAfterFailure() { if (this._destroyed) return false; - try { const snapshot = await this._usageService.readCachedSnapshot(); - 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) { this._prediction = null; } - return true; } catch (error) { return false; @@ -426,22 +334,13 @@ 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; } - const refreshCancellable = cancellable ?? new Gio.Cancellable(); if (!cancellable) this._bankedResetRefreshCancellable = refreshCancellable; - this._bankedResetRefreshPromise = this._refreshBankedResetsOnce(refreshCancellable); - try { await this._bankedResetRefreshPromise; } finally { @@ -454,24 +353,22 @@ 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._destroyed) 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) return; this._bankedResetCount = Math.max(0, response.available_count); this._lastBankedResetRefreshAt = Math.floor(Date.now() / 1000); await this._autoApplyExpiringBankedReset(response.credits, cancellable); @@ -479,7 +376,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { } 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; @@ -495,28 +391,22 @@ export class CodexMeterIndicator extends PanelMenu.Button { ) { 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."); await this._refreshUsage(); } catch (error) { if (this._destroyed && isCancellationError(error)) return; - console.warn("Unable to auto-apply expiring Codex banked reset", error); } finally { if (!this._destroyed) { @@ -528,41 +418,28 @@ 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; } - const cancellable = new Gio.Cancellable(); this._redeemingBankedReset = true; this._redeemBankedResetCancellable = cancellable; this._syncMenu(); - try { await redeemNextCodexBankedReset({ cancellable }); - 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; - notify("Codex Meter", formatBankedResetFailure(error)); } finally { if (this._redeemBankedResetCancellable === cancellable) { this._redeemBankedResetCancellable = null; } - if (this._destroyed) return; - this._redeemingBankedReset = false; this._syncMenu(); } @@ -570,21 +447,14 @@ export class CodexMeterIndicator extends PanelMenu.Button { _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() { @@ -592,9 +462,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { GLib.source_remove(this._refreshSpinId); this._refreshSpinId = 0; } - if (!this._headerItem?.refreshIcon) return; - this._headerItem.refreshIcon.rotation_angle_z = 0; this._headerItem.refreshButton.reactive = true; this._headerItem.refreshButton.can_focus = true; @@ -602,12 +470,7 @@ export class CodexMeterIndicator extends PanelMenu.Button { _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({ @@ -634,7 +497,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._prefixLabel.visible = settings.topPanelIndicatorIcon === "text"; this._codexIcon.visible = settings.topPanelIndicatorIcon === "codex"; this._openAiIcon.visible = settings.topPanelIndicatorIcon === "openai"; - } _syncMenu() { @@ -646,7 +508,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { this._errorMessage, this._cachedFailureMessage, ); - this._headerItem.datetimeLabel.text = viewModel.updatedAt; this._headerItem.datetimeLabel.visible = false; this._popupMenu.setError(viewModel.errorMessage); @@ -660,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; @@ -668,7 +529,6 @@ export class CodexMeterIndicator extends PanelMenu.Button { _queueMenuBarSync() { if (this._menuSyncId) return; - this._menuSyncId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { this._menuSyncId = 0; this._popupMenu.syncBars(); @@ -679,41 +539,21 @@ export class CodexMeterIndicator extends PanelMenu.Button { _setUsageItem(item, viewModel) { this._popupMenu.setUsageItem(item, viewModel); } - -} - -function createUsageService(providerId: ProviderId): UsageService { - 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: unknown): string { +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: 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/panel/menu.js similarity index 90% rename from src/ui/popup-menu.ts rename to src/panel/menu.js index 33b8703..92adc48 100644 --- a/src/ui/popup-menu.ts +++ b/src/panel/menu.js @@ -1,33 +1,44 @@ 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; + 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: 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(); @@ -57,16 +68,12 @@ export class CodexMeterPopupMenu { 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; - + 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 { @@ -92,7 +99,6 @@ export class CodexMeterPopupMenu { setError(message) { const hasError = Boolean(message); - this.errorItem.visible = hasError; this.primaryItem.visible = !hasError; this.secondaryItem.visible = !hasError; @@ -109,7 +115,6 @@ export class CodexMeterPopupMenu { 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; @@ -119,38 +124,32 @@ export class CodexMeterPopupMenu { : `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 +166,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 +179,25 @@ 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 +207,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 +224,10 @@ 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 +235,10 @@ export class CodexMeterPopupMenu { messageStyleClass: "cx-error-message-danger", }); item.message = ""; - return item; } - private _createStatusItem() { + _createStatusItem() { return this._createBannerItem({ iconName: "dialog-warning-symbolic", title: "", @@ -260,37 +247,32 @@ export class CodexMeterPopupMenu { }); } - 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 +280,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 +288,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 => { + 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", onClick: () => this._onRefresh(), }); refreshIcon.set_pivot_point(0.5, 0.5); - const datetimeLabel = new St.Label({ text: "--", x_expand: true, @@ -360,7 +328,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,74 +339,64 @@ 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() { + _updateTrendBarWidths() { if (!this.trendItem?.sparklineBox || !this.trendItem?.bars) return; - const visibleBars = this.trendItem.bars.length; 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"; - return `cx-color-${style}`; } diff --git a/src/ui/refresh-error-message.ts b/src/panel/refresh-error-message.js similarity index 50% rename from src/ui/refresh-error-message.ts rename to src/panel/refresh-error-message.js index db754ab..450a950 100644 --- a/src/ui/refresh-error-message.ts +++ b/src/panel/refresh-error-message.js @@ -1,9 +1,8 @@ -import { isRefreshFailureError } from "../domain/refresh-failure.js"; +import { isRefreshFailureError } from "../refresh/error.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/panel/usage-bar.js similarity index 69% rename from src/ui/usage-bar.ts rename to src/panel/usage-bar.js index 57c4cf7..eceaa3d 100644 --- a/src/ui/usage-bar.ts +++ b/src/panel/usage-bar.js @@ -1,6 +1,5 @@ import Clutter from "gi://Clutter"; import St from "gi://St"; - import { calculateBarFillWidth, calculateBarMarkerPosition, @@ -8,32 +7,23 @@ import { } from "./view-model.js"; const BASELINE_MARKER_WIDTH = 3; -const COLOR_STYLE_CLASSES = [ - "cx-color-green", - "cx-color-warning", - "cx-color-danger", - "cx-muted", -]; - -type UsageBarVariant = "menu" | "panel"; -type UsageBarState = { - percentValue: number; - displayPercentValue: number; - baselinePercentValue?: number | null; - displayBaselinePercentValue?: number | null; -}; +const COLOR_STYLE_CLASSES = ["cx-color-green", "cx-color-warning", "cx-color-danger", "cx-muted"]; export class UsageBar { - readonly actor: St.Widget; + actor; + + _variant; + + _barTrack; - private readonly _variant: UsageBarVariant; - private readonly _barTrack: St.BoxLayout; - private readonly _barFill: St.Widget; - private readonly _barMarker: St.Widget | null; - private _state: UsageBarState; + _barFill; - constructor(variant: UsageBarVariant) { + _barMarker; + + _state; + + constructor(variant) { this._variant = variant; this._state = { percentValue: 0, @@ -44,19 +34,15 @@ 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 })); - if (variant === "menu") { const overlay = new St.Widget({ x_expand: true, @@ -84,7 +70,7 @@ export class UsageBar { } } - update(state: UsageBarState) { + update(state) { this._state = state; this._syncColor(); this.sync(); @@ -92,19 +78,12 @@ export class UsageBar { 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, - ); - + 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); @@ -116,13 +95,10 @@ export class UsageBar { 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/panel/view-model.js similarity index 72% rename from src/ui/view-model.ts rename to src/panel/view-model.js index b9e924d..927284b 100644 --- a/src/ui/view-model.ts +++ b/src/panel/view-model.js @@ -1,54 +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 "../usage/model.js"; export function createPanelBarViewModel(settings, snapshot, errorMessage) { const sessionQuota = getQuota(snapshot, "session"); @@ -59,8 +9,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,20 +20,16 @@ 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, @@ -94,41 +39,32 @@ export function createPanelBarViewModel(settings, snapshot, errorMessage) { percentDisplayMode, ); } - 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[] = [], + snapshot, + prediction, + history = [], errorMessage = null, cachedFailureMessage = null, ) { const percentDisplayMode = settings.percentDisplayMode; - if (errorMessage) { return { updatedAt: "--", @@ -152,7 +88,6 @@ export function createMenuViewModel( trend: createUsageTrendViewModel(null, []), }; } - if (!snapshot) { return { updatedAt: "--", @@ -174,10 +109,8 @@ export function createMenuViewModel( trend: createUsageTrendViewModel(null, []), }; } - const primaryQuota = getQuota(snapshot, "session"); const secondaryQuota = getQuota(snapshot, "weekly"); - return { updatedAt: "", errorMessage: null, @@ -187,10 +120,7 @@ export function createMenuViewModel( primary: createUsageItemViewModel({ visible: Boolean(primaryQuota), title: primaryQuota?.label ?? "Primary", - value: formatPercentForDisplay( - primaryQuota?.usedPercent, - percentDisplayMode, - ), + value: formatPercentForDisplay(primaryQuota?.usedPercent, percentDisplayMode), prediction: formatLimitPrediction( primaryQuota ? prediction?.quotas?.[primaryQuota.id] : prediction?.primary, primaryQuota, @@ -207,10 +137,7 @@ export function createMenuViewModel( secondary: createUsageItemViewModel({ visible: Boolean(secondaryQuota), title: secondaryQuota?.label ?? "Secondary", - value: formatPercentForDisplay( - secondaryQuota?.usedPercent, - percentDisplayMode, - ), + value: formatPercentForDisplay(secondaryQuota?.usedPercent, percentDisplayMode), prediction: formatLimitPrediction( secondaryQuota ? prediction?.quotas?.[secondaryQuota.id] : prediction?.secondary, secondaryQuota, @@ -237,14 +164,13 @@ export function createUsageItemViewModel({ reset = "resets in --", percentValue = null, baselinePercentValue = null, - percentDisplayMode = "used" as PercentDisplayMode, - predictionStyle = "muted" as PredictionStyle, -}): UsageItemViewModel { + percentDisplayMode = "used", + predictionStyle = "muted", +}) { const normalizedPercentValue = normalizePercent(percentValue); const normalizedBaselinePercentValue = Number.isFinite(baselinePercentValue) ? normalizePercent(baselinePercentValue) : null; - return { visible, title, @@ -252,53 +178,38 @@ export function createUsageItemViewModel({ prediction, reset, percentValue: normalizedPercentValue, - displayPercentValue: convertPercentForDisplay( - normalizedPercentValue, - percentDisplayMode, - ), + 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; -type UsageTrendSample = { - timestamp: number; - usedPercent: number; -}; +const MIN_BURN_RATE_OBSERVED_SECONDS = 6 * 60 * 60; -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(); - 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; - addTrendDeltaToBuckets({ bucketValues, delta, @@ -308,26 +219,19 @@ export function createUsageTrendViewModel( bucketSize, }); } - const maxValue = Math.max(...bucketValues); - 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), - ); + return Math.max(TREND_MIN_BAR_PERCENT, Math.round((value / maxValue) * 100)); }), }; } -function createHiddenUsageTrendViewModel(): UsageTrendViewModel { +function createHiddenUsageTrendViewModel() { return { visible: false, title: TREND_TITLE, @@ -335,59 +239,45 @@ function createHiddenUsageTrendViewModel(): UsageTrendViewModel { }; } -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) ?? + 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) + .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; } - return total; } -export function calculateAverageBurnRatePercentPerDay(samples: UsageTrendSample[]): number | null { +export function calculateAverageBurnRatePercentPerDay(samples) { if (samples.length < 2) return null; - const positiveDelta = calculateRecentPositiveDelta(samples); 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; - + const observedDays = Math.max(observedSeconds, MIN_BURN_RATE_OBSERVED_SECONDS) / 86400; return positiveDelta / observedDays; } @@ -398,75 +288,48 @@ function addTrendDeltaToBuckets({ toTimestamp, minTimestamp, bucketSize, -}: { - bucketValues: number[]; - delta: number; - fromTimestamp: number; - toTimestamp: number; - minTimestamp: number; - bucketSize: number; }) { 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 { +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 { +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 { +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 { +export function calculateBaselinePercent(quota) { if (!quota) return null; - const { limitWindowSeconds, resetAfterSeconds } = quota; - if ( !Number.isFinite(limitWindowSeconds) || !Number.isFinite(resetAfterSeconds) || @@ -474,125 +337,91 @@ export function calculateBaselinePercent(quota: UsageQuota | null): number | nul ) { 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"; - 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 { +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) { +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}`; - return `resets in ${relative} (${absolute})`; } -export function formatDuration(totalSeconds: number, windowType?: UsageWindowType) { +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`; - const parts = []; - 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 { +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 { +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) { @@ -602,42 +431,32 @@ export function formatTimestamp(value: string, date: boolean = true): string { /** * Calculate seconds from now until timestamp - * + * * @param unixTimestamp UNIX timestamp in seconds - * @returns + * @returns */ -export function secondsUntil(unixTimestamp: number): number { +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, -) { +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"; @@ -650,38 +469,29 @@ export function getPredictionStyleClass(prediction: WindowPrediction): Predictio } } -export function formatFooter(snapshot: UsageSnapshot | null): string { +export function formatFooter(snapshot) { if (!snapshot) return "--"; - const parts = [formatPlan(snapshot.planType)]; const credits = formatCredits(snapshot.credits); - if (credits) parts.push(credits); - parts.push(`Updated ${formatUnixTimestamp(snapshot.fetchedAt, false)}`); - return parts.filter((part) => part && part !== "--").join(" ยท ") || "--"; } -function formatCredits(credits): string { +function formatCredits(credits) { if (!credits || credits.unlimited) return ""; - const balance = typeof credits.balance === "string" ? credits.balance.trim() : ""; - 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 "--"; - return value .toString() .split(/[_-]/) @@ -690,27 +500,21 @@ export function formatPlan(value) { .join(" "); } -function formatCompactDuration(totalSeconds: number, windowType?: UsageWindowType): string { +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`; - return `${Math.max(1, minutes)}m`; } -export function normalizePercent(value: number): number { +export function normalizePercent(value) { if (!Number.isFinite(value)) return 0; - return Math.max(0, Math.min(100, value)); } diff --git a/src/ui/preferences-pages.ts b/src/preferences/page.js similarity index 75% rename from src/ui/preferences-pages.ts rename to src/preferences/page.js index cccfd79..614d12b 100644 --- a/src/ui/preferences-pages.ts +++ b/src/preferences/page.js @@ -2,7 +2,6 @@ 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, @@ -12,30 +11,15 @@ import { 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; +} from "./settings.js"; export const PreferencesPage = GObject.registerClass( class PreferencesPage extends Adw.PreferencesPage { - _init( - settings: Gio.Settings, - snapshot: CodexBankedResetListResponse | null, - metadata: Metadata, - ) { + _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)); @@ -44,10 +28,7 @@ export const PreferencesPage = GObject.registerClass( }, ); -function createBankedResetsGroup( - settings: Gio.Settings, - snapshot: CodexBankedResetListResponse | null, -): Adw.PreferencesGroup { +function createBankedResetsGroup(settings, snapshot) { const group = new Adw.PreferencesGroup({ title: "Banked resets" }); group.add( createBoundSwitchRow({ @@ -57,27 +38,25 @@ function createBankedResetsGroup( 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) { group.add(createCreditRow(credit)); } - return group; } -function createCreditRow(credit: CodexBankedResetCredit): Adw.PreferencesRow { +function createCreditRow(credit) { const row = new Adw.PreferencesRow(); const box = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL, @@ -125,13 +104,11 @@ function createCreditRow(credit: CodexBankedResetCredit): Adw.PreferencesRow { 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); @@ -140,26 +117,22 @@ function createCreditRow(credit: CodexBankedResetCredit): Adw.PreferencesRow { box.append(description); box.append(dates); row.set_child(box); - return row; } -function getNoCodexCreditSnapshotMessage(): string { +function getNoCodexCreditSnapshotMessage() { return "No Codex credit snapshot is available yet. It will appear after the next successful panel refresh."; } -function createCreditTitle(credit: CodexBankedResetCredit): string { +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?: string | null): string { +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", @@ -170,16 +143,14 @@ function formatCreditDate(value?: string | null): string { }); } -function formatCreditStatus(credit: CodexBankedResetCredit): string { +function formatCreditStatus(credit) { return credit.status === "available" ? "Available" : credit.status; } - -function createTopPanelGroup(settings: Gio.Settings) { +function createTopPanelGroup(settings) { const group = new Adw.PreferencesGroup({ title: "Top panel indicator", }); - group.add(createTopPanelIndicatorIconRow(settings)); group.add(createTopPanelStyleRow(settings)); group.add( @@ -196,77 +167,59 @@ function createTopPanelGroup(settings: Gio.Settings) { title: "Display weekly usage", }), ); - return group; } -function createBehaviorGroup(settings: Gio.Settings) { +function createBehaviorGroup(settings) { const group = new Adw.PreferencesGroup({ title: "Behavior", }); - group.add(createPercentDisplayModeRow(settings)); group.add(createRefreshIntervalRow(settings)); - return group; } - - -function createTopPanelStyleRow(settings: Gio.Settings) { +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), - ), + 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) { +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), - ), + 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) { +function createTopPanelIndicatorIconRow(settings) { const row = new Adw.ComboRow({ title: "Icon", model: Gtk.StringList.new(["Codex icon", "OpenAI icon", "CX shortcode"]), @@ -274,55 +227,34 @@ function createTopPanelIndicatorIconRow(settings: Gio.Settings) { 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; - } = { +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: Gio.Settings) { +function createRefreshIntervalRow(settings) { const row = new Adw.SpinRow({ title: "Background refresh interval (minutes)", subtitle: "Set to 0 to refresh manually", @@ -331,34 +263,24 @@ function createRefreshIntervalRow(settings: Gio.Settings) { upper: 60, step_increment: 1, page_increment: 5, - value: settings.get_uint( - SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES, - ), + 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, - ); - }, - ); - + 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 { +function createFooterGroup(metadata) { const footerGroup = new Adw.PreferencesGroup(); const footerBox = new Gtk.Box({ orientation: Gtk.Orientation.VERTICAL, @@ -366,14 +288,12 @@ function createFooterGroup(metadata: Metadata): Adw.PreferencesGroup { 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.", @@ -382,7 +302,6 @@ function createFooterGroup(metadata: Metadata): Adw.PreferencesGroup { css_classes: ["caption", "dim-label"], }), ); - const links = new Gtk.Label({ css_classes: ["caption", "dim-label"], }); @@ -390,16 +309,12 @@ function createFooterGroup(metadata: Metadata): Adw.PreferencesGroup { `Source code ยท Report a bug`, ); footerBox.append(links); - footerGroup.add(footerBox); return footerGroup; } - - - -function getTopPanelDisplayModeIndex(value: string) { - switch (value as TopPanelDisplayMode) { +function getTopPanelDisplayModeIndex(value) { + switch (value) { case "percentages": return 1; default: @@ -407,7 +322,7 @@ function getTopPanelDisplayModeIndex(value: string) { } } -function getTopPanelDisplayModeValue(selected: number): TopPanelDisplayMode { +function getTopPanelDisplayModeValue(selected) { switch (selected) { case 1: return "percentages"; @@ -416,24 +331,22 @@ function getTopPanelDisplayModeValue(selected: number): TopPanelDisplayMode { } } -function getTopPanelIndicatorIconIndex(value: string) { +function getTopPanelIndicatorIconIndex(value) { if (value === "openai") return 1; if (value === "text") return 2; - return 0; } -function getTopPanelIndicatorIconValue(selected: number): TopPanelIndicatorIcon { +function getTopPanelIndicatorIconValue(selected) { if (selected === 1) return "openai"; if (selected === 2) return "text"; - return "codex"; } -function getPercentDisplayModeIndex(value: string) { +function getPercentDisplayModeIndex(value) { return value === "left" ? 1 : 0; } -function getPercentDisplayModeValue(selected: number): PercentDisplayMode { +function getPercentDisplayModeValue(selected) { return selected === 1 ? "left" : "used"; } diff --git a/src/app/settings.ts b/src/preferences/settings.js similarity index 54% rename from src/app/settings.ts rename to src/preferences/settings.js index 833f5b7..15cc69c 100644 --- a/src/app/settings.ts +++ b/src/preferences/settings.js @@ -1,136 +1,102 @@ -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_AUTO_APPLY_BANKED_RESET = "auto-apply-banked-reset"; -export const MIN_REFRESH_INTERVAL_MINUTES = 0; +export const SETTINGS_PERCENT_DISPLAY_MODE = "percent-display-mode"; +export const SETTINGS_BACKGROUND_REFRESH_INTERVAL_MINUTES = "background-refresh-interval-minutes"; -export type TopPanelDisplayMode = "percentages" | "bars"; -export type TopPanelIndicatorIcon = "text" | "codex" | "openai"; -export type PercentDisplayMode = "used" | "left"; +export const SETTINGS_AUTO_APPLY_BANKED_RESET = "auto-apply-banked-reset"; -export type ExtensionSettings = { - usageProvider: ProviderId; - showPrimary: boolean; - showSecondary: boolean; - topPanelDisplayMode: TopPanelDisplayMode; - topPanelIndicatorIcon: TopPanelIndicatorIcon; - percentDisplayMode: PercentDisplayMode; - backgroundRefreshIntervalMinutes: number; - backgroundRefreshIntervalSeconds: number; - autoApplyBankedReset: boolean; -}; +export const MIN_REFRESH_INTERVAL_MINUTES = 0; export class SettingsService { - constructor(private readonly settings: Gio.Settings) {} + settings; - getAll(): ExtensionSettings { - const backgroundRefreshIntervalMinutes = - this.getBackgroundRefreshIntervalMinutes(); + constructor(settings) { + this.settings = settings; + } + getAll() { + const backgroundRefreshIntervalMinutes = this.getBackgroundRefreshIntervalMinutes(); return { - usageProvider: this.getUsageProvider(), showPrimary: this.getShowPrimary(), showSecondary: this.getShowSecondary(), topPanelDisplayMode: this.getTopPanelDisplayMode(), topPanelIndicatorIcon: this.getTopPanelIndicatorIcon(), percentDisplayMode: this.getPercentDisplayMode(), backgroundRefreshIntervalMinutes, - backgroundRefreshIntervalSeconds: - backgroundRefreshIntervalMinutes * 60, + backgroundRefreshIntervalSeconds: backgroundRefreshIntervalMinutes * 60, autoApplyBankedReset: this.getAutoApplyBankedReset(), }; } - getUsageProvider(): ProviderId { - 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 { + 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 { + 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/prefs.ts b/src/prefs.js similarity index 69% rename from src/prefs.ts rename to src/prefs.js index ae26275..664a46f 100644 --- a/src/prefs.ts +++ b/src/prefs.js @@ -1,24 +1,18 @@ 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(); 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/refresh/error.js b/src/refresh/error.js new file mode 100644 index 0000000..9911304 --- /dev/null +++ b/src/refresh/error.js @@ -0,0 +1,16 @@ +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/app/scheduler.ts b/src/refresh/scheduler.js similarity index 73% rename from src/app/scheduler.ts rename to src/refresh/scheduler.js index 5d673d1..fc5b705 100644 --- a/src/app/scheduler.ts +++ b/src/refresh/scheduler.js @@ -1,53 +1,54 @@ import GLib from "gi://GLib"; export class Scheduler { - private timeoutId: number | null = null; - private running = false; - private started = false; + intervalSeconds; - constructor( - private intervalSeconds: number, - private readonly task: () => Promise, - ) {} + task; - start({ runImmediately = false }: { runImmediately?: boolean } = {}): void { + 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, @@ -56,29 +57,25 @@ export class Scheduler { 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) { 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 89% rename from schemas/org.gnome.shell.extensions.codex-meter.gschema.xml rename to src/schemas/org.gnome.shell.extensions.codex-meter.gschema.xml index ccd64ab..ed65a35 100644 --- a/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/infra/storage/history.ts b/src/usage/history-store.js similarity index 53% rename from src/infra/storage/history.ts rename to src/usage/history-store.js index f131248..54d07d5 100644 --- a/src/infra/storage/history.ts +++ b/src/usage/history-store.js @@ -1,24 +1,18 @@ 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"; +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: ProviderId) { - return GLib.build_filenamev([STATE_DIR, providerId, "usage-history.jsonl"]); -} +const MAX_HISTORY_AGE_SECONDS = 21 * 24 * 60 * 60; +const HISTORY_PATH = GLib.build_filenamev([STATE_DIR, "codex", "usage-history.jsonl"]); -export async function readHistory(providerId: ProviderId): Promise { - return readHistoryFromPath(getHistoryPath(providerId)); +export async function readHistory() { + return readHistoryFromPath(HISTORY_PATH); } -export async function readHistoryFromPath(path: string): Promise { +export async function readHistoryFromPath(path) { if (!fileExists(path)) return []; - try { return normalizeHistoryEntries(parseJsonlHistory(await readFile(path))); } catch (error) { @@ -27,76 +21,48 @@ export async function readHistoryFromPath(path: string): Promise } } - - -export async function appendHistory( - providerId: ProviderId, - row: HistoryEntry, -): Promise { - 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 { - return appendHistoryRowToPath(getHistoryPath(providerId), row); +export async function appendHistoryRow(row) { + return appendHistoryRowToPath(HISTORY_PATH, 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 { - await rewriteHistoryToPath(getHistoryPath(providerId), normalizeHistoryEntries(rows)); +export async function rewriteHistory(rows) { + await rewriteHistoryToPath(HISTORY_PATH, normalizeHistoryEntries(rows)); } -export async function appendHistoryToPath( - path: string, - row: HistoryEntry, - existingRows: HistoryEntry[] | null = null, -): Promise { - const rows = existingRows ?? await readHistoryFromPath(path); +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; - 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; - 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/) @@ -109,43 +75,33 @@ function parseJsonlHistory(text: string): HistoryEntry[] { return null; } }) - .filter((row): row is HistoryEntry => row !== null); + .filter((row) => row !== null); } -export function normalizeHistoryEntries(rows: HistoryEntry[]): HistoryEntry[] { - const minTimestamp = Date.now() - (MAX_HISTORY_AGE_SECONDS * 1000); - +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); - + const quotas = row.quotas.map(normalizeHistoryQuota).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; - return omitUndefined({ id, usedPercent, @@ -153,51 +109,44 @@ function normalizeHistoryQuota(quota): HistoryQuotaEntry | null { 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): number | null | undefined { +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 { +async function rewriteHistoryToPath(path, rows) { await writeFile( path, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length ? "\n" : ""), ); } -export function hasSameQuotaValues(left: HistoryEntry, right: HistoryEntry): boolean { +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) && + 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/model.js b/src/usage/model.js new file mode 100644 index 0000000..115936f --- /dev/null +++ b/src/usage/model.js @@ -0,0 +1,49 @@ +// @ts-check + +/** @typedef {import("../../types/index.js").UsageSnapshot} UsageSnapshot */ +/** @typedef {import("../../types/index.js").UsageQuota} UsageQuota */ +/** @typedef {import("../../types/index.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/prediction.ts b/src/usage/prediction.js similarity index 54% rename from src/domain/prediction.ts rename to src/usage/prediction.js index 820145f..c6423c1 100644 --- a/src/domain/prediction.ts +++ b/src/usage/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/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 */ + +/** + * @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,17 +50,18 @@ 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(); } - const startedAt = snapshot.fetchedAt - - (quota.limitWindowSeconds - quota.resetAfterSeconds); + const startedAt = snapshot.fetchedAt - (quota.limitWindowSeconds - quota.resetAfterSeconds); const historyWithSnapshot = [ ...history, { @@ -80,7 +82,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 +90,41 @@ function predictQuota( return predictWindow(quotaHistory, startedAt, quota.resetAt); } -function hasPredictionMetadata(quota: UsageQuota): quota is UsageQuota & { - limitWindowSeconds: number; - resetAfterSeconds: number; - resetAt: number; -} { - return Number.isFinite(quota.limitWindowSeconds) && +/** + * @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; + (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 +159,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 +177,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/usage/service.js b/src/usage/service.js new file mode 100644 index 0000000..b3e45ab --- /dev/null +++ b/src/usage/service.js @@ -0,0 +1,79 @@ +import { refreshCodexUsage } from "../codex/usage.js"; +import { predict } from "./prediction.js"; +import { toHistoryEntry } from "./model.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 { + history = null; + + async readCachedSnapshot() { + return await readSnapshot(); + } + + async refresh(options = {}) { + const snapshot = await refreshCodexUsage({ + cancellable: options.cancellable ?? null, + }); + + try { + await writeSnapshot(snapshot); + } catch (error) { + console.error("Unable to write Codex usage snapshot cache", error); + } + + try { + await this.appendSnapshotToHistory(snapshot); + } catch (error) { + console.error("Unable to append Codex usage history", error); + } + + return snapshot; + } + + async readHistory() { + return [...(await this.loadHistory())]; + } + + async loadHistory() { + if (this.history) return this.history; + + this.history = await readHistory(); + return this.history; + } + + async appendSnapshotToHistory(snapshot) { + 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(row); + history.push(row); + + if (history.length > MAX_HISTORY_ENTRIES) { + this.history = normalizeHistoryEntries(history); + await rewriteHistory(this.history); + } + } + + async predict(snapshot) { + 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/infra/storage/snapshot-cache.ts b/src/usage/snapshot-store.js similarity index 51% rename from src/infra/storage/snapshot-cache.ts rename to src/usage/snapshot-store.js index cb60fa1..d23463e 100644 --- a/src/infra/storage/snapshot-cache.ts +++ b/src/usage/snapshot-store.js @@ -1,42 +1,30 @@ import GLib from "gi://GLib"; +import { CACHE_DIR } from "../io/paths.js"; +import { readJsonFile, writeJsonFile } from "../io/files.js"; -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"; +const SNAPSHOT_PATH = GLib.build_filenamev([CACHE_DIR, "codex", "snapshot.json"]); -function getSnapshotPath(providerId: ProviderId) { - return GLib.build_filenamev([CACHE_DIR, providerId, "snapshot.json"]); +export async function writeSnapshot(snapshot) { + await writeJsonFile(SNAPSHOT_PATH, snapshot); } -export async function writeSnapshot( - providerId: ProviderId, - snapshot: UsageSnapshot, -): Promise { - await writeJsonFile(getSnapshotPath(providerId), snapshot); -} - -export async function readSnapshot(providerId: ProviderId): Promise { +export async function readSnapshot() { try { - const raw = await readJsonFile(getSnapshotPath(providerId)); - + const raw = await readJsonFile(SNAPSHOT_PATH); if (!isUsageSnapshot(raw)) { return null; } - return raw; } 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; - + const snapshot = value; return ( isFiniteNumber(snapshot.fetchedAt) && typeof snapshot.planType === "string" && @@ -46,13 +34,11 @@ function isUsageSnapshot(value: unknown): value is UsageSnapshot { ); } -function isUsageQuota(value: unknown): boolean { +function isUsageQuota(value) { if (!value || typeof value !== "object") { return false; } - - const quota = value as Record; - + const quota = value; return ( typeof quota.id === "string" && quota.id.length > 0 && @@ -65,10 +51,10 @@ function isUsageQuota(value: unknown): boolean { ); } -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/test/codex-auth.test.js b/test/codex-auth.test.js index daab599..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 "../dist/infra/providers/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 d2f0a25..64df5d5 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/banked-resets/response.js"; function assertUnexpectedResponse(fn) { assert.throws(fn, (error) => { @@ -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-usage-response.test.js similarity index 71% rename from test/codex-provider.test.js rename to test/codex-usage-response.test.js index 3bd96cd..f935497 100644 --- a/test/codex-provider.test.js +++ b/test/codex-usage-response.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/codex/usage-response.js"; function validUsageResponse(overrides = {}) { return { @@ -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], { @@ -61,39 +60,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 020a35a..1d75680 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/usage/history-store.js"; Gio._promisify(Gio.File.prototype, "load_contents_async"); @@ -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/prediction.test.js b/test/prediction.test.js index d40be39..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 "../dist/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 6bb1628..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 "../dist/domain/refresh-failure.js"; -import { formatRefreshFailure } from "../dist/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 78825dd..6512a6b 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/panel/view-model.js"; const now = 1_700_000_000; @@ -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 282c8ac..5c1637e 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/panel/view-model.js"; const sessionQuota = { id: "session", @@ -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 } }, diff --git a/tsconfig.json b/tsconfig.json index cbb74de..fffac26 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"], - "exclude": ["dist", "node_modules"] + "include": ["src/**/*.js", "types/**/*.js"], + "exclude": ["node_modules"] } diff --git a/types/index.js b/types/index.js new file mode 100644 index 0000000..b10fa5a --- /dev/null +++ b/types/index.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} 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 {};