Files
claude-usage/tray.py

461 lines
15 KiB
Python
Raw Normal View History

#!/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()