Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
16 changes: 0 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@
<a href="https://github.com/kcnewman/freeby/releases">Releases</a>
</p>

---

### Features

- **Panel indicator** — colored `ai·N` shows available providers at a glance
Expand All @@ -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 |
Expand All @@ -40,8 +36,6 @@
| 🟢 **Cursor** | `~/.config/cursor/auth.json` | Cursor API |
| 🟠 **Copilot** | `gh` CLI or `~/.config/freeby/copilot-token` | GitHub API |

---

### Install

**Prerequisites**
Expand Down Expand Up @@ -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 |
Expand All @@ -99,8 +91,6 @@ gsettings --schemadir ~/.local/share/glib-2.0/schemas \
set org.gnome.shell.extensions.freeby refresh-interval 60
```

---

### Uninstall

```bash
Expand All @@ -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
Expand All @@ -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)
244 changes: 122 additions & 122 deletions indicator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(); GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { this.menu.open(); return GLib.SOURCE_REMOVE; }); });
this.menu.addMenuItem(this._statusItem);

this._timerId = null;
this._setupTimer();
this._refresh();
this._monitorWake();
}

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();
}
});
2 changes: 1 addition & 1 deletion meson.build
Original file line number Diff line number Diff line change
@@ -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'])

Expand Down
3 changes: 1 addition & 2 deletions metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading