clauddy 1.10.1 → 1.11.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 CHANGED
@@ -87,6 +87,8 @@ curl -fsSL https://raw.githubusercontent.com/renatoaug/claude-usage-monitor/main
87
87
 
88
88
  **Clauddy is free and open source** — the command above just downloads the latest release from this repo and drops it in `/Applications`, nothing else (you can read [`install.sh`](install.sh) first if you'd like).
89
89
 
90
+ Once installed, you can update in-app: **⚙ Settings → Check for updates → Update now** runs the same installer and relaunches the new build.
91
+
90
92
  Why not a normal download? macOS blocks **unsigned** apps downloaded through a browser with a scary *"damaged, move to Trash"* warning — even when they're perfectly safe. It's a false alarm: the only way to silence it is to pay Apple **$99/year** to sign + notarize, which a free hobby app skips. Files fetched with `curl` aren't flagged, so this method simply **lets your Mac open the app** without the block. It then registers in **Login Items** and starts with your Mac — set it and forget it.
91
93
 
92
94
  #### 2. Run it via `bunx` (no install)
package/main.js CHANGED
@@ -12,9 +12,12 @@ const {
12
12
  const path = require('node:path')
13
13
  const fs = require('node:fs')
14
14
  const os = require('node:os')
15
+ const { spawn } = require('node:child_process')
15
16
  const { getUsage } = require('./usage')
16
17
  const auth = require('./auth')
17
18
 
19
+ const REPO = 'renatoaug/claude-usage-monitor'
20
+
18
21
  // data dir: kept outside the project folder so moving the repo doesn't break it.
19
22
  // when CLAUDE_CONFIG_DIR is set (e.g. via direnv for multi-account setups), nest
20
23
  // the widget's data under that account's Claude config dir so each account gets
@@ -163,12 +166,15 @@ function createWindow() {
163
166
  win.webContents.once('did-finish-load', () => {
164
167
  tick()
165
168
  win.webContents.send('config', publicConfig(config))
169
+ win.webContents.send('version', app.getVersion())
166
170
  win.webContents.send('auth-state', { connected: auth.isConnected() })
167
171
  pollTimer = setInterval(tick, config.pollIntervalMs)
168
172
  startUsagePoll()
169
173
  sendProfile()
170
174
  watchDebug()
171
175
  applyMode(config.mode) // reveal the widget, or set up the tray + popover
176
+ setTimeout(autoUpdateCheck, 8000) // check once shortly after launch…
177
+ setInterval(autoUpdateCheck, 6 * 60 * 60 * 1000) // …then every 6h
172
178
  })
173
179
  }
174
180
 
@@ -412,6 +418,64 @@ ipcMain.on('save-config', (_e, patch) => {
412
418
  applyMode(config.mode) // switch between floating widget and menu-bar popover live
413
419
  })
414
420
 
421
+ // ---- self-update (checks the latest GitHub release, runs install.sh) ---------
422
+ async function fetchLatestTag() {
423
+ const res = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
424
+ headers: { 'User-Agent': 'Clauddy', Accept: 'application/vnd.github+json' },
425
+ })
426
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
427
+ const j = await res.json()
428
+ return j.tag_name // e.g. "v1.9.1"
429
+ }
430
+ // compare two "1.2.3" versions; >0 if a is newer than b
431
+ function cmpVer(a, b) {
432
+ const pa = String(a).replace(/^v/, '').split('.').map(Number)
433
+ const pb = String(b).replace(/^v/, '').split('.').map(Number)
434
+ for (let i = 0; i < 3; i++) {
435
+ const d = (pa[i] || 0) - (pb[i] || 0)
436
+ if (d) return d > 0 ? 1 : -1
437
+ }
438
+ return 0
439
+ }
440
+ async function computeUpdate() {
441
+ const latest = await fetchLatestTag()
442
+ return { latest, available: cmpVer(latest, app.getVersion()) > 0 }
443
+ }
444
+ // manual check (from the Settings button): shows checking / result / error
445
+ ipcMain.on('check-updates', async () => {
446
+ if (!win || win.isDestroyed()) return
447
+ const send = (s) => !win.isDestroyed() && win.webContents.send('update-status', s)
448
+ send({ state: 'checking' })
449
+ try {
450
+ const u = await computeUpdate()
451
+ send(u.available ? { state: 'available', latest: u.latest } : { state: 'uptodate' })
452
+ } catch (e) {
453
+ send({ state: 'error', message: String(e?.message || e) })
454
+ }
455
+ })
456
+ // silent background check: only speaks up when there's actually an update, so
457
+ // the renderer can badge the gear without any UI churn on the common case
458
+ async function autoUpdateCheck() {
459
+ if (!win || win.isDestroyed()) return
460
+ try {
461
+ const u = await computeUpdate()
462
+ if (u.available) win.webContents.send('update-status', { state: 'available', latest: u.latest })
463
+ } catch {} // offline / rate-limited: stay quiet, try again on the next tick
464
+ }
465
+ ipcMain.on('do-update', () => {
466
+ // Windows/Linux have no install.sh: send them to the releases page instead.
467
+ if (process.platform !== 'darwin') {
468
+ shell.openExternal(`https://github.com/${REPO}/releases/latest`)
469
+ return
470
+ }
471
+ if (win && !win.isDestroyed()) win.webContents.send('update-status', { state: 'updating' })
472
+ const cmd = `curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | bash`
473
+ const child = spawn('/bin/bash', ['-lc', cmd], { detached: true, stdio: 'ignore' })
474
+ child.unref()
475
+ // quit so the installer can replace the running .app and relaunch the new build
476
+ setTimeout(() => app.quit(), 1500)
477
+ })
478
+
415
479
  ipcMain.on('quit', () => app.quit())
416
480
 
417
481
  // Electron's login-item API only covers macOS (SMAppService) and Windows (the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.10.1",
4
+ "version": "1.11.0",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
package/preload.js CHANGED
@@ -15,5 +15,9 @@ contextBridge.exposeInMainWorld('api', {
15
15
  authCode: (code) => ipcRenderer.send('auth-code', code),
16
16
  authLogout: () => ipcRenderer.send('auth-logout'),
17
17
  onDebugState: (cb) => ipcRenderer.on('debug-state', (_e, s) => cb(s)),
18
+ onVersion: (cb) => ipcRenderer.on('version', (_e, v) => cb(v)),
19
+ onUpdateStatus: (cb) => ipcRenderer.on('update-status', (_e, s) => cb(s)),
20
+ checkUpdates: () => ipcRenderer.send('check-updates'),
21
+ doUpdate: () => ipcRenderer.send('do-update'),
18
22
  quit: () => ipcRenderer.send('quit'),
19
23
  })
@@ -305,55 +305,72 @@
305
305
  </div>
306
306
  </div>
307
307
 
308
- <div class="set-hint set-hint-left">Where Clauddy lives: a floating pet, or a menu-bar icon with a pop-up.</div>
309
- <label class="set-field">Display
308
+ <div class="set-section">
309
+ <div class="set-sec-title">Display</div>
310
310
  <span class="seg" id="set-mode">
311
311
  <button type="button" class="seg-btn" data-mode="floating">Floating pet</button>
312
312
  <button type="button" class="seg-btn" data-mode="menubar">Menu bar</button>
313
313
  </span>
314
- </label>
314
+ </div>
315
315
 
316
- <label class="set-check"><input id="set-alerts" type="checkbox" /> Alerts</label>
317
- <div class="set-hint set-hint-left">A macOS notification when your session or weekly usage crosses each level.</div>
318
- <label class="set-field">Alert thresholds (%)
319
- <span class="set-two">
320
- <span class="num">
321
- <input id="set-t1" type="number" min="1" max="100" />
322
- <span class="num-spin">
323
- <button type="button" class="num-btn" data-for="set-t1" data-step="1">▲</button>
324
- <button type="button" class="num-btn" data-for="set-t1" data-step="-1">▼</button>
316
+ <div class="set-section">
317
+ <div class="set-sec-title">
318
+ Alerts
319
+ <input id="set-alerts" class="set-toggle" type="checkbox" aria-label="Enable alerts" />
320
+ </div>
321
+ <div class="set-thresholds">
322
+ <span class="thr">
323
+ <span class="thr-cap">heads-up</span>
324
+ <span class="num">
325
+ <input id="set-t1" type="number" min="1" max="100" />
326
+ <span class="num-spin">
327
+ <button type="button" class="num-btn" data-for="set-t1" data-step="1">▲</button>
328
+ <button type="button" class="num-btn" data-for="set-t1" data-step="-1">▼</button>
329
+ </span>
325
330
  </span>
326
331
  </span>
327
- <span class="num">
328
- <input id="set-t2" type="number" min="1" max="100" />
329
- <span class="num-spin">
330
- <button type="button" class="num-btn" data-for="set-t2" data-step="1">▲</button>
331
- <button type="button" class="num-btn" data-for="set-t2" data-step="-1">▼</button>
332
+ <span class="thr">
333
+ <span class="thr-cap">near limit</span>
334
+ <span class="num">
335
+ <input id="set-t2" type="number" min="1" max="100" />
336
+ <span class="num-spin">
337
+ <button type="button" class="num-btn" data-for="set-t2" data-step="1">▲</button>
338
+ <button type="button" class="num-btn" data-for="set-t2" data-step="-1">▼</button>
339
+ </span>
332
340
  </span>
333
341
  </span>
334
- </span>
335
- <span class="set-two set-caps">
336
- <span class="num-cap">heads-up</span>
337
- <span class="num-cap">near limit</span>
338
- </span>
339
- </label>
340
- <div class="set-hint set-hint-left">The pet catches fire past this session % (it's maxed out at 100%).</div>
341
- <label class="set-field">Catch fire at (%)
342
- <span class="set-two">
343
- <span class="num">
344
- <input id="set-fire" type="number" min="1" max="99" />
345
- <span class="num-spin">
346
- <button type="button" class="num-btn" data-for="set-fire" data-step="1">▲</button>
347
- <button type="button" class="num-btn" data-for="set-fire" data-step="-1">▼</button>
342
+ </div>
343
+ </div>
344
+
345
+ <div class="set-section">
346
+ <div class="set-sec-title">Catch fire</div>
347
+ <div class="set-thresholds">
348
+ <span class="thr">
349
+ <span class="thr-cap">session %</span>
350
+ <span class="num">
351
+ <input id="set-fire" type="number" min="1" max="99" />
352
+ <span class="num-spin">
353
+ <button type="button" class="num-btn" data-for="set-fire" data-step="1">▲</button>
354
+ <button type="button" class="num-btn" data-for="set-fire" data-step="-1">▼</button>
355
+ </span>
348
356
  </span>
349
357
  </span>
350
- <span class="num" aria-hidden="true"></span>
351
- </span>
352
- </label>
358
+ </div>
359
+ </div>
360
+
353
361
  <div class="set-actions">
354
362
  <button id="set-cancel">Cancel</button>
355
363
  <button id="set-save">Save</button>
356
364
  </div>
365
+
366
+ <div class="set-updates">
367
+ <span class="upd-ver">Clauddy v<span id="app-version">—</span></span>
368
+ <span class="upd-right">
369
+ <span id="upd-status"></span>
370
+ <button type="button" id="upd-check" class="acc-link">Check for updates</button>
371
+ <button type="button" id="upd-now" class="upd-btn" hidden>Update now</button>
372
+ </span>
373
+ </div>
357
374
  </div>
358
375
  </div>
359
376
  <script src="pet.js"></script>
package/renderer/pet.js CHANGED
@@ -640,6 +640,51 @@ el('acc-confirm').addEventListener('click', () => {
640
640
  })
641
641
  el('acc-logout').addEventListener('click', () => window.api.authLogout())
642
642
 
643
+ // self-update: show the version, check the latest release, one-click update
644
+ window.api.onVersion((v) => {
645
+ if (v) el('app-version').textContent = v
646
+ })
647
+ window.api.onUpdateStatus((s) => {
648
+ const status = el('upd-status')
649
+ const now = el('upd-now')
650
+ const check = el('upd-check')
651
+ // reset to the idle look, then layer each state on top
652
+ now.hidden = true
653
+ check.hidden = false
654
+ status.textContent = ''
655
+ status.className = ''
656
+ const state = s?.state
657
+ if (state === 'checking') {
658
+ status.textContent = 'Checking…'
659
+ check.hidden = true
660
+ } else if (state === 'uptodate') {
661
+ status.textContent = "You're up to date"
662
+ status.className = 'ok'
663
+ } else if (state === 'available') {
664
+ // the button carries the version, so no separate status text is needed
665
+ now.textContent = `Update to ${s.latest}`
666
+ now.hidden = false
667
+ check.hidden = true
668
+ } else if (state === 'updating') {
669
+ status.textContent = 'Updating… the app will restart'
670
+ check.hidden = true
671
+ } else if (state === 'error') {
672
+ status.textContent = 'Check failed'
673
+ status.className = 'err'
674
+ }
675
+ // pulse a dot on the gear whenever an update is waiting (cleared once it's
676
+ // up to date or actively updating) — so it's noticeable without opening Settings
677
+ if (state === 'available') document.body.classList.add('has-update')
678
+ else if (state === 'uptodate' || state === 'updating')
679
+ document.body.classList.remove('has-update')
680
+ if (document.body.classList.contains('settings-open')) fitSize()
681
+ })
682
+ el('upd-check').addEventListener('click', () => window.api.checkUpdates())
683
+ el('upd-now').addEventListener('click', () => {
684
+ el('upd-now').disabled = true
685
+ window.api.doUpdate()
686
+ })
687
+
643
688
  // settings panel
644
689
  // snapshot of the editable fields, to tell whether there are unsaved changes
645
690
  let settingsBaseline = null
@@ -104,6 +104,38 @@ body {
104
104
  #gear {
105
105
  font-size: 10px;
106
106
  }
107
+ /* "update available" cue: a soft pulsing coral dot on the gear's corner, so a
108
+ new version is noticeable without opening Settings */
109
+ body.has-update #gear {
110
+ position: relative;
111
+ overflow: visible;
112
+ }
113
+ body.has-update #gear::after {
114
+ content: "";
115
+ position: absolute;
116
+ top: -2px;
117
+ right: -2px;
118
+ width: 7px;
119
+ height: 7px;
120
+ border-radius: 50%;
121
+ background: var(--coral);
122
+ box-shadow:
123
+ 0 0 0 1.5px #2a1f19,
124
+ 0 0 5px 1px rgba(217, 119, 87, 0.6);
125
+ animation: upd-badge 1.6s ease-in-out infinite;
126
+ pointer-events: none;
127
+ }
128
+ @keyframes upd-badge {
129
+ 0%,
130
+ 100% {
131
+ transform: scale(1);
132
+ opacity: 0.9;
133
+ }
134
+ 50% {
135
+ transform: scale(1.3);
136
+ opacity: 1;
137
+ }
138
+ }
107
139
  /* minimize icon swaps to an "expand" icon when collapsed */
108
140
  .ic-max {
109
141
  display: none;
@@ -232,8 +264,8 @@ body.settings-open #settings {
232
264
  border-color: var(--coral);
233
265
  }
234
266
  /* hide the ugly native number spinners (custom ones below) */
235
- .set-field input[type="number"]::-webkit-inner-spin-button,
236
- .set-field input[type="number"]::-webkit-outer-spin-button {
267
+ input[type="number"]::-webkit-inner-spin-button,
268
+ input[type="number"]::-webkit-outer-spin-button {
237
269
  -webkit-appearance: none;
238
270
  appearance: none;
239
271
  margin: 0;
@@ -270,28 +302,73 @@ body.settings-open #settings {
270
302
  body.is-menubar #min {
271
303
  display: none;
272
304
  }
273
- .set-two {
305
+ /* settings sections: Display / Alerts / Catch fire — each with a standardized
306
+ title, separated by a hairline divider */
307
+ .set-section {
308
+ margin-bottom: 10px;
309
+ }
310
+ .set-section + .set-section {
311
+ border-top: 0.5px solid var(--hair);
312
+ padding-top: 10px;
313
+ }
314
+ .set-sec-title {
274
315
  display: flex;
275
- gap: 6px;
316
+ align-items: center;
317
+ justify-content: space-between;
318
+ font-size: 11px;
319
+ font-weight: 600;
320
+ color: var(--text);
321
+ margin-bottom: 8px;
276
322
  }
277
- /* small captions under each threshold box */
278
- .set-caps {
279
- margin-top: 3px;
323
+ /* alerts on/off toggle, sitting to the right of the section title */
324
+ .set-toggle {
325
+ margin: 0;
326
+ width: 13px;
327
+ height: 13px;
328
+ accent-color: var(--coral);
329
+ cursor: pointer;
280
330
  }
281
- .num-cap {
282
- flex: 1;
283
- font-size: 8.5px;
331
+ /* threshold fields: caption on top, number input below — same shape for all
332
+ three (heads-up, near limit, catch fire) so they read as one set */
333
+ .set-thresholds {
334
+ display: flex;
335
+ gap: 12px;
336
+ }
337
+ .thr {
338
+ display: flex;
339
+ flex-direction: column;
340
+ gap: 4px;
341
+ }
342
+ .thr-cap {
343
+ font-size: 9px;
284
344
  color: var(--muted);
285
- padding-left: 2px;
345
+ height: 12px;
346
+ line-height: 12px;
286
347
  }
287
- /* custom number stepper */
348
+ /* number field + custom stepper */
288
349
  .num {
289
350
  position: relative;
290
- flex: 1;
351
+ flex: none;
352
+ width: 66px;
291
353
  }
292
354
  .num input {
293
355
  width: 100%;
294
- padding-right: 24px;
356
+ background: rgba(255, 255, 255, 0.06);
357
+ border: 0.5px solid var(--hair);
358
+ border-radius: 8px;
359
+ color: var(--text);
360
+ font-size: 13px;
361
+ font-weight: 600;
362
+ font-variant-numeric: tabular-nums;
363
+ padding: 6px 24px 6px 10px;
364
+ outline: none;
365
+ transition:
366
+ border-color 0.15s,
367
+ background 0.15s;
368
+ }
369
+ .num input:focus {
370
+ border-color: var(--coral);
371
+ background: rgba(255, 255, 255, 0.09);
295
372
  }
296
373
  .num-spin {
297
374
  position: absolute;
@@ -387,6 +464,61 @@ body.is-menubar #min {
387
464
  }
388
465
  }
389
466
 
467
+ /* updates footer */
468
+ .set-updates {
469
+ display: flex;
470
+ align-items: center;
471
+ justify-content: space-between;
472
+ gap: 8px;
473
+ margin-top: 11px;
474
+ padding-top: 9px;
475
+ border-top: 0.5px solid var(--hair);
476
+ }
477
+ .upd-ver {
478
+ font-size: 10px;
479
+ color: var(--muted);
480
+ }
481
+ .upd-right {
482
+ display: flex;
483
+ align-items: center;
484
+ gap: 8px;
485
+ }
486
+ #upd-status {
487
+ font-size: 10px;
488
+ color: var(--muted);
489
+ }
490
+ #upd-status.ok {
491
+ color: var(--green);
492
+ }
493
+ #upd-status.err {
494
+ color: var(--coral);
495
+ }
496
+ /* compact update button — lighter than the full-width .acc-btn */
497
+ .upd-btn {
498
+ border: none;
499
+ border-radius: 7px;
500
+ padding: 4px 9px;
501
+ font-size: 10px;
502
+ font-weight: 600;
503
+ color: #fff;
504
+ background: var(--coral);
505
+ cursor: pointer;
506
+ -webkit-app-region: no-drag;
507
+ transition:
508
+ background 0.18s,
509
+ transform 0.1s;
510
+ }
511
+ .upd-btn:hover {
512
+ background: #e2876a;
513
+ }
514
+ .upd-btn:active {
515
+ transform: scale(0.97);
516
+ }
517
+ .upd-btn:disabled {
518
+ opacity: 0.6;
519
+ cursor: default;
520
+ }
521
+
390
522
  /* live tag */
391
523
  .live-only {
392
524
  display: none;
@@ -402,6 +534,8 @@ body.live .live-only {
402
534
  /* account / auth */
403
535
  #account {
404
536
  margin-bottom: 10px;
537
+ padding-bottom: 10px;
538
+ border-bottom: 0.5px solid var(--hair); /* divider before the first section */
405
539
  }
406
540
  .acc-btn {
407
541
  width: 100%;
@@ -434,12 +568,13 @@ body.live .live-only {
434
568
  color: var(--muted);
435
569
  font-size: 10px;
436
570
  cursor: pointer;
437
- text-decoration: underline;
571
+ text-decoration: none;
438
572
  -webkit-app-region: no-drag;
439
573
  transition: color 0.15s;
440
574
  }
441
575
  .acc-link:hover {
442
576
  color: var(--text);
577
+ text-decoration: underline;
443
578
  }
444
579
  .acc-ok {
445
580
  color: var(--green);