clauddy 1.21.0 → 1.21.2

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/auth.js CHANGED
@@ -35,12 +35,25 @@ function setDataDir(dir) {
35
35
  tokens = null
36
36
  profile = null
37
37
  pending = null
38
+ unreadable = false
38
39
  }
39
40
 
41
+ // Set when the file itself could not be read for a reason other than "it is not
42
+ // there" — a disk still waking up, say. That must not read as a logout, or the
43
+ // widget asks for a login it doesn't need and every account looks gone. A file
44
+ // we did read but cannot parse is a different matter: that one really is dead.
45
+ let unreadable = false
40
46
  function load() {
41
47
  if (tokens) return tokens
48
+ let raw = null
42
49
  try {
43
- tokens = JSON.parse(fs.readFileSync(tokenPath, 'utf8'))
50
+ raw = fs.readFileSync(tokenPath, 'utf8')
51
+ unreadable = false
52
+ } catch (e) {
53
+ unreadable = e?.code !== 'ENOENT'
54
+ }
55
+ try {
56
+ tokens = raw == null ? null : JSON.parse(raw)
44
57
  } catch {
45
58
  tokens = null
46
59
  }
@@ -56,12 +69,15 @@ function save(t) {
56
69
  function clear() {
57
70
  tokens = null
58
71
  profile = null
72
+ unreadable = false
59
73
  try {
60
74
  fs.unlinkSync(tokenPath)
61
75
  } catch {}
62
76
  }
63
77
  function isConnected() {
64
- return !!load()
78
+ // a token we merely failed to read still counts: the account is connected,
79
+ // we just couldn't see it this instant
80
+ return !!load() || unreadable
65
81
  }
66
82
 
67
83
  // Step 1: build the authorize URL (opens in the browser)
@@ -152,7 +168,13 @@ async function refresh() {
152
168
 
153
169
  async function validToken() {
154
170
  const t = load()
155
- if (!t) throw Object.assign(new Error('not connected'), { status: 401 })
171
+ // status 0 is "try again later", not "log in again": only a token that is
172
+ // genuinely absent should send the user back through the browser
173
+ if (!t) {
174
+ throw Object.assign(new Error(unreadable ? 'token unreadable' : 'not connected'), {
175
+ status: unreadable ? 0 : 401,
176
+ })
177
+ }
156
178
  if (!t.expires_at || t.expires_at - Date.now() < 60000) await refresh()
157
179
  return load().access_token
158
180
  }
package/main.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  Tray,
9
9
  Menu,
10
10
  nativeImage,
11
+ powerMonitor,
11
12
  } = require('electron')
12
13
  const path = require('node:path')
13
14
  const fs = require('node:fs')
@@ -336,6 +337,7 @@ function createWindow() {
336
337
  for (const a of accounts.list()) pruneEmpty(a.id)
337
338
  accounts.pruneOrphans() // folders those slots left behind
338
339
  applyAccount(accounts.active())
340
+ powerMonitor?.on?.('resume', onResume)
339
341
  const { workAreaSize } = screen.getPrimaryDisplay()
340
342
  const H = 480
341
343
 
@@ -648,6 +650,7 @@ function watchDebug() {
648
650
  // ---- real usage via OAuth (authoritative %), polled slowly with 429 backoff ----
649
651
  let usageTimer = null
650
652
  let usageBackoff = 5 * 60 * 1000
653
+ let authFails = 0 // consecutive 401s — see pollUsage
651
654
  function scheduleUsagePoll() {
652
655
  clearTimeout(usageTimer)
653
656
  if (auth.isConnected()) usageTimer = setTimeout(pollUsage, usageBackoff)
@@ -656,11 +659,21 @@ async function pollUsage() {
656
659
  try {
657
660
  const u = await auth.fetchUsage()
658
661
  usageBackoff = 5 * 60 * 1000
662
+ authFails = 0
659
663
  pushRealUsage(u)
660
664
  } catch (e) {
661
665
  if (e && e.status === 429) {
662
666
  usageBackoff = Math.min(usageBackoff * 2, 30 * 60 * 1000)
663
667
  } else if (e && e.status === 401) {
668
+ // Refreshing rotates the refresh token, so a request that raced the one
669
+ // that rotated it comes back rejected while the session is perfectly
670
+ // alive. Clearing on that first answer throws away a good login, so the
671
+ // second rejection in a row is what counts.
672
+ if (++authFails < 2) {
673
+ usageBackoff = 60 * 1000
674
+ scheduleUsagePoll()
675
+ return
676
+ }
664
677
  auth.clear()
665
678
  pushRealUsage(null)
666
679
  sendAccounts()
@@ -674,20 +687,50 @@ async function pollUsage() {
674
687
  scheduleUsagePoll()
675
688
  }
676
689
  function startUsagePoll() {
690
+ authFails = 0
677
691
  if (auth.isConnected()) pollUsage()
678
692
  }
679
693
 
694
+ // Waking up: the poll timer was frozen through the sleep, so the numbers on
695
+ // screen are as old as the nap. Re-state what we know — a read that failed on
696
+ // the way down would otherwise leave a stale "log in" panel up — and poll
697
+ // again, after a beat, since the network is rarely back the instant we are.
698
+ function onResume() {
699
+ if (!auth.isConnected()) return
700
+ if (win && !win.isDestroyed()) win.webContents.send('auth-state', { connected: true })
701
+ clearTimeout(usageTimer)
702
+ usageBackoff = 5 * 60 * 1000
703
+ usageTimer = setTimeout(pollUsage, 5000)
704
+ sendProfile()
705
+ }
706
+
680
707
  // push the logged-in account's identity (email + plan) to the renderer
681
- async function sendProfile() {
708
+ let profileTimer = null
709
+ async function sendProfile(tries = 0) {
710
+ clearTimeout(profileTimer)
682
711
  if (!auth.isConnected()) return
712
+ // whose profile this is, decided *before* the await: a switch during the
713
+ // fetch would otherwise label the new account with the old one's email
714
+ const id = accounts.activeId()
683
715
  try {
684
716
  const p = await auth.fetchProfile()
717
+ if (id !== accounts.activeId()) return // switched under us — this is stale
685
718
  if (p?.email) {
686
- accounts.label(accounts.activeId(), p.email) // a better name than "acct-xyz"
719
+ accounts.label(id, p.email) // a better name than "acct-xyz"
687
720
  sendAccounts()
688
721
  }
689
722
  if (win && !win.isDestroyed()) win.webContents.send('profile', p)
690
- } catch {} // non-fatal: the chip just stays hidden
723
+ } catch {
724
+ // a rate limit or a blip would otherwise hide the chip — and with it the
725
+ // account switcher — until the app is restarted, so keep trying for a while
726
+ if (tries >= 4) return
727
+ profileTimer = setTimeout(
728
+ () => {
729
+ if (id === accounts.activeId()) sendProfile(tries + 1)
730
+ },
731
+ 30 * 1000 * 2 ** tries,
732
+ )
733
+ }
691
734
  }
692
735
 
693
736
  // begin() has to run *after* any account switch: switching resets the pending
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "clauddy",
3
3
  "desktopName": "clauddy.desktop",
4
- "version": "1.21.0",
4
+ "version": "1.21.2",
5
5
  "description": "A cute desktop pet that tracks your Claude Code usage",
6
6
  "main": "main.js",
7
7
  "bin": {
package/renderer/pet.js CHANGED
@@ -700,26 +700,38 @@ window.api.onAuthState((s) => {
700
700
  })
701
701
 
702
702
  // logged-in account chip (email + plan) shown top-left when connected
703
+ let lastProfile = null
703
704
  function showProfile(p) {
705
+ lastProfile = p
706
+ paintChip()
707
+ }
708
+
709
+ // The chip is also the account switcher, so it must not vanish just because the
710
+ // profile fetch failed — that would strand the user on one account until a
711
+ // restart. The label main stored for the active account is the same email, so
712
+ // it stands in until the profile lands.
713
+ function paintChip() {
714
+ const p = lastProfile
715
+ const a = lastAccounts
716
+ const active = (a?.accounts || []).find((x) => x.id === a?.active)
717
+ const email = p?.email || (active?.connected ? active.label : null)
704
718
  // the settings line says who is connected, not just that someone is
705
- el('acc-ok').textContent = p?.email
706
- ? `● ${p.email}${p.plan ? ` · ${p.plan}` : ''}`
707
- : '● Connected'
719
+ el('acc-ok').textContent = email ? `● ${email}${p?.plan ? ` · ${p.plan}` : ''}` : '● Connected'
708
720
  const chip = el('account-chip')
709
721
  const mini = el('mini-acct')
710
- if (!p?.email) {
722
+ if (!email) {
711
723
  chip.hidden = true
712
724
  mini.hidden = true
713
725
  return
714
726
  }
715
727
  // expanded: full email chip in the top bar
716
- el('ac-email').textContent = p.email
717
- chip.title = p.name ? `${p.name} · ${p.email}` : p.email
718
- setPlan(el('ac-plan'), p.plan)
728
+ el('ac-email').textContent = email
729
+ chip.title = p?.name ? `${p.name} · ${email}` : email
730
+ setPlan(el('ac-plan'), p?.plan)
719
731
  chip.hidden = false
720
732
  // collapsed: short name + plan in the mini block (email won't fit at 116px)
721
- el('mini-acct-name').textContent = p.name || p.email.split('@')[0]
722
- setPlan(el('mini-acct-plan'), p.plan)
733
+ el('mini-acct-name').textContent = p?.name || email.split('@')[0]
734
+ setPlan(el('mini-acct-plan'), p?.plan)
723
735
  mini.hidden = false
724
736
  }
725
737
  function setPlan(node, plan) {
@@ -765,6 +777,8 @@ function accountRow(acc, a) {
765
777
  function renderAccounts(a) {
766
778
  lastAccounts = a
767
779
  document.body.classList.toggle('one-account', (a?.accounts || []).length < 2)
780
+ paintChip() // the label may be all the chip has to go on
781
+
768
782
  if (el('acc-menu').hidden) return
769
783
  // the switch is done: the chip now names the account the menu was pointing at
770
784
  if (switching && !a?.busy) closeAccountMenu()