clauddy 1.29.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/main.js DELETED
@@ -1,1305 +0,0 @@
1
- const {
2
- app,
3
- BrowserWindow,
4
- ipcMain,
5
- screen,
6
- Notification,
7
- shell,
8
- Tray,
9
- Menu,
10
- nativeImage,
11
- powerMonitor,
12
- } = require('electron')
13
- const path = require('node:path')
14
- const fs = require('node:fs')
15
- const os = require('node:os')
16
- const { spawn } = require('node:child_process')
17
- const usage = require('./usage')
18
- const { getUsage } = usage
19
- const auth = require('./auth')
20
- const accounts = require('./accounts')
21
- const codex = require('./codex')
22
- const cursor = require('./cursor')
23
- const { createReminders } = require('./reminders')
24
-
25
- const REPO = 'renatoaug/claude-usage-monitor'
26
- const USAGE_URLS = {
27
- claude: 'https://claude.ai/settings/usage',
28
- codex: 'https://chatgpt.com/usage#settings/Usage',
29
- cursor: 'https://cursor.com/dashboard?tab=usage',
30
- }
31
-
32
- // data dir: kept outside the project folder so moving the repo doesn't break it.
33
- // when CLAUDE_CONFIG_DIR is set (e.g. via direnv for multi-account setups), nest
34
- // the widget's data under that account's Claude config dir so each account gets
35
- // its own auth.json, config.json, and Electron userData.
36
- const DATA_DIR = process.env.CLAUDE_CONFIG_DIR
37
- ? path.join(process.env.CLAUDE_CONFIG_DIR, 'usage-monitor')
38
- : path.join(os.homedir(), '.claude-usage-monitor')
39
- // when isolating per-account, also pin Electron's userData under DATA_DIR so two
40
- // widgets can run in parallel without fighting over cookies/cache. left untouched
41
- // in the default case so existing installs keep their window position etc.
42
- if (process.env.CLAUDE_CONFIG_DIR) {
43
- fs.mkdirSync(DATA_DIR, { recursive: true })
44
- app.setPath('userData', path.join(DATA_DIR, 'electron'))
45
- }
46
- // external config: edit without rebuilding the .app
47
- const EXTERNAL_CONFIG = path.join(DATA_DIR, 'config.json')
48
- // debug channel: `./pet <state>` writes here to force a state (dev only)
49
- const DEBUG_FILE = path.join(DATA_DIR, 'debug.json')
50
- // remembered floating-widget position: survives restarts and lets the widget
51
- // return to the monitor the user parked it on after a display is unplugged/replugged
52
- const WINDOW_STATE = path.join(DATA_DIR, 'window.json')
53
-
54
- let win
55
- let pollTimer
56
- let petPointerTimer
57
- let config
58
- let doTick = null
59
- const W = 304
60
-
61
- // menu-bar (tray) mode state
62
- let tray = null
63
- let currentMode = 'floating' // 'floating' widget | 'menubar' popover
64
- let trayBounds = null // last known tray icon rect, to anchor the popover
65
- let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
66
- let sessionPct = null // authoritative session % shown in the tray title
67
- let codexUsage = null // last Codex payload, null while it isn't enabled
68
- let realUsageAt = 0
69
- let codexReadAt = 0
70
- let cursorUsage = null // last Cursor payload, null while it isn't enabled
71
- let cursorReadAt = 0
72
- let realUsage = null // last OAuth usage payload — the % alerts trust when logged in
73
- let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
74
- let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
75
-
76
- function publicConfig(c) {
77
- return {
78
- plan: c.plan,
79
- sessionTokenBudget: c.sessionTokenBudget,
80
- weeklyTokenBudget: c.weeklyTokenBudget,
81
- weeklyAnchorIso: c.weeklyAnchorIso,
82
- mode: c.mode,
83
- alerts: c.alerts,
84
- alertThresholds: c.alertThresholds,
85
- fireThreshold: c.fireThreshold,
86
- zoom: c.zoom,
87
- codex: !!c.codex?.enabled,
88
- cursor: !!c.cursor?.enabled,
89
- talk: c.talk,
90
- sound: c.sound,
91
- soundMutedUntil: c.soundMutedUntil,
92
- pet: c.pet,
93
- }
94
- }
95
-
96
- function loadConfig() {
97
- const defaults = {
98
- plan: 'max5x',
99
- sessionTokenBudget: 630000000,
100
- weeklyTokenBudget: 3450000000,
101
- weeklyAnchorIso: null,
102
- mode: 'floating', // 'floating' widget or 'menubar' popover
103
- alerts: true,
104
- alertThresholds: [80, 95],
105
- fireThreshold: 90, // session % at which the pet catches fire (tired still fixed at 100)
106
- zoom: 100, // widget scale %, 100-200
107
- codex: { enabled: false }, // opt-in from Settings, never on by finding ~/.codex
108
- cursor: { enabled: false }, // opt-in too: it reads the Cursor app's own login
109
- talk: true, // speech bubbles on transitions
110
- sound: false, // chiptune blips: opt-in, audio must never surprise anyone
111
- soundMutedUntil: 0, // the one-click "mute for 1 hour", as an epoch ms
112
- pet: 'clauddy', // renderer/pets.js; holidays override it on their own days
113
- pollIntervalMs: 4000,
114
- activeThresholdMs: 20000,
115
- sleepThresholdMs: 300000,
116
- }
117
- for (const p of [EXTERNAL_CONFIG, path.join(__dirname, 'config.json')]) {
118
- try {
119
- return { ...defaults, ...JSON.parse(fs.readFileSync(p, 'utf8')) }
120
- } catch {}
121
- }
122
- return defaults
123
- }
124
-
125
- // ---- alerts ------------------------------------------------------------------
126
- // One notification per (scope × threshold), re-armed when usage drops back below.
127
- // `armed` is persisted so relaunching at 85% doesn't repeat the 80% alert you
128
- // already dismissed; it also carries the last session % so a reset is detectable
129
- // across restarts.
130
- // per account: two subscriptions have separate windows, so an alert armed on
131
- // one says nothing about the other
132
- let alertsFile = path.join(DATA_DIR, 'alerts.json')
133
- let armed = new Set()
134
- let lastSessionPct = null
135
- function loadAlertState() {
136
- try {
137
- const j = JSON.parse(fs.readFileSync(alertsFile, 'utf8'))
138
- armed = new Set(Array.isArray(j.armed) ? j.armed : [])
139
- lastSessionPct = typeof j.lastSessionPct === 'number' ? j.lastSessionPct : null
140
- } catch {} // first run, or a corrupted file: start from a clean slate
141
- }
142
- function saveAlertState() {
143
- try {
144
- fs.mkdirSync(path.dirname(alertsFile), { recursive: true })
145
- fs.writeFileSync(alertsFile, JSON.stringify({ armed: [...armed], lastSessionPct }))
146
- } catch {}
147
- }
148
-
149
- function fmtDuration(ms) {
150
- const h = Math.floor(ms / 3600000)
151
- const m = Math.floor((ms % 3600000) / 60000)
152
- return h > 0 ? `${h}h ${m}m` : `${m}m`
153
- }
154
- // wall-clock time the window flips, in the machine's own locale + timezone
155
- function fmtClock(ms) {
156
- const at = new Date(Date.now() + ms)
157
- const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
158
- // a week or more out (Cursor's monthly cycle), a weekday would be ambiguous
159
- if (ms >= 6 * 86400000) return at.toLocaleDateString([], { month: 'short', day: 'numeric' })
160
- return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
161
- }
162
-
163
- function notify(title, body, { silent = true } = {}) {
164
- if (!Notification.isSupported()) return
165
- const n = new Notification({ title, body, silent })
166
- n.on('click', showWidget) // the obvious gesture: bring the pet to the front
167
- n.show()
168
- }
169
-
170
- // Explicit reminders survive restarts and belong to the account that armed them.
171
- const REMINDERS_FILE = path.join(DATA_DIR, 'reset-reminders.json')
172
- const resetReminders = createReminders({
173
- now: () => Date.now(),
174
- load: () => JSON.parse(fs.readFileSync(REMINDERS_FILE, 'utf8')),
175
- save: (data) => {
176
- fs.mkdirSync(DATA_DIR, { recursive: true })
177
- const tmp = `${REMINDERS_FILE}.tmp`
178
- fs.writeFileSync(tmp, JSON.stringify(data))
179
- fs.renameSync(tmp, REMINDERS_FILE)
180
- },
181
- })
182
- // Codex and Cursor have one connection each; Claude's reminders are per account
183
- const reminderKey = (provider) =>
184
- provider === 'claude' ? `claude:${accounts.activeId()}` : provider
185
- const NAMES = { claude: 'Claude', codex: 'Codex', cursor: 'Cursor' }
186
- // each service's reset window, when it was read, and whether it's still on
187
- function serviceState(provider) {
188
- if (provider === 'codex')
189
- return {
190
- session: codexUsage?.session,
191
- readAt: codexReadAt,
192
- freshAt: codexUsage?.limitsAt,
193
- on: !!config.codex?.enabled,
194
- }
195
- if (provider === 'cursor')
196
- return {
197
- session: cursorUsage?.session,
198
- readAt: cursorReadAt,
199
- freshAt: cursorUsage?.limitsAt,
200
- on: !!config.cursor?.enabled,
201
- }
202
- return { session: realUsage?.session, readAt: realUsageAt, freshAt: realUsageAt, on: null }
203
- }
204
- function sendReminders(error = null) {
205
- if (!win || win.isDestroyed()) return
206
- const find = (p) => resetReminders.list().find((r) => r.key === reminderKey(p)) || null
207
- win.webContents.send('reminders', {
208
- claude: find('claude'),
209
- codex: find('codex'),
210
- cursor: find('cursor'),
211
- error,
212
- })
213
- }
214
- function cancelReminder(key) {
215
- try {
216
- resetReminders.cancel(key)
217
- sendReminders()
218
- return true
219
- } catch (err) {
220
- console.error('reset reminder:', err)
221
- sendReminders('Could not cancel the reminder. Please try again.')
222
- return false
223
- }
224
- }
225
- function checkResetReminders() {
226
- try {
227
- for (const r of resetReminders.list()) {
228
- const connected =
229
- r.provider === 'claude'
230
- ? accounts.list().some((a) => r.key === `claude:${a.id}` && hasToken(a.id))
231
- : serviceState(r.provider).on
232
- if (!connected && !cancelReminder(r.key)) return
233
- }
234
- const due = resetReminders.takeDue()
235
- for (const r of due) {
236
- const isCurrent = r.provider !== 'claude' || r.key === reminderKey('claude')
237
- const { session, freshAt: readAt } = serviceState(r.provider)
238
- const confirmed = isCurrent && readAt >= r.at && session?.pct != null && session.pct < r.pct
239
- const message = confirmed
240
- ? `${r.label}: session budget is available again.`
241
- : `${r.label}: the scheduled session reset time has arrived. Open usage to confirm the new budget.`
242
- notify(
243
- confirmed ? 'Your session budget is back' : 'Time to check your session budget',
244
- message,
245
- )
246
- if (win && !win.isDestroyed())
247
- win.webContents.send('reminder-due', { ...r, confirmed, isCurrent })
248
- }
249
- if (due.length) sendReminders()
250
- } catch (err) {
251
- console.error('reset reminder:', err)
252
- }
253
- }
254
- ipcMain.on('set-reminder', (_e, provider, enabled) => {
255
- if (!Object.hasOwn(NAMES, provider) || typeof enabled !== 'boolean') return
256
- const key = reminderKey(provider)
257
- if (!enabled) return cancelReminder(key)
258
- const { session, readAt, freshAt, on } = serviceState(provider)
259
- const at = readAt + (session?.resetMs || 0)
260
- const connected = provider === 'claude' ? auth.isConnected() : on
261
- if (
262
- !connected ||
263
- !freshAt ||
264
- Date.now() - freshAt > 15 * 60000 ||
265
- !Number.isFinite(session?.pct)
266
- ) {
267
- return sendReminders('Wait for a fresh usage reading before setting a reminder.')
268
- }
269
- try {
270
- const label =
271
- provider === 'claude'
272
- ? `Claude · ${accounts.active().label || 'your account'}`
273
- : NAMES[provider]
274
- if (!resetReminders.arm({ key, provider, label, at, pct: session.pct })) {
275
- return sendReminders('This reset time has passed. Wait for a fresh reading.')
276
- }
277
- sendReminders()
278
- } catch {
279
- sendReminders('Could not save the reminder. Please try again.')
280
- }
281
- })
282
-
283
- // the alert lines carry the reset, since "what do I do about it" is a question
284
- // about time left, not about the threshold that happened to fire
285
- function resetLine(resetMs, withRemaining) {
286
- if (resetMs == null) return ''
287
- const at = `resets ${fmtClock(resetMs)}`
288
- return withRemaining && resetMs > 0 ? `${fmtDuration(resetMs)} left · ${at}` : at
289
- }
290
-
291
- // usage crossing a threshold. `scopes` are {label, pct, resetMs, session} rows;
292
- // the real (OAuth) numbers are preferred over the local token estimate, which is
293
- // only a budget guess and can be off by an order of magnitude.
294
- function alertScopes(config, scopes) {
295
- if (!config.alerts) return
296
- const ths = config.alertThresholds || [80, 95]
297
- const top = Math.max(...ths)
298
- let dirty = false
299
- for (const { label, pct, resetMs, session } of scopes) {
300
- if (pct == null) continue
301
- for (const t of ths) {
302
- const key = `${label}:${t}`
303
- if (pct >= t) {
304
- if (!armed.has(key)) {
305
- armed.add(key)
306
- dirty = true
307
- const urgent = t === top
308
- notify(
309
- `${label} at ${Math.round(pct)}%${urgent ? ' — almost out' : ''}`,
310
- resetLine(resetMs, session),
311
- { silent: !urgent }, // 80% is a heads-up; the last threshold earns a sound
312
- )
313
- }
314
- } else if (armed.delete(key)) {
315
- dirty = true // re-arm when it drops below
316
- }
317
- }
318
- }
319
- if (dirty) saveAlertState()
320
- }
321
-
322
- function checkAlerts(config, d) {
323
- const r = realUsage // authoritative when logged in; the estimate is the fallback
324
- const session = r?.session ?? d.session
325
- const week = r?.week ?? d.week
326
- alertScopes(config, [
327
- { label: 'Session', pct: session.pct, resetMs: session.resetMs, session: true },
328
- { label: 'Weekly usage', pct: week.pct, resetMs: week.resetMs },
329
- ])
330
- checkWindowReset(config, session.pct)
331
- }
332
- // Codex has its own windows, so its own keys: `Codex session:80` never
333
- // collides with Claude's `Session:80`
334
- function checkCodexAlerts(config, c) {
335
- if (!c) return
336
- alertScopes(config, [
337
- {
338
- // a weekly-only plan carries its week in `session`, so the key says weekly
339
- label: c.weeklyOnly ? 'Codex weekly' : 'Codex session',
340
- pct: c.session?.pct,
341
- resetMs: c.session?.resetMs,
342
- session: true,
343
- },
344
- { label: 'Codex weekly', pct: c.weekly?.pct, resetMs: c.weekly?.resetMs },
345
- ])
346
- }
347
- // Cursor's budget is the billing cycle; its API-model pool fills on its own
348
- function checkCursorAlerts(config, c) {
349
- if (!c) return
350
- alertScopes(config, [
351
- { label: 'Cursor usage', pct: c.session?.pct, resetMs: c.session?.resetMs },
352
- { label: 'Cursor API models', pct: c.api?.pct, resetMs: c.api?.resetMs },
353
- ])
354
- }
355
- // per-model weekly limits only exist on the account side, so they're checked
356
- // off the OAuth poll rather than the local tick
357
- function checkScopedAlerts(config, u) {
358
- alertScopes(
359
- config,
360
- (u?.scoped || []).map((s) => ({
361
- label: `${s.label} weekly`,
362
- pct: s.pct,
363
- resetMs: s.resetMs,
364
- })),
365
- )
366
- }
367
-
368
- // "you can work again" — only worth saying to someone who was actually near the
369
- // ceiling, so a window flipping at 20% stays silent
370
- const RESET_FROM = 80
371
- const RESET_TO = 5
372
- function checkWindowReset(config, pct) {
373
- if (pct == null) return
374
- const was = lastSessionPct
375
- lastSessionPct = pct
376
- if (
377
- config.alerts &&
378
- was != null &&
379
- was >= RESET_FROM &&
380
- pct <= RESET_TO &&
381
- !resetReminders.list().some((r) => r.key === reminderKey('claude')) &&
382
- !resetReminders.recentlyDelivered(reminderKey('claude'))
383
- ) {
384
- notify('Session window reset', 'full budget again')
385
- }
386
- if (was == null || Math.round(was) !== Math.round(pct)) saveAlertState()
387
- }
388
-
389
- // the token going dead is a silent failure otherwise: the widget quietly falls
390
- // back to the local estimate and keeps showing a number the user trusts
391
- function alertAuthLost(config) {
392
- if (!config.alerts) return
393
- notify('Clauddy lost access to your usage', 'open Settings to reconnect')
394
- }
395
-
396
- // ---- accounts ----------------------------------------------------------------
397
- // One widget, several Claude subscriptions. Switching rebinds the token store
398
- // (auth), the log dir (usage) and the alert state — everything that is "whose
399
- // usage is this" — while the window itself (position, mode, zoom, settings)
400
- // stays global, because there is only one pet.
401
- function applyAccount(acc) {
402
- const dir = accounts.dataDirOf(acc.id)
403
- alertsFile = path.join(dir, 'alerts.json')
404
- auth.setDataDir(dir)
405
- usage.setClaudeDir(acc.claudeDir)
406
- armed = new Set()
407
- lastSessionPct = null
408
- lastSeenTokens = null // the new account's first read is a baseline, not a spend
409
- loadAlertState()
410
- sendReminders()
411
- }
412
-
413
- // `connected` is read off disk rather than through auth, which only ever knows
414
- // about the active account
415
- function accountsPayload() {
416
- return {
417
- active: accounts.activeId(),
418
- accounts: accounts.list().map((a) => ({
419
- id: a.id,
420
- label: a.label,
421
- connected: hasToken(a.id),
422
- })),
423
- }
424
- }
425
-
426
- // `busy` is the account being switched to, while its usage is still loading
427
- function sendAccounts(busy = null) {
428
- if (win && !win.isDestroyed()) win.webContents.send('accounts', { ...accountsPayload(), busy })
429
- }
430
-
431
- // the numbers on screen belong to the old account: drop them before the new
432
- // account's first poll lands, so nothing stale is ever attributed to it
433
- function switchAccount(id) {
434
- const leaving = accounts.activeId()
435
- if (id === leaving) return
436
- const acc = accounts.setActive(id)
437
- if (!acc) return
438
- saveAlertState() // the account we're leaving keeps what it had armed
439
- pruneEmpty(leaving)
440
- applyAccount(acc)
441
- clearTimeout(usageTimer)
442
- usageBackoff = 5 * 60 * 1000
443
- pushRealUsage(null)
444
- if (win && !win.isDestroyed()) {
445
- win.webContents.send('auth-state', { connected: auth.isConnected() })
446
- win.webContents.send('profile', null)
447
- }
448
- profileShown = false
449
- sendAccounts(id) // the row goes pending right away…
450
-
451
- // …because the rest is synchronous and slow: re-reading the new account's
452
- // logs walks every .jsonl under its projects dir, which blocks this process
453
- // for a beat on a busy machine. Yielding first lets the renderer paint the
454
- // pending state instead of freezing mid-click.
455
- setTimeout(() => {
456
- if (doTick) doTick()
457
- startUsagePoll()
458
- sendProfile()
459
- updateTray()
460
- sendAccounts()
461
- }, 0)
462
- }
463
-
464
- // a slot only earns its place by holding a login. One that was never connected
465
- // — the browser flow abandoned, or "add" clicked twice — is dropped the moment
466
- // we leave it, so the list can't fill up with "Not connected yet".
467
- // asked of any account, not just the active one — auth only knows about the
468
- // dir it is currently pointed at
469
- function hasToken(id) {
470
- return fs.existsSync(path.join(accounts.dataDirOf(id), 'auth.json'))
471
- }
472
-
473
- function pruneEmpty(id) {
474
- if (id === accounts.DEFAULT_ID || id === accounts.activeId()) return // never auto-drop the original
475
- const acc = accounts.list().find((a) => a.id === id)
476
- if (!acc || acc.label || hasToken(id)) return
477
- accounts.remove(id)
478
- }
479
-
480
- ipcMain.on('accounts-switch', (_e, id) => switchAccount(String(id)))
481
- // "add an account" *is* "log in": the browser opens on the new, empty slot, so
482
- // the token lands in it and not in the account we just left
483
- // the account an "add" left behind: cancelling the login has to land back on
484
- // it, or the widget is stuck on an empty slot with no chip to switch from
485
- let addedFrom = null
486
- ipcMain.on('accounts-add', () => {
487
- addedFrom = accounts.activeId()
488
- switchAccount(accounts.add())
489
- startLogin()
490
- })
491
- ipcMain.on('accounts-cancel-add', () => {
492
- const from = addedFrom
493
- addedFrom = null
494
- if (!from || from === accounts.activeId() || hasToken(accounts.activeId())) return
495
- switchAccount(from) // the empty slot is pruned on the way out
496
- })
497
- ipcMain.on('accounts-remove', (_e, id) => {
498
- if (accounts.remove(String(id))) {
499
- cancelReminder(`claude:${id}`)
500
- sendAccounts()
501
- }
502
- })
503
-
504
- function createWindow() {
505
- config = loadConfig()
506
- // a login abandoned in an earlier run: nothing stays pending across a restart,
507
- // so fall back to the first account and let the empty slots go
508
- const boot = accounts.active()
509
- if (!boot.label && !hasToken(boot.id)) {
510
- // the default account can be gone by now, so land on whatever comes first
511
- const fallback = accounts.list().find((a) => a.id !== boot.id)
512
- if (fallback) accounts.setActive(fallback.id)
513
- }
514
- for (const a of accounts.list()) pruneEmpty(a.id)
515
- accounts.pruneOrphans() // folders those slots left behind
516
- applyAccount(accounts.active())
517
- powerMonitor?.on?.('resume', onResume)
518
- const { workAreaSize } = screen.getPrimaryDisplay()
519
- const H = 480
520
-
521
- win = new BrowserWindow({
522
- width: W,
523
- height: H,
524
- x: workAreaSize.width - W - 24,
525
- y: workAreaSize.height - H - 24,
526
- frame: false,
527
- transparent: true,
528
- backgroundColor: '#00000000', // fully transparent — Windows needs this or the window paints black
529
- resizable: false,
530
- show: false, // applyMode() reveals it (floating) or keeps it a hidden popover (menubar)
531
- alwaysOnTop: true,
532
- // macOS: the widget is rarely the focused window, so without this the first
533
- // click only focuses it and a button needs a second one
534
- acceptFirstMouse: true,
535
- skipTaskbar: true,
536
- hasShadow: false,
537
- fullscreenable: false,
538
- icon: path.join(__dirname, 'build', 'icon.png'),
539
- webPreferences: {
540
- preload: path.join(__dirname, 'preload.js'),
541
- contextIsolation: true,
542
- nodeIntegration: false,
543
- // the pet's blips start from a usage change, never from a click, so
544
- // Chromium would otherwise hold the AudioContext suspended
545
- autoplayPolicy: 'no-user-gesture-required',
546
- },
547
- })
548
-
549
- win.setAlwaysOnTop(true, 'floating')
550
- win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })
551
- win.loadFile(path.join(__dirname, 'renderer', 'index.html'))
552
-
553
- // in menu-bar mode the popover dismisses when it loses focus (click elsewhere)
554
- win.on('blur', () => {
555
- if (currentMode === 'menubar' && win.isVisible()) {
556
- win.hide()
557
- lastBlurHide = Date.now()
558
- }
559
- })
560
-
561
- // remember where the user parks the widget — but not the moves we make
562
- // ourselves (resize re-anchoring) nor the ones the OS forces when a display
563
- // (dis)connects, so a monitor going dark never overwrites the saved spot
564
- win.on('moved', () => {
565
- if (Date.now() - lastProgrammaticMove < 500) return
566
- if (Date.now() - displayChanging < 2000) return
567
- saveWindowState()
568
- })
569
-
570
- // when a monitor is unplugged/replugged (or its layout changes), put the
571
- // floating widget back on the display the user parked it on — the OS dumps
572
- // it on the primary display otherwise, and never moves it back on its own
573
- const onDisplayChange = () => {
574
- displayChanging = Date.now()
575
- if (currentMode === 'floating' && win && !win.isDestroyed() && win.isVisible())
576
- positionFloating()
577
- }
578
- screen.on('display-added', onDisplayChange)
579
- screen.on('display-removed', onDisplayChange)
580
- screen.on('display-metrics-changed', onDisplayChange)
581
-
582
- const tick = () => {
583
- if (!win || win.isDestroyed()) return
584
- try {
585
- const data = getUsage(config)
586
- win.webContents.send('usage', data)
587
- checkAlerts(config, data)
588
- onLocalActivity(data)
589
- } catch (err) {
590
- win.webContents.send('usage-error', String(err))
591
- }
592
- // Codex is a separate, optional source: its failure must not blank Claude's
593
- try {
594
- const c = config.codex?.enabled ? codex.getCodexUsage() : null
595
- codexUsage = c
596
- codexReadAt = Date.now()
597
- win.webContents.send('codex', c)
598
- checkCodexAlerts(config, c)
599
- updateTray()
600
- } catch (err) {
601
- console.error('codex:', err)
602
- }
603
- // Cursor too: limits come from a throttled background fetch
604
- try {
605
- const c = config.cursor?.enabled ? cursor.getCursorUsage() : null
606
- cursorUsage = c
607
- cursorReadAt = Date.now()
608
- win.webContents.send('cursor', c)
609
- checkCursorAlerts(config, c)
610
- updateTray()
611
- } catch (err) {
612
- console.error('cursor:', err)
613
- }
614
- checkResetReminders()
615
- }
616
- doTick = tick
617
-
618
- win.webContents.once('did-finish-load', () => {
619
- tick()
620
- win.webContents.send('config', publicConfig(config))
621
- win.webContents.send('version', app.getVersion())
622
- sendReminders()
623
- win.webContents.send('auth-state', { connected: auth.isConnected() })
624
- sendAccounts()
625
- pollTimer = setInterval(tick, config.pollIntervalMs)
626
- startUsagePoll()
627
- sendProfile()
628
- watchDebug()
629
- applyMode(config.mode) // reveal the widget, or set up the tray + popover
630
- setTimeout(autoUpdateCheck, 8000) // check once shortly after launch…
631
- setInterval(autoUpdateCheck, 6 * 60 * 60 * 1000) // …then every 6h
632
- })
633
- }
634
-
635
- // ---- menu-bar (tray) mode ----------------------------------------------------
636
- // Floating mode: the widget lives bottom-right, always visible. Menu-bar mode:
637
- // the same window becomes a popover shown under a tray icon on click. Switching
638
- // is live — no relaunch — so the Settings toggle applies immediately.
639
- function applyMode(mode) {
640
- const next = mode === 'menubar' ? 'menubar' : 'floating'
641
- const changed = next !== currentMode
642
- currentMode = next
643
- if (currentMode === 'menubar') {
644
- ensureTray()
645
- win.setSkipTaskbar(true)
646
- if (changed && win.isVisible()) win.hide() // hide only when switching in
647
- } else {
648
- destroyTray()
649
- if (changed || !win.isVisible()) {
650
- positionFloating()
651
- win.show()
652
- }
653
- }
654
- updateTray()
655
- }
656
-
657
- function ensureTray() {
658
- if (tray) return
659
- // macOS recolors a "template" (black+alpha) image for the light/dark menu
660
- // bar; Linux/Windows tray icons get no such recoloring, so they get the
661
- // pre-colored terracotta variant instead — a plain black icon disappears
662
- // on dark panels (e.g. Linux Mint's default Cinnamon taskbar).
663
- const isMac = process.platform === 'darwin'
664
- const iconFile = isMac ? 'trayTemplate.png' : 'trayColor.png'
665
- const img = nativeImage.createFromPath(path.join(__dirname, 'build', iconFile))
666
- if (isMac) img.setTemplateImage(true)
667
- tray = new Tray(img)
668
- tray.setToolTip('Clauddy')
669
- tray.on('click', (_e, bounds) => {
670
- trayBounds = bounds
671
- togglePopover()
672
- })
673
- tray.on('right-click', () => tray.popUpContextMenu(trayMenu()))
674
- }
675
-
676
- function destroyTray() {
677
- if (tray) {
678
- tray.destroy()
679
- tray = null
680
- }
681
- }
682
-
683
- // the accounts entry only earns its place once there's more than one
684
- function accountsMenuItems() {
685
- const list = accounts.list()
686
- if (list.length < 2) return []
687
- const activeId = accounts.activeId()
688
- return [
689
- {
690
- label: 'Account',
691
- submenu: list.map((a) => ({
692
- label: a.label || 'Not connected',
693
- type: 'radio',
694
- checked: a.id === activeId,
695
- click: () => switchAccount(a.id),
696
- })),
697
- },
698
- ]
699
- }
700
-
701
- function trayMenu() {
702
- return Menu.buildFromTemplate([
703
- { label: 'Open Clauddy', click: () => showPopover() },
704
- ...accountsMenuItems(),
705
- ...(config.codex?.enabled || config.cursor?.enabled
706
- ? ['claude', 'codex', 'cursor']
707
- .filter((p) => p === 'claude' || config[p]?.enabled)
708
- .map((p) => ({
709
- label: `Open ${NAMES[p]} usage`,
710
- click: () => shell.openExternal(USAGE_URLS[p]),
711
- }))
712
- : [{ label: 'Open Usage page', click: () => shell.openExternal(USAGE_URLS.claude) }]),
713
- { type: 'separator' },
714
- { label: 'Quit Clauddy', click: () => app.quit() },
715
- ])
716
- }
717
-
718
- function togglePopover() {
719
- if (Date.now() - lastBlurHide < 250) return // this click just dismissed it
720
- if (win.isVisible()) win.hide()
721
- else showPopover()
722
- }
723
-
724
- function showPopover() {
725
- positionUnderTray()
726
- win.show()
727
- win.focus()
728
- }
729
-
730
- // bring the widget to the front from wherever it is, in either mode
731
- function showWidget() {
732
- if (!win || win.isDestroyed()) return
733
- if (currentMode === 'menubar') showPopover()
734
- else {
735
- win.show()
736
- win.focus()
737
- }
738
- }
739
-
740
- // route every programmatic move through here so the 'moved' handler can tell
741
- // our own repositioning apart from a genuine user drag (and skip saving it)
742
- function moveWindow(x, y) {
743
- lastProgrammaticMove = Date.now()
744
- win.setPosition(Math.round(x), Math.round(y))
745
- }
746
-
747
- // persist the widget's anchor — its bottom-right corner, since the window's
748
- // size changes as the pet animates — so it can be restored later
749
- function saveWindowState() {
750
- if (currentMode !== 'floating' || !win || win.isDestroyed()) return
751
- const b = win.getBounds()
752
- try {
753
- fs.mkdirSync(DATA_DIR, { recursive: true })
754
- fs.writeFileSync(WINDOW_STATE, JSON.stringify({ right: b.x + b.width, bottom: b.y + b.height }))
755
- } catch {}
756
- }
757
-
758
- function loadWindowState() {
759
- try {
760
- const s = JSON.parse(fs.readFileSync(WINDOW_STATE, 'utf8'))
761
- return Number.isFinite(s?.right) && Number.isFinite(s?.bottom) ? s : null
762
- } catch {
763
- return null
764
- }
765
- }
766
-
767
- // is the saved bottom-right corner on a display that's currently connected?
768
- function cornerVisible(right, bottom) {
769
- const x = Math.round(right - 1)
770
- const y = Math.round(bottom - 1)
771
- return screen.getAllDisplays().some((d) => {
772
- const b = d.bounds
773
- return x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height
774
- })
775
- }
776
-
777
- // center the popover under the tray icon, kept on-screen
778
- function positionUnderTray() {
779
- const b = win.getBounds()
780
- const { workAreaSize } = screen.getPrimaryDisplay()
781
- let x = workAreaSize.width - b.width - 24
782
- let y = 24
783
- if (trayBounds?.width) {
784
- x = Math.round(trayBounds.x + trayBounds.width / 2 - b.width / 2)
785
- y = Math.round(trayBounds.y + trayBounds.height)
786
- }
787
- x = Math.max(8, Math.min(x, workAreaSize.width - b.width - 8))
788
- moveWindow(x, y)
789
- // the popover's nib points at the icon, even when the screen edge pushed the
790
- // window off-centre
791
- const iconX = trayBounds?.width ? trayBounds.x + trayBounds.width / 2 : x + b.width / 2
792
- win.webContents.send('popover-anchor', Math.round(iconX - x))
793
- }
794
-
795
- function positionFloating() {
796
- const b = win.getBounds()
797
- // reuse the saved spot when that monitor is still connected; otherwise fall
798
- // back to the primary display's bottom-right corner
799
- const saved = loadWindowState()
800
- if (saved && cornerVisible(saved.right, saved.bottom)) {
801
- moveWindow(saved.right - b.width, saved.bottom - b.height)
802
- return
803
- }
804
- const { workAreaSize } = screen.getPrimaryDisplay()
805
- moveWindow(workAreaSize.width - b.width - 24, workAreaSize.height - b.height - 24)
806
- }
807
-
808
- // the tray shows the live session % (macOS title), turning 🔥 near the limit
809
- function updateTray() {
810
- if (!tray) return
811
- const cx = codexUsage
812
- const cu = cursorUsage
813
- // several services: one icon, every number labelled — never one blended %
814
- if (cx || cu) {
815
- const fmt = (v) => (v == null ? '—' : `${Math.round(v)}%`)
816
- const age = (u) => (u.limitsAt ? ` (as of ${new Date(u.limitsAt).toLocaleTimeString()})` : '')
817
- const parts = [
818
- sessionPct != null && ['C', `Claude session ${fmt(sessionPct)}`, sessionPct],
819
- cx && [
820
- 'X',
821
- `Codex ${cx.weeklyOnly ? 'weekly' : 'session'} ${fmt(cx.session?.pct)}${age(cx)}`,
822
- cx.session?.pct,
823
- ],
824
- cu && ['Cu', `Cursor usage ${fmt(cu.session?.pct)}${age(cu)}`, cu.session?.pct],
825
- ].filter(Boolean)
826
- const title = parts.map(([k, , v]) => `${k} ${fmt(v)}`).join(' · ')
827
- if (process.platform === 'darwin') tray.setTitle(` ${title}`)
828
- tray.setToolTip(`Clauddy — ${parts.map(([, t]) => t).join(' · ')}`)
829
- return
830
- }
831
- if (sessionPct == null) {
832
- if (process.platform === 'darwin') tray.setTitle('')
833
- tray.setToolTip('Clauddy — connect your account for live %')
834
- return
835
- }
836
- const pct = Math.round(sessionPct)
837
- const hot = pct >= (config?.fireThreshold ?? 90)
838
- if (process.platform === 'darwin') tray.setTitle(hot ? ` ${pct}% 🔥` : ` ${pct}%`)
839
- tray.setToolTip(`Clauddy — session ${pct}%`)
840
- }
841
-
842
- // send real usage to the renderer and refresh the tray title in one place
843
- function pushRealUsage(u) {
844
- realUsage = u
845
- realUsageAt = u ? Date.now() : 0
846
- sessionPct = u?.session ? u.session.pct : null
847
- updateTray()
848
- checkScopedAlerts(config, u)
849
- if (win && !win.isDestroyed()) win.webContents.send('real-usage', u)
850
- }
851
-
852
- // Native drag regions swallow renderer pointer events. Poll only in pet-only
853
- // mode so its hover still works while the sprite remains a native drag handle.
854
- ipcMain.on('pet-pointer-watch', (_e, enabled) => {
855
- if (typeof enabled !== 'boolean') return
856
- clearInterval(petPointerTimer)
857
- petPointerTimer = null
858
- if (!enabled) return
859
- let last // only a moved cursor crosses IPC, not ten idle messages a second
860
- petPointerTimer = setInterval(() => {
861
- if (!win || win.isDestroyed()) return
862
- let point = null
863
- if (win.isVisible()) {
864
- const cursor = screen.getCursorScreenPoint()
865
- const bounds = win.getContentBounds()
866
- const x = cursor.x - bounds.x
867
- const y = cursor.y - bounds.y
868
- if (x >= 0 && y >= 0 && x < bounds.width && y < bounds.height) point = { x, y }
869
- }
870
- const key = point ? `${point.x},${point.y}` : ''
871
- if (key === last) return
872
- last = key
873
- win.webContents.send('pet-pointer', point)
874
- }, 100)
875
- })
876
-
877
- // resize the window to fit the content
878
- // Opening and closing Settings eases the window between sizes (an ease-out over
879
- // RESIZE_MS) instead of jumping; any other resize is immediate and cancels a
880
- // tween still running, so the latest size always wins.
881
- const RESIZE_MS = 240
882
- let resizeTween = null
883
- ipcMain.on('resize', (_e, w, h, animate) => {
884
- if (!win || win.isDestroyed()) return
885
- const width = Math.max(100, Math.round(w))
886
- const height = Math.max(110, Math.round(h))
887
- clearInterval(resizeTween)
888
- resizeTween = null
889
- if (!animate) return setWindowSize(width, height)
890
- const from = win.getBounds()
891
- const start = Date.now()
892
- const step = () => {
893
- if (!win || win.isDestroyed()) return clearInterval(resizeTween)
894
- const t = Math.min(1, (Date.now() - start) / RESIZE_MS)
895
- const k = 1 - (1 - t) ** 3
896
- setWindowSize(
897
- Math.round(from.width + (width - from.width) * k),
898
- Math.round(from.height + (height - from.height) * k),
899
- )
900
- if (t === 1) {
901
- clearInterval(resizeTween)
902
- resizeTween = null
903
- }
904
- }
905
- resizeTween = setInterval(step, 16)
906
- step()
907
- })
908
-
909
- function setWindowSize(width, height) {
910
- if (currentMode === 'menubar') {
911
- win.setContentSize(width, height)
912
- if (win.isVisible()) positionUnderTray() // keep it anchored under the tray
913
- } else {
914
- // keep the widget pinned to its current bottom-right corner on whatever
915
- // display the user dragged it to. setContentSize grows from the top-left
916
- // origin, so re-anchor by the old corner instead of snapping to the primary
917
- // display's bottom-right (which yanked the widget back on every update).
918
- const before = win.getBounds()
919
- const right = before.x + before.width
920
- const bottom = before.y + before.height
921
- win.setContentSize(width, height)
922
- const after = win.getBounds()
923
- moveWindow(right - after.width, bottom - after.height)
924
- }
925
- }
926
-
927
- // the arrow opens the page of whichever service the panel is showing
928
- ipcMain.on('open-usage', (_e, provider) =>
929
- shell.openExternal(USAGE_URLS[provider] || USAGE_URLS.claude),
930
- )
931
-
932
- // Settings → Connect Codex: look first; the renderer enables it when found
933
- ipcMain.on('codex-detect', () => {
934
- let r = { found: false, plan: null }
935
- try {
936
- r = codex.detectCodex()
937
- } catch {}
938
- if (win && !win.isDestroyed()) win.webContents.send('codex-detected', r)
939
- })
940
- ipcMain.on('codex-enable', (_e, on) => setCodexEnabled(!!on))
941
-
942
- // Settings → Connect Cursor: it needs the Cursor app signed in on this machine
943
- ipcMain.on('cursor-detect', () => {
944
- let r = { found: false, plan: null }
945
- try {
946
- r = cursor.detectCursor()
947
- } catch {}
948
- if (win && !win.isDestroyed()) win.webContents.send('cursor-detected', r)
949
- })
950
- ipcMain.on('cursor-enable', (_e, on) => setCursorEnabled(!!on))
951
-
952
- // watch the debug file; forward forced states to the renderer
953
- function watchDebug() {
954
- fs.watchFile(DEBUG_FILE, { interval: 400 }, () => {
955
- if (!win || win.isDestroyed()) return
956
- try {
957
- const txt = fs.readFileSync(DEBUG_FILE, 'utf8').trim()
958
- win.webContents.send('debug-state', txt ? JSON.parse(txt) : null)
959
- } catch {}
960
- })
961
- }
962
-
963
- // ---- real usage via OAuth (authoritative %), polled slowly with 429 backoff ----
964
- let usageTimer = null
965
- let usageBackoff = 5 * 60 * 1000
966
- let authFails = 0 // consecutive 401s — see pollUsage
967
- let lastPollAt = 0
968
-
969
- // The authoritative % only moves when tokens are actually spent, and the local
970
- // logs show that within a tick. Polling off that beats a faster clock: it
971
- // answers while the user is working and asks for nothing while they are not.
972
- // The floor is what bounds the cost — never more than one call per 90s, well
973
- // under what a fixed one-minute poll would spend.
974
- const POLL_FLOOR_MS = 90 * 1000
975
- let lastSeenTokens = null
976
- function onLocalActivity(data) {
977
- const t = data?.session?.tokens
978
- if (typeof t !== 'number') return
979
- const grew = lastSeenTokens != null && t > lastSeenTokens
980
- lastSeenTokens = t
981
- if (!grew || !auth.isConnected()) return
982
- if (Date.now() - lastPollAt < POLL_FLOOR_MS) return
983
- clearTimeout(usageTimer) // pollUsage schedules the next heartbeat itself
984
- pollUsage()
985
- }
986
-
987
- function scheduleUsagePoll() {
988
- clearTimeout(usageTimer)
989
- if (auth.isConnected()) usageTimer = setTimeout(pollUsage, usageBackoff)
990
- }
991
- async function pollUsage() {
992
- const accountId = accounts.activeId()
993
- lastPollAt = Date.now()
994
- try {
995
- const u = await auth.fetchUsage()
996
- if (accountId !== accounts.activeId()) return
997
- usageBackoff = 5 * 60 * 1000
998
- authFails = 0
999
- pushRealUsage(u)
1000
- // a token that came back after being cleared (a refresh that landed late)
1001
- // brings the % back, but nothing re-sent the chip: restate who this is
1002
- if (!profileShown) {
1003
- if (win && !win.isDestroyed()) win.webContents.send('auth-state', { connected: true })
1004
- sendAccounts()
1005
- sendProfile()
1006
- }
1007
- } catch (e) {
1008
- if (accountId !== accounts.activeId()) return
1009
- if (e && e.status === 429) {
1010
- usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
1011
- } else if (e && e.status === 401) {
1012
- // Refreshing rotates the refresh token, so a request that raced the one
1013
- // that rotated it comes back rejected while the session is perfectly
1014
- // alive. Clearing on that first answer throws away a good login, so the
1015
- // second rejection in a row is what counts.
1016
- if (++authFails < 2) {
1017
- usageBackoff = 60 * 1000
1018
- scheduleUsagePoll()
1019
- return
1020
- }
1021
- auth.clear()
1022
- pushRealUsage(null)
1023
- sendAccounts()
1024
- alertAuthLost(config)
1025
- if (win && !win.isDestroyed()) {
1026
- win.webContents.send('auth-state', { connected: false })
1027
- win.webContents.send('profile', null)
1028
- }
1029
- profileShown = false
1030
- }
1031
- }
1032
- scheduleUsagePoll()
1033
- }
1034
- function startUsagePoll() {
1035
- authFails = 0
1036
- if (auth.isConnected()) pollUsage()
1037
- }
1038
-
1039
- // Waking up: the poll timer was frozen through the sleep, so the numbers on
1040
- // screen are as old as the nap. Re-state what we know — a read that failed on
1041
- // the way down would otherwise leave a stale "log in" panel up — and poll
1042
- // again, after a beat, since the network is rarely back the instant we are.
1043
- function onResume() {
1044
- if (!auth.isConnected()) return
1045
- if (win && !win.isDestroyed()) win.webContents.send('auth-state', { connected: true })
1046
- clearTimeout(usageTimer)
1047
- usageBackoff = 5 * 60 * 1000
1048
- usageTimer = setTimeout(pollUsage, 5000)
1049
- sendProfile()
1050
- }
1051
-
1052
- // push the logged-in account's identity (email + plan) to the renderer
1053
- let profileTimer = null
1054
- let profileShown = false // the renderer holds this account's profile
1055
- async function sendProfile(tries = 0) {
1056
- clearTimeout(profileTimer)
1057
- if (!auth.isConnected()) return
1058
- // whose profile this is, decided *before* the await: a switch during the
1059
- // fetch would otherwise label the new account with the old one's email
1060
- const id = accounts.activeId()
1061
- try {
1062
- const p = await auth.fetchProfile()
1063
- if (id !== accounts.activeId()) return // switched under us — this is stale
1064
- if (p?.email) {
1065
- accounts.label(id, p.email) // a better name than "acct-xyz"
1066
- sendAccounts()
1067
- }
1068
- if (win && !win.isDestroyed()) win.webContents.send('profile', p)
1069
- profileShown = true
1070
- } catch {
1071
- // a rate limit or a blip would otherwise hide the chip — and with it the
1072
- // account switcher — until the app is restarted, so keep trying for a while
1073
- if (tries >= 4) return
1074
- profileTimer = setTimeout(
1075
- () => {
1076
- if (id === accounts.activeId()) sendProfile(tries + 1)
1077
- },
1078
- 30 * 1000 * 2 ** tries,
1079
- )
1080
- }
1081
- }
1082
-
1083
- // begin() has to run *after* any account switch: switching resets the pending
1084
- // PKCE verifier, which would strand a login started before it
1085
- function startLogin() {
1086
- shell.openExternal(auth.begin())
1087
- if (win && !win.isDestroyed()) win.webContents.send('auth-pending')
1088
- }
1089
- ipcMain.on('auth-start', startLogin)
1090
- ipcMain.on('auth-code', async (_e, code) => {
1091
- const ok = () => {
1092
- addedFrom = null // the new account is real now: nothing to roll back to
1093
- if (win && !win.isDestroyed()) {
1094
- win.webContents.send('auth-state', { connected: true })
1095
- win.webContents.send('auth-result', { ok: true })
1096
- }
1097
- sendAccounts()
1098
- sendProfile()
1099
- }
1100
- try {
1101
- await auth.complete(code)
1102
- usageBackoff = 5 * 60 * 1000
1103
- try {
1104
- const u = await auth.fetchUsage() // validate the token
1105
- ok()
1106
- pushRealUsage(u)
1107
- } catch (e) {
1108
- if (e && e.status === 429) {
1109
- // token is fine, the usage endpoint is just throttled — keep it and retry later
1110
- ok()
1111
- } else {
1112
- throw e
1113
- }
1114
- }
1115
- scheduleUsagePoll()
1116
- } catch (err) {
1117
- auth.clear() // don't keep an invalid token
1118
- if (win && !win.isDestroyed())
1119
- win.webContents.send('auth-result', { ok: false, error: String(err?.message || err) })
1120
- }
1121
- })
1122
- ipcMain.on('auth-logout', () => {
1123
- cancelReminder(reminderKey('claude'))
1124
- auth.clear()
1125
- clearTimeout(usageTimer)
1126
- pushRealUsage(null)
1127
- sendAccounts()
1128
- if (win && !win.isDestroyed()) {
1129
- win.webContents.send('auth-state', { connected: false })
1130
- win.webContents.send('profile', null)
1131
- }
1132
- profileShown = false
1133
- })
1134
-
1135
- // off only stops monitoring here; Codex's own logs and login are untouched
1136
- function setCodexEnabled(on) {
1137
- writeConfigPatch({ codex: { ...(config.codex || {}), enabled: on } })
1138
- config = loadConfig()
1139
- if (!on) {
1140
- codexUsage = null
1141
- cancelReminder('codex')
1142
- }
1143
- if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
1144
- if (doTick) doTick()
1145
- updateTray()
1146
- }
1147
-
1148
- // off only stops monitoring here; the Cursor app and its login are untouched
1149
- function setCursorEnabled(on) {
1150
- writeConfigPatch({ cursor: { ...(config.cursor || {}), enabled: on } })
1151
- config = loadConfig()
1152
- if (!on) {
1153
- cursorUsage = null
1154
- cancelReminder('cursor')
1155
- }
1156
- if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
1157
- if (doTick) doTick()
1158
- updateTray()
1159
- }
1160
-
1161
- function writeConfigPatch(patch) {
1162
- let obj = {}
1163
- for (const p of [EXTERNAL_CONFIG, path.join(__dirname, 'config.json')]) {
1164
- try {
1165
- obj = JSON.parse(fs.readFileSync(p, 'utf8'))
1166
- break
1167
- } catch {}
1168
- }
1169
- Object.assign(obj, patch)
1170
- try {
1171
- fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
1172
- fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
1173
- } catch {}
1174
- }
1175
-
1176
- ipcMain.on('save-config', (_e, patch) => {
1177
- writeConfigPatch(patch)
1178
- const prevThresholds = String(config.alertThresholds)
1179
- config = loadConfig()
1180
- // re-arm only when the thresholds actually moved: saving an unrelated setting
1181
- // (zoom, mode…) shouldn't replay an alert the user already dismissed
1182
- if (String(config.alertThresholds) !== prevThresholds) {
1183
- armed.clear()
1184
- saveAlertState()
1185
- }
1186
- if (doTick) doTick()
1187
- if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
1188
- applyMode(config.mode) // switch between floating widget and menu-bar popover live
1189
- })
1190
-
1191
- // ---- self-update (checks the latest GitHub release, runs install.sh) ---------
1192
- async function fetchLatestTag() {
1193
- const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
1194
- headers: { 'User-Agent': 'Clauddy', Accept: 'application/vnd.github+json' },
1195
- })
1196
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
1197
- const j = await res.json()
1198
- return j.tag_name // e.g. "v1.9.1"
1199
- }
1200
- // compare two "1.2.3" versions; >0 if a is newer than b
1201
- function cmpVer(a, b) {
1202
- const pa = String(a).replace(/^v/, '').split('.').map(Number)
1203
- const pb = String(b).replace(/^v/, '').split('.').map(Number)
1204
- for (let i = 0; i < 3; i++) {
1205
- const d = (pa[i] || 0) - (pb[i] || 0)
1206
- if (d) return d > 0 ? 1 : -1
1207
- }
1208
- return 0
1209
- }
1210
- async function computeUpdate() {
1211
- const latest = await fetchLatestTag()
1212
- return { latest, available: cmpVer(latest, app.getVersion()) > 0 }
1213
- }
1214
- // manual check (from the Settings button): shows checking / result / error
1215
- ipcMain.on('check-updates', async () => {
1216
- if (!win || win.isDestroyed()) return
1217
- const send = (s) => !win.isDestroyed() && win.webContents.send('update-status', s)
1218
- send({ state: 'checking' })
1219
- try {
1220
- const u = await computeUpdate()
1221
- send(u.available ? { state: 'available', latest: u.latest } : { state: 'uptodate' })
1222
- } catch (e) {
1223
- send({ state: 'error', message: String(e?.message || e) })
1224
- }
1225
- })
1226
- // silent background check: only speaks up when there's actually an update, so
1227
- // the renderer can badge the gear without any UI churn on the common case
1228
- async function autoUpdateCheck() {
1229
- if (!win || win.isDestroyed()) return
1230
- try {
1231
- const u = await computeUpdate()
1232
- if (u.available) win.webContents.send('update-status', { state: 'available', latest: u.latest })
1233
- } catch {} // offline / rate-limited: stay quiet, try again on the next tick
1234
- }
1235
- ipcMain.on('do-update', () => {
1236
- // Windows/Linux have no install.sh: send them to the releases page instead.
1237
- if (process.platform !== 'darwin') {
1238
- shell.openExternal(`https://github.com/${REPO}/releases/latest`)
1239
- return
1240
- }
1241
- if (win && !win.isDestroyed()) win.webContents.send('update-status', { state: 'updating' })
1242
- const cmd = `curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`
1243
- const child = spawn('/bin/bash', ['-lc', cmd], { detached: true, stdio: 'ignore' })
1244
- child.unref()
1245
- // quit so the installer can replace the running .app and relaunch the new build
1246
- setTimeout(() => app.quit(), 1500)
1247
- })
1248
-
1249
- ipcMain.on('quit', () => app.quit())
1250
-
1251
- // Electron's login-item API only covers macOS (SMAppService) and Windows (the
1252
- // registry Run key) — @platform darwin,win32 in electron.d.ts, a no-op on
1253
- // Linux. The real "start with the system" mechanism there is the XDG
1254
- // Autostart spec: a .desktop file in ~/.config/autostart, read by every major
1255
- // desktop environment's session manager at login (GNOME, KDE, XFCE, Cinnamon,
1256
- // MATE) — same role as the registry key or SMAppService, just file-based.
1257
- function enableLinuxAutostart() {
1258
- const exec = process.env.APPIMAGE || process.execPath
1259
- const autostartDir = path.join(os.homedir(), '.config', 'autostart')
1260
- const desktopFile = path.join(autostartDir, 'clauddy.desktop')
1261
- // AppImage isn't registered in the system's hicolor icon theme, so a bare
1262
- // "Icon=clauddy" name won't resolve — copy the icon to a path that outlives
1263
- // the AppImage's temp mount and reference it absolutely instead.
1264
- const iconFile = path.join(DATA_DIR, 'icon.png')
1265
- fs.mkdirSync(DATA_DIR, { recursive: true })
1266
- fs.copyFileSync(path.join(__dirname, 'build', 'icon.png'), iconFile)
1267
- const entry = [
1268
- '[Desktop Entry]',
1269
- 'Type=Application',
1270
- 'Name=Clauddy',
1271
- 'Comment=A cute pixel-art desktop pet that tracks your Claude Code usage',
1272
- `Exec="${exec}"`,
1273
- `Icon=${iconFile}`,
1274
- 'Terminal=false',
1275
- 'X-GNOME-Autostart-enabled=true',
1276
- '',
1277
- ].join('\n')
1278
- if (fs.existsSync(desktopFile) && fs.readFileSync(desktopFile, 'utf8') === entry) return
1279
- fs.mkdirSync(autostartDir, { recursive: true })
1280
- fs.writeFileSync(desktopFile, entry)
1281
- }
1282
-
1283
- app.whenReady().then(() => {
1284
- // Windows toast notifications need an explicit AppUserModelID to show reliably
1285
- if (process.platform === 'win32') app.setAppUserModelId('app.clauddy')
1286
- createWindow()
1287
- // open at login (packaged app only)
1288
- if (app.isPackaged) {
1289
- if (process.platform === 'linux') {
1290
- enableLinuxAutostart()
1291
- } else {
1292
- app.setLoginItemSettings({ openAtLogin: true, openAsHidden: false })
1293
- }
1294
- }
1295
- })
1296
-
1297
- app.on('window-all-closed', () => {
1298
- if (pollTimer) clearInterval(pollTimer)
1299
- clearInterval(petPointerTimer)
1300
- fs.unwatchFile(DEBUG_FILE)
1301
- destroyTray()
1302
- app.quit()
1303
- })
1304
-
1305
- if (process.platform === 'darwin' && app.dock) app.dock.hide()