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 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"
@@ -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
- return _build_result(groups_raw)
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) -> int:
217
- """Whole minutes since the cached snapshot was taken (never negative)."""
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, int(age_seconds // 60))
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: int = 0,
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
- if remaining == 100.0:
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 = max(1, window.resets_in_minutes - max(0, age_minutes))
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=used,
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
- self._clear_stale_update_cache()
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
- self._refresh(queue_if_busy=True)
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._refresh_in_flight = False
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
- try:
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
- _bar_color,
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=QuotaRowState(
65
- title=_t(language, "weekly_label"),
66
- percent=used,
67
- percent_text=_t(language, "percent_used", value=_format_percent(used)),
68
- reset_text=_t(
69
- language,
70
- "reset_in",
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)