codeburn/gnome/dataClient.js

189 lines
5.3 KiB
JavaScript

import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
const TIMEOUT_SECONDS = 15;
const SAFE_ARG_RE = /^[A-Za-z0-9 ._/\-]+$/;
function isCombinedDetailsUnsupported(error) {
return /unknown option[^\n]*--combined-details/i.test(error?.message || '');
}
function buildAdditionalPaths() {
const home = GLib.get_home_dir();
return [
'/usr/local/bin',
`${home}/.local/bin`,
`${home}/.npm-global/bin`,
`${home}/.volta/bin`,
`${home}/.bun/bin`,
`${home}/.cargo/bin`,
`${home}/.asdf/shims`,
`${home}/.local/share/fnm/aliases/default/bin`,
`${home}/.local/share/pnpm`,
];
}
export class DataClient {
_cache = new Map();
_inFlight = null;
_combinedDetailsSupported = null;
_codeburnPath;
_augmentedPath;
constructor(codeburnPath) {
this._codeburnPath = codeburnPath || '';
this._augmentedPath = this._buildAugmentedPath();
}
setCodeburnPath(path) {
this._codeburnPath = path || '';
this._combinedDetailsSupported = null;
}
cancelInFlight() {
if (this._inFlight) {
this._inFlight.cancellable.cancel();
this._inFlight = null;
}
}
getCached(period, provider, scope = 'local') {
const key = `${scope}:${period}:${provider}`;
return this._cache.get(key) ?? null;
}
async fetch(period, provider, scope = 'local') {
this.cancelInFlight();
const cancellable = new Gio.Cancellable();
this._inFlight = { cancellable };
try {
let payload;
if (scope === 'combined' && this._combinedDetailsSupported !== false) {
try {
payload = await this._spawn(period, provider, scope, cancellable, true);
this._combinedDetailsSupported = true;
} catch (e) {
// Keep the extension usable with an older globally installed CLI.
// It can still provide combined totals, but does not know how to
// emit the optional detailed per-device payloads.
if (!isCombinedDetailsUnsupported(e)) throw e;
this._combinedDetailsSupported = false;
payload = await this._spawn(period, provider, scope, cancellable, false);
}
} else {
payload = await this._spawn(period, provider, scope, cancellable, false);
}
const key = `${scope}:${period}:${provider}`;
this._cache.set(key, payload);
return payload;
} finally {
if (this._inFlight?.cancellable === cancellable)
this._inFlight = null;
}
}
_buildArgv(period, provider, scope = 'local', combinedDetails = true) {
let base;
if (this._codeburnPath && SAFE_ARG_RE.test(this._codeburnPath)) {
base = this._codeburnPath.split(' ').filter(s => s.length > 0);
} else {
base = ['codeburn'];
}
const args = [
...base,
'status',
'--format', 'menubar-json',
'--period', period,
'--no-optimize',
];
// Combined usage is intentionally unfiltered: paired devices expose
// aggregate all-provider data, and the CLI rejects scoped combinations.
if (scope === 'combined') {
args.push('--scope', 'combined');
if (combinedDetails) args.push('--combined-details');
} else if (provider && provider !== 'all') {
args.push('--provider', provider);
}
return args;
}
_buildAugmentedPath() {
const currentPath = GLib.getenv('PATH') || '/usr/bin:/bin';
const parts = currentPath.split(':');
for (const extra of buildAdditionalPaths()) {
if (!parts.includes(extra))
parts.push(extra);
}
return parts.join(':');
}
_spawn(period, provider, scope, cancellable, combinedDetails = true) {
return new Promise((resolve, reject) => {
const argv = this._buildArgv(period, provider, scope, combinedDetails);
let settled = false;
const settle = (fn, value) => {
if (settled) return;
settled = true;
fn(value);
};
let proc;
try {
const launcher = Gio.SubprocessLauncher.new(
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
launcher.setenv('PATH', this._augmentedPath, true);
proc = launcher.spawnv(argv);
} catch (e) {
settle(reject, new Error(`CLI not found: ${e.message}`));
return;
}
let timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, TIMEOUT_SECONDS, () => {
timeoutId = 0;
proc.force_exit();
settle(reject, new Error('CLI timeout'));
return GLib.SOURCE_REMOVE;
});
proc.communicate_utf8_async(null, cancellable, (_proc, res) => {
if (timeoutId) {
GLib.Source.remove(timeoutId);
timeoutId = 0;
}
try {
const [, stdout, stderr] = _proc.communicate_utf8_finish(res);
if (!_proc.get_successful()) {
const msg = stderr?.trim() || 'CLI exited with error';
settle(reject, new Error(msg));
return;
}
if (!stdout || stdout.trim().length === 0) {
settle(reject, new Error('CLI returned empty output'));
return;
}
const payload = JSON.parse(stdout);
settle(resolve, payload);
} catch (e) {
settle(reject, e);
}
});
});
}
destroy() {
this.cancelInFlight();
this._cache.clear();
this._combinedDetailsSupported = null;
}
}