clauddy 1.17.1 → 1.18.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 +13 -1
- package/main.js +131 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -177,7 +177,19 @@ Switching is instant — no restart. (On Windows/Linux the icon lives in the sys
|
|
|
177
177
|
|
|
178
178
|
## Alerts
|
|
179
179
|
|
|
180
|
-
Optional **macOS notifications
|
|
180
|
+
Optional **macOS notifications**, toggled (with their thresholds) in **⚙ Settings**:
|
|
181
|
+
|
|
182
|
+
| Notification | When |
|
|
183
|
+
| --- | --- |
|
|
184
|
+
| _Session at 82%_ — `2h 39m left · resets 6:50 PM` | Your session crosses a threshold (default **80%** and **95%**) |
|
|
185
|
+
| _Weekly usage at 84%_ — `resets Fri 7:00 AM` | Same, for the weekly limit |
|
|
186
|
+
| _Fable weekly at 84%_ — `resets Fri 7:00 AM` | Same, for a per-model weekly limit |
|
|
187
|
+
| _Session window reset_ — `full budget again` | A session you had pushed past 80% rolls over |
|
|
188
|
+
| _Clauddy lost access to your usage_ | The OAuth token expired or was revoked, so the % went back to being an estimate |
|
|
189
|
+
|
|
190
|
+
The last threshold you set is the only one that makes a sound; the earlier ones arrive silently. Clicking any of them brings the widget to the front. Each fires once and re-arms when usage drops back below — remembered across restarts, so relaunching at 85% doesn't repeat an alert you already dismissed.
|
|
191
|
+
|
|
192
|
+
Percentages come from your account when you're logged in, and fall back to the local token estimate when you're not.
|
|
181
193
|
|
|
182
194
|
The first alert asks macOS for permission; after that the app shows up in **System Settings > Notifications** like any other.
|
|
183
195
|
|
package/main.js
CHANGED
|
@@ -52,6 +52,7 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
|
|
|
52
52
|
let trayBounds = null // last known tray icon rect, to anchor the popover
|
|
53
53
|
let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
|
|
54
54
|
let sessionPct = null // authoritative session % shown in the tray title
|
|
55
|
+
let realUsage = null // last OAuth usage payload — the % alerts trust when logged in
|
|
55
56
|
let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
|
|
56
57
|
let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
|
|
57
58
|
|
|
@@ -92,46 +93,133 @@ function loadConfig() {
|
|
|
92
93
|
return defaults
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
96
|
+
// ---- alerts ------------------------------------------------------------------
|
|
97
|
+
// One notification per (scope × threshold), re-armed when usage drops back below.
|
|
98
|
+
// `armed` is persisted so relaunching at 85% doesn't repeat the 80% alert you
|
|
99
|
+
// already dismissed; it also carries the last session % so a reset is detectable
|
|
100
|
+
// across restarts.
|
|
101
|
+
const ALERTS_FILE = path.join(DATA_DIR, 'alerts.json')
|
|
102
|
+
let armed = new Set()
|
|
103
|
+
let lastSessionPct = null
|
|
104
|
+
function loadAlertState() {
|
|
105
|
+
try {
|
|
106
|
+
const j = JSON.parse(fs.readFileSync(ALERTS_FILE, 'utf8'))
|
|
107
|
+
armed = new Set(Array.isArray(j.armed) ? j.armed : [])
|
|
108
|
+
lastSessionPct = typeof j.lastSessionPct === 'number' ? j.lastSessionPct : null
|
|
109
|
+
} catch {} // first run, or a corrupted file: start from a clean slate
|
|
102
110
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
function saveAlertState() {
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
114
|
+
fs.writeFileSync(ALERTS_FILE, JSON.stringify({ armed: [...armed], lastSessionPct }))
|
|
115
|
+
} catch {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fmtDuration(ms) {
|
|
119
|
+
const h = Math.floor(ms / 3600000)
|
|
120
|
+
const m = Math.floor((ms % 3600000) / 60000)
|
|
121
|
+
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
122
|
+
}
|
|
123
|
+
// wall-clock time the window flips, in the machine's own locale + timezone
|
|
124
|
+
function fmtClock(ms) {
|
|
125
|
+
const at = new Date(Date.now() + ms)
|
|
126
|
+
const time = at.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
|
|
127
|
+
return ms >= 86400000 ? `${at.toLocaleDateString([], { weekday: 'short' })} ${time}` : time
|
|
110
128
|
}
|
|
129
|
+
|
|
130
|
+
function notify(title, body, { silent = true } = {}) {
|
|
131
|
+
if (!Notification.isSupported()) return
|
|
132
|
+
const n = new Notification({ title, body, silent })
|
|
133
|
+
n.on('click', showWidget) // the obvious gesture: bring the pet to the front
|
|
134
|
+
n.show()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// the alert lines carry the reset, since "what do I do about it" is a question
|
|
138
|
+
// about time left, not about the threshold that happened to fire
|
|
139
|
+
function resetLine(resetMs, withRemaining) {
|
|
140
|
+
if (resetMs == null) return ''
|
|
141
|
+
const at = `resets ${fmtClock(resetMs)}`
|
|
142
|
+
return withRemaining && resetMs > 0 ? `${fmtDuration(resetMs)} left · ${at}` : at
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// usage crossing a threshold. `scopes` are {label, pct, resetMs, session} rows;
|
|
146
|
+
// the real (OAuth) numbers are preferred over the local token estimate, which is
|
|
147
|
+
// only a budget guess and can be off by an order of magnitude.
|
|
111
148
|
function alertScopes(config, scopes) {
|
|
112
|
-
if (!config.alerts
|
|
149
|
+
if (!config.alerts) return
|
|
113
150
|
const ths = config.alertThresholds || [80, 95]
|
|
114
|
-
|
|
151
|
+
const top = Math.max(...ths)
|
|
152
|
+
let dirty = false
|
|
153
|
+
for (const { label, pct, resetMs, session } of scopes) {
|
|
154
|
+
if (pct == null) continue
|
|
115
155
|
for (const t of ths) {
|
|
116
|
-
const key = `${
|
|
156
|
+
const key = `${label}:${t}`
|
|
117
157
|
if (pct >= t) {
|
|
118
158
|
if (!armed.has(key)) {
|
|
119
159
|
armed.add(key)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
160
|
+
dirty = true
|
|
161
|
+
const urgent = t === top
|
|
162
|
+
notify(
|
|
163
|
+
`${label} at ${Math.round(pct)}%${urgent ? ' — almost out' : ''}`,
|
|
164
|
+
resetLine(resetMs, session),
|
|
165
|
+
{ silent: !urgent }, // 80% is a heads-up; the last threshold earns a sound
|
|
166
|
+
)
|
|
125
167
|
}
|
|
126
|
-
} else {
|
|
127
|
-
|
|
168
|
+
} else if (armed.delete(key)) {
|
|
169
|
+
dirty = true // re-arm when it drops below
|
|
128
170
|
}
|
|
129
171
|
}
|
|
130
172
|
}
|
|
173
|
+
if (dirty) saveAlertState()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function checkAlerts(config, d) {
|
|
177
|
+
const r = realUsage // authoritative when logged in; the estimate is the fallback
|
|
178
|
+
const session = r?.session ?? d.session
|
|
179
|
+
const week = r?.week ?? d.week
|
|
180
|
+
alertScopes(config, [
|
|
181
|
+
{ label: 'Session', pct: session.pct, resetMs: session.resetMs, session: true },
|
|
182
|
+
{ label: 'Weekly usage', pct: week.pct, resetMs: week.resetMs },
|
|
183
|
+
])
|
|
184
|
+
checkWindowReset(config, session.pct)
|
|
185
|
+
}
|
|
186
|
+
// per-model weekly limits only exist on the account side, so they're checked
|
|
187
|
+
// off the OAuth poll rather than the local tick
|
|
188
|
+
function checkScopedAlerts(config, u) {
|
|
189
|
+
alertScopes(
|
|
190
|
+
config,
|
|
191
|
+
(u?.scoped || []).map((s) => ({
|
|
192
|
+
label: `${s.label} weekly`,
|
|
193
|
+
pct: s.pct,
|
|
194
|
+
resetMs: s.resetMs,
|
|
195
|
+
})),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// "you can work again" — only worth saying to someone who was actually near the
|
|
200
|
+
// ceiling, so a window flipping at 20% stays silent
|
|
201
|
+
const RESET_FROM = 80
|
|
202
|
+
const RESET_TO = 5
|
|
203
|
+
function checkWindowReset(config, pct) {
|
|
204
|
+
if (pct == null) return
|
|
205
|
+
const was = lastSessionPct
|
|
206
|
+
lastSessionPct = pct
|
|
207
|
+
if (config.alerts && was != null && was >= RESET_FROM && pct <= RESET_TO) {
|
|
208
|
+
notify('Session window reset', 'full budget again')
|
|
209
|
+
}
|
|
210
|
+
if (was == null || Math.round(was) !== Math.round(pct)) saveAlertState()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// the token going dead is a silent failure otherwise: the widget quietly falls
|
|
214
|
+
// back to the local estimate and keeps showing a number the user trusts
|
|
215
|
+
function alertAuthLost(config) {
|
|
216
|
+
if (!config.alerts) return
|
|
217
|
+
notify('Clauddy lost access to your usage', 'open Settings to reconnect')
|
|
131
218
|
}
|
|
132
219
|
|
|
133
220
|
function createWindow() {
|
|
134
221
|
config = loadConfig()
|
|
222
|
+
loadAlertState()
|
|
135
223
|
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
136
224
|
const H = 480
|
|
137
225
|
|
|
@@ -289,6 +377,16 @@ function showPopover() {
|
|
|
289
377
|
win.focus()
|
|
290
378
|
}
|
|
291
379
|
|
|
380
|
+
// bring the widget to the front from wherever it is, in either mode
|
|
381
|
+
function showWidget() {
|
|
382
|
+
if (!win || win.isDestroyed()) return
|
|
383
|
+
if (currentMode === 'menubar') showPopover()
|
|
384
|
+
else {
|
|
385
|
+
win.show()
|
|
386
|
+
win.focus()
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
292
390
|
// route every programmatic move through here so the 'moved' handler can tell
|
|
293
391
|
// our own repositioning apart from a genuine user drag (and skip saving it)
|
|
294
392
|
function moveWindow(x, y) {
|
|
@@ -369,6 +467,7 @@ function updateTray() {
|
|
|
369
467
|
|
|
370
468
|
// send real usage to the renderer and refresh the tray title in one place
|
|
371
469
|
function pushRealUsage(u) {
|
|
470
|
+
realUsage = u
|
|
372
471
|
sessionPct = u?.session ? u.session.pct : null
|
|
373
472
|
updateTray()
|
|
374
473
|
checkScopedAlerts(config, u)
|
|
@@ -428,6 +527,7 @@ async function pollUsage() {
|
|
|
428
527
|
} else if (e && e.status === 401) {
|
|
429
528
|
auth.clear()
|
|
430
529
|
pushRealUsage(null)
|
|
530
|
+
alertAuthLost(config)
|
|
431
531
|
if (win && !win.isDestroyed()) {
|
|
432
532
|
win.webContents.send('auth-state', { connected: false })
|
|
433
533
|
win.webContents.send('profile', null)
|
|
@@ -503,8 +603,14 @@ ipcMain.on('save-config', (_e, patch) => {
|
|
|
503
603
|
fs.mkdirSync(path.dirname(EXTERNAL_CONFIG), { recursive: true })
|
|
504
604
|
fs.writeFileSync(EXTERNAL_CONFIG, JSON.stringify(obj, null, 2))
|
|
505
605
|
} catch {}
|
|
606
|
+
const prevThresholds = String(config.alertThresholds)
|
|
506
607
|
config = loadConfig()
|
|
507
|
-
|
|
608
|
+
// re-arm only when the thresholds actually moved: saving an unrelated setting
|
|
609
|
+
// (zoom, mode…) shouldn't replay an alert the user already dismissed
|
|
610
|
+
if (String(config.alertThresholds) !== prevThresholds) {
|
|
611
|
+
armed.clear()
|
|
612
|
+
saveAlertState()
|
|
613
|
+
}
|
|
508
614
|
if (doTick) doTick()
|
|
509
615
|
if (win && !win.isDestroyed()) win.webContents.send('config', publicConfig(config))
|
|
510
616
|
applyMode(config.mode) // switch between floating widget and menu-bar popover live
|