From bb5d3c0d098bb5cbd70cfe852023c26a6a0a9e06 Mon Sep 17 00:00:00 2001 From: WaylonSmithersJr <291144809+WaylonSmithersJr@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:08:37 +0000 Subject: [PATCH] feat: add Xfce Genmon adapter for Codex usage --- .github/workflows/xfce.yml | 36 ++++++++++++++++++++++++ README.md | 2 ++ tests/xfceFormat.test.mjs | 30 ++++++++++++++++++++ tests/xfceRunner.test.mjs | 23 ++++++++++++++++ xfce/README.md | 43 +++++++++++++++++++++++++++++ xfce/format.mjs | 56 ++++++++++++++++++++++++++++++++++++++ xfce/genmon.js | 49 +++++++++++++++++++++++++++++++++ xfce/runner.mjs | 11 ++++++++ 8 files changed, 250 insertions(+) create mode 100644 .github/workflows/xfce.yml create mode 100644 tests/xfceFormat.test.mjs create mode 100644 tests/xfceRunner.test.mjs create mode 100644 xfce/README.md create mode 100644 xfce/format.mjs create mode 100644 xfce/genmon.js create mode 100644 xfce/runner.mjs diff --git a/.github/workflows/xfce.yml b/.github/workflows/xfce.yml new file mode 100644 index 0000000..d3a3d6f --- /dev/null +++ b/.github/workflows/xfce.yml @@ -0,0 +1,36 @@ +name: Xfce Genmon + +on: + push: + paths: + - 'xfce/**' + - 'tests/xfce*.mjs' + - 'codexAuth.js' + - 'usageApi.js' + - '.github/workflows/xfce.yml' + pull_request: + paths: + - 'xfce/**' + - 'tests/xfce*.mjs' + - 'codexAuth.js' + - 'usageApi.js' + - '.github/workflows/xfce.yml' + +jobs: + genmon: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install GJS runtime + run: sudo apt-get update && sudo apt-get install -y gjs gir1.2-soup-3.0 + - name: Test formatting and account handoff + run: node --test tests/xfceFormat.test.mjs tests/xfceRunner.test.mjs + - name: Prove CLI loads without GNOME Shell + run: | + output=$(gjs -m xfce/genmon.js --auth-file /tmp/codex-indicator-missing-auth.json) + test "$output" = 'Codex ?Codex CLI login required; check the auth file or run codex login.' + - name: Run shared parser checks + run: | + gjs -m tests/codexAuth.test.js + gjs -m tests/usageApi.test.js + gjs -m tests/usageApiHttp.test.js diff --git a/README.md b/README.md index fe2585f..4e01e64 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ GNOME Shell extension for GNOME Shell `45`โ€“`51` that shows remaining Codex usage in the top bar and displays the current 5-hour and weekly windows in the popup. +**Xfce:** GNOME Shell extensions cannot run in the Xfce panel. This fork also includes a [Generic Monitor (Genmon) adapter](xfce/README.md) that uses the same Codex auth reader and usage API normalizer; it does not require GNOME Shell. + ## Features - Automatically reads the bearer token from the local Codex CLI auth file at `~/.codex/auth.json` diff --git a/tests/xfceFormat.test.mjs b/tests/xfceFormat.test.mjs new file mode 100644 index 0000000..de270a4 --- /dev/null +++ b/tests/xfceFormat.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {renderGenmon, renderGenmonError} from '../xfce/format.mjs'; + +test('Genmon shows 5h/week usage, resets and credits', () => { + const output = renderGenmon({ + primaryWindow: {id: 'primary', percent: 0.25, windowSeconds: 18000, resetAt: 2030000000}, + weekWindow: {id: 'weekly', percent: 0.6, windowSeconds: 604800}, + credits: {balance: '12.5'}, + }); + assert.match(output, /Codex 75% left<\/txt>/); + assert.match(output, /5h: 75% left/); + assert.match(output, /Week: 40% left/); + assert.match(output, /12.5 credits remaining/); + assert.match(output, /resets/); +}); + +test('used mode, weekly-only fallback and XML escaping are safe', () => { + const output = renderGenmon({weekWindow: {id: 'weekly', percent: 1, windowSeconds: 604800}}, + {label: 'Work <&>', mode: 'used'}); + assert.match(output, /Work <&> 100% used<\/txt>/); + assert.match(output, /Week: 100% used/); + assert.doesNotMatch(output, /Work <&>/); +}); + +test('unknown usage and errors never leak raw markup', () => { + assert.match(renderGenmon({}), /Codex โ€”/); + assert.equal(renderGenmonError('Bad &', {label: 'A&B'}), + 'A&B ?Bad <token>&'); +}); diff --git a/tests/xfceRunner.test.mjs b/tests/xfceRunner.test.mjs new file mode 100644 index 0000000..7c69ca7 --- /dev/null +++ b/tests/xfceRunner.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {fetchPanelSummary} from '../xfce/runner.mjs'; + +test('selected auth file, token and account ID reach the shared API client', async () => { + const calls = []; + const summary = {primaryWindow: {percent: 0.25}}; + const result = await fetchPanelSummary({authFile: '/tmp/work-auth.json'}, + async options => { calls.push(options); return {accessToken: 'test-token', accountId: 'test-account'}; }, + () => ({ + async fetchSummary(token, accountId) { calls.push([token, accountId]); return summary; }, + destroy() { calls.push('destroy'); }, + })); + assert.equal(result, summary); + assert.deepEqual(calls, [{path: '/tmp/work-auth.json'}, ['test-token', 'test-account'], 'destroy']); +}); + +test('API errors still close the client', async () => { + let closed = false; + await assert.rejects(fetchPanelSummary({}, async () => ({accessToken: 'test', accountId: null}), + () => ({fetchSummary: async () => { throw new Error('offline'); }, destroy() { closed = true; }})), /offline/); + assert.equal(closed, true); +}); diff --git a/xfce/README.md b/xfce/README.md new file mode 100644 index 0000000..4f426bf --- /dev/null +++ b/xfce/README.md @@ -0,0 +1,43 @@ +# Xfce panel adapter + +The root extension targets **GNOME Shell only**. On Xfce, use the official [Generic Monitor panel plugin](https://docs.xfce.org/panel-plugins/xfce4-genmon-plugin/start) to run `genmon.js` every few minutes. The script reuses this repository's `codexAuth.js` and `usageApi.js`; it neither imports GNOME Shell nor makes a copy of the API parser. It outputs Genmon's documented `` and `` tags for the panel label and tooltip. + +## Install + +On Debian/Ubuntu/Xubuntu: + +```bash +sudo apt install xfce4-genmon-plugin gjs gir1.2-soup-3.0 +git clone https://github.com/WaylonSmithersJr/codex-usage-indicator.git "$HOME/.local/share/codex-usage-indicator" +``` + +Sign in with Codex CLI using file-backed credentials (`~/.codex/auth.json`). If your Codex CLI uses another home, you can pass its file explicitly. Add **Generic Monitor** to the Xfce panel, open **Properties**, and set **Command** to the absolute path command below (replace ``): + +```text +gjs -m /home//.local/share/codex-usage-indicator/xfce/genmon.js +``` + +Set **Period** to `300` seconds. Do not use an interval shorter than 60 seconds; the script makes an authenticated usage request each refresh. The initial run can take several seconds. The panel shows the remaining 5-hour allowance, or the weekly allowance if that is the only window available. Hover to see both windows, reset times and credits. Errors appear as `Codex ?` with a non-sensitive explanation in the tooltip. + +Options after the script path: + +- `--used`: show used rather than remaining percentage. +- `--label NAME`: replace the `Codex` label (quote names with spaces in the Genmon command). +- `--auth-file /absolute/path/auth.json`: use a separate Codex CLI login, useful for a second Genmon instance/account. `~/...` is also supported by the shared auth reader, but an absolute path avoids shell/Genmon expansion differences. + +For example, a work account panel item can use: + +```text +gjs -m /home//.local/share/codex-usage-indicator/xfce/genmon.js --auth-file /home//.codex-work/auth.json --label Work +``` + +Xfce's Genmon plugin supplies the panel lifecycle and refresh timer; this is **not** a GNOME extension converted to a native Xfce C plugin. The GNOME preferences UI and its account selector do not appear on Xfce. Use separate Genmon instances for multiple accounts. The Codex CLI file must contain a valid access token; this adapter does not refresh credentials itself. It sends the token only to the same `chatgpt.com` usage endpoints as the GNOME extension, never prints it, and prints generic errors rather than raw API responses. The existing GPL-3.0 license applies. + +## Checks + +```bash +node --test tests/xfceFormat.test.mjs tests/xfceRunner.test.mjs +gjs -m xfce/genmon.js --auth-file /tmp/codex-indicator-missing-auth.json +``` + +The second command should show a Genmon `Codex ?` element and safe login guidance. The fork's CI installs GJS/Libsoup and runs these checks plus the shared auth/API parser tests. A live visual panel test still requires a machine with Xfce and `xfce4-genmon-plugin`; this headless repository check does not claim that visual sign-off. diff --git a/xfce/format.mjs b/xfce/format.mjs new file mode 100644 index 0000000..69ef28e --- /dev/null +++ b/xfce/format.mjs @@ -0,0 +1,56 @@ +// Xfce Genmon accepts and on stdout. Keep this module free of +// GNOME/GI imports so its presentation and escaping can be tested anywhere. +export function escapeMarkup(value) { + return String(value).replaceAll('&', '&').replaceAll('<', '<') + .replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +function percent(value) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.round(Math.max(0, Math.min(1, value)) * 100) + : null; +} + +function windowName(window) { + return window?.windowSeconds >= 5 * 86400 || /week|secondary/i.test(`${window?.id ?? ''} ${window?.label ?? ''}`) + ? 'Week' : '5h'; +} + +function resetTime(window) { + const timestamp = window?.resetAt ?? (window?.resetAfterSeconds != null + ? Date.now() / 1000 + window.resetAfterSeconds : null); + return typeof timestamp === 'number' && Number.isFinite(timestamp) && timestamp > 0 + ? new Date(timestamp * 1000).toLocaleString() : null; +} + +function creditLabel(credits) { + if (!credits) return null; + if (credits.unlimited) return 'Unlimited credits'; + const balance = Number(credits.balance); + if (credits.balance == null || !Number.isFinite(balance)) return 'Credits unavailable'; + return `${new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format(balance)} credits remaining`; +} + +export function renderGenmon(summary, {label = 'Codex', mode = 'left'} = {}) { + const windows = [summary?.primaryWindow, summary?.weekWindow].filter(Boolean) + .filter((window, index, all) => all.findIndex(other => other.id === window.id) === index); + const active = summary?.primaryWindow ?? summary?.weekWindow ?? null; + const used = percent(active?.percent ?? summary?.percent); + const shown = used === null ? null : mode === 'used' ? used : 100 - used; + const caption = shown === null ? `${label} โ€”` : `${label} ${shown}% ${mode === 'used' ? 'used' : 'left'}`; + const tooltip = windows.map(window => { + const usedValue = percent(window.percent); + const value = usedValue === null ? 'unavailable' + : `${mode === 'used' ? usedValue : 100 - usedValue}% ${mode === 'used' ? 'used' : 'left'}`; + const reset = resetTime(window); + return `${windowName(window)}: ${value}${reset ? ` ยท resets ${reset}` : ''}`; + }); + const credits = creditLabel(summary?.credits); + if (credits) tooltip.push(credits); + if (tooltip.length === 0) tooltip.push('No usage windows available'); + return `${escapeMarkup(caption)}${escapeMarkup(tooltip.join('\n'))}`; +} + +export function renderGenmonError(message, {label = 'Codex'} = {}) { + return `${escapeMarkup(label)} ?${escapeMarkup(message)}`; +} diff --git a/xfce/genmon.js b/xfce/genmon.js new file mode 100644 index 0000000..b2ddd7c --- /dev/null +++ b/xfce/genmon.js @@ -0,0 +1,49 @@ +#!/usr/bin/env -S gjs -m + +// One-shot Xfce panel adapter. It shares the GNOME extension's auth reader and +// API normalizer, without importing GNOME Shell or reading browser cookies. +import {loadCodexCliAuth, CodexCliAuthError} from '../codexAuth.js'; +import {UsageApiClient, UsageApiError} from '../usageApi.js'; +import {renderGenmon, renderGenmonError} from './format.mjs'; +import {fetchPanelSummary} from './runner.mjs'; + +function parseOptions(args) { + const options = {label: 'Codex', mode: 'left'}; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--used') options.mode = 'used'; + else if ((arg === '--auth-file' || arg === '--label') && args[index + 1]) { + options[arg === '--auth-file' ? 'authFile' : 'label'] = args[++index]; + } else if (arg === '--help') return null; + else throw new Error(`Unknown or incomplete option: ${arg}`); + } + return options; +} + +function safeError(error) { + if (error instanceof CodexCliAuthError) return 'Codex CLI login required; check the auth file or run codex login.'; + if (error instanceof UsageApiError) { + if (error.isAuthError) return 'Codex login expired; run codex login again.'; + if (error.statusCode === 429) return 'Usage API rate-limited; retry at the next refresh.'; + return `Usage API unavailable (HTTP ${error.statusCode || '?'})`; + } + return 'Usage unavailable; check network and panel command.'; +} + +let options; +try { + options = parseOptions(ARGV); +} catch (error) { + print(renderGenmonError(error.message)); +} +if (options === null) { + print('Usage: gjs -m xfce/genmon.js [--auth-file /path/to/auth.json] [--label NAME] [--used]'); +} else if (options) { + try { + const summary = await fetchPanelSummary(options, loadCodexCliAuth, () => new UsageApiClient()); + print(renderGenmon(summary, options)); + } catch (error) { + // A panel display must never print the bearer token or raw API payload. + print(renderGenmonError(safeError(error), options)); + } +} diff --git a/xfce/runner.mjs b/xfce/runner.mjs new file mode 100644 index 0000000..205e110 --- /dev/null +++ b/xfce/runner.mjs @@ -0,0 +1,11 @@ +// Dependencies are passed in by the GJS entrypoint; this keeps account/cleanup +// behavior testable without a real token or a live API request. +export async function fetchPanelSummary(options, loadAuth, createClient) { + const auth = await loadAuth(options.authFile ? {path: options.authFile} : {}); + const client = createClient(); + try { + return await client.fetchSummary(auth.accessToken, auth.accountId); + } finally { + client.destroy(); + } +}