usage-cli 0.29.32__py3-none-any.whl

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.
Files changed (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
wintray.py ADDED
@@ -0,0 +1,2014 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import ctypes
8
+ import importlib
9
+ import json
10
+ import logging
11
+ import os
12
+ import threading
13
+ import time
14
+ import tomllib
15
+ import webbrowser
16
+ from collections import deque
17
+ from collections.abc import Callable
18
+ from dataclasses import dataclass
19
+ from datetime import date, datetime, timedelta
20
+ from enum import IntEnum
21
+ from importlib import metadata
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any
24
+ from uuid import UUID
25
+
26
+ import agy_window_keeper
27
+ import codex_loader
28
+ import menubar_agy
29
+ import menubar_state
30
+ import service_status
31
+ import update_checker
32
+ import update_gate
33
+ import usage_diagnosis_snapshot
34
+ import win_login_item
35
+ import window_keeper
36
+ import wintray_menu
37
+ from burn_rate import BurnRateTracker
38
+ from history_loader import UsageEntry, load_entries
39
+ from i18n import _t
40
+ from menubar_prefs import (
41
+ _auto_update_check_enabled,
42
+ _hide_agy_enabled,
43
+ _hide_claude_enabled,
44
+ _hide_codex_enabled,
45
+ _panel_flavor,
46
+ _quota_card_order,
47
+ _quota_notification_thresholds,
48
+ _quota_notifications_enabled,
49
+ _save_panel_flavor,
50
+ _window_keeper_enabled,
51
+ )
52
+ from panels.dynamic_height import clamp_content_height, inject_content_height_script
53
+ from panels.payload import _load_panel_html, _state_payload
54
+ from prefs import _load_preferences, _save_preferences
55
+ from pricing import calculate_cost
56
+ from statusline_settings import _statusline_enabled, _toggle_statusline_settings
57
+ from update_release_notes import format_release_notes
58
+ from usage_client import ClaudeUsageClient, PollState
59
+ from usage_lang import detect_lang
60
+ from usage_notifications import NotificationEvent, QuotaNotifier
61
+ from usage_rate import UsageRateTracker
62
+ from windows_watch import (
63
+ WindowsFileEventChanges,
64
+ WindowsUsageWatcher,
65
+ setup_windows_watcher,
66
+ )
67
+
68
+ if TYPE_CHECKING:
69
+ from PIL.Image import Image
70
+
71
+ logger = logging.getLogger(__name__)
72
+
73
+ SLOW_POLL_INTERVAL_S = 300
74
+ HISTORY_SCAN_CACHE_SECONDS = 30.0
75
+ UPDATE_ALERT_BODY_LIMIT = 2000
76
+ PANEL_WIDTH = 380
77
+ _TOAST_AUMID = "com.lollapalooza.usage"
78
+ _TOAST_OPEN_PANEL_ACTION = "open_panel"
79
+ WINDOWS_PANELS = (
80
+ ("classic", "panel_default_name", "classic.html"),
81
+ ("matrix", "panel_matrix", "matrix.html"),
82
+ ("win95", "panel_win95", "win95.html"),
83
+ ("newspaper", "panel_newspaper", "newspaper.html"),
84
+ ("cloud_observation", "panel_cloud_observation", "cloud_observation.html"),
85
+ ("aquarium", "panel_aquarium", "aquarium.html"),
86
+ ("prism_arcade", "panel_prism_arcade", "prism_arcade.html"),
87
+ ("black_hole", "panel_black_hole", "black_hole.html"),
88
+ ("lepidoptera", "panel_lepidoptera", "lepidoptera.html"),
89
+ ("world_cup", "panel_world_cup", "world_cup.html"),
90
+ ("stained_glass", "panel_stained_glass", "stained_glass.html"),
91
+ ("origami", "panel_origami", "origami.html"),
92
+ ("catppuccin", "panel_catppuccin", "catppuccin.html"),
93
+ )
94
+ # These are only the initial-placeholder fallback used before the WebView
95
+ # reports its real content height (see panel_height()); kept in sync with
96
+ # panels/__init__.py's Mac heights from 64a7c0b (Recalibrate HTML panel
97
+ # heights and status-wrap growth) so the brief pre-measurement window isn't
98
+ # ~17-24pt too tall.
99
+ PANEL_HEIGHTS = {
100
+ "classic": 1004,
101
+ "matrix": 1046,
102
+ "win95": 1055,
103
+ "newspaper": 1051,
104
+ "cloud_observation": 1006,
105
+ "aquarium": 1006,
106
+ "prism_arcade": 1006,
107
+ "black_hole": 1006,
108
+ "lepidoptera": 1046,
109
+ "world_cup": 812,
110
+ "stained_glass": 1004,
111
+ "origami": 1004,
112
+ "catppuccin": 1038,
113
+ }
114
+
115
+ TRAY_UNKNOWN_COLOR = (110, 118, 129, 255)
116
+ TRAY_NORMAL_COLOR = (244, 145, 100, 255)
117
+ TRAY_PAUSED_COLOR = (255, 196, 57, 255)
118
+ TRAY_ERROR_COLOR = (255, 69, 58, 255)
119
+
120
+
121
+ class TaskbarProgressState(IntEnum):
122
+ """TBPFLAG values used by ITaskbarList3::SetProgressState."""
123
+
124
+ NO_PROGRESS = 0x0
125
+ NORMAL = 0x2
126
+ ERROR = 0x4
127
+ PAUSED = 0x8
128
+
129
+
130
+ class _GUID(ctypes.Structure):
131
+ _fields_ = [
132
+ ("data1", ctypes.c_uint32),
133
+ ("data2", ctypes.c_ushort),
134
+ ("data3", ctypes.c_ushort),
135
+ ("data4", ctypes.c_ubyte * 8),
136
+ ]
137
+
138
+ @classmethod
139
+ def from_string(cls, value: str) -> _GUID:
140
+ parsed = UUID(value)
141
+ return cls(
142
+ parsed.time_low,
143
+ parsed.time_mid,
144
+ parsed.time_hi_version,
145
+ (ctypes.c_ubyte * 8)(*parsed.bytes[8:]),
146
+ )
147
+
148
+
149
+ _CLSID_TASKBAR_LIST = _GUID.from_string("56FDF344-FD6D-11D0-958A-006097C9A090")
150
+ _IID_ITASKBAR_LIST3 = _GUID.from_string("EA1AFB91-9E28-4B86-90E9-9E9F8A5EEFAF")
151
+ _RPC_E_CHANGED_MODE = -2147417850
152
+
153
+ JS_SHIM = """
154
+ <script>
155
+ window.webkit = window.webkit || {};
156
+ window.webkit.messageHandlers = window.webkit.messageHandlers || {};
157
+ window.webkit.messageHandlers.usage = {
158
+ postMessage: function(message) { return window.pywebview.api.postMessage(message); }
159
+ };
160
+
161
+ // The panel assets are shared with macOS. On Windows, intercept their
162
+ // built-in switch button and provide the equivalent of the native menu here.
163
+ (function() {
164
+ var menuRoot;
165
+
166
+ function closeMenu() {
167
+ if (menuRoot) {
168
+ menuRoot.remove();
169
+ menuRoot = null;
170
+ }
171
+ }
172
+
173
+ function post(action, extra) {
174
+ var message = Object.assign({ action: action }, extra || {});
175
+ return Promise.resolve(
176
+ window.webkit.messageHandlers.usage.postMessage(JSON.stringify(message))
177
+ );
178
+ }
179
+
180
+ function menuItem(item) {
181
+ if (item.type === 'separator') {
182
+ var separator = document.createElement('div');
183
+ separator.className = 'usage-panel-menu-separator';
184
+ separator.setAttribute('role', 'separator');
185
+ return separator;
186
+ }
187
+ if (item.children) {
188
+ var group = document.createElement('div');
189
+ group.className = 'usage-panel-menu-accordion';
190
+ var row = document.createElement('button');
191
+ row.type = 'button';
192
+ row.className = 'usage-panel-menu-item usage-panel-menu-parent';
193
+ row.setAttribute('role', 'menuitem');
194
+ row.setAttribute('aria-expanded', 'false');
195
+ row.textContent = item.label + ' ›';
196
+ var submenu = document.createElement('div');
197
+ submenu.className = 'usage-panel-menu-submenu';
198
+ submenu.setAttribute('role', 'menu');
199
+ item.children.forEach(function(child) { submenu.appendChild(menuItem(child)); });
200
+ row.addEventListener('click', function() {
201
+ var expanded = row.getAttribute('aria-expanded') === 'true';
202
+ row.setAttribute('aria-expanded', String(!expanded));
203
+ row.textContent = item.label + (!expanded ? ' ˅' : ' ›');
204
+ submenu.hidden = expanded;
205
+ });
206
+ submenu.hidden = true;
207
+ group.appendChild(row);
208
+ group.appendChild(submenu);
209
+ return group;
210
+ }
211
+ var row = document.createElement('button');
212
+ row.type = 'button';
213
+ row.className = 'usage-panel-menu-item';
214
+ row.setAttribute('role', 'menuitemcheckbox');
215
+ row.textContent = (item.checked ? '✓ ' : ' ') + item.label;
216
+ row.addEventListener('click', function() {
217
+ var extra = item.panelId ? { panel_id: item.panelId } :
218
+ item.preferenceKey ? { preference_key: item.preferenceKey } : undefined;
219
+ post(item.action, extra);
220
+ closeMenu();
221
+ });
222
+ return row;
223
+ }
224
+
225
+ function showMenu(items) {
226
+ closeMenu();
227
+ menuRoot = document.createElement('div');
228
+ menuRoot.className = 'usage-panel-menu-backdrop';
229
+ menuRoot.setAttribute('aria-hidden', 'false');
230
+ var menu = document.createElement('div');
231
+ menu.className = 'usage-panel-menu';
232
+ menu.setAttribute('role', 'menu');
233
+ items.forEach(function(item) { menu.appendChild(menuItem(item)); });
234
+ menuRoot.appendChild(menu);
235
+ menuRoot.addEventListener('click', function(event) {
236
+ if (event.target === menuRoot) closeMenu();
237
+ });
238
+ document.body.appendChild(menuRoot);
239
+ }
240
+
241
+ document.addEventListener('click', function(event) {
242
+ var button = event.target.closest && event.target.closest('[data-action="switch"]');
243
+ if (!button) return;
244
+ event.preventDefault();
245
+ event.stopImmediatePropagation();
246
+ post('open_menu').then(function(items) {
247
+ if (Array.isArray(items)) showMenu(items);
248
+ });
249
+ }, true);
250
+ document.addEventListener('keydown', function(event) {
251
+ if (event.key === 'Escape') closeMenu();
252
+ });
253
+ })();
254
+
255
+ // Panel assets register their card reorder handler in the bubbling phase. This
256
+ // earlier capture listener turns their empty card area into a native drag
257
+ // region without changing the shared macOS HTML. Add the class only after
258
+ // excluding controls, so pywebview never treats a button click as a window drag.
259
+ document.addEventListener('pointerdown', function(event) {
260
+ var target = event.target;
261
+ var card = target && target.closest && target.closest(
262
+ '[data-card="claude"], [data-card="codex"], [data-card="agy"]'
263
+ );
264
+ var interactive = target && target.closest && target.closest(
265
+ 'button, a, input, select, textarea, label, summary, [contenteditable], '
266
+ + '[role="button"], .codex-stale-info, .stale-info'
267
+ );
268
+ if (!card || event.button !== 0 || interactive) return;
269
+ card.classList.add('pywebview-drag-region', 'usage-card-window-dragging');
270
+ var clearDragRegion = function() {
271
+ card.classList.remove('pywebview-drag-region', 'usage-card-window-dragging');
272
+ document.removeEventListener('pointerup', clearDragRegion, true);
273
+ document.removeEventListener('pointercancel', clearDragRegion, true);
274
+ };
275
+ document.addEventListener('pointerup', clearDragRegion, true);
276
+ document.addEventListener('pointercancel', clearDragRegion, true);
277
+ event.stopImmediatePropagation();
278
+ }, true);
279
+
280
+ // Keep the native drag target deliberately small so it remains distinct from
281
+ // normal panel interaction.
282
+ document.addEventListener('DOMContentLoaded', function() {
283
+ var handle = document.createElement('div');
284
+ handle.className = 'usage-window-drag-handle pywebview-drag-region';
285
+ handle.setAttribute('aria-hidden', 'true');
286
+ document.body.appendChild(handle);
287
+ });
288
+ </script>
289
+ <style>
290
+ .usage-window-drag-handle {
291
+ position: fixed;
292
+ top: 4px;
293
+ left: 50%;
294
+ z-index: 2147483647;
295
+ width: 56px;
296
+ height: 7px;
297
+ margin-left: -28px;
298
+ border-radius: 99px;
299
+ background: rgba(127, 127, 127, .28);
300
+ cursor: grab;
301
+ opacity: .35;
302
+ transition: opacity .15s ease, background .15s ease;
303
+ }
304
+ .usage-window-drag-handle:hover {
305
+ background: rgba(127, 127, 127, .65);
306
+ opacity: 1;
307
+ }
308
+ .usage-window-drag-handle:active,
309
+ .usage-card-window-dragging {
310
+ cursor: grabbing;
311
+ }
312
+ .usage-panel-menu-backdrop {
313
+ position: fixed;
314
+ inset: 0;
315
+ z-index: 2147483646;
316
+ background: rgba(0, 0, 0, .12);
317
+ }
318
+ .usage-panel-menu {
319
+ position: absolute;
320
+ top: 36px;
321
+ right: 12px;
322
+ min-width: 220px;
323
+ max-height: 80vh;
324
+ overflow-y: auto;
325
+ padding: 6px;
326
+ border: 1px solid rgba(127, 127, 127, .55);
327
+ border-radius: 9px;
328
+ background: rgba(30, 32, 36, .96);
329
+ color: #f5f5f5;
330
+ box-shadow: 0 12px 32px rgba(0, 0, 0, .32);
331
+ font: 13px/1.3 system-ui, sans-serif;
332
+ }
333
+ .usage-panel-menu-item {
334
+ position: relative;
335
+ display: block;
336
+ width: 100%;
337
+ padding: 7px 10px;
338
+ border: 0;
339
+ border-radius: 5px;
340
+ background: transparent;
341
+ color: inherit;
342
+ text-align: left;
343
+ white-space: nowrap;
344
+ cursor: pointer;
345
+ }
346
+ .usage-panel-menu-item:hover, .usage-panel-menu-item:focus {
347
+ background: rgba(120, 160, 255, .32);
348
+ outline: none;
349
+ }
350
+ .usage-panel-menu-accordion { display: block; }
351
+ .usage-panel-menu-submenu {
352
+ padding-left: 16px;
353
+ }
354
+ .usage-panel-menu-submenu[hidden] {
355
+ display: none;
356
+ }
357
+ .usage-panel-menu-separator { height: 1px; margin: 5px 4px; background: rgba(180, 180, 180, .35); }
358
+ </style>
359
+ """.strip()
360
+
361
+
362
+ def _winreg() -> Any:
363
+ import winreg
364
+
365
+ return winreg
366
+
367
+
368
+ def _register_toast_aumid(aumid: str = _TOAST_AUMID) -> None:
369
+ """Register the unpackaged tray app as a Windows toast sender."""
370
+ winreg = _winreg()
371
+ key_path = rf"Software\Classes\AppUserModelId\{aumid}"
372
+ with winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, key_path) as key:
373
+ winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, "usage")
374
+
375
+
376
+ def _create_toast_backend(aumid: str = _TOAST_AUMID) -> Any:
377
+ _register_toast_aumid(aumid)
378
+ from windows_toasts import InteractableWindowsToaster
379
+
380
+ return InteractableWindowsToaster("usage", notifierAUMID=aumid)
381
+
382
+
383
+ def _system_background_color() -> str:
384
+ try:
385
+ winreg = _winreg()
386
+ with winreg.OpenKey(
387
+ winreg.HKEY_CURRENT_USER,
388
+ r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
389
+ ) as key:
390
+ value, _value_type = winreg.QueryValueEx(key, "AppsUseLightTheme")
391
+ if value == 0:
392
+ return "#080d12"
393
+ except Exception:
394
+ pass
395
+ return "#eef2f7"
396
+
397
+
398
+ def _system_accent_color() -> str | None:
399
+ try:
400
+ winreg = _winreg()
401
+ with winreg.OpenKey(
402
+ winreg.HKEY_CURRENT_USER,
403
+ r"Software\Microsoft\Windows\DWM",
404
+ ) as key:
405
+ value, value_type = winreg.QueryValueEx(key, "AccentColor")
406
+ if (
407
+ value_type != winreg.REG_DWORD
408
+ or isinstance(value, bool)
409
+ or not isinstance(value, int)
410
+ or not 0 <= value <= 0xFFFFFFFF
411
+ ):
412
+ return None
413
+ except Exception:
414
+ return None
415
+ return f"#{value & 0xFF:02x}{value >> 8 & 0xFF:02x}{value >> 16 & 0xFF:02x}"
416
+
417
+
418
+ def available_panels() -> tuple[tuple[str, str, str], ...]:
419
+ """Windows excludes talent_market because its vendored CLI is macOS-only."""
420
+ return tuple(panel for panel in WINDOWS_PANELS if panel[0] != "talent_market")
421
+
422
+
423
+ def tray_icon_style(used_percent: float | None) -> tuple[str, tuple[int, int, int, int]]:
424
+ if used_percent is None:
425
+ return ("--", TRAY_UNKNOWN_COLOR)
426
+ remaining = max(0, min(100, round(100.0 - used_percent)))
427
+ if remaining <= 20:
428
+ color = TRAY_ERROR_COLOR
429
+ elif remaining <= 50:
430
+ color = TRAY_PAUSED_COLOR
431
+ else:
432
+ color = TRAY_NORMAL_COLOR
433
+ return (str(remaining), color)
434
+
435
+
436
+ def taskbar_progress_state(used_percent: float | None) -> TaskbarProgressState:
437
+ """Map the tray icon's existing quota color tier to a taskbar progress state."""
438
+ if used_percent is None:
439
+ return TaskbarProgressState.NO_PROGRESS
440
+ _text, color = tray_icon_style(used_percent)
441
+ if color == TRAY_ERROR_COLOR:
442
+ return TaskbarProgressState.ERROR
443
+ if color == TRAY_PAUSED_COLOR:
444
+ return TaskbarProgressState.PAUSED
445
+ return TaskbarProgressState.NORMAL
446
+
447
+
448
+ def _taskbar_window_handle(window: Any) -> int | None:
449
+ """Return the pywebview WinForms HWND only when it owns a taskbar button."""
450
+ try:
451
+ native = window.native
452
+ if not native.ShowInTaskbar:
453
+ return None
454
+ handle = native.Handle
455
+ to_int64 = getattr(handle, "ToInt64", None)
456
+ value = to_int64() if callable(to_int64) else handle
457
+ hwnd = int(value)
458
+ return hwnd or None
459
+ except (AttributeError, TypeError, ValueError, OverflowError):
460
+ return None
461
+
462
+
463
+ def _raise_for_hresult(result: int, operation: str) -> None:
464
+ if result < 0:
465
+ code = result & 0xFFFFFFFF
466
+ raise OSError(f"{operation} failed with HRESULT 0x{code:08X}")
467
+
468
+
469
+ def _set_taskbar_progress(
470
+ hwnd: int,
471
+ completed: int,
472
+ total: int,
473
+ state: TaskbarProgressState,
474
+ ) -> None:
475
+ """Apply taskbar progress with a thread-local, short-lived ITaskbarList3."""
476
+ if os.name != "nt":
477
+ return
478
+
479
+ # WinDLL leaves HRESULT handling to us, including RPC_E_CHANGED_MODE;
480
+ # OleDLL would raise before we could safely reuse an existing apartment.
481
+ library_name = "WinDLL"
482
+ win_dll: Any = getattr(ctypes, library_name)
483
+ ole32: Any = win_dll("ole32", use_last_error=True)
484
+ function_type_name = "WINFUNCTYPE"
485
+ win_function_type: Any = getattr(ctypes, function_type_name)
486
+ ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
487
+ ole32.CoInitializeEx.restype = ctypes.c_long
488
+ ole32.CoCreateInstance.argtypes = [
489
+ ctypes.POINTER(_GUID),
490
+ ctypes.c_void_p,
491
+ ctypes.c_ulong,
492
+ ctypes.POINTER(_GUID),
493
+ ctypes.POINTER(ctypes.c_void_p),
494
+ ]
495
+ ole32.CoCreateInstance.restype = ctypes.c_long
496
+ ole32.CoUninitialize.argtypes = []
497
+ ole32.CoUninitialize.restype = None
498
+
499
+ initialize_result = int(ole32.CoInitializeEx(None, 0x2)) # COINIT_APARTMENTTHREADED
500
+ initialized_here = initialize_result in {0, 1} # S_OK or S_FALSE
501
+ if initialize_result < 0 and initialize_result != _RPC_E_CHANGED_MODE:
502
+ _raise_for_hresult(initialize_result, "CoInitializeEx")
503
+
504
+ taskbar = ctypes.c_void_p()
505
+ try:
506
+ result = int(
507
+ ole32.CoCreateInstance(
508
+ ctypes.byref(_CLSID_TASKBAR_LIST),
509
+ None,
510
+ 0x1, # CLSCTX_INPROC_SERVER
511
+ ctypes.byref(_IID_ITASKBAR_LIST3),
512
+ ctypes.byref(taskbar),
513
+ )
514
+ )
515
+ _raise_for_hresult(result, "CoCreateInstance(CLSID_TaskbarList)")
516
+
517
+ vtable = ctypes.cast(
518
+ taskbar, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))
519
+ ).contents
520
+ hresult_method = win_function_type(ctypes.c_long, ctypes.c_void_p)
521
+ set_progress_value_method = win_function_type(
522
+ ctypes.c_long,
523
+ ctypes.c_void_p,
524
+ ctypes.c_void_p,
525
+ ctypes.c_ulonglong,
526
+ ctypes.c_ulonglong,
527
+ )
528
+ set_progress_state_method = win_function_type(
529
+ ctypes.c_long, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int
530
+ )
531
+ release_method = win_function_type(ctypes.c_ulong, ctypes.c_void_p)
532
+
533
+ _raise_for_hresult(int(hresult_method(vtable[3])(taskbar)), "ITaskbarList3.HrInit")
534
+ if state != TaskbarProgressState.NO_PROGRESS:
535
+ _raise_for_hresult(
536
+ int(
537
+ set_progress_value_method(vtable[9])(
538
+ taskbar,
539
+ ctypes.c_void_p(hwnd),
540
+ completed,
541
+ total,
542
+ )
543
+ ),
544
+ "ITaskbarList3.SetProgressValue",
545
+ )
546
+ _raise_for_hresult(
547
+ int(
548
+ set_progress_state_method(vtable[10])(
549
+ taskbar, ctypes.c_void_p(hwnd), int(state)
550
+ )
551
+ ),
552
+ "ITaskbarList3.SetProgressState",
553
+ )
554
+ finally:
555
+ if taskbar.value:
556
+ vtable = ctypes.cast(
557
+ taskbar, ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))
558
+ ).contents
559
+ release_method = win_function_type(ctypes.c_ulong, ctypes.c_void_p)
560
+ release_method(vtable[2])(taskbar)
561
+ if initialized_here:
562
+ ole32.CoUninitialize()
563
+
564
+
565
+ def build_tooltip(state: menubar_state.PopoverState) -> str:
566
+ def line(name: str, row: menubar_state.QuotaRowState) -> str:
567
+ used = (
568
+ "--"
569
+ if row.percent is None
570
+ else str(min(100, max(0, round(row.percent))))
571
+ )
572
+ return f"{name} {row.title}: {used}%"
573
+
574
+ lines = [
575
+ f"{line('Claude', state.claude_session)} · "
576
+ f"{line('Claude', state.claude_weekly).removeprefix('Claude ')}",
577
+ f"{line('Codex', state.codex_session)} · "
578
+ f"{line('Codex', state.codex_weekly).removeprefix('Codex ')}",
579
+ ]
580
+ if not state.hide_agy:
581
+ lines.append(
582
+ f"{line('Antigravity', state.agy_session)} · "
583
+ f"{line('Antigravity', state.agy_weekly).removeprefix('Antigravity ')}"
584
+ )
585
+ return "\n".join(lines)
586
+
587
+
588
+ def draw_tray_icon(used_percent: float | None) -> Image:
589
+ from PIL import Image, ImageDraw, ImageFont
590
+
591
+ text, color = tray_icon_style(used_percent)
592
+ image = Image.new("RGBA", (64, 64), (0, 0, 0, 0))
593
+ draw = ImageDraw.Draw(image)
594
+ draw.rounded_rectangle((2, 2, 62, 62), radius=14, fill=color)
595
+ font = ImageFont.load_default(size=24)
596
+ box = draw.textbbox((0, 0), text, font=font)
597
+ draw.text(
598
+ ((64 - (box[2] - box[0])) / 2, (64 - (box[3] - box[1])) / 2 - box[1]),
599
+ text,
600
+ font=font,
601
+ fill=(10, 15, 20, 255),
602
+ )
603
+ return image
604
+
605
+
606
+ def panel_html(filename: str) -> str:
607
+ html = _load_panel_html(filename)
608
+ html = html.replace("{{PANEL_FLAVOR}}", _panel_flavor())
609
+ html = inject_content_height_script(html)
610
+ marker = "<head>"
611
+ return html.replace(marker, f"{marker}\n{JS_SHIM}", 1)
612
+
613
+
614
+ def _active_panel_id() -> str:
615
+ panel_ids = {panel[0] for panel in available_panels()}
616
+ value = _load_preferences().get("usage.activePanelId", "classic")
617
+ return str(value) if value in panel_ids else "classic"
618
+
619
+
620
+ def _save_active_panel_id(panel_id: str) -> None:
621
+ preferences = _load_preferences()
622
+ preferences["usage.activePanelId"] = panel_id
623
+ _save_preferences(preferences)
624
+
625
+
626
+ def _current_version() -> str:
627
+ try:
628
+ return metadata.version("usage-cli")
629
+ except metadata.PackageNotFoundError:
630
+ from i18n import packaged_resource_path
631
+
632
+ pyproject = packaged_resource_path(
633
+ "pyproject.toml", Path(__file__).with_name("pyproject.toml")
634
+ )
635
+ data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
636
+ value = data["project"]["version"]
637
+ return str(value)
638
+
639
+
640
+ def _statusline_payload(language: str) -> dict[str, object]:
641
+ return {
642
+ "enabled": _statusline_enabled(),
643
+ "enabledText": _t(language, "cli_enabled"),
644
+ "disabledText": _t(language, "cli_disabled"),
645
+ }
646
+
647
+
648
+ def _today_text(entries: list[UsageEntry], language: str) -> str:
649
+ today = datetime.now().astimezone().date()
650
+ selected = [entry for entry in entries if entry.timestamp.astimezone().date() == today]
651
+ return _t(
652
+ language,
653
+ "today_text",
654
+ cost=f"{sum(calculate_cost(entry) for entry in selected):.2f}",
655
+ tokens=f"{sum(entry.total_tokens for entry in selected):,}",
656
+ )
657
+
658
+
659
+ def _yesterday_text(entries: list[UsageEntry], language: str) -> str:
660
+ yesterday = datetime.now().astimezone().date() - timedelta(days=1)
661
+ selected = [entry for entry in entries if entry.timestamp.astimezone().date() == yesterday]
662
+ return _t(
663
+ language,
664
+ "yesterday_text",
665
+ cost=f"{sum(calculate_cost(entry) for entry in selected):.2f}",
666
+ tokens=f"{sum(entry.total_tokens for entry in selected):,}",
667
+ )
668
+
669
+
670
+ def _mock_projects() -> tuple[
671
+ list[tuple[str, int, float | None]],
672
+ list[tuple[str, int, float | None]],
673
+ list[tuple[str, int, float | None]],
674
+ list[tuple[str, int, float | None]],
675
+ list[tuple[str, int, float | None]],
676
+ ]:
677
+ return (
678
+ [("usage", 11_200_000, 6.47), ("FinMind", 3_100_000, 1.82), ("AI客服", 800_000, 0.48)],
679
+ [("usage", 10_800_000, 6.21), ("FinMind", 2_900_000, 1.70)],
680
+ [("usage", 78_400_000, 45.20), ("FinMind", 21_700_000, 12.74), ("AI客服", 5_600_000, 3.36)],
681
+ [
682
+ ("usage", 312_000_000, 180.50),
683
+ ("FinMind", 86_400_000, 50.12),
684
+ ("AI客服", 22_000_000, 13.20),
685
+ ],
686
+ [
687
+ ("usage", 624_000_000, 361.00),
688
+ ("FinMind", 172_800_000, 100.24),
689
+ ("AI客服", 44_000_000, 26.40),
690
+ ],
691
+ )
692
+
693
+
694
+ @dataclass(slots=True)
695
+ class _RefreshData:
696
+ entries: list[UsageEntry]
697
+ history_error_key: str | None
698
+
699
+
700
+ class _JSApi:
701
+ def __init__(self, controller: _WindowsTrayController) -> None:
702
+ # Underscore-private: pywebview serializes every public attribute of a
703
+ # js_api object into the JS bridge, and walking the controller (and its
704
+ # WinForms window graph) recurses forever.
705
+ self._controller = controller
706
+
707
+ def postMessage( # noqa: N802 - JavaScript contract
708
+ self, message: object
709
+ ) -> list[dict[str, object]] | None:
710
+ return self._controller.handle_panel_message(message)
711
+
712
+
713
+ class _WindowsTrayController:
714
+ def __init__(self, mock: bool, interval: int) -> None:
715
+ self.mock = mock
716
+ self.interval = max(30, interval)
717
+ self.language = detect_lang()
718
+ self.active_panel_id = _active_panel_id()
719
+ self._switch_pending: bool = False
720
+ self.latest_state = self._empty_state()
721
+ self.tracker = UsageRateTracker(mock=mock)
722
+ self.burn_rate_trackers = {
723
+ "claude_session": BurnRateTracker(),
724
+ "claude_weekly": BurnRateTracker(),
725
+ "codex_session": BurnRateTracker(),
726
+ "codex_weekly": BurnRateTracker(),
727
+ }
728
+ self.icon: Any = None
729
+ self.window: Any = None
730
+ self.visible = False
731
+ self._positioned_this_show = False
732
+ self.stopping = threading.Event()
733
+ self.refresh_lock = threading.Lock()
734
+ self._refresh_in_flight = False
735
+ self._refresh_queued = False
736
+ self._refresh_thread: threading.Thread | None = None
737
+ self._poll_thread: threading.Thread | None = None
738
+ self._watcher_lock = threading.Lock()
739
+ self._windows_watcher: WindowsUsageWatcher | None = None
740
+ self._file_event_lock = threading.Lock()
741
+ self._file_event_refresh_timer: threading.Timer | None = None
742
+ self._last_file_event_refresh_started_at: float | None = None
743
+ self._history_source_tracker = menubar_state.HistorySourceTracker()
744
+ self._quota_notifier = QuotaNotifier(_quota_notification_thresholds())
745
+ self.usage_client = ClaudeUsageClient(mock=mock)
746
+ self._last_tray_percent: float | None = None
747
+ self._last_tray_tooltip: str | None = None
748
+ self._last_injected_state: str | None = None
749
+ self._toast_backend: Any = None
750
+ self._toast_backend_attempted = False
751
+ self._history_fingerprint: tuple[tuple[str, int, float], ...] | None = None
752
+ self._history_cache_date: date | None = None
753
+ self._cached_history: _RefreshData | None = None
754
+ self._cached_projects: tuple[list[tuple[str, int, float | None]], ...] | None = None
755
+ self._history_scan: menubar_state.HistorySourceScan | None = None
756
+ self._history_scan_at: float | None = None
757
+ self._content_height: int | None = None
758
+ self._window_mutations: deque[Callable[[], None]] = deque()
759
+ self._window_mutation_lock = threading.Lock()
760
+ self._window_mutation_scheduled = False
761
+
762
+ def _empty_state(self) -> menubar_state.PopoverState:
763
+ missing = menubar_state._missing_row
764
+ return menubar_state.PopoverState(
765
+ language=self.language,
766
+ claude_session=missing(
767
+ _t(self.language, "session_label"), menubar_state.CLAUDE_COLOR, self.language
768
+ ),
769
+ claude_weekly=missing(
770
+ _t(self.language, "weekly_label"), menubar_state.CLAUDE_COLOR, self.language
771
+ ),
772
+ codex_session=missing(
773
+ _t(self.language, "session_label"), menubar_state.CODEX_COLOR, self.language
774
+ ),
775
+ codex_weekly=missing(
776
+ _t(self.language, "weekly_label"), menubar_state.CODEX_COLOR, self.language
777
+ ),
778
+ agy_session=missing(
779
+ _t(self.language, "session_label"), menubar_state.AGY_COLOR, self.language
780
+ ),
781
+ agy_weekly=missing(
782
+ _t(self.language, "weekly_label"), menubar_state.AGY_COLOR, self.language
783
+ ),
784
+ agy_group_name="",
785
+ projects=[],
786
+ projects_yesterday=[],
787
+ projects_7d=[],
788
+ projects_30d=[],
789
+ projects_all=[],
790
+ rate_text=_t(self.language, "rate_text", value="--"),
791
+ status_text=_t(self.language, "status_text", value=_t(self.language, "status_loading")),
792
+ today_text=_t(self.language, "today_text", cost="0.00", tokens="0"),
793
+ yesterday_text=_t(self.language, "yesterday_text", cost="0.00", tokens="0"),
794
+ statusline=_statusline_payload(self.language),
795
+ hide_claude=_hide_claude_enabled(),
796
+ hide_codex=_hide_codex_enabled(),
797
+ hide_agy=_hide_agy_enabled(),
798
+ card_order=_quota_card_order(),
799
+ )
800
+
801
+ def panel_filename(self) -> str:
802
+ return next(item[2] for item in available_panels() if item[0] == self.active_panel_id)
803
+
804
+ def panel_height(self) -> int:
805
+ return self._content_height or PANEL_HEIGHTS[self.active_panel_id]
806
+
807
+ def _apply_content_height(self, value: object) -> None:
808
+ self._dispatch_window_mutation(lambda: self._apply_content_height_on_ui_thread(value))
809
+
810
+ def _apply_content_height_on_ui_thread(self, value: object) -> None:
811
+ if self.stopping.is_set():
812
+ return
813
+ current_position = self._current_window_position()
814
+ work_area = self._work_area_for_point(current_position) or self._working_area()
815
+ maximum = (
816
+ float(work_area[3] - work_area[1] - 24)
817
+ if work_area is not None
818
+ else float(PANEL_HEIGHTS[self.active_panel_id])
819
+ )
820
+ height = clamp_content_height(value, maximum)
821
+ if height is None:
822
+ return
823
+ rounded = int(round(height))
824
+ if rounded == self._content_height:
825
+ return
826
+ self._content_height = rounded
827
+ if self.visible:
828
+ self._place_window_on_ui_thread()
829
+
830
+ def attach(self, icon: Any, window: Any) -> None:
831
+ self.icon = icon
832
+ self.window = window
833
+ self._update_tray()
834
+ threading.Thread(target=self._startup_maintenance, daemon=True).start()
835
+ threading.Thread(target=self._poll_loop, daemon=True).start()
836
+ self.refresh()
837
+
838
+ def _startup_maintenance(self) -> None:
839
+ usage_diagnosis_snapshot.maybe_schedule_refresh()
840
+ self._clear_stale_update_cache()
841
+ self._check_update_in_background(
842
+ manual=False,
843
+ ignore_cooldown=False,
844
+ ignore_skipped=False,
845
+ )
846
+
847
+ def on_loaded(self) -> None:
848
+ # pywebview's resize()/move() call SetWindowPos with SWP_SHOWWINDOW,
849
+ # so placing the window while it is hidden would drag the bare panel
850
+ # onto the screen. Placement happens in show_panel() instead; here it
851
+ # only re-applies after a visible panel switch reloads the document.
852
+ if self.visible and not self.stopping.is_set():
853
+ self._place_window()
854
+ self.inject_state(force=True)
855
+ # A panel reload can recreate its taskbar button. Reapply the
856
+ # latest value once the visible native window has loaded.
857
+ self._update_taskbar_progress(self.latest_state.claude_session.percent)
858
+
859
+ @staticmethod
860
+ def _screen_rectangle(value: object) -> tuple[int, int, int, int] | None:
861
+ """Return a pywebview/WinForms rectangle as logical left/top/right/bottom."""
862
+ left = getattr(value, "Left", getattr(value, "x", None))
863
+ top = getattr(value, "Top", getattr(value, "y", None))
864
+ right = getattr(value, "Right", None)
865
+ bottom = getattr(value, "Bottom", None)
866
+ if right is None:
867
+ width = getattr(value, "Width", getattr(value, "width", None))
868
+ right = (
869
+ left + width
870
+ if isinstance(left, int | float) and isinstance(width, int | float)
871
+ else None
872
+ )
873
+ if bottom is None:
874
+ height = getattr(value, "Height", getattr(value, "height", None))
875
+ bottom = (
876
+ top + height
877
+ if isinstance(top, int | float) and isinstance(height, int | float)
878
+ else None
879
+ )
880
+ coordinates = (left, top, right, bottom)
881
+ if any(isinstance(item, bool) or not isinstance(item, int | float) for item in coordinates):
882
+ return None
883
+ assert isinstance(left, int | float)
884
+ assert isinstance(top, int | float)
885
+ assert isinstance(right, int | float)
886
+ assert isinstance(bottom, int | float)
887
+ return (int(left), int(top), int(right), int(bottom))
888
+
889
+ def _logical_screens(
890
+ self,
891
+ ) -> list[tuple[tuple[int, int, int, int], tuple[int, int, int, int]]]:
892
+ """Return pywebview screen bounds and work areas, all in logical pixels."""
893
+ try:
894
+ webview = importlib.import_module("webview")
895
+ screens = webview.screens
896
+ except Exception:
897
+ return []
898
+
899
+ result = []
900
+ for screen in screens:
901
+ bounds = self._screen_rectangle(screen)
902
+ work_area = self._screen_rectangle(getattr(screen, "frame", None)) or bounds
903
+ if bounds is not None and work_area is not None:
904
+ result.append((bounds, work_area))
905
+ return result
906
+
907
+ def _working_area(self) -> tuple[int, int, int, int] | None:
908
+ """Return the primary monitor work area in pywebview logical pixels."""
909
+ screens = self._logical_screens()
910
+ for bounds, work_area in screens:
911
+ left, top, right, bottom = bounds
912
+ if left <= 0 < right and top <= 0 < bottom:
913
+ return work_area
914
+ return screens[0][1] if screens else None
915
+
916
+ def _work_area_for_point(
917
+ self, point: tuple[int, int] | None
918
+ ) -> tuple[int, int, int, int] | None:
919
+ """Logical work area of the pywebview monitor nearest ``point``."""
920
+ if point is None:
921
+ return self._working_area()
922
+ screens = self._logical_screens()
923
+ if not screens:
924
+ return None
925
+ for bounds, work_area in screens:
926
+ left, top, right, bottom = bounds
927
+ if left <= point[0] < right and top <= point[1] < bottom:
928
+ return work_area
929
+
930
+ def distance(bounds: tuple[int, int, int, int]) -> int:
931
+ left, top, right, bottom = bounds
932
+ dx = max(left - point[0], 0, point[0] - (right - 1))
933
+ dy = max(top - point[1], 0, point[1] - (bottom - 1))
934
+ return dx * dx + dy * dy
935
+
936
+ return min(screens, key=lambda screen: distance(screen[0]))[1]
937
+
938
+ def _saved_window_position(self) -> tuple[int, int] | None:
939
+ value = _load_preferences().get("usage.windowPosition")
940
+ if not isinstance(value, dict):
941
+ return None
942
+ x, y = value.get("x"), value.get("y")
943
+ if isinstance(x, bool) or isinstance(y, bool):
944
+ return None
945
+ if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
946
+ return None
947
+ return (int(x), int(y))
948
+
949
+ def _current_window_position(self) -> tuple[int, int] | None:
950
+ if self.window is None:
951
+ return None
952
+ try:
953
+ x, y = self.window.x, self.window.y
954
+ except (AttributeError, TypeError, ValueError):
955
+ return None
956
+ if isinstance(x, bool) or isinstance(y, bool):
957
+ return None
958
+ if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
959
+ return None
960
+ return (int(x), int(y))
961
+
962
+ @staticmethod
963
+ def _clamp_window_position(
964
+ position: tuple[int, int], work_area: tuple[int, int, int, int], height: int
965
+ ) -> tuple[int, int]:
966
+ left, top, right, bottom = work_area
967
+ return (
968
+ min(max(position[0], left + 12), max(left + 12, right - PANEL_WIDTH - 12)),
969
+ min(max(position[1], top + 12), max(top + 12, bottom - height - 12)),
970
+ )
971
+
972
+ @staticmethod
973
+ def _default_window_position(
974
+ work_area: tuple[int, int, int, int], height: int
975
+ ) -> tuple[int, int]:
976
+ left, top, right, bottom = work_area
977
+ return (max(left + 12, right - PANEL_WIDTH - 12), max(top + 12, bottom - height - 12))
978
+
979
+ def _place_window(self, *, force_default: bool = False) -> None:
980
+ self._dispatch_window_mutation(
981
+ lambda: self._place_window_on_ui_thread(force_default=force_default)
982
+ )
983
+
984
+ def _place_window_on_ui_thread(self, *, force_default: bool = False) -> None:
985
+ if self.window is None or self.stopping.is_set():
986
+ return
987
+ primary_work_area = self._working_area()
988
+ if primary_work_area is None:
989
+ return
990
+
991
+ # Resolve the *target* anchor point before picking a work area, then
992
+ # look up the work area of whichever monitor that point is on. The
993
+ # primary monitor's work area is only a fallback for the "no anchor
994
+ # yet" (first-ever launch) case — using it unconditionally would
995
+ # clamp a window the user dragged onto a secondary display back onto
996
+ # the primary one every time the panel is switched.
997
+ if force_default:
998
+ anchor = None
999
+ elif self._positioned_this_show:
1000
+ anchor = self._current_window_position() or self._saved_window_position()
1001
+ else:
1002
+ anchor = self._saved_window_position()
1003
+
1004
+ work_area = self._work_area_for_point(anchor) or primary_work_area
1005
+ left, top, right, bottom = work_area
1006
+ height = min(self.panel_height(), max(240, bottom - top - 24))
1007
+ self.window.resize(PANEL_WIDTH, height)
1008
+ position = anchor if anchor is not None else self._default_window_position(
1009
+ work_area, height
1010
+ )
1011
+ self.window.move(*self._clamp_window_position(position, work_area, height))
1012
+ self._positioned_this_show = True
1013
+
1014
+ def _dispatch_window_mutation(self, mutation: Callable[[], None]) -> None:
1015
+ """Serialize geometry mutations onto the native WinForms UI thread."""
1016
+ if self.stopping.is_set():
1017
+ return
1018
+ with self._window_mutation_lock:
1019
+ self._window_mutations.append(mutation)
1020
+ if self._window_mutation_scheduled:
1021
+ return
1022
+ self._window_mutation_scheduled = True
1023
+
1024
+ if self._schedule_window_mutation_drain():
1025
+ return
1026
+ with self._window_mutation_lock:
1027
+ self._window_mutation_scheduled = False
1028
+
1029
+ def _schedule_window_mutation_drain(self) -> bool:
1030
+ window = self.window
1031
+ if window is None:
1032
+ return False
1033
+ if not hasattr(window, "native"):
1034
+ # Lightweight test doubles have no native control and execute synchronously.
1035
+ self._drain_window_mutations()
1036
+ return True
1037
+ native = window.native
1038
+ if native is None:
1039
+ # The tray can receive a click before pywebview has created its Form.
1040
+ # Leave the work queued; on_loaded() will schedule another drain.
1041
+ return False
1042
+ try:
1043
+ if native.InvokeRequired:
1044
+ system = importlib.import_module("System")
1045
+ native.BeginInvoke(system.Action(self._drain_window_mutations))
1046
+ else:
1047
+ self._drain_window_mutations()
1048
+ except Exception:
1049
+ if os.environ.get("USAGE_DEBUG") == "1":
1050
+ logger.warning("Failed to dispatch window mutation", exc_info=True)
1051
+ return False
1052
+ return True
1053
+
1054
+ def _drain_window_mutations(self) -> None:
1055
+ while True:
1056
+ with self._window_mutation_lock:
1057
+ if self.stopping.is_set():
1058
+ self._window_mutations.clear()
1059
+ self._window_mutation_scheduled = False
1060
+ return
1061
+ if not self._window_mutations:
1062
+ self._window_mutation_scheduled = False
1063
+ return
1064
+ mutation = self._window_mutations.popleft()
1065
+ try:
1066
+ mutation()
1067
+ except Exception:
1068
+ if os.environ.get("USAGE_DEBUG") == "1":
1069
+ logger.warning("Window mutation failed", exc_info=True)
1070
+
1071
+ def _save_window_position(self) -> None:
1072
+ position = self._current_window_position()
1073
+ if position is None:
1074
+ return
1075
+ preferences = _load_preferences()
1076
+ preferences["usage.windowPosition"] = {"x": position[0], "y": position[1]}
1077
+ _save_preferences(preferences)
1078
+
1079
+ def reset_panel_position(self, _icon: Any = None, _item: Any = None) -> None:
1080
+ if self.stopping.is_set():
1081
+ return
1082
+ preferences = _load_preferences()
1083
+ preferences.pop("usage.windowPosition", None)
1084
+ _save_preferences(preferences)
1085
+ if self.visible:
1086
+ self._place_window(force_default=True)
1087
+
1088
+ def _poll_loop(self) -> None:
1089
+ current_thread = threading.current_thread()
1090
+ self._poll_thread = current_thread
1091
+ try:
1092
+ while not self.stopping.wait(
1093
+ self.interval if self.visible else max(self.interval, SLOW_POLL_INTERVAL_S)
1094
+ ):
1095
+ self.refresh()
1096
+ finally:
1097
+ if self._poll_thread is current_thread:
1098
+ self._poll_thread = None
1099
+
1100
+ def _ensure_windows_watcher(self) -> None:
1101
+ if self.mock or self.stopping.is_set():
1102
+ return
1103
+ with self._watcher_lock:
1104
+ if self._windows_watcher is not None or self.stopping.is_set():
1105
+ return
1106
+ watcher = setup_windows_watcher(self._refresh_from_file_event)
1107
+ if watcher is not None:
1108
+ self._windows_watcher = watcher
1109
+ self._history_source_tracker.set_incremental_enabled(True)
1110
+
1111
+ def _refresh_from_file_event(self, changes: WindowsFileEventChanges) -> None:
1112
+ self._history_source_tracker.record_changes(
1113
+ set(changes.paths),
1114
+ needs_full_scan=changes.needs_full_scan,
1115
+ )
1116
+ refresh_now = False
1117
+ timer_to_start: threading.Timer | None = None
1118
+ with self._file_event_lock:
1119
+ if self.stopping.is_set():
1120
+ return
1121
+ now = time.monotonic()
1122
+ decision = menubar_state.file_event_refresh_decision(
1123
+ now,
1124
+ self._last_file_event_refresh_started_at,
1125
+ self._file_event_refresh_timer is not None,
1126
+ )
1127
+ if decision.refresh_now:
1128
+ self._last_file_event_refresh_started_at = now
1129
+ refresh_now = True
1130
+ elif decision.trailing_delay is not None:
1131
+ timer_to_start = threading.Timer(
1132
+ decision.trailing_delay,
1133
+ self._refresh_from_trailing_file_event,
1134
+ )
1135
+ timer_to_start.daemon = True
1136
+ self._file_event_refresh_timer = timer_to_start
1137
+ if timer_to_start is not None:
1138
+ timer_to_start.start()
1139
+ if refresh_now:
1140
+ self.refresh()
1141
+
1142
+ def _refresh_from_trailing_file_event(self) -> None:
1143
+ with self._file_event_lock:
1144
+ self._file_event_refresh_timer = None
1145
+ if self.stopping.is_set():
1146
+ return
1147
+ self._last_file_event_refresh_started_at = time.monotonic()
1148
+ self.refresh()
1149
+
1150
+ def refresh(self) -> None:
1151
+ with self.refresh_lock:
1152
+ if self.stopping.is_set():
1153
+ return
1154
+ if self._refresh_in_flight:
1155
+ self._refresh_queued = True
1156
+ return
1157
+ self._refresh_in_flight = True
1158
+ self._refresh_thread = threading.Thread(
1159
+ target=self._refresh_worker,
1160
+ name="usage-refresh",
1161
+ daemon=True,
1162
+ )
1163
+ self._refresh_thread.start()
1164
+
1165
+ def _refresh_worker(self) -> None:
1166
+ self._ensure_windows_watcher()
1167
+ debug_timing = os.environ.get("USAGE_DEBUG") == "1"
1168
+
1169
+ def measure(stage: str, started_at: float) -> None:
1170
+ if debug_timing:
1171
+ elapsed_ms = (time.monotonic() - started_at) * 1000
1172
+ logger.debug("refresh_timing stage=%s elapsed_ms=%.1f", stage, elapsed_ms)
1173
+
1174
+ while True:
1175
+ try:
1176
+ self.latest_state = self._build_state(
1177
+ measure=measure,
1178
+ debug_timing=debug_timing,
1179
+ )
1180
+ self._process_quota_notifications(self.latest_state)
1181
+ started_at = time.monotonic() if debug_timing else 0.0
1182
+ self._update_tray()
1183
+ measure("update_tray", started_at)
1184
+ if self.visible:
1185
+ started_at = time.monotonic() if debug_timing else 0.0
1186
+ self.inject_state()
1187
+ measure("inject_state", started_at)
1188
+ except Exception:
1189
+ if os.environ.get("USAGE_DEBUG") == "1":
1190
+ logger.warning("Windows tray refresh failed", exc_info=True)
1191
+
1192
+ with self.refresh_lock:
1193
+ if self._refresh_queued and not self.stopping.is_set():
1194
+ self._refresh_queued = False
1195
+ continue
1196
+ self._refresh_queued = False
1197
+ self._refresh_in_flight = False
1198
+ self._refresh_thread = None
1199
+ return
1200
+
1201
+ def _load_entries(self, scan: menubar_state.HistorySourceScan) -> _RefreshData:
1202
+ if self.mock:
1203
+ return _RefreshData([], None)
1204
+ entries: list[UsageEntry] = []
1205
+ error_key = None
1206
+ try:
1207
+ entries.extend(load_entries(hours_back=0, jsonl_paths=scan.claude_paths))
1208
+ except OSError:
1209
+ error_key = "history_load_error_file"
1210
+ except (ValueError, KeyError, TypeError):
1211
+ error_key = "history_load_error_parse"
1212
+ try:
1213
+ entries.extend(codex_loader.load_entries(hours_back=0, jsonl_paths=scan.codex_paths))
1214
+ except OSError:
1215
+ error_key = "history_load_error_file"
1216
+ except (ValueError, KeyError, TypeError):
1217
+ error_key = "history_load_error_parse"
1218
+ return _RefreshData(entries, error_key)
1219
+
1220
+ def _history_source_scan(self) -> menubar_state.HistorySourceScan:
1221
+ """Avoid recursively statting every session JSONL on each tray tick."""
1222
+ if self._windows_watcher is not None:
1223
+ return self._history_source_tracker.scan()
1224
+ now = time.monotonic()
1225
+ if (
1226
+ self._history_scan is not None
1227
+ and self._history_scan_at is not None
1228
+ and now - self._history_scan_at < HISTORY_SCAN_CACHE_SECONDS
1229
+ ):
1230
+ return self._history_scan
1231
+ self._history_scan = menubar_state.history_source_scan()
1232
+ self._history_scan_at = now
1233
+ return self._history_scan
1234
+
1235
+ def _build_state(
1236
+ self,
1237
+ *,
1238
+ measure: Any = lambda _stage, _started_at: None,
1239
+ debug_timing: bool = False,
1240
+ ) -> menubar_state.PopoverState:
1241
+ started_at = time.monotonic() if debug_timing else 0.0
1242
+ scan = self._history_source_scan()
1243
+ codex_rows, _codex_pct, _model, codex_stale, codex_credits = menubar_state.codex_rows(
1244
+ mock=self.mock,
1245
+ language=self.language,
1246
+ burn_rate_trackers=self.burn_rate_trackers,
1247
+ jsonl_candidates=scan.codex_rate_limit_candidates,
1248
+ )
1249
+ measure("codex_load", started_at)
1250
+ started_at = time.monotonic() if debug_timing else 0.0
1251
+ agy_result = menubar_agy.load_refresh_result(self.language)
1252
+ agy = agy_result.projection or menubar_agy.fallback_projection(self.language)
1253
+ measure("agy_load", started_at)
1254
+ started_at = time.monotonic() if debug_timing else 0.0
1255
+ local_date = datetime.now().astimezone().date()
1256
+ if self._history_cache_date != local_date or menubar_state.history_cache_needs_reload(
1257
+ self._history_fingerprint,
1258
+ scan.fingerprint,
1259
+ has_cached_result=(
1260
+ self._cached_history is not None and self._cached_projects is not None
1261
+ ),
1262
+ ):
1263
+ self._cached_history = self._load_entries(scan)
1264
+ self._cached_projects = (
1265
+ _mock_projects()
1266
+ if self.mock
1267
+ else menubar_state.project_rows_for_windows(self._cached_history.entries)
1268
+ )
1269
+ # A load error may be transient (e.g. a file locked mid-write); keep the
1270
+ # fingerprint unset so the next poll retries instead of pinning the error.
1271
+ self._history_fingerprint = (
1272
+ scan.fingerprint if self._cached_history.history_error_key is None else None
1273
+ )
1274
+ self._history_cache_date = local_date
1275
+ history = self._cached_history
1276
+ projects = self._cached_projects
1277
+ assert history is not None and projects is not None
1278
+ measure("history_load", started_at)
1279
+ started_at = time.monotonic() if debug_timing else 0.0
1280
+ outcome = asyncio.run(self._fetch())
1281
+ measure("fetch", started_at)
1282
+ service_statuses = self._service_statuses()
1283
+ if outcome.snapshot is not None:
1284
+ window_keeper.maybe_ping(
1285
+ outcome.snapshot.current_reset_at,
1286
+ outcome.snapshot.current_percent,
1287
+ outcome.snapshot.data_source,
1288
+ self.mock,
1289
+ )
1290
+ agy_window_keeper.maybe_ping(agy_result, self.mock)
1291
+ return menubar_state.build_popover_state(
1292
+ outcome=outcome,
1293
+ codex_rows=codex_rows,
1294
+ agy_rows=(agy.session, agy.weekly),
1295
+ agy_group_name=agy.group_name,
1296
+ projects=projects[0],
1297
+ projects_yesterday=projects[1],
1298
+ projects_7d=projects[2],
1299
+ projects_30d=projects[3],
1300
+ projects_all=projects[4],
1301
+ language=self.language,
1302
+ group=self.tracker.group(),
1303
+ burn_rate_trackers=self.burn_rate_trackers,
1304
+ today_text=(
1305
+ _t(self.language, "today_text", cost="45.20", tokens="50,193,442")
1306
+ if self.mock
1307
+ else _today_text(history.entries, self.language)
1308
+ ),
1309
+ yesterday_text=(
1310
+ _t(self.language, "yesterday_text", cost="41.10", tokens="48,200,000")
1311
+ if self.mock
1312
+ else _yesterday_text(history.entries, self.language)
1313
+ ),
1314
+ statusline=_statusline_payload(self.language),
1315
+ show_install_button=outcome.state == PollState.TOKEN_ERROR,
1316
+ hide_claude=_hide_claude_enabled(),
1317
+ hide_codex=_hide_codex_enabled(),
1318
+ hide_agy=agy_result.hide_agy or _hide_agy_enabled(),
1319
+ codex_stale=codex_stale,
1320
+ codex_credits=codex_credits,
1321
+ agy_stale=agy.stale,
1322
+ card_order=_quota_card_order(),
1323
+ history_error=menubar_state.history_load_error_state(
1324
+ history.history_error_key, self.language
1325
+ ),
1326
+ service_statuses=service_statuses,
1327
+ )
1328
+
1329
+ def _service_statuses(self) -> tuple[service_status.ServiceStatus, ...]:
1330
+ statuses: list[service_status.ServiceStatus] = []
1331
+ for config in (service_status.CLAUDE_STATUS, service_status.CODEX_STATUS):
1332
+ try:
1333
+ statuses.append(service_status.get_service_status(config))
1334
+ except Exception:
1335
+ if os.environ.get("USAGE_DEBUG") == "1":
1336
+ logger.warning(
1337
+ "Windows %s service status refresh failed",
1338
+ config.service_name,
1339
+ exc_info=True,
1340
+ )
1341
+ return tuple(statuses)
1342
+
1343
+ async def _fetch(self) -> Any:
1344
+ return await self.usage_client.fetch_once()
1345
+
1346
+ def _update_tray(self) -> None:
1347
+ percent = self.latest_state.claude_session.percent
1348
+ self._update_taskbar_progress(percent)
1349
+ if self.icon is None:
1350
+ return
1351
+ tooltip = build_tooltip(self.latest_state)
1352
+ if percent == self._last_tray_percent and tooltip == self._last_tray_tooltip:
1353
+ return
1354
+ self.icon.icon = draw_tray_icon(percent)
1355
+ self.icon.title = tooltip
1356
+ self._last_tray_percent = percent
1357
+ self._last_tray_tooltip = tooltip
1358
+
1359
+ def _update_taskbar_progress(self, used_percent: float | None) -> None:
1360
+ # ITaskbarList3 targets a window's taskbar button, not the pystray icon.
1361
+ # This popover has such a button only while its WinForms Form is visible.
1362
+ if not self.visible or self.window is None:
1363
+ return
1364
+ hwnd = _taskbar_window_handle(self.window)
1365
+ if hwnd is None:
1366
+ return
1367
+ completed = 0 if used_percent is None else max(0, min(100, round(used_percent)))
1368
+ try:
1369
+ _set_taskbar_progress(
1370
+ hwnd,
1371
+ completed,
1372
+ 100,
1373
+ taskbar_progress_state(used_percent),
1374
+ )
1375
+ except Exception:
1376
+ if os.environ.get("USAGE_DEBUG") == "1":
1377
+ logger.warning("Windows taskbar progress update failed", exc_info=True)
1378
+
1379
+ def inject_state(self, *, force: bool = False) -> None:
1380
+ if self.window is None:
1381
+ return
1382
+ encoded = json.dumps(
1383
+ _state_payload(
1384
+ self.latest_state,
1385
+ system_accent_color=_system_accent_color(),
1386
+ ),
1387
+ ensure_ascii=False,
1388
+ separators=(",", ":"),
1389
+ )
1390
+ if not force and encoded == self._last_injected_state:
1391
+ return
1392
+ self.window.evaluate_js(f"window.usageApplyState({encoded})")
1393
+ self._last_injected_state = encoded
1394
+
1395
+ def show_panel(self, _icon: Any = None, _item: Any = None) -> None:
1396
+ if self.stopping.is_set():
1397
+ return
1398
+ if self.visible:
1399
+ self._save_window_position()
1400
+ self.visible = False
1401
+ self._positioned_this_show = False
1402
+ self.window.hide()
1403
+ return
1404
+ self.visible = True
1405
+ self._place_window()
1406
+ self.window.show()
1407
+ self._update_taskbar_progress(self.latest_state.claude_session.percent)
1408
+ self.inject_state(force=True)
1409
+ self.refresh()
1410
+
1411
+ def _activate_panel(self) -> None:
1412
+ """Show or foreground the existing tray panel without toggling it closed."""
1413
+ if self.window is None:
1414
+ return
1415
+ if not self.visible:
1416
+ self.show_panel()
1417
+ return
1418
+ self.window.show()
1419
+
1420
+ def switch_panel(self, panel_id: str) -> None:
1421
+ self.active_panel_id = panel_id
1422
+ # Deliberately keep the previous panel's measured height instead of
1423
+ # resetting to None: on_loaded() clamps the window to fit before the
1424
+ # new panel reports its real height, and PANEL_HEIGHTS' fallback
1425
+ # values are near-fullscreen placeholders that would clamp a dragged
1426
+ # window's Y position back up to the top of the screen every switch.
1427
+ _save_active_panel_id(panel_id)
1428
+ # A panel reload is initialized from ``latest_state`` in ``on_loaded``.
1429
+ # Card order is changed directly by the JS bridge, outside the refresh
1430
+ # worker, so refresh this field from the shared preferences before the
1431
+ # next theme receives that state.
1432
+ self.latest_state.card_order = _quota_card_order()
1433
+ self.window.load_html(panel_html(self.panel_filename()))
1434
+
1435
+ def _deferred_switch_panel(self, panel_id: str) -> None:
1436
+ self._switch_pending = False
1437
+ self.switch_panel(panel_id)
1438
+
1439
+ def _schedule_panel_switch(self, panel_id: str) -> None:
1440
+ if self._switch_pending or panel_id not in {panel[0] for panel in available_panels()}:
1441
+ return
1442
+ self._switch_pending = True
1443
+ # postMessage is a pywebview promise. Reloading the document before
1444
+ # that promise resolves destroys its callback and can leave the Edge
1445
+ # WebView as a blank white window. Keep the existing short deferral,
1446
+ # but now reload the panel explicitly chosen from the HTML menu.
1447
+ threading.Timer(0.05, lambda: self._deferred_switch_panel(panel_id)).start()
1448
+
1449
+ def _panel_menu_data(self) -> list[dict[str, object]]:
1450
+ """Return fresh, localized data for the HTML panel menu."""
1451
+ entries = wintray_menu.entries_for_surface(_menu_model(), wintray_menu.PANEL)
1452
+ return [_panel_menu_entry(self, entry) for entry in entries]
1453
+
1454
+ def toggle_login(self, _icon: Any = None, _item: Any = None) -> None:
1455
+ win_login_item.disable() if win_login_item.is_enabled() else win_login_item.enable()
1456
+
1457
+ def open_ai_daily(self, _icon: Any = None, _item: Any = None) -> None:
1458
+ webbrowser.open("https://aqua5230.github.io/ai-updates/")
1459
+
1460
+ def toggle_hide_section(self, preference_key: str) -> None:
1461
+ preferences = _load_preferences()
1462
+ preferences[preference_key] = preferences.get(preference_key) is not True
1463
+ _save_preferences(preferences)
1464
+ self.latest_state.hide_claude = _hide_claude_enabled()
1465
+ self.latest_state.hide_codex = _hide_codex_enabled()
1466
+ self.latest_state.hide_agy = _hide_agy_enabled()
1467
+ if self.visible:
1468
+ self.inject_state()
1469
+
1470
+ def toggle_quota_notifications(self, _icon: Any = None, _item: Any = None) -> None:
1471
+ preferences = _load_preferences()
1472
+ preferences["quota_notifications"] = not _quota_notifications_enabled(preferences)
1473
+ _save_preferences(preferences)
1474
+
1475
+ def toggle_window_keeper(self, _icon: Any = None, _item: Any = None) -> None:
1476
+ preferences = _load_preferences()
1477
+ enabled = not _window_keeper_enabled(preferences)
1478
+ preferences["window_keeper"] = enabled
1479
+ preferences.pop("agy_window_keeper", None)
1480
+ _save_preferences(preferences)
1481
+ if enabled:
1482
+ self._message_box(
1483
+ f"{_t(self.language, 'window_keeper_sleep_title')}\n\n"
1484
+ f"{_t(self.language, 'window_keeper_sleep_body_windows')}"
1485
+ )
1486
+
1487
+ def toggle_session_resume(self, _icon: Any = None, _item: Any = None) -> None:
1488
+ threading.Thread(target=self._toggle_session_resume_in_background, daemon=True).start()
1489
+
1490
+ def _toggle_session_resume_in_background(self) -> None:
1491
+ import session_hooks
1492
+
1493
+ try:
1494
+ if session_hooks.is_resume_enabled():
1495
+ session_hooks.disable_session_resume()
1496
+ else:
1497
+ session_hooks.enable_session_resume()
1498
+ except Exception:
1499
+ if os.environ.get("USAGE_DEBUG") == "1":
1500
+ logger.warning("toggle session resume failed", exc_info=True)
1501
+
1502
+ def toggle_terse_mode(self, _icon: Any = None, _item: Any = None) -> None:
1503
+ threading.Thread(target=self._toggle_terse_mode_in_background, daemon=True).start()
1504
+
1505
+ def _toggle_terse_mode_in_background(self) -> None:
1506
+ import session_hooks
1507
+
1508
+ try:
1509
+ if session_hooks.is_terse_mode_enabled():
1510
+ session_hooks.disable_terse_mode()
1511
+ else:
1512
+ session_hooks.enable_terse_mode()
1513
+ except Exception:
1514
+ if os.environ.get("USAGE_DEBUG") == "1":
1515
+ logger.warning("toggle terse mode failed", exc_info=True)
1516
+
1517
+ def _process_quota_notifications(self, state: menubar_state.PopoverState) -> None:
1518
+ try:
1519
+ events = self._quota_notifier.update(
1520
+ {
1521
+ "claude_session": (
1522
+ state.claude_session.percent,
1523
+ state.claude_session.available,
1524
+ ),
1525
+ "claude_weekly": (
1526
+ state.claude_weekly.percent,
1527
+ state.claude_weekly.available,
1528
+ ),
1529
+ "codex_session": (state.codex_session.percent, state.codex_session.available),
1530
+ "codex_weekly": (state.codex_weekly.percent, state.codex_weekly.available),
1531
+ }
1532
+ )
1533
+ if _quota_notifications_enabled() and not self.mock:
1534
+ for event in events:
1535
+ self._send_quota_notification(event, state)
1536
+ except Exception:
1537
+ if os.environ.get("USAGE_DEBUG") == "1":
1538
+ logger.warning("Windows quota notification processing failed", exc_info=True)
1539
+
1540
+ def _send_quota_notification(
1541
+ self, event: NotificationEvent, state: menubar_state.PopoverState
1542
+ ) -> None:
1543
+ rows = {
1544
+ "claude_session": state.claude_session,
1545
+ "claude_weekly": state.claude_weekly,
1546
+ "codex_session": state.codex_session,
1547
+ "codex_weekly": state.codex_weekly,
1548
+ }
1549
+ row = rows[event.channel]
1550
+ scope = row.title or _t(
1551
+ self.language, "session_label" if event.channel.endswith("_session") else "weekly_label"
1552
+ )
1553
+ message = _t(
1554
+ self.language,
1555
+ f"notif_{event.kind}_body",
1556
+ tool="Claude" if event.channel.startswith("claude_") else "Codex",
1557
+ scope=scope,
1558
+ pct=f"{round(row.percent or event.threshold or 0.0):g}",
1559
+ reset=row.reset_text,
1560
+ )
1561
+ title = _t(self.language, f"notif_{event.kind}_title")
1562
+ if not self._show_interactive_toast(title, message):
1563
+ self._show_balloon_notification(title, message)
1564
+
1565
+ def _toast_toaster(self) -> Any:
1566
+ if self._toast_backend_attempted:
1567
+ return self._toast_backend
1568
+ self._toast_backend_attempted = True
1569
+ try:
1570
+ self._toast_backend = _create_toast_backend()
1571
+ except Exception:
1572
+ if os.environ.get("USAGE_DEBUG") == "1":
1573
+ logger.warning("Windows interactive toast backend unavailable", exc_info=True)
1574
+ return self._toast_backend
1575
+
1576
+ def _show_interactive_toast(self, title: str, message: str) -> bool:
1577
+ toaster = self._toast_toaster()
1578
+ if toaster is None:
1579
+ return False
1580
+ try:
1581
+ from windows_toasts import Toast, ToastButton
1582
+
1583
+ toast = Toast(text_fields=[title, message])
1584
+ toast.AddAction(
1585
+ ToastButton(
1586
+ content=_t(self.language, "usage_title"),
1587
+ arguments=_TOAST_OPEN_PANEL_ACTION,
1588
+ )
1589
+ )
1590
+ toast.on_activated = self._on_toast_activated
1591
+ toaster.show_toast(toast)
1592
+ return True
1593
+ except Exception:
1594
+ self._toast_backend = None
1595
+ if os.environ.get("USAGE_DEBUG") == "1":
1596
+ logger.warning("Windows interactive toast failed", exc_info=True)
1597
+ return False
1598
+
1599
+ def _on_toast_activated(self, event_args: Any) -> None:
1600
+ if getattr(event_args, "arguments", None) != _TOAST_OPEN_PANEL_ACTION:
1601
+ return
1602
+ try:
1603
+ self._activate_panel()
1604
+ except Exception:
1605
+ if os.environ.get("USAGE_DEBUG") == "1":
1606
+ logger.warning("Windows toast panel activation failed", exc_info=True)
1607
+
1608
+ def _show_balloon_notification(self, title: str, message: str) -> None:
1609
+ if self.icon is None or not hasattr(self.icon, "notify"):
1610
+ return
1611
+ try:
1612
+ self.icon.notify(message, title)
1613
+ except Exception:
1614
+ if os.environ.get("USAGE_DEBUG") == "1":
1615
+ logger.warning("Windows balloon notification failed", exc_info=True)
1616
+
1617
+ def check_update(self, _icon: Any = None, _item: Any = None) -> None:
1618
+ threading.Thread(
1619
+ target=self._check_update_in_background,
1620
+ kwargs={
1621
+ "manual": True,
1622
+ "ignore_cooldown": True,
1623
+ "ignore_skipped": True,
1624
+ },
1625
+ daemon=True,
1626
+ ).start()
1627
+
1628
+ def _clear_stale_update_cache(self) -> None:
1629
+ try:
1630
+ current_version = _current_version()
1631
+ preferences = _load_preferences()
1632
+ updated_cache = update_gate.stale_cache_reset(preferences, current_version)
1633
+ if updated_cache is not None:
1634
+ preferences["last_update_check"] = updated_cache
1635
+ _save_preferences(preferences)
1636
+ except Exception:
1637
+ if os.environ.get("USAGE_DEBUG") == "1":
1638
+ logger.warning("Windows stale update cache reset failed", exc_info=True)
1639
+
1640
+ def _check_update_in_background(
1641
+ self,
1642
+ *,
1643
+ manual: bool,
1644
+ ignore_cooldown: bool,
1645
+ ignore_skipped: bool,
1646
+ ) -> None:
1647
+ preferences = _load_preferences()
1648
+ if not manual and not _auto_update_check_enabled(preferences):
1649
+ return
1650
+ if not manual and not update_gate.auto_check_is_due(preferences):
1651
+ return
1652
+ if not ignore_cooldown and update_gate.dismissed_recently(preferences):
1653
+ return
1654
+
1655
+ try:
1656
+ current_version = _current_version()
1657
+ result = update_checker.check_latest_release_result(current_version)
1658
+ except Exception:
1659
+ if os.environ.get("USAGE_DEBUG") == "1":
1660
+ logger.warning("Windows update check failed", exc_info=True)
1661
+ if manual:
1662
+ self._message_box(_t(self.language, "update_check_failed"))
1663
+ return
1664
+
1665
+ if result.failed:
1666
+ if manual:
1667
+ self._message_box(_t(self.language, "update_check_failed"))
1668
+ return
1669
+
1670
+ release = result.release
1671
+ preferences["last_update_check"] = update_gate.build_check_cache_entry(
1672
+ current_version, release
1673
+ )
1674
+ _save_preferences(preferences)
1675
+
1676
+ if release is None:
1677
+ if manual:
1678
+ self._message_box(_t(self.language, "update_no_new_version"))
1679
+ return
1680
+ if not ignore_skipped and preferences.get("update_skipped_version") == release.version:
1681
+ return
1682
+ self._show_update_alert(release)
1683
+
1684
+ def _show_update_alert(self, release: update_checker.ReleaseInfo) -> None:
1685
+ title = _t(self.language, "update_alert_title", version=release.version)
1686
+ body = format_release_notes(release.body, UPDATE_ALERT_BODY_LIMIT)
1687
+ result = self._message_box(f"{title}\n\n{body}", style=0x44)
1688
+ action, preference_updates = update_gate.resolve_alert_choice(
1689
+ 1000 if result == 6 else 1001,
1690
+ release.version,
1691
+ )
1692
+ if action == "open":
1693
+ webbrowser.open(release.html_url)
1694
+ return
1695
+
1696
+ preferences = _load_preferences()
1697
+ preferences.update(preference_updates)
1698
+ if action == "dismiss":
1699
+ preferences["update_dismissed_at"] = time.time()
1700
+ _save_preferences(preferences)
1701
+
1702
+ def _message_box(self, text: str, *, style: int = 0x40) -> int:
1703
+ import ctypes
1704
+
1705
+ library_name = "windll"
1706
+ windll: Any = getattr(ctypes, library_name)
1707
+ return int(windll.user32.MessageBoxW(0, text, "usage", style))
1708
+
1709
+ def handle_panel_message(self, message: object) -> list[dict[str, object]] | None:
1710
+ if self.stopping.is_set():
1711
+ return None
1712
+ payload: object = message
1713
+ if isinstance(message, str) and message.startswith("{"):
1714
+ try:
1715
+ payload = json.loads(message)
1716
+ except ValueError:
1717
+ return None
1718
+ if isinstance(payload, dict):
1719
+ action = payload.get("action")
1720
+ if action == "open_menu":
1721
+ return self._panel_menu_data()
1722
+ if action == "content_height":
1723
+ self._apply_content_height(payload.get("height"))
1724
+ return None
1725
+ if action == "set_card_order":
1726
+ order = payload.get("order")
1727
+ if (
1728
+ isinstance(order, list)
1729
+ and all(isinstance(item, str) for item in order)
1730
+ and len(order) == 3
1731
+ and set(order) == {"agy", "claude", "codex"}
1732
+ ):
1733
+ preferences = _load_preferences()
1734
+ preferences["quota_card_order"] = order
1735
+ _save_preferences(preferences)
1736
+ elif action == "set_panel_flavor":
1737
+ _save_panel_flavor(payload.get("flavor"))
1738
+ elif action == "switch_panel":
1739
+ panel_id = payload.get("panel_id")
1740
+ if isinstance(panel_id, str):
1741
+ self._schedule_panel_switch(panel_id)
1742
+ elif action == "toggle_hide_section":
1743
+ preference_key = payload.get("preference_key")
1744
+ if preference_key in {
1745
+ "hide_claude_section",
1746
+ "hide_codex_section",
1747
+ "hide_agy_section",
1748
+ }:
1749
+ self.toggle_hide_section(preference_key)
1750
+ elif action == "open_ai_daily":
1751
+ self.open_ai_daily()
1752
+ elif action == "reset_panel_position":
1753
+ self.reset_panel_position()
1754
+ elif action == "refresh":
1755
+ self.refresh()
1756
+ elif action == "toggle_login":
1757
+ self.toggle_login()
1758
+ elif action == "toggle_quota_notifications":
1759
+ self.toggle_quota_notifications()
1760
+ elif action == "toggle_window_keeper":
1761
+ self.toggle_window_keeper()
1762
+ elif action == "toggle_session_resume":
1763
+ self.toggle_session_resume()
1764
+ elif action == "toggle_terse_mode":
1765
+ self.toggle_terse_mode()
1766
+ elif action == "check_update":
1767
+ self.check_update()
1768
+ elif action == "quit":
1769
+ self.quit()
1770
+ return None
1771
+ action = str(payload)
1772
+ if action == "refresh":
1773
+ self.refresh()
1774
+ elif action == "quit":
1775
+ self.quit()
1776
+ elif action == "switch":
1777
+ # Older panel assets post this action directly. Return menu data
1778
+ # instead of cycling themes so the bridge remains forwards-safe.
1779
+ return self._panel_menu_data()
1780
+ elif action in {"toggle_statusline", "toggle-statusline"}:
1781
+ threading.Thread(target=self._toggle_statusline, daemon=True).start()
1782
+ elif action == "install":
1783
+ threading.Thread(target=self._install_hook, daemon=True).start()
1784
+ elif action == "analyze":
1785
+ project_range = self.window.evaluate_js(
1786
+ "typeof projectRange === 'string' ? projectRange : '30d'"
1787
+ )
1788
+ threading.Thread(
1789
+ target=self._analyze_usage,
1790
+ args=(str(project_range or "30d"),),
1791
+ daemon=True,
1792
+ ).start()
1793
+ return None
1794
+
1795
+ def _toggle_statusline(self) -> None:
1796
+ _toggle_statusline_settings()
1797
+ self.refresh()
1798
+
1799
+ def _install_hook(self) -> None:
1800
+ import session_hooks
1801
+ import setup_hook
1802
+
1803
+ if setup_hook.setup() == 0:
1804
+ session_hooks._migrate_bundled_python_commands_if_needed()
1805
+ self.refresh()
1806
+
1807
+ def _analyze_usage(self, project_range: str) -> None:
1808
+ from adapters.registry import detect_agents
1809
+ from analyzer.reporter import build_report_data
1810
+ from ui.html_report import save_and_open
1811
+
1812
+ periods = {"1d": "today", "7d": "last7", "30d": "last30", "all": "all"}
1813
+ period = periods.get(project_range, "month")
1814
+ save_and_open(build_report_data(detect_agents(), period), language=self.language)
1815
+
1816
+ def quit(self, _icon: Any = None, _item: Any = None) -> None:
1817
+ self.stopping.set()
1818
+ with self._file_event_lock:
1819
+ timer = self._file_event_refresh_timer
1820
+ self._file_event_refresh_timer = None
1821
+ if timer is not None:
1822
+ timer.cancel()
1823
+ with self._watcher_lock:
1824
+ watcher = self._windows_watcher
1825
+ self._windows_watcher = None
1826
+ if watcher is not None:
1827
+ watcher.stop()
1828
+
1829
+ current_thread = threading.current_thread()
1830
+ with self.refresh_lock:
1831
+ refresh_thread = self._refresh_thread
1832
+ for worker in (self._poll_thread, refresh_thread):
1833
+ if worker is not None and worker is not current_thread:
1834
+ worker.join(3.0)
1835
+ if self.icon is not None:
1836
+ self.icon.stop()
1837
+ if self.window is not None:
1838
+ self.window.destroy()
1839
+
1840
+
1841
+ def _menu(controller: _WindowsTrayController) -> Any:
1842
+ import pystray
1843
+
1844
+ entries = wintray_menu.entries_for_surface(_menu_model(), wintray_menu.TRAY)
1845
+ recovery_items = tuple(
1846
+ _tray_menu_entry(pystray, controller, entry) for entry in entries
1847
+ )
1848
+ return pystray.Menu(
1849
+ pystray.MenuItem("Open", controller.show_panel, default=True, visible=False),
1850
+ *recovery_items,
1851
+ )
1852
+
1853
+
1854
+ def _menu_model() -> tuple[wintray_menu.MenuEntry, ...]:
1855
+ return wintray_menu.windows_menu_model(available_panels())
1856
+
1857
+
1858
+ def _menu_checked(controller: _WindowsTrayController, entry: wintray_menu.MenuCommand) -> bool:
1859
+ checks = {
1860
+ "active_panel": lambda: controller.active_panel_id == entry.argument_value,
1861
+ "hide_claude": _hide_claude_enabled,
1862
+ "hide_codex": _hide_codex_enabled,
1863
+ "hide_agy": _hide_agy_enabled,
1864
+ "launch_at_login": win_login_item.is_enabled,
1865
+ "quota_notifications": _quota_notifications_enabled,
1866
+ "window_keeper": _window_keeper_enabled,
1867
+ "session_resume": _session_resume_enabled,
1868
+ "terse_mode": _terse_mode_enabled,
1869
+ }
1870
+ return checks[entry.checked_by]() if entry.checked_by is not None else False
1871
+
1872
+
1873
+ def _panel_menu_entry(
1874
+ controller: _WindowsTrayController, entry: wintray_menu.MenuEntry
1875
+ ) -> dict[str, object]:
1876
+ if isinstance(entry, wintray_menu.MenuSeparator):
1877
+ return {"type": "separator"}
1878
+ data: dict[str, object] = {
1879
+ "i18nKey": entry.i18n_key,
1880
+ "label": _t(controller.language, entry.i18n_key),
1881
+ }
1882
+ if isinstance(entry, wintray_menu.MenuGroup):
1883
+ data["action"] = ""
1884
+ data["children"] = [_panel_menu_entry(controller, child) for child in entry.children]
1885
+ return data
1886
+ data["action"] = entry.action
1887
+ if entry.checked_by is not None:
1888
+ data["checked"] = _menu_checked(controller, entry)
1889
+ if entry.argument_name is not None:
1890
+ data[entry.argument_name] = entry.argument_value
1891
+ return data
1892
+
1893
+
1894
+ def _tray_menu_entry(
1895
+ pystray: Any,
1896
+ controller: _WindowsTrayController,
1897
+ entry: wintray_menu.MenuEntry,
1898
+ ) -> Any:
1899
+ if isinstance(entry, wintray_menu.MenuSeparator):
1900
+ return pystray.Menu.SEPARATOR
1901
+ if isinstance(entry, wintray_menu.MenuGroup):
1902
+ children = tuple(_tray_menu_entry(pystray, controller, child) for child in entry.children)
1903
+ return pystray.MenuItem(
1904
+ _t(controller.language, entry.i18n_key), pystray.Menu(*children)
1905
+ )
1906
+ action = getattr(controller, entry.action)
1907
+ kwargs: dict[str, object] = {"radio": entry.radio}
1908
+ if entry.checked_by is not None:
1909
+ kwargs["checked"] = lambda _item: _menu_checked(controller, entry)
1910
+ if entry.argument_value is not None:
1911
+ value = entry.argument_value
1912
+
1913
+ def action(_icon: Any, _item: Any, *, value: str = value) -> Any:
1914
+ return getattr(controller, entry.action)(value)
1915
+
1916
+ return pystray.MenuItem(_t(controller.language, entry.i18n_key), action, **kwargs)
1917
+
1918
+
1919
+ def _session_resume_enabled() -> bool:
1920
+ try:
1921
+ import session_hooks
1922
+
1923
+ return session_hooks.is_resume_enabled()
1924
+ except Exception:
1925
+ return False
1926
+
1927
+
1928
+ def _terse_mode_enabled() -> bool:
1929
+ try:
1930
+ import session_hooks
1931
+
1932
+ return session_hooks.is_terse_mode_enabled()
1933
+ except Exception:
1934
+ return False
1935
+
1936
+
1937
+ _SINGLE_INSTANCE_MUTEX = "usage-windows-tray-single-instance"
1938
+ _ERROR_ALREADY_EXISTS = 183
1939
+ _single_instance_handle: int | None = None
1940
+
1941
+
1942
+ def _acquire_single_instance_lock() -> bool:
1943
+ """Hold a named mutex for the process lifetime; False if another tray owns it.
1944
+
1945
+ Two tray instances fight over the same WebView2 user-data directory: the
1946
+ loser's panel fails to initialize and lingers as a bare white window.
1947
+ """
1948
+ global _single_instance_handle
1949
+ import ctypes
1950
+
1951
+ library_name = "windll"
1952
+ windll: Any = getattr(ctypes, library_name)
1953
+ handle = windll.kernel32.CreateMutexW(None, False, _SINGLE_INSTANCE_MUTEX)
1954
+ if not handle:
1955
+ return True
1956
+ if windll.kernel32.GetLastError() == _ERROR_ALREADY_EXISTS:
1957
+ windll.kernel32.CloseHandle(handle)
1958
+ return False
1959
+ _single_instance_handle = handle
1960
+ return True
1961
+
1962
+
1963
+ def _release_single_instance_lock() -> None:
1964
+ global _single_instance_handle
1965
+ if _single_instance_handle is None:
1966
+ return
1967
+ import ctypes
1968
+
1969
+ library_name = "windll"
1970
+ windll: Any = getattr(ctypes, library_name)
1971
+ windll.kernel32.CloseHandle(_single_instance_handle)
1972
+ _single_instance_handle = None
1973
+
1974
+
1975
+ def _show_already_running_notice() -> None:
1976
+ import ctypes
1977
+
1978
+ library_name = "windll"
1979
+ windll: Any = getattr(ctypes, library_name)
1980
+ windll.user32.MessageBoxW(0, _t(detect_lang(), "wintray_already_running"), "usage", 0x40)
1981
+
1982
+
1983
+ def run_app(mock: bool = False, interval: int = 60) -> None:
1984
+ if not _acquire_single_instance_lock():
1985
+ _show_already_running_notice()
1986
+ return
1987
+
1988
+ import pystray
1989
+ import webview
1990
+
1991
+ controller = _WindowsTrayController(mock, interval)
1992
+ window = webview.create_window(
1993
+ "usage",
1994
+ html=panel_html(controller.panel_filename()),
1995
+ js_api=_JSApi(controller),
1996
+ width=PANEL_WIDTH,
1997
+ height=controller.panel_height(),
1998
+ frameless=True,
1999
+ easy_drag=False,
2000
+ on_top=True,
2001
+ hidden=True,
2002
+ background_color=_system_background_color(),
2003
+ )
2004
+ if window is None:
2005
+ raise RuntimeError("pywebview did not create a window")
2006
+ window.events.loaded += controller.on_loaded
2007
+ icon = pystray.Icon("usage", draw_tray_icon(None), "usage", _menu(controller))
2008
+ controller.attach(icon, window)
2009
+ icon.run_detached()
2010
+ try:
2011
+ webview.start(gui="edgechromium", debug=os.environ.get("USAGE_DEBUG") == "1")
2012
+ finally:
2013
+ controller.stopping.set()
2014
+ _release_single_instance_lock()