From fb370bc37e9a2b96e17d213869bda0b476604db7 Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 09:49:00 +0000 Subject: [PATCH 01/11] feat: add accessibility, system theme support, CI, and tests - Accessible names for screen readers on panel indicator, dots, and summaries - CSS uses currentColor and opacity for better theme integration - CONTRIBUTING.md with development setup and code style guidelines - GitHub Actions CI: shellcheck, meson build, bats tests - Bats test suite for shell script (JSON structure, required fields, cleanup) --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++ CHANGELOG.md | 13 +++++++++ CONTRIBUTING.md | 61 ++++++++++++++++++++++++++++++++++++++++ README.md | 6 ++++ indicator.js | 12 ++++---- meson.build | 2 +- metadata.json | 2 +- stylesheet.css | 13 ++++++--- tests/freeby.bats | 42 +++++++++++++++++++++++++++ 9 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 tests/freeby.bats diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7c78902 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install shellcheck + run: sudo apt-get install -y shellcheck + - name: Lint shell scripts + run: shellcheck scripts/freeby.sh scripts/copilot-setup.sh + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: sudo apt-get install -y meson ninja-build libglib2.0-dev-bin python3 + - name: Build + run: meson setup build --prefix=$HOME/.local && meson install -C build + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: sudo apt-get install -y bats python3 curl + - name: Run tests + run: bats tests/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 67dda60..b0bf72a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## v1.0.1 + +### Added +- Accessible names for screen readers on panel indicator, dots, and summaries +- System theme support in CSS (uses `currentColor` and opacity for better theme integration) +- CONTRIBUTING.md with development setup and code style guidelines +- GitHub Actions CI: shellcheck, meson build, bats tests +- Bats test suite for shell script (JSON structure, required fields, cleanup) + +### Changed +- CSS colors use `currentColor` where possible for better theme compatibility +- Status dot and summary text use opacity for dimmed states instead of hardcoded colors + ## v1.0.0 ### Fixed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d65d184 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing + +Thanks for your interest in Freeby. + +## Development + +Clone and install locally: + +```bash +git clone https://github.com/kcnewman/freeby.git && cd freeby +git checkout dev +meson setup build --prefix=$HOME/.local +meson install -C build +``` + +Test your changes by restarting GNOME Shell (Alt+F2, type `r`, Enter) and enabling the extension: + +```bash +gnome-extensions enable freeby@kelvin.local +``` + +## Code style + +- Keep it simple. No unnecessary abstraction. +- Lowercase sentence case for user-facing text. +- Follow existing patterns in the codebase. + +## Commits + +Use [conventional commits](https://www.conventionalcommits.org/): + +- `feat:` new feature +- `fix:` bug fix +- `docs:` documentation only +- `chore:` maintenance, tests, CI + +## Testing + +Test the shell script directly: + +```bash +bash ~/.local/bin/freeby.sh | python3 -m json.tool +``` + +Run shellcheck: + +```bash +shellcheck scripts/freeby.sh scripts/copilot-setup.sh +``` + +## Branches + +- `main` — stable releases +- `dev` — active development + +## Pull requests + +1. Fork and create a branch from `dev` +2. Make your changes +3. Test manually (the extension runs in your live desktop) +4. Open a PR against `dev` diff --git a/README.md b/README.md index 43929f5..9898fd6 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,10 @@ journalctl -f -o cat /usr/bin/gnome-shell --- +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. + +--- + [MIT](LICENSE) diff --git a/indicator.js b/indicator.js index f323d71..ac688b5 100644 --- a/indicator.js +++ b/indicator.js @@ -21,18 +21,18 @@ class FreebyIndicator extends PanelMenu.Button { for (const k of PROVIDERS) this._prev[k] = null; this._refreshSeq = 0; - this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel' }); - this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label' }); + this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' }); + this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' }); this._panelBox.add_child(this._label); this.add_child(this._panelBox); this.connect('button-press-event', () => { this._refresh(); return false; }); this._items = {}; for (const k of PROVIDERS) { - const box = new St.BoxLayout({ style_class: 'freeby-item-box' }); - const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER }); + const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` }); + const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` }); const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER }); - const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER }); + const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` }); box.add_child(dot); box.add_child(name); box.add_child(summary); @@ -43,7 +43,7 @@ class FreebyIndicator extends PanelMenu.Button { } this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false }); + this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false, accessible_name: 'Refresh status' }); this._statusItem.label.add_style_class_name('freeby-status'); this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); }); this.menu.addMenuItem(this._statusItem); diff --git a/meson.build b/meson.build index 8e46ec0..22d6b06 100644 --- a/meson.build +++ b/meson.build @@ -1,4 +1,4 @@ -project('freeby', version: '1.0.0', license: 'MIT', +project('freeby', version: '1.0.1', license: 'MIT', meson_version: '>= 0.56.0', default_options: ['warning_level=0']) diff --git a/metadata.json b/metadata.json index f8ccf99..5a8b29a 100644 --- a/metadata.json +++ b/metadata.json @@ -3,7 +3,7 @@ "name": "Freeby", "description": "Minimal panel indicator for local AI coding tool usage (Codex, Cursor, Copilot)", "shell-version": ["45", "46", "47", "48"], - "version": 1, + "version": 2, "settings-schema": "org.gnome.shell.extensions.freeby", "url": "https://github.com/kcnewman/freeby" } diff --git a/stylesheet.css b/stylesheet.css index 0fde099..ac2912b 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -6,6 +6,7 @@ font-weight: bold; font-size: 10pt; min-width: 16px; + color: currentColor; } .freeby-panel-green { @@ -30,7 +31,8 @@ } .freeby-dot-off { - color: #888; + color: currentColor; + opacity: 0.4; } .freeby-dot-green { @@ -49,15 +51,17 @@ font-weight: bold; font-size: 9pt; min-width: 52px; + color: currentColor; } .freeby-item-summary { font-size: 9pt; - color: #ccc; + color: currentColor; + opacity: 0.8; } .freeby-dim { - color: #888; + opacity: 0.4; } .freeby-green { @@ -74,5 +78,6 @@ .freeby-status { font-size: 9pt; - color: #bbb; + color: currentColor; + opacity: 0.7; } diff --git a/tests/freeby.bats b/tests/freeby.bats new file mode 100644 index 0000000..b092960 --- /dev/null +++ b/tests/freeby.bats @@ -0,0 +1,42 @@ +#!/usr/bin/env bats + +SCRIPT="$BATS_TEST_DIRNAME/../scripts/freeby.sh" + +@test "outputs valid JSON" { + run bash "$SCRIPT" + [ "$status" -eq 0 ] + echo "$output" | python3 -c "import json,sys; json.load(sys.stdin)" +} + +@test "JSON contains codex, cursor, copilot keys" { + run bash "$SCRIPT" + echo "$output" | python3 -c " +import json,sys +d = json.load(sys.stdin) +assert 'codex' in d, 'missing codex' +assert 'cursor' in d, 'missing cursor' +assert 'copilot' in d, 'missing copilot' +" +} + +@test "each provider has required fields" { + run bash "$SCRIPT" + echo "$output" | python3 -c " +import json,sys +d = json.load(sys.stdin) +for k in ('codex','cursor','copilot'): + p = d[k] + assert 'available' in p, f'{k} missing available' + assert 'has_remaining' in p, f'{k} missing has_remaining' + assert 'summary' in p, f'{k} missing summary' + assert isinstance(p['available'], bool), f'{k} available not bool' + assert isinstance(p['has_remaining'], bool), f'{k} has_remaining not bool' +" +} + +@test "script cleans up temp directory" { + before=$(ls /tmp | grep -c 'tmp\.' || true) + run bash "$SCRIPT" + after=$(ls /tmp | grep -c 'tmp\.' || true) + [ "$after" -le "$before" ] +} From caa0346212acaba9417959ab5211380df3d1e32f Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 09:52:27 +0000 Subject: [PATCH 02/11] fix: resolve shellcheck SC2002 (useless cat) --- scripts/freeby.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/freeby.sh b/scripts/freeby.sh index 0a9e53a..4da906b 100755 --- a/scripts/freeby.sh +++ b/scripts/freeby.sh @@ -113,7 +113,7 @@ fetch_copilot() { token="$(gh auth token 2>/dev/null | tr -d '[:space:]')" fi if [ -z "$token" ] && [ -f "$HOME/.config/freeby/copilot-token" ]; then - token="$(cat "$HOME/.config/freeby/copilot-token" 2>/dev/null | tr -d '[:space:]')" + token="$(tr -d '[:space:]' < "$HOME/.config/freeby/copilot-token" 2>/dev/null)" fi if [ -z "$token" ]; then if [ ! -f "$HOME/.config/freeby/copilot-token" ] && ! command -v gh >/dev/null 2>&1; then From adfd377d567ba8283d8470caca37e39cba0f9f4c Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 09:55:00 +0000 Subject: [PATCH 03/11] docs: rewrite README and update CONTRIBUTING for v1.0.1 --- CONTRIBUTING.md | 42 ++++++++++++++++----- README.md | 98 +++++++++++++++++++++++++++++-------------------- 2 files changed, 90 insertions(+), 50 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d65d184..27606ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,8 +4,6 @@ Thanks for your interest in Freeby. ## Development -Clone and install locally: - ```bash git clone https://github.com/kcnewman/freeby.git && cd freeby git checkout dev @@ -13,12 +11,35 @@ meson setup build --prefix=$HOME/.local meson install -C build ``` -Test your changes by restarting GNOME Shell (Alt+F2, type `r`, Enter) and enabling the extension: +Restart GNOME Shell (Alt+F2, type `r`, Enter) and enable: ```bash gnome-extensions enable freeby@kelvin.local ``` +## Project structure + +``` +freeby/ +├── extension.js # Entry point (enable/disable) +├── indicator.js # Panel UI, dropdown, refresh +├── prefs.js # Settings UI in Extension Manager +├── scripts/ +│ ├── freeby.sh # Data aggregator (parallel provider fetches) +│ └── copilot-setup.sh # GitHub device-flow auth +├── schemas/ +│ └── *.gschema.xml # GSettings schema +├── build-aux/ +│ └── compile-schemas.sh +├── tests/ +│ └── freeby.bats # Shell script tests +├── .github/workflows/ +│ └── ci.yml # shellcheck, meson build, bats tests +└── docs/ + ├── s1.png + └── s2.png +``` + ## Code style - Keep it simple. No unnecessary abstraction. @@ -36,18 +57,19 @@ Use [conventional commits](https://www.conventionalcommits.org/): ## Testing -Test the shell script directly: - ```bash -bash ~/.local/bin/freeby.sh | python3 -m json.tool -``` - -Run shellcheck: +# test the data script +bash scripts/freeby.sh | python3 -m json.tool -```bash +# run shellcheck shellcheck scripts/freeby.sh scripts/copilot-setup.sh + +# run bats tests +bats tests/ ``` +CI runs automatically on push to `main` or `dev`, and on all pull requests. + ## Branches - `main` — stable releases diff --git a/README.md b/README.md index 9898fd6..8457680 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,75 @@ -# Freeby - -> GNOME Shell extension that tracks remaining usage across your free-tier AI coding tools. - - - - - - -
+

+

Freeby

+

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

+

+ +

+ Panel indicator + Dropdown menu +

+ +

+ Features · + Providers · + Install · + Settings · + Contributing · + Releases +

--- ### Features -- **Panel indicator** — colored `ai·N` shows available providers at a glance -- **Dropdown** — per-provider usage, limits, and reset countdown -- **Notifications** — desktop alert when a provider hits its limit -- **Auto-refresh on wake** — refreshes immediately after sleep -- **Fast** — all providers queried in parallel (~2s) -- **Configurable** — adjust refresh interval and notifications in settings +| | Feature | Description | +|---|---|---| +| ![#4ade80](https://via.placeholder.com/10/4ade80/000000?text=+) | **Panel indicator** | Colored `ai·N` shows available providers at a glance | +| | **Dropdown** | Per-provider usage, limits, and reset countdown | +| | **Notifications** | Desktop alert when a provider hits its limit | +| | **Auto-refresh on wake** | Refreshes immediately after sleep | +| | **Parallel fetches** | All providers queried in parallel (~2s) | +| | **Configurable** | Adjust refresh interval and notifications in settings | +| | **Accessible** | Screen reader support for all UI elements | +| | **Theme-aware** | Works with light and dark GNOME themes | --- -### Providers +### Supported providers -| | Provider | Source | -|---|---|---| -| ![#f87171](https://via.placeholder.com/10/f87171/000000?text=+) | **Codex** | `~/.codex/auth.json` → `codex-check` CLI | -| ![#4ade80](https://via.placeholder.com/10/4ade80/000000?text=+) | **Cursor** | `~/.config/cursor/auth.json` → Cursor API | -| ![#facc15](https://via.placeholder.com/10/facc15/000000?text=+) | **Copilot** | `gh` CLI → GitHub API | +| | Provider | Auth source | Data source | +|---|---|---|---| +| ![#f87171](https://via.placeholder.com/10/f87171/000000?text=+) | **Codex** | `~/.codex/auth.json` | `codex-check` CLI | +| ![#4ade80](https://via.placeholder.com/10/4ade80/000000?text=+) | **Cursor** | `~/.config/cursor/auth.json` | Cursor API | +| ![#facc15](https://via.placeholder.com/10/facc15/000000?text=+) | **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API | --- -### Prerequisites +### Install + +**Prerequisites** - GNOME Shell 45+ (Wayland or X11) -- `python3` -- `curl` -- `meson` and `ninja-build` (for building) -- `glib-compile-schemas` (usually pre-installed) -- `npx` (for Codex usage tracking) -- `gh` CLI (for Copilot usage tracking, optional) +- `python3`, `curl`, `meson`, `ninja-build` +- `npx` (for Codex) +- `gh` CLI (for Copilot, optional) -Install build dependencies on Fedora: +
+Fedora ```bash sudo dnf install meson ninja-build python3 curl glib2-devel ``` +
-On Ubuntu/Debian: +
+Ubuntu / Debian ```bash sudo apt install meson ninja-build python3 curl libglib2.0-dev-bin ``` +
---- - -### Install +**Build and install** ```bash git clone https://github.com/kcnewman/freeby.git && cd freeby @@ -64,7 +77,7 @@ meson setup build --prefix=$HOME/.local meson install -C build ``` -Restart your session, then: +Then restart your session and enable: ```bash gnome-extensions enable freeby@kelvin.local @@ -76,10 +89,10 @@ gnome-extensions enable freeby@kelvin.local ### Settings -| Setting | Default | | -|---|---|---| -| Refresh interval | `120s` | min 30s | -| Notifications | `on` | alerts on limit hit | +| Setting | Default | Range | Description | +|---|---|---|---| +| Refresh interval | `120s` | 30–3600s | How often to check usage | +| Notifications | `on` | — | Alert when a provider hits its limit | Configure via Extension Manager or CLI: @@ -105,16 +118,21 @@ rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled ### Debug ```bash +# test the data script bash ~/.local/bin/freeby.sh | python3 -m json.tool + +# watch extension logs journalctl -f -o cat /usr/bin/gnome-shell ``` --- -## Contributing +### Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. --- +### License + [MIT](LICENSE) From 10f39f2ccbdcc4b8bd33ba6a82577043c04ec35d Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 09:56:23 +0000 Subject: [PATCH 04/11] fix: replace removed messageTray.get_source for GNOME 50 --- indicator.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/indicator.js b/indicator.js index ac688b5..9f0eb5c 100644 --- a/indicator.js +++ b/indicator.js @@ -77,8 +77,8 @@ class FreebyIndicator extends PanelMenu.Button { _notify(title, body) { if (!this._settings.get_boolean('notifications-enabled')) return; try { - let src = Main.messageTray.get_source('Freeby'); - if (!src) { src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic'); Main.messageTray.add(src); } + const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic'); + Main.messageTray.add(src); src.addNotification(new Main.messageTray.Notification({ source: src, title, body })); } catch (e) { logError(e, 'freeby: notification failed'); } } From 820b28e8d50367805900eaec2827a6400383d51c Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 10:12:41 +0000 Subject: [PATCH 05/11] docs: fix broken image links with inline SVG data URIs --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8457680..f57a5cf 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | | Feature | Description | |---|---|---| -| ![#4ade80](https://via.placeholder.com/10/4ade80/000000?text=+) | **Panel indicator** | Colored `ai·N` shows available providers at a glance | +| | **Panel indicator** | Colored `ai·N` shows available providers at a glance | | | **Dropdown** | Per-provider usage, limits, and reset countdown | | | **Notifications** | Desktop alert when a provider hits its limit | | | **Auto-refresh on wake** | Refreshes immediately after sleep | @@ -38,9 +38,9 @@ | | Provider | Auth source | Data source | |---|---|---|---| -| ![#f87171](https://via.placeholder.com/10/f87171/000000?text=+) | **Codex** | `~/.codex/auth.json` | `codex-check` CLI | -| ![#4ade80](https://via.placeholder.com/10/4ade80/000000?text=+) | **Cursor** | `~/.config/cursor/auth.json` | Cursor API | -| ![#facc15](https://via.placeholder.com/10/facc15/000000?text=+) | **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API | +| | **Codex** | `~/.codex/auth.json` | `codex-check` CLI | +| | **Cursor** | `~/.config/cursor/auth.json` | Cursor API | +| | **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API | --- From bad080694e122b54171267018a1785171ab92ec4 Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 10:14:27 +0000 Subject: [PATCH 06/11] docs: simplify tables, use emoji for provider colors --- README.md | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f57a5cf..3e42e07 100644 --- a/README.md +++ b/README.md @@ -21,26 +21,24 @@ ### Features -| | Feature | Description | -|---|---|---| -| | **Panel indicator** | Colored `ai·N` shows available providers at a glance | -| | **Dropdown** | Per-provider usage, limits, and reset countdown | -| | **Notifications** | Desktop alert when a provider hits its limit | -| | **Auto-refresh on wake** | Refreshes immediately after sleep | -| | **Parallel fetches** | All providers queried in parallel (~2s) | -| | **Configurable** | Adjust refresh interval and notifications in settings | -| | **Accessible** | Screen reader support for all UI elements | -| | **Theme-aware** | Works with light and dark GNOME themes | +- **Panel indicator** — colored `ai·N` shows available providers at a glance +- **Dropdown** — per-provider usage, limits, and reset countdown +- **Notifications** — desktop alert when a provider hits its limit +- **Auto-refresh on wake** — refreshes immediately after sleep +- **Parallel fetches** — all providers queried in parallel (~2s) +- **Configurable** — adjust refresh interval and notifications in settings +- **Accessible** — screen reader support for all UI elements +- **Theme-aware** — works with light and dark GNOME themes --- ### Supported providers -| | Provider | Auth source | Data source | -|---|---|---|---| -| | **Codex** | `~/.codex/auth.json` | `codex-check` CLI | -| | **Cursor** | `~/.config/cursor/auth.json` | Cursor API | -| | **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API | +| Provider | Auth source | Data source | +|---|---|---| +| 🟡 **Codex** | `~/.codex/auth.json` | `codex-check` CLI | +| 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API | +| 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API | --- From e85ee4feae1b3db19eafbc903c9addbe4e7ff254 Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 10:22:37 +0000 Subject: [PATCH 07/11] docs: remove dividers for seamless flow --- README.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/README.md b/README.md index 3e42e07..8ecdf50 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ Releases

---- - ### Features - **Panel indicator** — colored `ai·N` shows available providers at a glance @@ -30,8 +28,6 @@ - **Accessible** — screen reader support for all UI elements - **Theme-aware** — works with light and dark GNOME themes ---- - ### Supported providers | Provider | Auth source | Data source | @@ -40,8 +36,6 @@ | 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API | | 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API | ---- - ### Install **Prerequisites** @@ -83,8 +77,6 @@ gnome-extensions enable freeby@kelvin.local > **Copilot users:** If `gh` isn't installed, run `copilot-setup.sh` first. ---- - ### Settings | Setting | Default | Range | Description | @@ -99,8 +91,6 @@ gsettings --schemadir ~/.local/share/glib-2.0/schemas \ set org.gnome.shell.extensions.freeby refresh-interval 60 ``` ---- - ### Uninstall ```bash @@ -111,8 +101,6 @@ rm -f ~/.local/share/glib-2.0/schemas/org.gnome.shell.extensions.freeby.gschema. rm -f ~/.local/share/glib-2.0/schemas/gschemas.compiled ``` ---- - ### Debug ```bash @@ -123,14 +111,10 @@ bash ~/.local/bin/freeby.sh | python3 -m json.tool journalctl -f -o cat /usr/bin/gnome-shell ``` ---- - ### Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines. ---- - ### License [MIT](LICENSE) From c552a4373398630de2299cea3b2c5c5a1e9a413d Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 10:42:54 +0000 Subject: [PATCH 08/11] fix: add GNOME 50 support and remove deprecated version field --- metadata.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/metadata.json b/metadata.json index 5a8b29a..f131767 100644 --- a/metadata.json +++ b/metadata.json @@ -2,8 +2,7 @@ "uuid": "freeby@kelvin.local", "name": "Freeby", "description": "Minimal panel indicator for local AI coding tool usage (Codex, Cursor, Copilot)", - "shell-version": ["45", "46", "47", "48"], - "version": 2, + "shell-version": ["45", "46", "47", "48", "49", "50"], "settings-schema": "org.gnome.shell.extensions.freeby", "url": "https://github.com/kcnewman/freeby" } From 095efaf8d6e45103763aad46ff068a95f4f2271f Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 10:48:35 +0000 Subject: [PATCH 09/11] fix: remove invalid accessible_name from PopupMenuItem --- indicator.js | 244 +++++++++++++++++++++++++-------------------------- 1 file changed, 122 insertions(+), 122 deletions(-) diff --git a/indicator.js b/indicator.js index 9f0eb5c..3ad2559 100644 --- a/indicator.js +++ b/indicator.js @@ -13,128 +13,128 @@ const LABELS = { codex: 'Codex', cursor: 'Cursor', copilot: 'Copilot' }; const SCRIPT = GLib.build_filenamev([GLib.get_home_dir(), '.local', 'bin', 'freeby.sh']); export const FreebyIndicator = GObject.registerClass( -class FreebyIndicator extends PanelMenu.Button { - _init(settings) { - super._init(0.0, 'Freeby', false); - this._settings = settings; - this._prev = {}; - for (const k of PROVIDERS) this._prev[k] = null; - this._refreshSeq = 0; - - this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' }); - this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' }); - this._panelBox.add_child(this._label); - this.add_child(this._panelBox); - this.connect('button-press-event', () => { this._refresh(); return false; }); - - this._items = {}; - for (const k of PROVIDERS) { - const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` }); - const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` }); - const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER }); - const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` }); - box.add_child(dot); - box.add_child(name); - box.add_child(summary); - const item = new PopupMenu.PopupMenuItem('', { reactive: false, can_focus: false }); - item.add_child(box); - this.menu.addMenuItem(item); - this._items[k] = { dot, summary }; + class FreebyIndicator extends PanelMenu.Button { + _init(settings) { + super._init(0.0, 'Freeby', false); + this._settings = settings; + this._prev = {}; + for (const k of PROVIDERS) this._prev[k] = null; + this._refreshSeq = 0; + + this._panelBox = new St.BoxLayout({ style_class: 'panel-status-menu-box freeby-panel', accessible_name: 'Freeby usage indicator' }); + this._label = new St.Label({ text: 'AI', y_align: Clutter.ActorAlign.CENTER, style_class: 'freeby-panel-label', accessible_name: 'Usage count' }); + this._panelBox.add_child(this._label); + this.add_child(this._panelBox); + this.connect('button-press-event', () => { this._refresh(); return false; }); + + this._items = {}; + for (const k of PROVIDERS) { + const box = new St.BoxLayout({ style_class: 'freeby-item-box', accessible_name: `${LABELS[k]} usage` }); + const dot = new St.Label({ text: '\u25CB', style_class: 'freeby-dot freeby-dot-off', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} status` }); + const name = new St.Label({ text: LABELS[k], style_class: 'freeby-item-name', y_align: Clutter.ActorAlign.CENTER }); + const summary = new St.Label({ text: '\u2014', style_class: 'freeby-item-summary', y_align: Clutter.ActorAlign.CENTER, accessible_name: `${LABELS[k]} summary` }); + box.add_child(dot); + box.add_child(name); + box.add_child(summary); + const item = new PopupMenu.PopupMenuItem('', { reactive: false, can_focus: false }); + item.add_child(box); + this.menu.addMenuItem(item); + this._items[k] = { dot, summary }; + } + + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false }); + this._statusItem.label.add_style_class_name('freeby-status'); + this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); }); + this.menu.addMenuItem(this._statusItem); + + this._timerId = null; + this._setupTwimer(); + this._refresh(); + this._monitorWake(); } - this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); - this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false, accessible_name: 'Refresh status' }); - this._statusItem.label.add_style_class_name('freeby-status'); - this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); }); - this.menu.addMenuItem(this._statusItem); - - this._timerId = null; - this._setupTimer(); - this._refresh(); - this._monitorWake(); - } - - _setupTimer() { - if (this._timerId) GLib.source_remove(this._timerId); - const sec = Math.max(30, this._settings.get_int('refresh-interval')); - this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, sec, () => { this._refresh(); return GLib.SOURCE_CONTINUE; }); - } - - _monitorWake() { - try { - this._sleepId = Gio.DBus.system.signal_subscribe( - 'org.freedesktop.login1', 'org.freedesktop.login1.Manager', - 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE, - (_, __, ___, ____, _____, params) => { - if (params.get_child_value(0).get_boolean()) { - log('freeby: woke from sleep, refreshing'); - this._refresh(); - } - }); - } catch (e) { logError(e, 'freeby: could not monitor sleep/wake'); } - } - - _notify(title, body) { - if (!this._settings.get_boolean('notifications-enabled')) return; - try { - const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic'); - Main.messageTray.add(src); - src.addNotification(new Main.messageTray.Notification({ source: src, title, body })); - } catch (e) { logError(e, 'freeby: notification failed'); } - } - - _setError() { - this._label.text = 'ai'; - this._label.style_class = 'freeby-panel-label'; - } - - _refresh() { - this._refreshSeq++; - const seq = this._refreshSeq; - let proc; - try { proc = Gio.Subprocess.new(['/bin/bash', SCRIPT], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); } - catch (e) { this._setError(); logError(e, 'freeby: failed to spawn script'); return; } - - proc.communicate_utf8_async(null, null, (p, res) => { - if (seq !== this._refreshSeq) return; - let stdout, stderr; - try { [, stdout, stderr] = p.communicate_utf8_finish(res); } - catch (e) { this._setError(); logError(e, 'freeby: subprocess failed'); return; } - if (!p.get_successful()) { this._setError(); log(`freeby: script error: ${stderr}`); return; } - this._apply(stdout); - }); - } - - _apply(stdout) { - let data; - try { data = JSON.parse(stdout); } - catch (e) { this._setError(); logError(e, 'freeby: bad JSON'); return; } - if (!data || typeof data !== 'object') { this._setError(); log('freeby: empty or invalid data'); return; } - - const active = PROVIDERS.filter(k => data[k]?.has_remaining).length; - const total = PROVIDERS.filter(k => data[k]?.available).length; - this._label.text = active > 0 ? `ai\u00B7${active}` : 'ai'; - this._label.style_class = 'freeby-panel-label' + (active === total && total > 0 ? ' freeby-panel-green' : active > 0 ? ' freeby-panel-yellow' : total > 0 ? ' freeby-panel-red' : ''); - - const hit = PROVIDERS.filter(k => data[k]?.available && !data[k].has_remaining && this._prev[k]?.has_remaining !== false).map(k => LABELS[k]); - this._prev = {}; - for (const k of PROVIDERS) this._prev[k] = data[k] ? { has_remaining: data[k].has_remaining } : null; - if (hit.length) this._notify('Usage limit reached', `${hit.join(', ')} ${hit.length === 1 ? 'has' : 'have'} hit their limit`); - - for (const k of PROVIDERS) { - const d = data[k], { dot, summary } = this._items[k]; - if (!d) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = 'no data'; summary.style_class = 'freeby-item-summary'; } - else if (!d.available) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = d.summary || 'not available'; summary.style_class = 'freeby-item-summary freeby-dim'; } - else if (d.summary?.includes('limit reached')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-red'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-red'; } - else if (d.summary?.includes('expired') || d.summary?.includes('parse error')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-yellow'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-yellow'; } - else { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-green'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-green'; } + _setupTimer() { + if (this._timerId) GLib.source_remove(this._timerId); + const sec = Math.max(30, this._settings.get_int('refresh-interval')); + this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, sec, () => { this._refresh(); return GLib.SOURCE_CONTINUE; }); } - this._statusItem.label.text = `\u21BB Last checked: ${GLib.DateTime.new_now_local().format('%H:%M:%S')}`; - } - - destroy() { - if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; } - if (this._sleepId) { Gio.DBus.system.signal_unsubscribe(this._sleepId); this._sleepId = null; } - super.destroy(); - } -}); + + _monitorWake() { + try { + this._sleepId = Gio.DBus.system.signal_subscribe( + 'org.freedesktop.login1', 'org.freedesktop.login1.Manager', + 'PrepareForSleep', '/org/freedesktop/login1', null, Gio.DBusSignalFlags.NONE, + (_, __, ___, ____, _____, params) => { + if (params.get_child_value(0).get_boolean()) { + log('freeby: woke from sleep, refreshing'); + this._refresh(); + } + }); + } catch (e) { logError(e, 'freeby: could not monitor sleep/wake'); } + } + + _notify(title, body) { + if (!this._settings.get_boolean('notifications-enabled')) return; + try { + const src = new Main.messageTray.Source('Freeby', 'dialog-information-symbolic'); + Main.messageTray.add(src); + src.addNotification(new Main.messageTray.Notification({ source: src, title, body })); + } catch (e) { logError(e, 'freeby: notification failed'); } + } + + _setError() { + this._label.text = 'ai'; + this._label.style_class = 'freeby-panel-label'; + } + + _refresh() { + this._refreshSeq++; + const seq = this._refreshSeq; + let proc; + try { proc = Gio.Subprocess.new(['/bin/bash', SCRIPT], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); } + catch (e) { this._setError(); logError(e, 'freeby: failed to spawn script'); return; } + + proc.communicate_utf8_async(null, null, (p, res) => { + if (seq !== this._refreshSeq) return; + let stdout, stderr; + try { [, stdout, stderr] = p.communicate_utf8_finish(res); } + catch (e) { this._setError(); logError(e, 'freeby: subprocess failed'); return; } + if (!p.get_successful()) { this._setError(); log(`freeby: script error: ${stderr}`); return; } + this._apply(stdout); + }); + } + + _apply(stdout) { + let data; + try { data = JSON.parse(stdout); } + catch (e) { this._setError(); logError(e, 'freeby: bad JSON'); return; } + if (!data || typeof data !== 'object') { this._setError(); log('freeby: empty or invalid data'); return; } + + const active = PROVIDERS.filter(k => data[k]?.has_remaining).length; + const total = PROVIDERS.filter(k => data[k]?.available).length; + this._label.text = active > 0 ? `ai\u00B7${active}` : 'ai'; + this._label.style_class = 'freeby-panel-label' + (active === total && total > 0 ? ' freeby-panel-green' : active > 0 ? ' freeby-panel-yellow' : total > 0 ? ' freeby-panel-red' : ''); + + const hit = PROVIDERS.filter(k => data[k]?.available && !data[k].has_remaining && this._prev[k]?.has_remaining !== false).map(k => LABELS[k]); + this._prev = {}; + for (const k of PROVIDERS) this._prev[k] = data[k] ? { has_remaining: data[k].has_remaining } : null; + if (hit.length) this._notify('Usage limit reached', `${hit.join(', ')} ${hit.length === 1 ? 'has' : 'have'} hit their limit`); + + for (const k of PROVIDERS) { + const d = data[k], { dot, summary } = this._items[k]; + if (!d) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = 'no data'; summary.style_class = 'freeby-item-summary'; } + else if (!d.available) { dot.text = '\u25CB'; dot.style_class = 'freeby-dot freeby-dot-off'; summary.text = d.summary || 'not available'; summary.style_class = 'freeby-item-summary freeby-dim'; } + else if (d.summary?.includes('limit reached')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-red'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-red'; } + else if (d.summary?.includes('expired') || d.summary?.includes('parse error')) { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-yellow'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-yellow'; } + else { dot.text = '\u25CF'; dot.style_class = 'freeby-dot freeby-dot-green'; summary.text = d.summary; summary.style_class = 'freeby-item-summary freeby-green'; } + } + this._statusItem.label.text = `\u21BB Last checked: ${GLib.DateTime.new_now_local().format('%H:%M:%S')}`; + } + + destroy() { + if (this._timerId) { GLib.source_remove(this._timerId); this._timerId = null; } + if (this._sleepId) { Gio.DBus.system.signal_unsubscribe(this._sleepId); this._sleepId = null; } + super.destroy(); + } + }); From d2c2d8624d19dffa18ab54de03fa42dda5ef14f4 Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 10:54:18 +0000 Subject: [PATCH 10/11] fix: keep dropdown open when clicking refresh --- indicator.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/indicator.js b/indicator.js index 3ad2559..b2aaa8a 100644 --- a/indicator.js +++ b/indicator.js @@ -45,11 +45,11 @@ export const FreebyIndicator = GObject.registerClass( this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); this._statusItem = new PopupMenu.PopupMenuItem('\u21BB Last checked: never', { reactive: true, can_focus: false }); this._statusItem.label.add_style_class_name('freeby-status'); - this._statusItem.connect('activate', () => { this._refresh(); this.menu.open(); }); + this._statusItem.connect('activate', () => { this._refresh(); GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { this.menu.open(); return GLib.SOURCE_REMOVE; }); }); this.menu.addMenuItem(this._statusItem); this._timerId = null; - this._setupTwimer(); + this._setupTimer(); this._refresh(); this._monitorWake(); } From 822d1636a7acc0c75a47ebfc663ed1428979bd4b Mon Sep 17 00:00:00 2001 From: kcnewman Date: Sun, 26 Jul 2026 10:55:43 +0000 Subject: [PATCH 11/11] release: v1.0.2 --- CHANGELOG.md | 8 ++++++++ meson.build | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0bf72a..5c3f947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## v1.0.2 + +### Fixed +- Added GNOME 49 and 50 to shell-version compatibility +- Removed deprecated `version` field from metadata.json +- Dropdown now stays open when clicking refresh (deferred reopen) +- Removed invalid `accessible_name` from PopupMenuItem (caused extension to fail loading) + ## v1.0.1 ### Added diff --git a/meson.build b/meson.build index 22d6b06..6af6f8f 100644 --- a/meson.build +++ b/meson.build @@ -1,4 +1,4 @@ -project('freeby', version: '1.0.1', license: 'MIT', +project('freeby', version: '1.0.2', license: 'MIT', meson_version: '>= 0.56.0', default_options: ['warning_level=0'])