clauddy 1.29.1 → 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/README.md +10 -9
- package/bin/clauddy.js +82 -79
- package/package.json +10 -82
- package/accounts.js +0 -181
- package/auth.js +0 -300
- package/codex.js +0 -345
- package/config.json +0 -11
- package/cursor.js +0 -278
- package/main.js +0 -1305
- package/preload.js +0 -43
- package/reminders.js +0 -54
- package/renderer/burn.js +0 -98
- package/renderer/companion.js +0 -52
- package/renderer/index.html +0 -527
- package/renderer/motion.css +0 -646
- package/renderer/pet.js +0 -2394
- package/renderer/pets.css +0 -916
- package/renderer/pets.js +0 -579
- package/renderer/style.css +0 -3758
- package/renderer/voice.js +0 -244
- package/usage.js +0 -539
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()
|