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:
2026-05-05 15:52:55 +02:00
commit 690f8abd4d
3 changed files with 788 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
session/
__pycache__/
*.pyc
node_modules/
.DS_Store

323
scraper.ts Normal file
View 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
View 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()