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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- 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()
|