Skip to content
Open
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
21 changes: 20 additions & 1 deletion gnome/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,33 @@ 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 |
| Budget Threshold | $0 | Daily budget alert (0 = disabled) |
| 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
Expand Down
179 changes: 167 additions & 12 deletions gnome/indicator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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();
}

Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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 --
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions gnome/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}/"

Expand Down
2 changes: 1 addition & 1 deletion gnome/prefs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
109 changes: 109 additions & 0 deletions gnome/refreshCadence.js
Original file line number Diff line number Diff line change
@@ -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,
}
}
Loading
Loading