initial — claude usage tray (scraper.ts + tray.py)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
session/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
node_modules/
|
||||||
|
.DS_Store
|
||||||
323
scraper.ts
Normal file
323
scraper.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import { chromium, type BrowserContext } from 'playwright';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const SESSION_DIR = path.join(process.env.HOME!, '.local/share/claude-usage/session');
|
||||||
|
const STATE_FILE = path.join(SESSION_DIR, 'state.json');
|
||||||
|
const USAGE_URL = 'https://claude.ai/settings/usage';
|
||||||
|
|
||||||
|
async function scrapeUsage(context: BrowserContext): Promise<string | null> {
|
||||||
|
const page = await context.newPage();
|
||||||
|
try {
|
||||||
|
await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
|
||||||
|
// Wait for usage data to render (poll for "used" text)
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const t = await page.evaluate(() => document.body?.innerText || '');
|
||||||
|
if (t.includes('% used')) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await page.evaluate(() => document.body?.innerText || '');
|
||||||
|
|
||||||
|
if (text.includes('Log in') || text.includes('Continue with Google') || text.includes('Continue with email')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (text.includes('security verification') || text.includes('not a bot')) {
|
||||||
|
await page.waitForTimeout(10000);
|
||||||
|
const t2 = await page.evaluate(() => document.body?.innerText || '');
|
||||||
|
if (t2.includes('security verification')) return null;
|
||||||
|
return t2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-save session state to keep it fresh
|
||||||
|
await context.storageState({ path: STATE_FILE });
|
||||||
|
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUsage(raw: string) {
|
||||||
|
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Extract "Current session" block
|
||||||
|
const sessionIdx = lines.findIndex(l => l === 'Current session');
|
||||||
|
if (sessionIdx >= 0) {
|
||||||
|
for (let i = sessionIdx + 1; i < Math.min(sessionIdx + 5, lines.length); i++) {
|
||||||
|
const resetMatch = lines[i].match(/Resets (.+)/);
|
||||||
|
if (resetMatch) result.sessionResets = resetMatch[1];
|
||||||
|
const pctMatch = lines[i].match(/(\d+)%\s*used/);
|
||||||
|
if (pctMatch) result.sessionUsed = parseInt(pctMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract "All models" (weekly)
|
||||||
|
const allModelsIdx = lines.findIndex(l => l === 'All models');
|
||||||
|
if (allModelsIdx >= 0) {
|
||||||
|
for (let i = allModelsIdx + 1; i < Math.min(allModelsIdx + 5, lines.length); i++) {
|
||||||
|
const resetMatch = lines[i].match(/Resets (.+)/);
|
||||||
|
if (resetMatch) result.weeklyResets = resetMatch[1];
|
||||||
|
const pctMatch = lines[i].match(/(\d+)%\s*used/);
|
||||||
|
if (pctMatch) result.weeklyUsed = parseInt(pctMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract "Sonnet only"
|
||||||
|
const sonnetIdx = lines.findIndex(l => l === 'Sonnet only');
|
||||||
|
if (sonnetIdx >= 0) {
|
||||||
|
for (let i = sonnetIdx + 1; i < Math.min(sonnetIdx + 5, lines.length); i++) {
|
||||||
|
const pctMatch = lines[i].match(/(\d+)%\s*used/);
|
||||||
|
if (pctMatch) result.sonnetUsed = parseInt(pctMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract extra usage
|
||||||
|
const extraIdx = lines.findIndex(l => l.includes('Extra usage'));
|
||||||
|
if (extraIdx >= 0) {
|
||||||
|
for (let i = extraIdx + 1; i < Math.min(extraIdx + 5, lines.length); i++) {
|
||||||
|
const spentMatch = lines[i].match(/\$([\d.]+)\s*spent/);
|
||||||
|
if (spentMatch) result.extraSpent = spentMatch[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert 12h to 24h format (e.g. "Sat 12:00 PM" -> "Sat 12:00", "Sun 1:30 AM" -> "Sun 01:30")
|
||||||
|
for (const key of ['sessionResets', 'weeklyResets']) {
|
||||||
|
if (result[key]) {
|
||||||
|
result[key] = result[key].replace(
|
||||||
|
/(\d{1,2}):(\d{2})\s*(AM|PM)/i,
|
||||||
|
(_: string, h: string, m: string, ampm: string) => {
|
||||||
|
let hour = parseInt(h);
|
||||||
|
if (ampm.toUpperCase() === 'PM' && hour !== 12) hour += 12;
|
||||||
|
if (ampm.toUpperCase() === 'AM' && hour === 12) hour = 0;
|
||||||
|
return hour.toString().padStart(2, '0') + ':' + m;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsage(data: Record<string, any>) {
|
||||||
|
const GREEN = '\x1b[0;32m';
|
||||||
|
const YELLOW = '\x1b[1;33m';
|
||||||
|
const RED = '\x1b[0;31m';
|
||||||
|
const CYAN = '\x1b[0;36m';
|
||||||
|
const DIM = '\x1b[2m';
|
||||||
|
const BOLD = '\x1b[1m';
|
||||||
|
const NC = '\x1b[0m';
|
||||||
|
|
||||||
|
function bar(pct: number, width = 20): string {
|
||||||
|
const filled = Math.round(pct * width / 100);
|
||||||
|
const empty = width - filled;
|
||||||
|
const color = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN;
|
||||||
|
return color + '█'.repeat(filled) + NC + DIM + '░'.repeat(empty) + NC;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(`${BOLD} Claude Usage${NC}`);
|
||||||
|
console.log(' ──────────────────────────────');
|
||||||
|
|
||||||
|
if (data.sessionUsed !== undefined) {
|
||||||
|
console.log(` ${CYAN}Session${NC} ${bar(data.sessionUsed)} ${BOLD}${data.sessionUsed}%${NC} ${DIM}resets ${data.sessionResets || '?'}${NC}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.weeklyUsed !== undefined) {
|
||||||
|
console.log(` ${CYAN}Weekly${NC} ${bar(data.weeklyUsed)} ${BOLD}${data.weeklyUsed}%${NC} ${DIM}resets ${data.weeklyResets || '?'}${NC}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.sonnetUsed !== undefined) {
|
||||||
|
console.log(` ${CYAN}Sonnet${NC} ${bar(data.sonnetUsed)} ${BOLD}${data.sonnetUsed}%${NC}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.extraSpent !== undefined) {
|
||||||
|
console.log(` ${DIM}Extra usage: $${data.extraSpent} spent${NC}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
console.error('Opening browser — log in to Claude...');
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: false,
|
||||||
|
args: ['--disable-blink-features=AutomationControlled'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reuse existing session if available, so user doesn't get sent to login page
|
||||||
|
const contextOptions: any = { viewport: { width: 1280, height: 800 } };
|
||||||
|
if (fs.existsSync(STATE_FILE)) {
|
||||||
|
contextOptions.storageState = STATE_FILE;
|
||||||
|
}
|
||||||
|
const context = await browser.newContext(contextOptions);
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||||
|
|
||||||
|
console.error('Waiting for usage page...');
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const text = await page.evaluate(() => document.body?.innerText || '');
|
||||||
|
if (text.includes('used') && text.includes('Resets')) {
|
||||||
|
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||||
|
await context.storageState({ path: STATE_FILE });
|
||||||
|
console.error('Session saved!');
|
||||||
|
const data = parseUsage(text);
|
||||||
|
renderUsage(data);
|
||||||
|
await browser.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Timed out.');
|
||||||
|
await browser.close();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
// Must use headed mode to bypass Cloudflare.
|
||||||
|
// Xvfb creates a truly invisible virtual display — no windows on screen.
|
||||||
|
const display = ':' + (99 + Math.floor(Math.random() * 100));
|
||||||
|
const xvfb = require('child_process').spawn('Xvfb', [
|
||||||
|
display, '-screen', '0', '1280x720x24', '-nolisten', 'tcp',
|
||||||
|
], { stdio: 'ignore', detached: true });
|
||||||
|
xvfb.unref();
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: false,
|
||||||
|
args: [
|
||||||
|
'--disable-blink-features=AutomationControlled',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-gpu',
|
||||||
|
],
|
||||||
|
env: { ...process.env, DISPLAY: display },
|
||||||
|
});
|
||||||
|
|
||||||
|
const origClose = browser.close.bind(browser);
|
||||||
|
(browser as any).close = async () => {
|
||||||
|
await origClose();
|
||||||
|
try { xvfb.kill('SIGTERM'); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
storageState: STATE_FILE,
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
});
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
|
});
|
||||||
|
const text = await scrapeUsage(context);
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
if (jsonMode) {
|
||||||
|
console.log(JSON.stringify({ loggedIn: false }));
|
||||||
|
} else {
|
||||||
|
console.error('Session expired. Run: claude-usage --login');
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parseUsage(text);
|
||||||
|
if (jsonMode) {
|
||||||
|
console.log(JSON.stringify({ ...data, loggedIn: true }));
|
||||||
|
} else {
|
||||||
|
renderUsage(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function live() {
|
||||||
|
// Keep Chromium running, read DOM every 15 seconds, output JSON lines to stdout.
|
||||||
|
// The page updates itself live, so no need to reload.
|
||||||
|
const display = ':' + (99 + Math.floor(Math.random() * 100));
|
||||||
|
const xvfb = require('child_process').spawn('Xvfb', [
|
||||||
|
display, '-screen', '0', '1280x720x24', '-nolisten', 'tcp',
|
||||||
|
], { stdio: 'ignore', detached: true });
|
||||||
|
xvfb.unref();
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: false,
|
||||||
|
args: ['--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-gpu'],
|
||||||
|
env: { ...process.env, DISPLAY: display },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
try { await browser.close(); } catch {}
|
||||||
|
try { xvfb.kill('SIGTERM'); } catch {}
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', cleanup);
|
||||||
|
process.on('SIGINT', cleanup);
|
||||||
|
|
||||||
|
const context = await browser.newContext({
|
||||||
|
storageState: STATE_FILE,
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
});
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(USAGE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
|
||||||
|
// Wait for initial load
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
const t = await page.evaluate(() => document.body?.innerText || '');
|
||||||
|
if (t.includes('% used')) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep session fresh
|
||||||
|
await context.storageState({ path: STATE_FILE });
|
||||||
|
|
||||||
|
// Poll DOM every 15 seconds and output JSON lines
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const text = await page.evaluate(() => document.body?.innerText || '');
|
||||||
|
if (text.includes('% used')) {
|
||||||
|
const data = parseUsage(text);
|
||||||
|
console.log(JSON.stringify({ ...data, loggedIn: true }));
|
||||||
|
} else if (text.includes('Log in') || text.includes('Continue with')) {
|
||||||
|
console.log(JSON.stringify({ loggedIn: false }));
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
console.log(JSON.stringify({ loggedIn: false }));
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(15000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main
|
||||||
|
const jsonMode = process.argv.includes('--json');
|
||||||
|
|
||||||
|
if (process.argv.includes('--login')) {
|
||||||
|
await login();
|
||||||
|
} else if (process.argv.includes('--live')) {
|
||||||
|
if (!fs.existsSync(STATE_FILE)) {
|
||||||
|
console.log(JSON.stringify({ loggedIn: false }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
await live();
|
||||||
|
} else if (fs.existsSync(STATE_FILE)) {
|
||||||
|
await check();
|
||||||
|
} else {
|
||||||
|
if (jsonMode) {
|
||||||
|
console.log(JSON.stringify({ loggedIn: false }));
|
||||||
|
} else {
|
||||||
|
await login();
|
||||||
|
}
|
||||||
|
}
|
||||||
460
tray.py
Normal file
460
tray.py
Normal file
@@ -0,0 +1,460 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Claude Usage system tray indicator for Cinnamon."""
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version('Gtk', '3.0')
|
||||||
|
gi.require_version('AyatanaAppIndicator3', '0.1')
|
||||||
|
|
||||||
|
from gi.repository import Gtk, AyatanaAppIndicator3, GLib
|
||||||
|
import cairo
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
|
||||||
|
SCRAPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'scraper.ts')
|
||||||
|
BUN = os.path.expanduser('~/.bun/bin/bun')
|
||||||
|
ICON_DIR = os.path.join(tempfile.gettempdir(), 'claude-usage-icons')
|
||||||
|
LOCK_FILE = os.path.join(tempfile.gettempdir(), 'claude-usage-tray.pid')
|
||||||
|
ICON_SIZE = 48
|
||||||
|
POLL_SECONDS = 600 # 10 minutes
|
||||||
|
RETRY_SECONDS = 30 # retry quickly after failure
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_lock():
|
||||||
|
"""Ensure only one instance runs. Kill ALL other tray.py processes."""
|
||||||
|
import signal
|
||||||
|
|
||||||
|
my_pid = os.getpid()
|
||||||
|
|
||||||
|
# Kill any other running instances of this script (not just by PID file)
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
['pgrep', '-f', 'claude-usage/tray.py'],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
for line in result.stdout.strip().split('\n'):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
pid = int(line.strip())
|
||||||
|
if pid != my_pid:
|
||||||
|
try:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, PermissionError):
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also check PID file for good measure
|
||||||
|
if os.path.exists(LOCK_FILE):
|
||||||
|
try:
|
||||||
|
old_pid = int(open(LOCK_FILE).read().strip())
|
||||||
|
if old_pid != my_pid:
|
||||||
|
os.kill(old_pid, signal.SIGTERM)
|
||||||
|
except (ProcessLookupError, ValueError, PermissionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
with open(LOCK_FILE, 'w') as f:
|
||||||
|
f.write(str(my_pid))
|
||||||
|
|
||||||
|
|
||||||
|
def release_lock():
|
||||||
|
try:
|
||||||
|
os.unlink(LOCK_FILE)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UsageIndicator:
|
||||||
|
def __init__(self):
|
||||||
|
self.data = None
|
||||||
|
self.timer_id = None
|
||||||
|
self.updating = False
|
||||||
|
self.icon_counter = 0
|
||||||
|
self.live_mode = False
|
||||||
|
self.live_proc = None
|
||||||
|
# Remember the real display so login browser opens on the user's screen
|
||||||
|
self.real_display = os.environ.get('DISPLAY', ':0')
|
||||||
|
|
||||||
|
os.makedirs(ICON_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# Draw initial icon (grey, unknown state)
|
||||||
|
initial_icon = self._draw_icon(None)
|
||||||
|
initial_icon_name = os.path.splitext(os.path.basename(initial_icon))[0]
|
||||||
|
|
||||||
|
self.indicator = AyatanaAppIndicator3.Indicator.new(
|
||||||
|
'claude-usage',
|
||||||
|
initial_icon_name,
|
||||||
|
AyatanaAppIndicator3.IndicatorCategory.SYSTEM_SERVICES,
|
||||||
|
)
|
||||||
|
# Set the icon theme path so AppIndicator looks in our directory
|
||||||
|
self.indicator.set_icon_theme_path(ICON_DIR)
|
||||||
|
self.indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.ACTIVE)
|
||||||
|
|
||||||
|
self._build_menu()
|
||||||
|
self.indicator.set_menu(self.menu)
|
||||||
|
|
||||||
|
# Initial fetch
|
||||||
|
self._schedule_fetch()
|
||||||
|
# Start periodic timer
|
||||||
|
self._start_timer()
|
||||||
|
|
||||||
|
def _start_timer(self):
|
||||||
|
if self.timer_id:
|
||||||
|
GLib.source_remove(self.timer_id)
|
||||||
|
self.timer_id = GLib.timeout_add_seconds(POLL_SECONDS, self._on_timer)
|
||||||
|
|
||||||
|
def _on_timer(self):
|
||||||
|
self._schedule_fetch()
|
||||||
|
return True # keep timer running
|
||||||
|
|
||||||
|
def _schedule_fetch(self):
|
||||||
|
if self.updating:
|
||||||
|
return
|
||||||
|
self.updating = True
|
||||||
|
self._set_update_label('Updating...')
|
||||||
|
t = threading.Thread(target=self._fetch_usage, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _fetch_usage(self):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[BUN, SCRAPER, '--json'],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
env={**os.environ, 'NODE_NO_WARNINGS': '1'},
|
||||||
|
)
|
||||||
|
if result.returncode == 0 and result.stdout.strip():
|
||||||
|
data = json.loads(result.stdout.strip())
|
||||||
|
GLib.idle_add(self._on_data, data)
|
||||||
|
else:
|
||||||
|
self._log('Scraper failed: rc={} stdout={} stderr={}'.format(
|
||||||
|
result.returncode, result.stdout[:200], result.stderr[:200]))
|
||||||
|
GLib.idle_add(self._on_data, {'loggedIn': False})
|
||||||
|
except Exception as e:
|
||||||
|
self._log('Fetch error: {}'.format(e))
|
||||||
|
GLib.idle_add(self._on_data, {'loggedIn': False})
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _log(msg):
|
||||||
|
import datetime
|
||||||
|
with open(os.path.join(tempfile.gettempdir(), 'claude-usage-tray.log'), 'a') as f:
|
||||||
|
f.write('{} {}\n'.format(datetime.datetime.now().isoformat(), msg))
|
||||||
|
|
||||||
|
def _on_data(self, data):
|
||||||
|
self.data = data
|
||||||
|
self.updating = False
|
||||||
|
|
||||||
|
if data.get('loggedIn'):
|
||||||
|
pct = data.get('sessionUsed', 0)
|
||||||
|
icon_path = self._draw_icon(pct)
|
||||||
|
self._log('OK: session={}% weekly={}%'.format(
|
||||||
|
data.get('sessionUsed'), data.get('weeklyUsed')))
|
||||||
|
else:
|
||||||
|
icon_path = self._draw_icon_error()
|
||||||
|
self._log('No data, will retry in {}s'.format(RETRY_SECONDS))
|
||||||
|
# Schedule a fast retry instead of waiting 10 minutes
|
||||||
|
GLib.timeout_add_seconds(RETRY_SECONDS, self._retry_after_failure)
|
||||||
|
|
||||||
|
# set_icon_full wants icon NAME (no extension), looks in icon_theme_path
|
||||||
|
icon_name = os.path.splitext(os.path.basename(icon_path))[0]
|
||||||
|
self.indicator.set_icon_full(icon_name, 'Claude Usage {}%'.format(
|
||||||
|
data.get('sessionUsed', '?') if data.get('loggedIn') else 'error'))
|
||||||
|
self._update_menu()
|
||||||
|
|
||||||
|
def _retry_after_failure(self):
|
||||||
|
"""Retry fetch after a short delay. Only retries if we still have no data."""
|
||||||
|
if not self.data or not self.data.get('loggedIn'):
|
||||||
|
self._schedule_fetch()
|
||||||
|
return False # don't repeat this timer
|
||||||
|
|
||||||
|
def _next_icon_path(self):
|
||||||
|
"""Return a unique icon file path so AppIndicator detects changes."""
|
||||||
|
self.icon_counter += 1
|
||||||
|
return os.path.join(ICON_DIR, 'icon-{}.png'.format(self.icon_counter))
|
||||||
|
|
||||||
|
def _draw_icon(self, pct):
|
||||||
|
"""Draw a circular progress indicator. pct=None for unknown state. Returns icon path."""
|
||||||
|
icon_path = self._next_icon_path()
|
||||||
|
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, ICON_SIZE, ICON_SIZE)
|
||||||
|
cr = cairo.Context(surface)
|
||||||
|
cx, cy = ICON_SIZE / 2, ICON_SIZE / 2
|
||||||
|
radius = ICON_SIZE / 2 - 3
|
||||||
|
line_width = 5.0
|
||||||
|
|
||||||
|
# Background ring
|
||||||
|
cr.set_line_width(line_width)
|
||||||
|
cr.set_source_rgba(0.3, 0.3, 0.3, 0.8)
|
||||||
|
cr.arc(cx, cy, radius, 0, 2 * math.pi)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
if pct is not None:
|
||||||
|
# Progress arc (even at 0%, show green to distinguish from loading)
|
||||||
|
if pct >= 90:
|
||||||
|
cr.set_source_rgb(0.9, 0.2, 0.2) # red
|
||||||
|
elif pct >= 70:
|
||||||
|
cr.set_source_rgb(0.9, 0.7, 0.1) # yellow
|
||||||
|
else:
|
||||||
|
cr.set_source_rgb(0.2, 0.8, 0.3) # green
|
||||||
|
|
||||||
|
cr.set_line_width(line_width)
|
||||||
|
cr.set_line_cap(cairo.LINE_CAP_ROUND)
|
||||||
|
angle = (pct / 100.0) * 2 * math.pi
|
||||||
|
if angle > 0.01:
|
||||||
|
cr.arc(cx, cy, radius, -math.pi / 2, -math.pi / 2 + angle)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
# Percentage text in center
|
||||||
|
cr.set_source_rgb(0.9, 0.9, 0.9)
|
||||||
|
cr.select_font_face('Sans', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
|
||||||
|
cr.set_font_size(14)
|
||||||
|
text = str(pct)
|
||||||
|
extents = cr.text_extents(text)
|
||||||
|
cr.move_to(cx - extents.width / 2, cy + extents.height / 2)
|
||||||
|
cr.show_text(text)
|
||||||
|
else:
|
||||||
|
# Loading state: show "..." in center
|
||||||
|
cr.set_source_rgba(0.6, 0.6, 0.6, 0.8)
|
||||||
|
cr.select_font_face('Sans', cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_BOLD)
|
||||||
|
cr.set_font_size(14)
|
||||||
|
text = '...'
|
||||||
|
extents = cr.text_extents(text)
|
||||||
|
cr.move_to(cx - extents.width / 2, cy + extents.height / 2)
|
||||||
|
cr.show_text(text)
|
||||||
|
|
||||||
|
surface.write_to_png(icon_path)
|
||||||
|
return icon_path
|
||||||
|
|
||||||
|
def _draw_icon_error(self):
|
||||||
|
"""Draw an X icon for logged-out / error state. Returns icon path."""
|
||||||
|
icon_path = self._next_icon_path()
|
||||||
|
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, ICON_SIZE, ICON_SIZE)
|
||||||
|
cr = cairo.Context(surface)
|
||||||
|
cx, cy = ICON_SIZE / 2, ICON_SIZE / 2
|
||||||
|
radius = ICON_SIZE / 2 - 3
|
||||||
|
|
||||||
|
# Background ring
|
||||||
|
cr.set_line_width(5.0)
|
||||||
|
cr.set_source_rgba(0.3, 0.3, 0.3, 0.8)
|
||||||
|
cr.arc(cx, cy, radius, 0, 2 * math.pi)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
# Red X
|
||||||
|
cr.set_source_rgb(0.9, 0.2, 0.2)
|
||||||
|
cr.set_line_width(4.0)
|
||||||
|
cr.set_line_cap(cairo.LINE_CAP_ROUND)
|
||||||
|
offset = ICON_SIZE / 5
|
||||||
|
cr.move_to(cx - offset, cy - offset)
|
||||||
|
cr.line_to(cx + offset, cy + offset)
|
||||||
|
cr.stroke()
|
||||||
|
cr.move_to(cx + offset, cy - offset)
|
||||||
|
cr.line_to(cx - offset, cy + offset)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
surface.write_to_png(icon_path)
|
||||||
|
return icon_path
|
||||||
|
|
||||||
|
def _build_menu(self):
|
||||||
|
self.menu = Gtk.Menu()
|
||||||
|
|
||||||
|
self.item_session = Gtk.MenuItem(label='Session: --')
|
||||||
|
self.item_session.set_sensitive(False)
|
||||||
|
self.menu.append(self.item_session)
|
||||||
|
|
||||||
|
self.item_session_reset = Gtk.MenuItem(label='')
|
||||||
|
self.item_session_reset.set_sensitive(False)
|
||||||
|
self.menu.append(self.item_session_reset)
|
||||||
|
|
||||||
|
self.menu.append(Gtk.SeparatorMenuItem())
|
||||||
|
|
||||||
|
self.item_weekly = Gtk.MenuItem(label='Weekly: --')
|
||||||
|
self.item_weekly.set_sensitive(False)
|
||||||
|
self.menu.append(self.item_weekly)
|
||||||
|
|
||||||
|
self.item_weekly_reset = Gtk.MenuItem(label='')
|
||||||
|
self.item_weekly_reset.set_sensitive(False)
|
||||||
|
self.menu.append(self.item_weekly_reset)
|
||||||
|
|
||||||
|
self.menu.append(Gtk.SeparatorMenuItem())
|
||||||
|
|
||||||
|
self.item_update = Gtk.MenuItem(label='Update Now')
|
||||||
|
self.item_update.connect('activate', self._on_update)
|
||||||
|
self.menu.append(self.item_update)
|
||||||
|
|
||||||
|
self.item_live = Gtk.MenuItem(label='Live Mode')
|
||||||
|
self.item_live.connect('activate', self._on_toggle_live)
|
||||||
|
self.menu.append(self.item_live)
|
||||||
|
|
||||||
|
self.item_login = Gtk.MenuItem(label='Login')
|
||||||
|
self.item_login.connect('activate', self._on_login)
|
||||||
|
self.menu.append(self.item_login)
|
||||||
|
|
||||||
|
self.menu.append(Gtk.SeparatorMenuItem())
|
||||||
|
|
||||||
|
item_quit = Gtk.MenuItem(label='Quit')
|
||||||
|
item_quit.connect('activate', lambda _: Gtk.main_quit())
|
||||||
|
self.menu.append(item_quit)
|
||||||
|
|
||||||
|
self.menu.show_all()
|
||||||
|
|
||||||
|
def _update_menu(self):
|
||||||
|
if not self.data or not self.data.get('loggedIn'):
|
||||||
|
self.item_session.set_label('Session: not logged in')
|
||||||
|
self.item_session_reset.set_label('')
|
||||||
|
self.item_weekly.set_label('Weekly: --')
|
||||||
|
self.item_weekly_reset.set_label('')
|
||||||
|
self._set_update_label('Update Now')
|
||||||
|
return
|
||||||
|
|
||||||
|
d = self.data
|
||||||
|
self.item_session.set_label(
|
||||||
|
'Session: {}%'.format(d.get('sessionUsed', '?'))
|
||||||
|
)
|
||||||
|
self.item_session_reset.set_label(
|
||||||
|
'Resets {}'.format(d.get('sessionResets', '?'))
|
||||||
|
)
|
||||||
|
self.item_weekly.set_label(
|
||||||
|
'Weekly: {}%'.format(d.get('weeklyUsed', '?'))
|
||||||
|
)
|
||||||
|
self.item_weekly_reset.set_label(
|
||||||
|
'Resets {}'.format(d.get('weeklyResets', '?'))
|
||||||
|
)
|
||||||
|
self._set_update_label('Update Now')
|
||||||
|
|
||||||
|
def _set_update_label(self, text):
|
||||||
|
self.item_update.set_label(text)
|
||||||
|
|
||||||
|
def _on_update(self, _widget):
|
||||||
|
self._start_timer() # reset the 10-min timer
|
||||||
|
self._schedule_fetch()
|
||||||
|
|
||||||
|
def _on_toggle_live(self, widget):
|
||||||
|
"""Toggle live mode on/off."""
|
||||||
|
if self.live_mode:
|
||||||
|
self._stop_live()
|
||||||
|
else:
|
||||||
|
self._start_live()
|
||||||
|
|
||||||
|
def _start_live(self):
|
||||||
|
"""Start live mode: keep Chromium open, read DOM every 15s."""
|
||||||
|
if self.live_proc:
|
||||||
|
return
|
||||||
|
self.live_mode = True
|
||||||
|
self._update_live_label()
|
||||||
|
self._log('Live mode started')
|
||||||
|
|
||||||
|
# Stop the normal polling timer
|
||||||
|
if self.timer_id:
|
||||||
|
GLib.source_remove(self.timer_id)
|
||||||
|
self.timer_id = None
|
||||||
|
|
||||||
|
def _run_live():
|
||||||
|
try:
|
||||||
|
self.live_proc = subprocess.Popen(
|
||||||
|
[BUN, SCRAPER, '--live'],
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||||
|
env={**os.environ, 'NODE_NO_WARNINGS': '1'},
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
for line in iter(self.live_proc.stdout.readline, b''):
|
||||||
|
line = line.decode('utf-8').strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(line)
|
||||||
|
GLib.idle_add(self._on_data, data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
# Process ended
|
||||||
|
self.live_proc = None
|
||||||
|
GLib.idle_add(self._on_live_stopped)
|
||||||
|
except Exception as e:
|
||||||
|
self._log('Live error: {}'.format(e))
|
||||||
|
self.live_proc = None
|
||||||
|
GLib.idle_add(self._on_live_stopped)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_run_live, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _stop_live(self):
|
||||||
|
"""Stop live mode, return to normal polling."""
|
||||||
|
self.live_mode = False
|
||||||
|
self._update_live_label()
|
||||||
|
self._log('Live mode stopped')
|
||||||
|
|
||||||
|
if self.live_proc:
|
||||||
|
try:
|
||||||
|
self.live_proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.live_proc = None
|
||||||
|
|
||||||
|
# Resume normal polling
|
||||||
|
self._start_timer()
|
||||||
|
self._schedule_fetch()
|
||||||
|
|
||||||
|
def _on_live_stopped(self):
|
||||||
|
"""Called when live process exits unexpectedly."""
|
||||||
|
if self.live_mode:
|
||||||
|
self.live_mode = False
|
||||||
|
self._update_live_label()
|
||||||
|
self._log('Live process ended, resuming normal polling')
|
||||||
|
self._start_timer()
|
||||||
|
self._schedule_fetch()
|
||||||
|
|
||||||
|
def _update_live_label(self):
|
||||||
|
if self.live_mode:
|
||||||
|
self.item_live.set_label('Live Mode [ON]')
|
||||||
|
else:
|
||||||
|
self.item_live.set_label('Live Mode')
|
||||||
|
|
||||||
|
def _on_login(self, _widget):
|
||||||
|
"""Open browser for login on the user's real display, then auto-refresh."""
|
||||||
|
def _do_login():
|
||||||
|
env = {**os.environ, 'NODE_NO_WARNINGS': '1', 'DISPLAY': self.real_display}
|
||||||
|
proc = subprocess.run(
|
||||||
|
[BUN, SCRAPER, '--login'],
|
||||||
|
capture_output=True, text=True, timeout=300,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
# After login completes, fetch fresh usage data
|
||||||
|
GLib.idle_add(self._schedule_fetch)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_do_login, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import signal
|
||||||
|
|
||||||
|
acquire_lock()
|
||||||
|
|
||||||
|
indicator = UsageIndicator()
|
||||||
|
|
||||||
|
def _shutdown(*args):
|
||||||
|
# Kill live process if running
|
||||||
|
if indicator.live_proc:
|
||||||
|
try:
|
||||||
|
indicator.live_proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Hide the tray icon so Cinnamon removes it immediately
|
||||||
|
indicator.indicator.set_status(AyatanaAppIndicator3.IndicatorStatus.PASSIVE)
|
||||||
|
release_lock()
|
||||||
|
Gtk.main_quit()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _shutdown)
|
||||||
|
signal.signal(signal.SIGINT, _shutdown)
|
||||||
|
|
||||||
|
try:
|
||||||
|
Gtk.main()
|
||||||
|
finally:
|
||||||
|
release_lock()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user