diff --git a/gnome/README.md b/gnome/README.md index c8da9f8fc..2e4791b6b 100644 --- a/gnome/README.md +++ b/gnome/README.md @@ -48,7 +48,7 @@ unavailable rather than being counted. | Setting | Default | Description | |---------|---------|-------------| -| Refresh Interval | 30s | How often to poll CodeBurn CLI | +| Refresh Interval | 30s | Fastest poll while the menu is open or a session is working. See [Refresh cadence](#refresh-cadence). | | Default Period | Today | Period shown on open | | Default Scope | This device | Show this device or all paired devices on open | | Compact Mode | Off | Hide cost label, show icon only | @@ -56,6 +56,25 @@ unavailable rather than being counted. | Budget Alerts | Off | Show warning when budget exceeded | | CLI Path | (auto) | Custom path to `codeburn` binary | +## Refresh cadence + +Background polling follows the macOS menubar (`RefreshCadence` in `mac/Sources/CodeBurnMenubar/RefreshCadence.swift`), not a fixed timer. + +| State | Next CLI poll | +| --- | --- | +| Menu open, or a live session still working | The Refresh Interval setting, capped at 30s. A shorter setting is kept. | +| Menu closed and no working session, on AC | At least 120s, or the setting if it is already slower | +| On battery | At least 150s | +| Power saver | At least 300s | + +Opening the menu fetches immediately. A session counts as working when its `idleSeconds` is at most 120 (the menubar's live-session threshold). A payload with no `liveSessions` block does not count as active. + +The panel keeps polling while the session is locked or you are away. Only the interval changes. It does not pause when the screen sleeps. A machine that does not answer UPower stays on the AC intervals, which is the extension's previous behavior of not adding a battery penalty it cannot see. + +The macOS menubar also arms a nap backstop (every 180s, with 60s of tolerance) because App Nap can defer its timer. GNOME Shell does not nap this extension, so that backstop is not scheduled. The same numbers live in `refreshCadence.js` next to the decision. + +The CLI dashboard's `--refresh` flag is a separate control and will not run faster than 60 seconds. The panel uses the menubar intervals above. Period and provider switches still reuse the last payload for up to 5 minutes; a cadence tick does not. + ## Uninstall ```bash diff --git a/gnome/indicator.js b/gnome/indicator.js index bad6f1515..19dfbd84a 100644 --- a/gnome/indicator.js +++ b/gnome/indicator.js @@ -7,6 +7,7 @@ import Soup from 'gi://Soup?version=3.0'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import { DataClient } from './dataClient.js'; +import { napBackstopPlan, refreshIntervalSeconds, sessionsAreActive } from './refreshCadence.js'; const CACHE_TTL_MS = 300_000; const TOP_ACTIVITIES = 10; @@ -285,6 +286,13 @@ class CodeBurnIndicator extends PanelMenu.Button { this._inFlightKeys = new Set(); this._refreshGen = 0; this._refreshSourceId = 0; + this._napSourceId = 0; + this._menuOpen = false; + this._menuOpenId = 0; + this._sessionsActive = false; + this._onBattery = false; + this._lowPowerMode = false; + this._powerWatches = []; this._chartSummaryText = ''; this._destroyed = false; @@ -295,8 +303,11 @@ class CodeBurnIndicator extends PanelMenu.Button { this._buildPanelButton(); this._buildPopup(); + this._connectMenu(); this._connectSettings(); - this._startRefreshLoop(); + this._watchPower(); + this._armRefreshTimer(); + this._armNapBackstop(); this._refresh(); } @@ -659,7 +670,7 @@ class CodeBurnIndicator extends PanelMenu.Button { const id = this._settings.connect(`changed::${key}`, cb); this._settingsChangedIds.push(id); }; - watch('refresh-interval', () => this._restartRefreshLoop()); + watch('refresh-interval', () => this._armRefreshTimer()); watch('compact-mode', () => { this._panelLabel.visible = !this._settings.get_boolean('compact-mode'); }); watch('codeburn-path', () => { this._dataClient.setCodeburnPath(this._settings.get_string('codeburn-path')); @@ -718,21 +729,158 @@ class CodeBurnIndicator extends PanelMenu.Button { } // -- Refresh loop -- + // + // The timer interval is the spawn interval. It is fast while the menu is + // open or a session is still working, and stretches once both are idle. + // Power only changes the idle floor. Opening the menu fetches immediately, + // the same as the menubar popover. The 5-minute payload cache still covers + // period and provider switches; a cadence tick bypasses it. + + _connectMenu() { + this._menuOpenId = this.menu.connect('open-state-changed', (_menu, open) => { + this._menuOpen = !!open; + if (this._menuOpen) this._refresh(true); + this._armRefreshTimer(); + }); + } - _startRefreshLoop() { - const interval = this._settings.get_uint('refresh-interval') || 30; - this._refreshSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, interval, () => { - this._refresh(); - return GLib.SOURCE_CONTINUE; + _currentRefreshIntervalSeconds() { + return refreshIntervalSeconds({ + menuOpen: this._menuOpen, + sessionsActive: this._sessionsActive, + onBattery: this._onBattery, + lowPowerMode: this._lowPowerMode, + configuredSeconds: this._settings.get_uint('refresh-interval'), }); } - _restartRefreshLoop() { + _armRefreshTimer() { + this._clearRefreshTimer(); + if (this._destroyed) return; + const seconds = this._currentRefreshIntervalSeconds(); + this._refreshSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, () => { + this._refreshSourceId = 0; + if (!this._destroyed) { + this._refresh(true); + this._armRefreshTimer(); + } + return GLib.SOURCE_REMOVE; + }); + } + + _clearRefreshTimer() { if (this._refreshSourceId) { GLib.Source.remove(this._refreshSourceId); this._refreshSourceId = 0; } - this._startRefreshLoop(); + } + + // App Nap does not apply to a GNOME Shell extension, so napBackstopPlan() + // leaves this unarmed. The call stays so the decision lives next to the + // menubar constants rather than as a missing branch. + _armNapBackstop() { + if (this._napSourceId) { + GLib.Source.remove(this._napSourceId); + this._napSourceId = 0; + } + const plan = napBackstopPlan(); + if (!plan.applies || this._destroyed) return; + this._napSourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, plan.intervalSeconds, () => { + if (!this._destroyed) this._refresh(true); + return GLib.SOURCE_CONTINUE; + }); + } + + _noteSessions(payload) { + const next = sessionsAreActive(payload?.liveSessions); + if (next === this._sessionsActive) return; + this._sessionsActive = next; + this._armRefreshTimer(); + } + + // UPower is optional. A missing service leaves both flags false, which is + // the AC idle interval: the extension used to have no battery penalty, and + // a machine that cannot answer should not grow one. + _watchPower() { + this._watchBusProxy( + 'org.freedesktop.UPower', + '/org/freedesktop/UPower', + 'org.freedesktop.UPower', + ); + this._watchBusProxy( + 'org.freedesktop.UPower.PowerProfiles', + '/org/freedesktop/UPower/PowerProfiles', + 'org.freedesktop.UPower.PowerProfiles', + ); + } + + _watchBusProxy(name, path, iface) { + try { + Gio.DBusProxy.new_for_bus( + Gio.BusType.SYSTEM, + Gio.DBusProxyFlags.NONE, + null, + name, + path, + iface, + null, + (_source, result) => { + if (this._destroyed) return; + try { + const proxy = Gio.DBusProxy.new_for_bus_finish(result); + const signalId = proxy.connect('g-properties-changed', () => this._applyPowerState(true)); + this._powerWatches.push({ proxy, signalId }); + this._applyPowerState(true); + } catch (e) { + log(`CodeBurn: ${name} unavailable: ${e.message}`); + } + }, + ); + } catch (e) { + log(`CodeBurn: ${name} watch failed: ${e.message}`); + } + } + + _readPowerFlags() { + let onBattery = false; + let lowPowerMode = false; + for (const { proxy } of this._powerWatches) { + let iface = ''; + try { + iface = proxy.get_interface_name?.() || ''; + } catch (e) { + continue; + } + if (iface === 'org.freedesktop.UPower') { + try { + const battery = proxy.get_cached_property('OnBattery'); + if (battery) onBattery = battery.unpack() === true; + } catch (e) { /* a missing answer stays on the AC interval */ } + } else if (iface === 'org.freedesktop.UPower.PowerProfiles') { + try { + const profile = proxy.get_cached_property('ActiveProfile'); + if (profile) lowPowerMode = profile.unpack() === 'power-saver'; + } catch (e) { /* a missing answer stays out of power saver */ } + } + } + return { onBattery, lowPowerMode }; + } + + _applyPowerState(rearm) { + const next = this._readPowerFlags(); + const changed = next.onBattery !== this._onBattery || next.lowPowerMode !== this._lowPowerMode; + this._onBattery = next.onBattery; + this._lowPowerMode = next.lowPowerMode; + if (rearm && changed && !this._destroyed) this._armRefreshTimer(); + } + + _stopPowerWatch() { + for (const { proxy, signalId } of this._powerWatches) { + try { + if (signalId) proxy.disconnect(signalId); + } catch (e) { /* proxy may already be gone */ } + } + this._powerWatches = []; } // -- Data fetching with cache -- @@ -770,6 +918,7 @@ class CodeBurnIndicator extends PanelMenu.Button { const payload = await this._dataClient.fetch(this._period, this._provider, this._scope); this._inFlightKeys.delete(key); if (this._destroyed || gen !== this._refreshGen) return; + this._noteSessions(payload); this._payloadCache.set(key, { payload, fetchedAt: Date.now() }); if (this._cacheKey() === key) { this._payload = payload; @@ -1293,10 +1442,16 @@ class CodeBurnIndicator extends PanelMenu.Button { destroy() { this._destroyed = true; - if (this._refreshSourceId) { - GLib.Source.remove(this._refreshSourceId); - this._refreshSourceId = 0; + this._clearRefreshTimer(); + if (this._napSourceId) { + GLib.Source.remove(this._napSourceId); + this._napSourceId = 0; + } + if (this._menuOpenId) { + this.menu.disconnect(this._menuOpenId); + this._menuOpenId = 0; } + this._stopPowerWatch(); if (this._themeSettings && this._themeSignal) { this._themeSettings.disconnect(this._themeSignal); this._themeSignal = null; diff --git a/gnome/install.sh b/gnome/install.sh index df0388122..98ea6e3cf 100755 --- a/gnome/install.sh +++ b/gnome/install.sh @@ -19,6 +19,7 @@ cp "${SCRIPT_DIR}/metadata.json" "${INSTALL_DIR}/" cp "${SCRIPT_DIR}/extension.js" "${INSTALL_DIR}/" cp "${SCRIPT_DIR}/indicator.js" "${INSTALL_DIR}/" cp "${SCRIPT_DIR}/dataClient.js" "${INSTALL_DIR}/" +cp "${SCRIPT_DIR}/refreshCadence.js" "${INSTALL_DIR}/" cp "${SCRIPT_DIR}/prefs.js" "${INSTALL_DIR}/" cp "${SCRIPT_DIR}/stylesheet.css" "${INSTALL_DIR}/" diff --git a/gnome/prefs.js b/gnome/prefs.js index c977dbf24..84ac9b645 100644 --- a/gnome/prefs.js +++ b/gnome/prefs.js @@ -54,7 +54,7 @@ export default class CodeBurnPreferences extends ExtensionPreferences { const refreshRow = new Adw.SpinRow({ title: 'Refresh Interval', - subtitle: 'Seconds between data refreshes', + subtitle: 'Fastest poll while the menu is open or a session is working. Idle waits longer, and longer on battery.', adjustment: new Gtk.Adjustment({ lower: 5, upper: 300, diff --git a/gnome/refreshCadence.js b/gnome/refreshCadence.js new file mode 100644 index 000000000..2d878b5a3 --- /dev/null +++ b/gnome/refreshCadence.js @@ -0,0 +1,109 @@ +// Activity-aware spawn interval for the GNOME panel. +// +// Mirrors mac/Sources/CodeBurnMenubar/RefreshCadence.swift (and the Windows +// port in windows/src-tauri/src/refresh.rs): fast while the user is looking +// or a session is still working, slower once everything is idle, and slower +// again on battery and in power saver. The nap backstop constants match +// CodeBurnApp.startNapBackstop; GNOME Shell does not App Nap the extension, +// so that scheduler is not armed (see napBackstopPlan). +// +// The CLI dashboard is a different control. `getRefreshIntervalMs` in +// src/dashboard.tsx floors an explicit `--refresh` at 60s and never adapts. +// The panel follows the menubar intervals below, whose idle floors all sit +// at or above that 60s CLI minimum. The active interval stays 30s so an open +// menu matches the menubar rather than the dashboard flag. + +export const ACTIVE_SECONDS = 30 +export const AC_IDLE_SECONDS = 120 +export const BATTERY_IDLE_SECONDS = 150 +export const LOW_POWER_IDLE_SECONDS = 300 + +// A live session waiting on the user rather than generating. Same threshold +// as LiveSession.idleThresholdSeconds in the macOS menubar. +export const SESSION_IDLE_THRESHOLD_SECONDS = 120 + +// CLI dashboard floor (`getRefreshIntervalMs`). Not applied to the active +// menubar interval; documented so the two policies stay comparable. +export const CLI_MIN_REFRESH_SECONDS = 60 + +// NSBackgroundActivityScheduler interval and tolerance. The menubar arms +// this because App Nap can coalesce its 30s timer. The tick still has to +// pass the same cadence gate; it is not a faster poll. +export const NAP_BACKSTOP_INTERVAL_SECONDS = 180 +export const NAP_BACKSTOP_TOLERANCE_SECONDS = 60 + +// GSettings range for refresh-interval. A missing or hand-edited value falls +// back to the active cadence, which is also the schema default. +const CONFIGURED_MIN_SECONDS = 5 +const CONFIGURED_MAX_SECONDS = 300 + +export function normalizeConfiguredSeconds(value) { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) return ACTIVE_SECONDS + return Math.min(CONFIGURED_MAX_SECONDS, Math.max(CONFIGURED_MIN_SECONDS, Math.round(n))) +} + +// Absence of the block means the CLI did not compute it ("unknown"), not +// "nothing is running". Unknown does not count as active, so a closed menu +// can still take the idle interval. An empty list was computed and is idle. +// A session with no idleSeconds reads as not idle, matching the menubar. +export function sessionsAreActive(liveSessions) { + const sessions = liveSessions?.sessions + if (!Array.isArray(sessions) || sessions.length === 0) return false + return sessions.some(session => { + const idle = session?.idleSeconds + if (idle == null) return true + const seconds = Number(idle) + if (!Number.isFinite(seconds)) return true + return seconds <= SESSION_IDLE_THRESHOLD_SECONDS + }) +} + +/** + * Seconds until the next CLI spawn. + * + * `menuOpen` is the GNOME popup, the counterpart of the menubar's + * `popoverOpen`. A working session counts as active too, so the panel + * number keeps up while an agent is writing even if the menu is closed. + * + * The GNOME refresh-interval setting is kept: + * - while active, the interval is the setting capped at the menubar's + * active cadence (a shorter setting is honored; a longer one still + * catches up while someone is looking or a session is working) + * - while idle, the interval is at least the power-aware floor, and a + * setting slower than that floor is honored + * + * Unknown power (UPower missing) is AC. Callers pass `onBattery` and + * `lowPowerMode` as false in that case, which is the extension's previous + * behavior of not inventing a battery penalty. + */ +export function refreshIntervalSeconds({ + menuOpen = false, + sessionsActive = false, + onBattery = false, + lowPowerMode = false, + configuredSeconds = ACTIVE_SECONDS, +} = {}) { + const configured = normalizeConfiguredSeconds(configuredSeconds) + if (menuOpen || sessionsActive) return Math.min(configured, ACTIVE_SECONDS) + const idle = lowPowerMode + ? LOW_POWER_IDLE_SECONDS + : onBattery + ? BATTERY_IDLE_SECONDS + : AC_IDLE_SECONDS + return Math.max(configured, idle) +} + +/** + * App Nap backstop. `applies` is false here: GNOME Shell runs this + * extension on its own main loop and does not nap it, so a second timer + * would only duplicate the cadence timer. The menubar intervals stay in + * the plan so a later port can arm the same scheduler without retuning it. + */ +export function napBackstopPlan() { + return { + applies: false, + intervalSeconds: NAP_BACKSTOP_INTERVAL_SECONDS, + toleranceSeconds: NAP_BACKSTOP_TOLERANCE_SECONDS, + } +} diff --git a/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml index 8af97a427..f7abfb38a 100644 --- a/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml +++ b/gnome/schemas/org.gnome.shell.extensions.codeburn.gschema.xml @@ -6,7 +6,7 @@ 30 Refresh interval - Seconds between automatic data refreshes + Fastest gap, in seconds, between polls while the menu is open or a session is working. Idle polling waits at least two minutes on AC, longer on battery and in power saver. A value slower than that idle floor is kept. diff --git a/tests/gnome-refresh-cadence.test.ts b/tests/gnome-refresh-cadence.test.ts new file mode 100644 index 000000000..08e2957ea --- /dev/null +++ b/tests/gnome-refresh-cadence.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' + +import { + AC_IDLE_SECONDS, + ACTIVE_SECONDS, + BATTERY_IDLE_SECONDS, + CLI_MIN_REFRESH_SECONDS, + LOW_POWER_IDLE_SECONDS, + NAP_BACKSTOP_INTERVAL_SECONDS, + NAP_BACKSTOP_TOLERANCE_SECONDS, + SESSION_IDLE_THRESHOLD_SECONDS, + napBackstopPlan, + refreshIntervalSeconds, + sessionsAreActive, +} from '../gnome/refreshCadence.js' + +const AC = { onBattery: false, lowPowerMode: false } +const BATTERY = { onBattery: true, lowPowerMode: false } +const SAVER = { onBattery: true, lowPowerMode: true } + +describe('GNOME refresh cadence', () => { + it('uses the menubar constants', () => { + expect(ACTIVE_SECONDS).toBe(30) + expect(AC_IDLE_SECONDS).toBe(120) + expect(BATTERY_IDLE_SECONDS).toBe(150) + expect(LOW_POWER_IDLE_SECONDS).toBe(300) + expect(SESSION_IDLE_THRESHOLD_SECONDS).toBe(120) + expect(NAP_BACKSTOP_INTERVAL_SECONDS).toBe(180) + expect(NAP_BACKSTOP_TOLERANCE_SECONDS).toBe(60) + expect(CLI_MIN_REFRESH_SECONDS).toBe(60) + expect(ACTIVE_SECONDS).toBeLessThan(AC_IDLE_SECONDS) + expect(AC_IDLE_SECONDS).toBeLessThan(BATTERY_IDLE_SECONDS) + expect(BATTERY_IDLE_SECONDS).toBeLessThan(LOW_POWER_IDLE_SECONDS) + for (const idle of [AC_IDLE_SECONDS, BATTERY_IDLE_SECONDS, LOW_POWER_IDLE_SECONDS]) { + expect(idle).toBeGreaterThanOrEqual(CLI_MIN_REFRESH_SECONDS) + } + }) + + it('stays on the active cadence while the menu is open, on any power state', () => { + for (const power of [AC, BATTERY, SAVER, { onBattery: false, lowPowerMode: true }]) { + expect(refreshIntervalSeconds({ menuOpen: true, configuredSeconds: 30, ...power })).toBe(ACTIVE_SECONDS) + } + }) + + it('honors a faster setting while active and caps a slower one at 30s', () => { + expect(refreshIntervalSeconds({ menuOpen: true, configuredSeconds: 10, ...SAVER })).toBe(10) + expect(refreshIntervalSeconds({ sessionsActive: true, menuOpen: false, configuredSeconds: 300, ...SAVER })).toBe(ACTIVE_SECONDS) + }) + + it('stretches a closed idle menu the way the menubar does', () => { + expect(refreshIntervalSeconds({ menuOpen: false, sessionsActive: false, configuredSeconds: 30, ...AC })).toBe(AC_IDLE_SECONDS) + expect(refreshIntervalSeconds({ menuOpen: false, sessionsActive: false, configuredSeconds: 30, ...BATTERY })).toBe(BATTERY_IDLE_SECONDS) + expect(refreshIntervalSeconds({ menuOpen: false, sessionsActive: false, configuredSeconds: 30, ...SAVER })).toBe(LOW_POWER_IDLE_SECONDS) + expect(refreshIntervalSeconds({ + menuOpen: false, + sessionsActive: false, + configuredSeconds: 30, + onBattery: false, + lowPowerMode: true, + })).toBe(LOW_POWER_IDLE_SECONDS) + }) + + it('keeps a slower user setting when it already waits longer than the idle floor', () => { + expect(refreshIntervalSeconds({ menuOpen: false, configuredSeconds: 200, ...AC })).toBe(200) + expect(refreshIntervalSeconds({ menuOpen: false, configuredSeconds: 200, ...BATTERY })).toBe(200) + expect(refreshIntervalSeconds({ menuOpen: false, configuredSeconds: 200, ...SAVER })).toBe(LOW_POWER_IDLE_SECONDS) + }) + + it('treats a working session as active even with the menu closed', () => { + expect(sessionsAreActive({ + windowSeconds: 600, + sessions: [{ idleSeconds: 0 }, { idleSeconds: SESSION_IDLE_THRESHOLD_SECONDS }], + })).toBe(true) + expect(sessionsAreActive({ + windowSeconds: 600, + sessions: [{ idleSeconds: SESSION_IDLE_THRESHOLD_SECONDS }], + })).toBe(true) + expect(sessionsAreActive({ + windowSeconds: 600, + sessions: [{ idleSeconds: SESSION_IDLE_THRESHOLD_SECONDS + 1 }], + })).toBe(false) + expect(sessionsAreActive({ windowSeconds: 600, sessions: [{}] })).toBe(true) + expect(sessionsAreActive(undefined)).toBe(false) + expect(sessionsAreActive({ windowSeconds: 600, sessions: [] })).toBe(false) + + expect(refreshIntervalSeconds({ + menuOpen: false, + sessionsActive: true, + configuredSeconds: 30, + ...BATTERY, + })).toBe(ACTIVE_SECONDS) + }) + + it('falls back to the active cadence for an unset interval and never returns zero', () => { + expect(refreshIntervalSeconds({ menuOpen: true, configuredSeconds: 0 })).toBe(ACTIVE_SECONDS) + expect(refreshIntervalSeconds({ menuOpen: false, configuredSeconds: Number.NaN, ...AC })).toBe(AC_IDLE_SECONDS) + expect(refreshIntervalSeconds()).toBeGreaterThan(0) + }) + + it('does not arm the nap backstop on GNOME', () => { + expect(napBackstopPlan()).toEqual({ + applies: false, + intervalSeconds: NAP_BACKSTOP_INTERVAL_SECONDS, + toleranceSeconds: NAP_BACKSTOP_TOLERANCE_SECONDS, + }) + }) +})