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/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) diff --git a/indicator.js b/indicator.js index 9f0eb5c..b2aaa8a 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(); 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(); + } + }); 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']) 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" }