commit 690f8abd4d9d35cfa7b3ab43a9884f29242a5199 Author: Sterister Date: Tue May 5 15:52:55 2026 +0200 initial — claude usage tray (scraper.ts + tray.py) Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7438643 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +session/ +__pycache__/ +*.pyc +node_modules/ +.DS_Store diff --git a/scraper.ts b/scraper.ts new file mode 100644 index 0000000..d3fdcac --- /dev/null +++ b/scraper.ts @@ -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 { + 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 = {}; + + // 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) { + 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(); + } +} diff --git a/tray.py b/tray.py new file mode 100644 index 0000000..267e9a1 --- /dev/null +++ b/tray.py @@ -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()