usage-cli 0.32.2__py3-none-any.whl → 0.32.4__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.
- doctor.py +8 -0
- installer/codex_hook_trust.py +66 -0
- loaders/agy_quota_probe.py +34 -1
- menubar/agy.py +22 -7
- menubar/app.py +10 -41
- menubar/grok.py +8 -17
- menubar/manual_refresh.py +51 -0
- menubar/menu.py +14 -0
- menubar/notify.py +100 -0
- menubar/prefs.py +0 -5
- menubar/refresh.py +7 -0
- menubar/state.py +20 -4
- menubar/title.py +19 -13
- menubar/update.py +33 -50
- panels/payload.py +1 -1
- quota/agy_window_keeper.py +37 -54
- quota/codex_window_keeper.py +38 -64
- quota/keeper_outcome.py +195 -0
- quota/window_keeper.py +34 -62
- setup_app.py +1 -0
- updates/checker.py +9 -9
- updates/gate.py +28 -0
- {usage_cli-0.32.2.data → usage_cli-0.32.4.data}/data/share/usage/i18n.json +110 -5
- {usage_cli-0.32.2.dist-info → usage_cli-0.32.4.dist-info}/METADATA +5 -2
- {usage_cli-0.32.2.dist-info → usage_cli-0.32.4.dist-info}/RECORD +37 -29
- usage_cli-0.32.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +28 -0
- usage_cli.py +2 -0
- wintray/app.py +196 -33
- wintray/menu.py +19 -0
- wintray/prefs.py +25 -0
- wintray/quota_label.py +184 -0
- wintray/taskbar_obstacles.py +71 -0
- wintray/taskbar_overlay.py +296 -0
- {usage_cli-0.32.2.dist-info → usage_cli-0.32.4.dist-info}/WHEEL +0 -0
- {usage_cli-0.32.2.dist-info → usage_cli-0.32.4.dist-info}/entry_points.txt +0 -0
- {usage_cli-0.32.2.dist-info → usage_cli-0.32.4.dist-info}/licenses/LICENSE +0 -0
- {usage_cli-0.32.2.dist-info → usage_cli-0.32.4.dist-info}/top_level.txt +0 -0
doctor.py
CHANGED
|
@@ -20,6 +20,7 @@ from typing import Final
|
|
|
20
20
|
|
|
21
21
|
from i18n import packaged_resource_path
|
|
22
22
|
from installer import setup_hook
|
|
23
|
+
from installer.codex_hook_trust import codex_terse_hook_trust
|
|
23
24
|
|
|
24
25
|
SEPARATOR = "-" * 29
|
|
25
26
|
RATE_LIMIT_FRESH_SECONDS: Final = 15 * 60
|
|
@@ -54,6 +55,7 @@ CHECK_LABELS: Final = {
|
|
|
54
55
|
CODEX_RATE_LIMITS: "codex rate limits",
|
|
55
56
|
CODEX_HISTORY: "codex history",
|
|
56
57
|
CLAUDE_COST: "claude cost",
|
|
58
|
+
"codex_terse_hook_trust": "codex terse hook trust",
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
|
|
@@ -87,6 +89,7 @@ def collect() -> DoctorReport:
|
|
|
87
89
|
("optional", _field(CODEX_LOGS, _codex_logs)),
|
|
88
90
|
("optional", _field(CODEX_RATE_LIMITS, _codex_rate_limits)),
|
|
89
91
|
("optional", _field(CLAUDE_COST, _claude_cost)),
|
|
92
|
+
("optional", _field("codex_terse_hook_trust", _codex_terse_trust)),
|
|
90
93
|
]
|
|
91
94
|
return DoctorReport(
|
|
92
95
|
version=_text_field(_current_version),
|
|
@@ -514,3 +517,8 @@ def _ago(mtime: float) -> str:
|
|
|
514
517
|
return f"{hours}h"
|
|
515
518
|
days = hours // 24
|
|
516
519
|
return f"{days}d"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _codex_terse_trust() -> CheckResult:
|
|
523
|
+
trust = codex_terse_hook_trust()
|
|
524
|
+
return CheckResult("codex_terse_hook_trust", "ok" if trust == "trusted" else "warn", trust)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Read Codex's approval record without granting hook trust."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
from installer import session_hooks
|
|
10
|
+
from loaders.codex_paths import codex_home
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def codex_terse_hook_trust() -> str:
|
|
14
|
+
"""Return the usage SessionStart hook's trust state.
|
|
15
|
+
|
|
16
|
+
A trusted_hash alone cannot detect Modified (a hook edited after approval).
|
|
17
|
+
Codex remains responsible for verifying the hash and asking for approval.
|
|
18
|
+
"""
|
|
19
|
+
path = session_hooks.CODEX_HOOKS_JSON
|
|
20
|
+
try:
|
|
21
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
22
|
+
if not isinstance(data, dict):
|
|
23
|
+
return "unknown"
|
|
24
|
+
groups = session_hooks._codex_session_start_list(data)
|
|
25
|
+
if groups is None:
|
|
26
|
+
return "not_installed"
|
|
27
|
+
index = None
|
|
28
|
+
for group_index, group in enumerate(groups):
|
|
29
|
+
handlers = group.get("hooks") if isinstance(group, dict) else None
|
|
30
|
+
if not isinstance(handlers, list):
|
|
31
|
+
continue
|
|
32
|
+
for handler_index, handler in enumerate(handlers):
|
|
33
|
+
command = handler.get("command") if isinstance(handler, dict) else None
|
|
34
|
+
if isinstance(command, str) and session_hooks._TERSE_MARKER in command:
|
|
35
|
+
index = (group_index, handler_index)
|
|
36
|
+
break
|
|
37
|
+
if index is not None:
|
|
38
|
+
break
|
|
39
|
+
if index is None:
|
|
40
|
+
return "not_installed"
|
|
41
|
+
with (codex_home() / "config.toml").open("rb") as handle:
|
|
42
|
+
config = tomllib.load(handle)
|
|
43
|
+
hooks = config.get("hooks", {})
|
|
44
|
+
states = hooks.get("state", {}) if isinstance(hooks, dict) else {}
|
|
45
|
+
if not isinstance(states, dict):
|
|
46
|
+
return "unknown"
|
|
47
|
+
suffix = f":session_start:{index[0]}:{index[1]}"
|
|
48
|
+
entry = states.get(f"{path}{suffix}")
|
|
49
|
+
if entry is None:
|
|
50
|
+
entry = next(
|
|
51
|
+
(
|
|
52
|
+
value
|
|
53
|
+
for key, value in states.items()
|
|
54
|
+
if key.endswith(suffix)
|
|
55
|
+
and os.path.realpath(key[: -len(suffix)]) == os.path.realpath(path)
|
|
56
|
+
),
|
|
57
|
+
{},
|
|
58
|
+
)
|
|
59
|
+
if not isinstance(entry, dict):
|
|
60
|
+
return "untrusted"
|
|
61
|
+
if entry.get("enabled") is False:
|
|
62
|
+
return "disabled"
|
|
63
|
+
trusted_hash = entry.get("trusted_hash")
|
|
64
|
+
return "trusted" if isinstance(trusted_hash, str) and trusted_hash else "untrusted"
|
|
65
|
+
except (OSError, UnicodeError, ValueError):
|
|
66
|
+
return "unknown"
|
loaders/agy_quota_probe.py
CHANGED
|
@@ -37,6 +37,19 @@ from urllib.request import Request, urlopen
|
|
|
37
37
|
from usage_common.subprocess_utils import hidden_console_kwargs
|
|
38
38
|
|
|
39
39
|
CACHE_PATH = Path(os.path.expanduser("~/.usage/agy_quota_cache.json"))
|
|
40
|
+
AUTH_EXPIRED_PATH = CACHE_PATH.parent / "agy_auth_expired"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def auth_expired() -> bool:
|
|
44
|
+
return AUTH_EXPIRED_PATH.exists()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _mark_auth_expired() -> None:
|
|
48
|
+
with suppress(OSError):
|
|
49
|
+
AUTH_EXPIRED_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
AUTH_EXPIRED_PATH.touch()
|
|
51
|
+
|
|
52
|
+
|
|
40
53
|
# Legacy OAuth token written by older Antigravity CLI versions. Read-only: we
|
|
41
54
|
# never write back here (that is the CLI's home and could corrupt its login).
|
|
42
55
|
_TOKEN_PATH = Path(os.path.expanduser("~/.gemini/antigravity-cli/antigravity-oauth-token"))
|
|
@@ -117,7 +130,11 @@ def probe_quota(timeout_seconds: float = 15) -> AgyQuotaResult | None:
|
|
|
117
130
|
groups_raw = _extract_groups(raw)
|
|
118
131
|
if not groups_raw:
|
|
119
132
|
return None
|
|
120
|
-
|
|
133
|
+
result = _build_result(groups_raw)
|
|
134
|
+
if result is not None:
|
|
135
|
+
with suppress(OSError):
|
|
136
|
+
AUTH_EXPIRED_PATH.unlink(missing_ok=True)
|
|
137
|
+
return result
|
|
121
138
|
|
|
122
139
|
|
|
123
140
|
def find_agy() -> str | None:
|
|
@@ -353,6 +370,20 @@ def _refresh_token(refresh_token: str, timeout: float) -> tuple[str, int] | None
|
|
|
353
370
|
payload = json.loads(raw)
|
|
354
371
|
except HTTPError as exc:
|
|
355
372
|
_handle_http_error(exc)
|
|
373
|
+
if exc.code == 401:
|
|
374
|
+
_mark_auth_expired()
|
|
375
|
+
elif exc.code == 400:
|
|
376
|
+
try:
|
|
377
|
+
raw_error = exc.read(_MAX_RESPONSE_BYTES + 1)
|
|
378
|
+
if len(raw_error) <= _MAX_RESPONSE_BYTES:
|
|
379
|
+
error_payload = json.loads(raw_error)
|
|
380
|
+
if (
|
|
381
|
+
isinstance(error_payload, dict)
|
|
382
|
+
and error_payload.get("error") == "invalid_grant"
|
|
383
|
+
):
|
|
384
|
+
_mark_auth_expired()
|
|
385
|
+
except (OSError, ValueError):
|
|
386
|
+
pass
|
|
356
387
|
return None
|
|
357
388
|
except (URLError, OSError, ValueError):
|
|
358
389
|
return None
|
|
@@ -387,6 +418,8 @@ def _post_json(url: str, access_token: str, body: dict[str, object], timeout: fl
|
|
|
387
418
|
return json.loads(raw)
|
|
388
419
|
except HTTPError as exc:
|
|
389
420
|
_handle_http_error(exc)
|
|
421
|
+
if exc.code == 401:
|
|
422
|
+
_mark_auth_expired()
|
|
390
423
|
return None
|
|
391
424
|
except (URLError, OSError, ValueError):
|
|
392
425
|
return None
|
menubar/agy.py
CHANGED
|
@@ -18,6 +18,7 @@ from loaders.agy_quota_probe import (
|
|
|
18
18
|
AgyQuotaGroup,
|
|
19
19
|
AgyQuotaResult,
|
|
20
20
|
AgyQuotaWindow,
|
|
21
|
+
auth_expired,
|
|
21
22
|
load_quota,
|
|
22
23
|
)
|
|
23
24
|
from loaders.agy_quota_probe import (
|
|
@@ -213,20 +214,20 @@ def _remaining_percent(window: AgyQuotaWindow) -> float:
|
|
|
213
214
|
return max(0.0, min(100.0, float(window.remaining_percent)))
|
|
214
215
|
|
|
215
216
|
|
|
216
|
-
def _cache_age_minutes(fetched_at: str, now: float) ->
|
|
217
|
-
"""
|
|
217
|
+
def _cache_age_minutes(fetched_at: str, now: float) -> float:
|
|
218
|
+
"""Minutes since the cached snapshot was taken (never negative)."""
|
|
218
219
|
try:
|
|
219
220
|
age_seconds = now - parse_iso8601_utc_or_raise(fetched_at).timestamp()
|
|
220
221
|
except (TypeError, ValueError):
|
|
221
222
|
return 0
|
|
222
|
-
return max(0,
|
|
223
|
+
return max(0.0, age_seconds / 60)
|
|
223
224
|
|
|
224
225
|
|
|
225
226
|
def _window_row(
|
|
226
227
|
title: str,
|
|
227
228
|
window: AgyQuotaWindow,
|
|
228
229
|
language: str,
|
|
229
|
-
age_minutes:
|
|
230
|
+
age_minutes: float = 0,
|
|
230
231
|
forecast_seconds: float | None = None,
|
|
231
232
|
warning_max_seconds: float | None = None,
|
|
232
233
|
*,
|
|
@@ -236,12 +237,20 @@ def _window_row(
|
|
|
236
237
|
used = 100.0 - remaining
|
|
237
238
|
warning = False
|
|
238
239
|
warning_seconds: float | None = None
|
|
239
|
-
|
|
240
|
+
reset_done = window.resets_in_minutes is not None and window.resets_in_minutes <= max(
|
|
241
|
+
0, age_minutes
|
|
242
|
+
)
|
|
243
|
+
if reset_done:
|
|
244
|
+
used = 0.0
|
|
245
|
+
reset_text = _t(language, "reset_done")
|
|
246
|
+
elif remaining == 100.0:
|
|
240
247
|
reset_text = _t(language, "agy_quota_full")
|
|
241
248
|
elif window.resets_in_minutes is None:
|
|
242
249
|
reset_text = _t(language, "reset_placeholder")
|
|
250
|
+
elif window.resets_in_minutes - max(0, age_minutes) < 1:
|
|
251
|
+
reset_text = _t(language, "reset_imminent")
|
|
243
252
|
else:
|
|
244
|
-
minutes_left =
|
|
253
|
+
minutes_left = window.resets_in_minutes - int(max(0, age_minutes))
|
|
245
254
|
time_to_reset = minutes_left * 60
|
|
246
255
|
if window_seconds is None:
|
|
247
256
|
if (
|
|
@@ -275,16 +284,22 @@ def _window_row(
|
|
|
275
284
|
)
|
|
276
285
|
return QuotaRowState(
|
|
277
286
|
title=title,
|
|
278
|
-
percent=
|
|
287
|
+
percent=100.0 - remaining,
|
|
279
288
|
percent_text=_t(language, "percent_used", value=_format_percent(used)),
|
|
280
289
|
reset_text=reset_text,
|
|
281
290
|
color=_bar_color(used, AGY_COLOR),
|
|
282
291
|
warning=warning,
|
|
283
292
|
available=True,
|
|
293
|
+
reset_done=reset_done,
|
|
284
294
|
)
|
|
285
295
|
|
|
286
296
|
|
|
287
297
|
def _stale_state(fetched_at: str, now: float, language: str) -> AgyStaleState | None:
|
|
298
|
+
if auth_expired():
|
|
299
|
+
return {
|
|
300
|
+
"ageText": _t(language, "agy_signin_expired"),
|
|
301
|
+
"tooltip": _t(language, "agy_signin_expired_tooltip"),
|
|
302
|
+
}
|
|
288
303
|
try:
|
|
289
304
|
age_seconds = now - parse_iso8601_utc_or_raise(fetched_at).timestamp()
|
|
290
305
|
except (TypeError, ValueError):
|
menubar/app.py
CHANGED
|
@@ -70,6 +70,7 @@ from loaders.history_loader import (
|
|
|
70
70
|
flush_caches_on_terminate as flush_history_cache,
|
|
71
71
|
)
|
|
72
72
|
from menubar import actions as menubar_actions
|
|
73
|
+
from menubar import manual_refresh
|
|
73
74
|
from menubar import menu as menubar_menu
|
|
74
75
|
from menubar import notify as menubar_notify
|
|
75
76
|
from menubar import refresh as menubar_refresh
|
|
@@ -84,7 +85,6 @@ from menubar.chrome import (
|
|
|
84
85
|
)
|
|
85
86
|
from menubar.popover import PopoverViewController, _popover_size
|
|
86
87
|
from menubar.prefs import (
|
|
87
|
-
_auto_update_check_enabled,
|
|
88
88
|
_hide_agy_enabled,
|
|
89
89
|
_hide_claude_enabled,
|
|
90
90
|
_hide_codex_enabled,
|
|
@@ -180,7 +180,6 @@ __all__ = [
|
|
|
180
180
|
"_missing_row",
|
|
181
181
|
"_quota_row",
|
|
182
182
|
"format_human_time",
|
|
183
|
-
"_auto_update_check_enabled",
|
|
184
183
|
"_hide_agy_enabled",
|
|
185
184
|
"_hide_claude_enabled",
|
|
186
185
|
"_hide_codex_enabled",
|
|
@@ -305,6 +304,7 @@ class AppDelegate(NSObject):
|
|
|
305
304
|
"agy_session": BurnRateTracker(),
|
|
306
305
|
"agy_weekly": BurnRateTracker(),
|
|
307
306
|
}
|
|
307
|
+
self._auto_check_schedule = update_gate.AutoCheckSchedule()
|
|
308
308
|
self._quota_notifier = QuotaNotifier(_quota_notification_thresholds())
|
|
309
309
|
self._refresh_in_flight = False
|
|
310
310
|
self._refresh_queued = False
|
|
@@ -382,7 +382,7 @@ class AppDelegate(NSObject):
|
|
|
382
382
|
|
|
383
383
|
def timerFired_(self, timer: Any) -> None:
|
|
384
384
|
self._refresh()
|
|
385
|
-
|
|
385
|
+
menubar_update.on_poll_tick(self)
|
|
386
386
|
|
|
387
387
|
def _reschedule_poll_timer(self, interval: float) -> None:
|
|
388
388
|
if self.timer is not None and self.timer_interval == interval:
|
|
@@ -419,7 +419,7 @@ class AppDelegate(NSObject):
|
|
|
419
419
|
self._panel_window_did_hide()
|
|
420
420
|
|
|
421
421
|
def refreshNow_(self, sender: Any) -> None:
|
|
422
|
-
|
|
422
|
+
manual_refresh.start_mac(self)
|
|
423
423
|
|
|
424
424
|
def installHook_(self, sender: Any) -> None:
|
|
425
425
|
thread = threading.Thread(target=self._install_hook_in_background, daemon=True)
|
|
@@ -610,19 +610,8 @@ class AppDelegate(NSObject):
|
|
|
610
610
|
def _maybe_check_update_in_background(self) -> None:
|
|
611
611
|
menubar_update.maybe_check_update_in_background(self)
|
|
612
612
|
|
|
613
|
-
def _check_update_in_background(
|
|
614
|
-
self
|
|
615
|
-
*,
|
|
616
|
-
manual: bool,
|
|
617
|
-
ignore_cooldown: bool,
|
|
618
|
-
ignore_skipped: bool,
|
|
619
|
-
) -> None:
|
|
620
|
-
menubar_update.check_update_in_background(
|
|
621
|
-
self,
|
|
622
|
-
manual=manual,
|
|
623
|
-
ignore_cooldown=ignore_cooldown,
|
|
624
|
-
ignore_skipped=ignore_skipped,
|
|
625
|
-
)
|
|
613
|
+
def _check_update_in_background(self) -> bool:
|
|
614
|
+
return menubar_update.check_update_in_background(self)
|
|
626
615
|
|
|
627
616
|
def _showUpdateAlert_(self, release: update_checker.ReleaseInfo) -> None:
|
|
628
617
|
alert = _make_alert()
|
|
@@ -643,16 +632,6 @@ class AppDelegate(NSObject):
|
|
|
643
632
|
prefs["update_dismissed_at"] = time.time()
|
|
644
633
|
_save_preferences(prefs)
|
|
645
634
|
|
|
646
|
-
def _showNoUpdateAvailable_(self, result: Any) -> None:
|
|
647
|
-
alert = _make_alert()
|
|
648
|
-
alert.setMessageText_(_t(self.language, "update_no_new_version"))
|
|
649
|
-
alert.runModal()
|
|
650
|
-
|
|
651
|
-
def _showUpdateCheckFailed_(self, result: Any) -> None:
|
|
652
|
-
alert = _make_alert()
|
|
653
|
-
alert.setMessageText_(_t(self.language, "update_check_failed"))
|
|
654
|
-
alert.runModal()
|
|
655
|
-
|
|
656
635
|
def _set_active_panel_id(self, panel_id: str) -> None:
|
|
657
636
|
panel = panels.get_panel(panel_id)
|
|
658
637
|
save_active_panel_id(panel.id)
|
|
@@ -822,7 +801,7 @@ class AppDelegate(NSObject):
|
|
|
822
801
|
codex_model = result.get("codex_model", "unknown")
|
|
823
802
|
self.codex_5h_pct = codex_5h_pct
|
|
824
803
|
self.codex_model = codex_model
|
|
825
|
-
self.latest_state = state
|
|
804
|
+
self.latest_state = manual_refresh.finish_mac(self, state)
|
|
826
805
|
self._process_quota_notifications(state)
|
|
827
806
|
if self._panel_window_is_visible():
|
|
828
807
|
self.popover_controller.setState_(self.latest_state)
|
|
@@ -831,8 +810,7 @@ class AppDelegate(NSObject):
|
|
|
831
810
|
menubar_title._set_button_title(self, state)
|
|
832
811
|
finally:
|
|
833
812
|
should_refresh_again = bool(self._refresh_queued)
|
|
834
|
-
self._refresh_queued = False
|
|
835
|
-
self._refresh_in_flight = False
|
|
813
|
+
self._refresh_queued = self._refresh_in_flight = False
|
|
836
814
|
if should_refresh_again:
|
|
837
815
|
self._refresh()
|
|
838
816
|
if started_at:
|
|
@@ -842,7 +820,7 @@ class AppDelegate(NSObject):
|
|
|
842
820
|
)
|
|
843
821
|
|
|
844
822
|
def _clearRefreshInFlight_(self, _sender: Any) -> None:
|
|
845
|
-
self
|
|
823
|
+
manual_refresh.fail_mac(self)
|
|
846
824
|
|
|
847
825
|
def _load_codex_refresh_result(self) -> dict[str, Any]:
|
|
848
826
|
history_scan = None if self.mock else self._history_source_scan()
|
|
@@ -905,16 +883,7 @@ class AppDelegate(NSObject):
|
|
|
905
883
|
def _request_notification_authorization(self) -> None:
|
|
906
884
|
if self.mock or not _quota_notifications_enabled():
|
|
907
885
|
return
|
|
908
|
-
|
|
909
|
-
center, constants = menubar_notify.user_notification_center()
|
|
910
|
-
options = constants["badge"] | constants["sound"] | constants["alert"]
|
|
911
|
-
center.requestAuthorizationWithOptions_completionHandler_(
|
|
912
|
-
options,
|
|
913
|
-
lambda granted, error: None,
|
|
914
|
-
)
|
|
915
|
-
except Exception:
|
|
916
|
-
if os.environ.get("USAGE_DEBUG") == "1":
|
|
917
|
-
logger.warning("notification authorization failed", exc_info=True)
|
|
886
|
+
menubar_notify.request_notification_authorization()
|
|
918
887
|
|
|
919
888
|
def _process_quota_notifications(self, state: PopoverState) -> None:
|
|
920
889
|
try:
|
menubar/grok.py
CHANGED
|
@@ -18,9 +18,7 @@ from menubar.state import (
|
|
|
18
18
|
GROK_COLOR,
|
|
19
19
|
GrokStaleState,
|
|
20
20
|
QuotaRowState,
|
|
21
|
-
|
|
22
|
-
_format_percent,
|
|
23
|
-
format_human_time,
|
|
21
|
+
_quota_row,
|
|
24
22
|
)
|
|
25
23
|
from usage_common.time_utils import parse_iso8601_utc_or_raise
|
|
26
24
|
|
|
@@ -56,22 +54,15 @@ def project_quota(
|
|
|
56
54
|
period_end = parse_iso8601_utc_or_raise(quota.period_end).timestamp()
|
|
57
55
|
except (TypeError, ValueError):
|
|
58
56
|
return None
|
|
59
|
-
if period_end < current_time:
|
|
60
|
-
return None
|
|
61
57
|
stale = _stale_state(quota.fetched_at, current_time, language)
|
|
62
|
-
used = max(0.0, min(100.0, quota.used_percent))
|
|
63
58
|
return GrokQuotaProjection(
|
|
64
|
-
weekly=
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
time=format_human_time(max(0.0, period_end - current_time), language),
|
|
72
|
-
),
|
|
73
|
-
color=_bar_color(used, GROK_COLOR),
|
|
74
|
-
available=True,
|
|
59
|
+
weekly=_quota_row(
|
|
60
|
+
_t(language, "weekly_label"),
|
|
61
|
+
quota.used_percent,
|
|
62
|
+
period_end,
|
|
63
|
+
current_time,
|
|
64
|
+
GROK_COLOR,
|
|
65
|
+
language,
|
|
75
66
|
),
|
|
76
67
|
stale=stale,
|
|
77
68
|
)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Status-chip feedback for user-requested refreshes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from i18n import _t
|
|
8
|
+
from menubar.state import PopoverState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def begin(state: PopoverState, *, queued: bool = False) -> None:
|
|
12
|
+
if state.refresh_status is None:
|
|
13
|
+
state.refresh_status = (state.status_text, state.status_long)
|
|
14
|
+
state.refresh_queued = state.refresh_queued or queued
|
|
15
|
+
state.status_text = _t(
|
|
16
|
+
state.language, "status_text", value=_t(state.language, "status_refreshing")
|
|
17
|
+
)
|
|
18
|
+
state.status_long = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def finish(
|
|
22
|
+
previous: PopoverState | None, state: PopoverState, *, queued: bool = False
|
|
23
|
+
) -> PopoverState:
|
|
24
|
+
if previous is not None and previous.refresh_status is not None:
|
|
25
|
+
keep = previous.refresh_queued and queued
|
|
26
|
+
previous.status_text, previous.status_long = previous.refresh_status
|
|
27
|
+
previous.refresh_status = None
|
|
28
|
+
previous.refresh_queued = False
|
|
29
|
+
if keep:
|
|
30
|
+
begin(state)
|
|
31
|
+
return state
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def finish_mac(app: Any, state: PopoverState) -> PopoverState:
|
|
35
|
+
return finish(app.latest_state, state, queued=bool(app._refresh_queued))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def start_mac(app: Any) -> None:
|
|
39
|
+
begin(app.latest_state, queued=bool(app._refresh_in_flight))
|
|
40
|
+
app._refresh(queue_if_busy=True)
|
|
41
|
+
if app._panel_window_is_visible():
|
|
42
|
+
app.popover_controller.setState_(app.latest_state)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def fail_mac(app: Any) -> None:
|
|
46
|
+
state = getattr(app, "latest_state", None)
|
|
47
|
+
if state is not None:
|
|
48
|
+
finish(state, state)
|
|
49
|
+
app._refresh_in_flight = False
|
|
50
|
+
if state is not None and app._panel_window_is_visible():
|
|
51
|
+
app.popover_controller.setState_(state)
|
menubar/menu.py
CHANGED
|
@@ -51,6 +51,20 @@ def build_menu_item(
|
|
|
51
51
|
item.setState_(1 if state else 0)
|
|
52
52
|
if tooltip_key is not None:
|
|
53
53
|
item.setToolTip_(_t(language, tooltip_key))
|
|
54
|
+
if title_key == "window_keeper_menu" and state:
|
|
55
|
+
from quota.keeper_outcome import failure_tooltip
|
|
56
|
+
|
|
57
|
+
failures = failure_tooltip(language)
|
|
58
|
+
if failures:
|
|
59
|
+
item.setTitle_(_t(language, title_key) + " ⚠")
|
|
60
|
+
item.setToolTip_(failures + "\n" + _t(language, "window_keeper_tooltip"))
|
|
61
|
+
if title_key == "terse_mode_menu" and state:
|
|
62
|
+
from installer.codex_hook_trust import codex_terse_hook_trust
|
|
63
|
+
|
|
64
|
+
trust = codex_terse_hook_trust()
|
|
65
|
+
if trust in ("untrusted", "disabled"):
|
|
66
|
+
item.setTitle_(_t(language, title_key) + " ⚠")
|
|
67
|
+
item.setToolTip_(_t(language, f"terse_codex_hook_{trust}"))
|
|
54
68
|
return item
|
|
55
69
|
|
|
56
70
|
|
menubar/notify.py
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
9
11
|
from typing import Any
|
|
10
12
|
|
|
11
13
|
import objc
|
|
@@ -45,6 +47,9 @@ def user_notification_classes() -> tuple[Any, Any, Any]:
|
|
|
45
47
|
return UNMutableNotificationContent, UNNotificationRequest, UNNotificationSound
|
|
46
48
|
|
|
47
49
|
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
|
|
48
53
|
ALERT_SOUND = "usage_uhoh.wav"
|
|
49
54
|
|
|
50
55
|
|
|
@@ -56,6 +61,20 @@ def notification_sound(sound_cls: Any, kind: str) -> Any:
|
|
|
56
61
|
|
|
57
62
|
|
|
58
63
|
def register_user_notification_block_metadata() -> None:
|
|
64
|
+
objc.registerMetaDataForSelector(
|
|
65
|
+
b"UNUserNotificationCenter",
|
|
66
|
+
b"getNotificationSettingsWithCompletionHandler:",
|
|
67
|
+
{
|
|
68
|
+
"arguments": {
|
|
69
|
+
2: {
|
|
70
|
+
"callable": {
|
|
71
|
+
"retval": {"type": b"v"},
|
|
72
|
+
"arguments": {0: {"type": b"^v"}, 1: {"type": b"@"}},
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
)
|
|
59
78
|
objc.registerMetaDataForSelector(
|
|
60
79
|
b"UNUserNotificationCenter",
|
|
61
80
|
b"requestAuthorizationWithOptions:completionHandler:",
|
|
@@ -117,3 +136,84 @@ def notification_row(state: PopoverState, channel: str) -> QuotaRowState:
|
|
|
117
136
|
"agy_weekly": state.agy_weekly,
|
|
118
137
|
}
|
|
119
138
|
return rows[channel]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Keep the instance alive: UNUserNotificationCenter holds its delegate weakly.
|
|
142
|
+
_notification_delegate: Any = None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def install_notification_delegate(center: Any) -> None:
|
|
146
|
+
global _notification_delegate
|
|
147
|
+
if _notification_delegate is None:
|
|
148
|
+
import UserNotifications # noqa: F401 — load the delegate protocol lazily
|
|
149
|
+
from Foundation import NSObject
|
|
150
|
+
|
|
151
|
+
objc.registerMetaDataForSelector(
|
|
152
|
+
b"UsageNotificationDelegate",
|
|
153
|
+
b"userNotificationCenter:willPresentNotification:withCompletionHandler:",
|
|
154
|
+
{
|
|
155
|
+
"arguments": {
|
|
156
|
+
4: {
|
|
157
|
+
"callable": {
|
|
158
|
+
"retval": {"type": b"v"},
|
|
159
|
+
"arguments": {0: {"type": b"^v"}, 1: {"type": objc._C_NSUInteger}},
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
class UsageNotificationDelegate( # type: ignore[call-arg]
|
|
167
|
+
NSObject, # type: ignore[misc]
|
|
168
|
+
protocols=[objc.protocolNamed("UNUserNotificationCenterDelegate")],
|
|
169
|
+
):
|
|
170
|
+
def userNotificationCenter_willPresentNotification_withCompletionHandler_(
|
|
171
|
+
self,
|
|
172
|
+
center: Any,
|
|
173
|
+
notification: Any,
|
|
174
|
+
completion: Any,
|
|
175
|
+
) -> None:
|
|
176
|
+
completion((1 << 4) | (1 << 3) | (1 << 1))
|
|
177
|
+
|
|
178
|
+
_notification_delegate = UsageNotificationDelegate.alloc().init()
|
|
179
|
+
center.setDelegate_(_notification_delegate)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _authorization_result(granted: bool, error: Any) -> None:
|
|
183
|
+
# The file log keeps WARNING and up outside debug mode; a denial must reach it.
|
|
184
|
+
log = logger.info if granted else logger.warning
|
|
185
|
+
log("notification authorization granted=%s error=%s", granted, error)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def request_notification_authorization() -> None:
|
|
189
|
+
try:
|
|
190
|
+
center, constants = user_notification_center()
|
|
191
|
+
try:
|
|
192
|
+
install_notification_delegate(center)
|
|
193
|
+
except Exception:
|
|
194
|
+
logger.warning("notification delegate setup failed", exc_info=True)
|
|
195
|
+
options = constants["badge"] | constants["sound"] | constants["alert"]
|
|
196
|
+
center.requestAuthorizationWithOptions_completionHandler_(options, _authorization_result)
|
|
197
|
+
except Exception:
|
|
198
|
+
logger.warning("notification authorization failed", exc_info=True)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def send_simple_notification(title: str, body: str) -> None:
|
|
202
|
+
try:
|
|
203
|
+
center, _ = user_notification_center()
|
|
204
|
+
content_cls, request_cls, sound_cls = user_notification_classes()
|
|
205
|
+
content = content_cls.alloc().init()
|
|
206
|
+
content.setTitle_(title)
|
|
207
|
+
content.setBody_(body)
|
|
208
|
+
content.setSound_(sound_cls.defaultSound())
|
|
209
|
+
request = request_cls.requestWithIdentifier_content_trigger_(
|
|
210
|
+
f"usage.keeper.{time.time_ns()}", content, None
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def completed(error: Any) -> None:
|
|
214
|
+
if error is not None:
|
|
215
|
+
logger.warning("keeper notification failed: %s", error)
|
|
216
|
+
|
|
217
|
+
center.addNotificationRequest_withCompletionHandler_(request, completed)
|
|
218
|
+
except Exception:
|
|
219
|
+
logger.warning("keeper notification failed", exc_info=True)
|
menubar/prefs.py
CHANGED
|
@@ -20,11 +20,6 @@ def _resolved_preferences(prefs: Mapping[str, object] | None = None) -> Mapping[
|
|
|
20
20
|
return _load_preferences() if prefs is None else prefs
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def _auto_update_check_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
24
|
-
data = _resolved_preferences(prefs)
|
|
25
|
-
return data.get("auto_update_check") is not False
|
|
26
|
-
|
|
27
|
-
|
|
28
23
|
def _hide_claude_enabled(prefs: Mapping[str, object] | None = None) -> bool:
|
|
29
24
|
data = _resolved_preferences(prefs)
|
|
30
25
|
return data.get("hide_claude_section") is True
|
menubar/refresh.py
CHANGED
|
@@ -228,6 +228,13 @@ def build_result(app: _RefreshApp, sources: RefreshSources) -> dict[str, Any]:
|
|
|
228
228
|
)
|
|
229
229
|
agy_window_keeper.maybe_ping(agy_result, app.mock)
|
|
230
230
|
codex_window_keeper.maybe_ping(app.mock)
|
|
231
|
+
from menubar.notify import send_simple_notification
|
|
232
|
+
from menubar.prefs import _quota_notifications_enabled
|
|
233
|
+
from quota.keeper_outcome import notify_failures
|
|
234
|
+
|
|
235
|
+
notify_failures(
|
|
236
|
+
app.language, _quota_notifications_enabled(), app.mock, send_simple_notification
|
|
237
|
+
)
|
|
231
238
|
except Exception as exc:
|
|
232
239
|
if os.environ.get("USAGE_DEBUG") == "1":
|
|
233
240
|
logger.warning("refresh failed", exc_info=True)
|