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();
+ }
+}