Compare commits

...
214 Commits
Author SHA1 Message Date
SuperDooper fd82177a7d beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:30 +02:00
SuperDooper 112210a99b beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:29 +02:00
SuperDooper f4a83940b1 beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:28 +02:00
SuperDooper 377888a427 beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:26 +02:00
SuperDooper e24113a78b beta.17: adopt login WebView for API calls, add diagnostics 2026-05-11 12:00:24 +02:00
github-actions[bot] 3baab91f70 Beta release v1.2.1-beta.16 2026-05-11 09:47:48 +00:00
SuperDooper 0aa8837b2f beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:09 +02:00
SuperDooper 373ca1dc14 beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:08 +02:00
SuperDooper 7a4c21768f beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:06 +02:00
SuperDooper 7b26629dc7 beta.16: anchor background WebView in hidden NSWindow to prevent WebKit throttling 2026-05-11 11:47:05 +02:00
github-actions[bot] e446ea97f9 Beta release v1.2.1-beta.15 2026-05-11 09:43:20 +00:00
SuperDooper 36af325e2d beta.15: only detect auth when back on claude.ai, not on OAuth provider pages 2026-05-11 11:42:26 +02:00
SuperDooper e2ef8b6f2a beta.15: only detect auth when back on claude.ai, not on OAuth provider pages 2026-05-11 11:42:25 +02:00
SuperDooper 7a4975fa3e beta.15: only detect auth when back on claude.ai, not on OAuth provider pages 2026-05-11 11:42:24 +02:00
github-actions[bot] c9912f8463 Beta release v1.2.1-beta.14 2026-05-11 09:30:35 +00:00
SuperDooper 238614a94b beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:40 +02:00
SuperDooper b11507221f beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:38 +02:00
SuperDooper b6fef53264 beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:37 +02:00
SuperDooper ec6ae6621a beta.14: fix httpShouldHandleCookies=false so Cookie header is actually sent 2026-05-11 11:29:35 +02:00
github-actions[bot] 105a7706fa Beta release v1.2.1-beta.13 2026-05-11 09:21:33 +00:00
SuperDooper a950392a6f beta.13: wait for apiWebView didFinish before making JS fetch calls 2026-05-11 11:20:51 +02:00
SuperDooper 8087cc029d beta.13: wait for apiWebView didFinish before making JS fetch calls 2026-05-11 11:20:50 +02:00
SuperDooper 11f9e0c9b6 beta.13: wait for apiWebView didFinish before making JS fetch calls 2026-05-11 11:20:49 +02:00
github-actions[bot] cc835ee2b0 Beta release v1.2.1-beta.12 2026-05-11 09:03:50 +00:00
SuperDooper 8e7328b2c5 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:34 +02:00
SuperDooper 709eb12382 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:33 +02:00
SuperDooper e866324a48 beta.12: route all API calls through background WKWebView 2026-05-11 11:02:32 +02:00
SuperDooper a62584221b beta.12: route all API calls through background WKWebView 2026-05-11 11:02:30 +02:00
github-actions[bot] fc7fd19652 Beta release v1.2.1-beta.11 2026-05-11 08:47:11 +00:00
superdooper86 58b947e515 fix: add KVO on webView.url to catch SPA pushState navigation
didFinish only fires for cross-document (full page) navigations. After
loading https://claude.ai/login the SPA redirects authenticated users
via history.pushState to /new — this changes the URL visually but never
fires didFinish, so auth was never detected.

KVO on webView.url fires for every URL change including SPA pushState,
covering the case where the app routes client-side after the initial
page load. Both KVO and didFinish now call the same checkCurrentURL
helper so detection is not missed regardless of navigation type.
2026-05-11 10:46:11 +02:00
github-actions[bot] 88252e4d59 Beta release v1.2.1-beta.10 2026-05-11 08:36:44 +00:00
superdooper86 ad2ff3a7b6 fix: revert to URL-based auth detection; remove browser headers
Every JS/cookie-based detection approach failed. Reverting to the
simplest reliable mechanism: if the WebView navigates to any non-login,
non-auth URL, the server redirected us after sign-in — fire onAuthenticated.

Also removing the browser headers added in beta.6. The 1.1.4 version
worked without them and they may be triggering server-side bot detection.
All-cookies approach (beta.8) is kept.
2026-05-11 10:35:30 +02:00
github-actions[bot] 1a9d9cd22a Beta release v1.2.1-beta.9 2026-05-11 08:23:48 +00:00
superdooper86 b8826ace9b fix: NSNumber cast and use .page world in callAsyncJavaScript auth check
callAsyncJavaScript returns JS numbers as NSNumber (Double-backed).
'val as? Int' silently returns nil for 200.0, so onAuthenticated never
fired. Fixed with 'val as? NSNumber then .intValue == 200'.

Also switched content world from .defaultClient to .page so the fetch
runs in the same JS context as the loaded page.
2026-05-11 10:22:59 +02:00
github-actions[bot] 2a0de269ff Beta release v1.2.1-beta.8 2026-05-11 08:07:51 +00:00
superdooper86 ac7ffe81af fix: use WebView JS fetch for auth detection; send all cookies to API
Cookie domain filtering was wrong — the session token domain is unknown
and was never found by claude.ai/anthropic.com filters.

LoginView: replace getAllCookies domain check with callAsyncJavaScript
that fetches /api/bootstrap directly from the WebView. The WebView uses
its own full session (all cookies, any domain) so auth is detected
correctly regardless of where the token lives.

UsageViewModel: claudeCookieHeader now sends all cookies from the app's
WKWebsiteDataStore instead of filtering by domain. checkInitialSignInState
likewise checks for any cookie.
2026-05-11 10:06:33 +02:00
github-actions[bot] f28f7b8a5a Beta release v1.2.1-beta.7 2026-05-11 07:51:25 +00:00
superdooper86 aaed64484c fix: include anthropic.com cookies in all auth checks and API requests
Claude session cookies are on anthropic.com, not claude.ai. The login
window was not detecting auth (Cancel stayed, no Done) and API calls
were sent without the actual session token.

- claudeCookieHeader: include anthropic.com cookies so the token is
  sent to the usage/bootstrap endpoints
- checkInitialSignInState: detect anthropic.com cookies on startup
- didFinish in LoginView: fire auth when anthropic.com cookies found
- signOut: clear anthropic.com data alongside claude.ai
- notAuthenticated catch: set isSignedIn = false so Settings stays
  in sync with the main panel
2026-05-11 09:49:57 +02:00
github-actions[bot] 2e3ee350ca Beta release v1.2.1-beta.6 2026-05-11 07:37:40 +00:00
superdooper86 e939bdb88b fix: add browser headers to all API requests to resolve 403 on usage endpoint
Claude's usage/prepaid/overage endpoints require Origin, Referer, and
User-Agent headers to pass CORS/auth checks. Without them, bootstrap
succeeds (more permissive) but usage returns 403 -> 'Not signed in'.

Added claudeAPIRequest(for:) helper that sets all required browser-like
headers on every request. Bootstrap, usage, prepaid, overage, and the
orgs fallback all go through it.
2026-05-11 09:36:35 +02:00
github-actions[bot] 6749c5f158 Beta release v1.2.1-beta.5 2026-05-11 07:27:17 +00:00
superdooper86 de71ec2794 fix: load /login instead of root so premature auth detection is prevented
Loading https://claude.ai as the start URL caused didFinish to fire on
the landing page while stale/tracking cookies were already in
WKWebsiteDataStore. The 'any claude.ai cookie' check then fired
immediately, closing the login sheet before the user could sign in.

Loading /login ensures the URL-guard catches the initial page load and
only checks cookies after the real post-login redirect.
2026-05-11 09:25:45 +02:00
github-actions[bot] ac17ce154f Beta release v1.2.1-beta.4 2026-05-11 06:59:20 +00:00
SuperDooper d2eac62897 chore: bump to v1.2.1-beta.4 2026-05-11 08:58:05 +02:00
SuperDooper 8c64fc50ad fix: checkInitialSignInState uses any claude.ai cookie, not specific names 2026-05-11 08:58:04 +02:00
SuperDooper b2c138f665 fix: detect auth by any claude.ai cookie, not specific cookie names 2026-05-11 08:58:02 +02:00
github-actions[bot] aa6c299e2a Beta release v1.2.1-beta.3 2026-05-10 21:41:28 +00:00
SuperDooper ecbae7d7ca chore: release notes for v1.2.1-beta.3 2026-05-10 23:40:31 +02:00
SuperDooper 37a039e1d3 chore: bump to v1.2.1-beta.3 2026-05-10 23:40:30 +02:00
SuperDooper 8f3ead2f65 fix: replace stale 'No API key configured' with correct signed-out message 2026-05-10 23:40:29 +02:00
github-actions[bot] 734670bb3f Beta release v1.2.1-beta.2 2026-05-10 21:29:37 +00:00
SuperDooper 94430e00ba chore: release notes for v1.2.1-beta.2 2026-05-10 23:28:40 +02:00
SuperDooper 7b0368b001 chore: bump to v1.2.1-beta.2 2026-05-10 23:28:39 +02:00
SuperDooper 4629aeffbc fix: load claude.ai instead of /login so already-signed-in users are detected 2026-05-10 23:28:38 +02:00
github-actions[bot] 2cb4ddfe97 Beta release v1.2.1-beta.1 2026-05-10 21:16:08 +00:00
SuperDooper 73b251a1e9 chore: release notes for v1.2.1-beta.1 2026-05-10 23:14:48 +02:00
SuperDooper 7bab6a0df0 chore: bump to v1.2.1-beta.1 2026-05-10 23:14:47 +02:00
SuperDooper db6380fa66 fix: detect sign-in state from cookies on startup, add orgs API fallback for org ID 2026-05-10 23:14:45 +02:00
github-actions[bot] 47a148fa67 Release v1.2.0 2026-05-08 21:20:18 +00:00
SuperDooper 85ac329d36 chore: release notes for v1.2.0 2026-05-08 23:19:24 +02:00
SuperDooper a0fb6eabcf chore: bump to v1.2.0 2026-05-08 23:19:23 +02:00
github-actions[bot] b778d03323 Beta release v1.1.4-beta.7 2026-05-08 21:11:36 +00:00
SuperDooper 13387888b2 chore: release notes for 1.1.4-beta.7 2026-05-08 23:10:35 +02:00
SuperDooper 44be1649a1 chore: bump to 1.1.4-beta.7 2026-05-08 23:10:34 +02:00
SuperDooper 1160712e1b fix: remove ScrollView from main panel so popover auto-sizes to content 2026-05-08 23:10:22 +02:00
github-actions[bot] e4f55c80ee Beta release v1.1.4-beta.6 2026-05-08 21:05:48 +00:00
SuperDooper 526752dc36 chore: release notes for 1.1.4-beta.6 2026-05-08 23:04:54 +02:00
SuperDooper a2d983b555 chore: bump to 1.1.4-beta.6 2026-05-08 23:04:53 +02:00
SuperDooper 0d807c2285 fix: Session Diary — remove Claude icon/header, split stats left/right 2026-05-08 23:04:52 +02:00
SuperDooper 1c42433145 Windows beta release win-v0.0.1-beta.49 2026-05-08 20:46:42 +00:00
SuperDooper c0ac2e5a66 Windows beta release win-v0.0.1-beta.48 2026-05-08 20:39:17 +00:00
SuperDooper 405e65f9f7 Windows beta release win-v0.0.1-beta.47 2026-05-08 20:28:41 +00:00
SuperDooper 1c7433ebea Windows beta release win-v0.0.1-beta.46 2026-05-08 20:19:52 +00:00
SuperDooper a39ef800e8 Windows beta release win-v0.0.1-beta.45 2026-05-08 20:05:58 +00:00
SuperDooper 76dec8b85d Windows beta release win-v0.0.1-beta.44 2026-05-08 20:00:55 +00:00
SuperDooper 2f04f8581c Windows beta release win-v0.0.1-beta.42 2026-05-08 19:41:45 +00:00
SuperDooper 788c066a1e Windows beta release win-v0.0.1-beta.41 2026-05-08 19:39:07 +00:00
SuperDooper 31280d4ab2 Windows beta release win-v0.0.1-beta.40 2026-05-08 19:33:28 +00:00
SuperDooper 7b1c5bbf8e Windows beta release win-v0.0.1-beta.39 2026-05-08 19:19:11 +00:00
SuperDooper f2ba44bbaa Windows beta release win-v0.0.1-beta.38 2026-05-08 19:07:16 +00:00
SuperDooper de1fffe0e3 Windows beta release win-v0.0.1-beta.37 2026-05-08 19:00:02 +00:00
SuperDooper e80f07d1b9 Windows beta release win-v0.0.1-beta.36 2026-05-08 17:40:07 +00:00
github-actions[bot] e9ca8a7efc Beta release v1.1.4-beta.5 2026-05-08 17:32:10 +00:00
SuperDooper 63ef2005a4 Update release notes for 1.1.4-beta.5 2026-05-08 19:31:20 +02:00
SuperDooper 5ad7bb26f0 Bump version to 1.1.4-beta.5 2026-05-08 19:31:18 +02:00
SuperDooper 132a2a8f8d Consolidate bootstrap into single fetchBootstrap() call 2026-05-08 19:31:06 +02:00
github-actions[bot] debd1c1850 Beta release v1.1.4-beta.4 2026-05-08 17:27:59 +00:00
SuperDooper 9bcbb2ae7d Update release notes for 1.1.4-beta.4 2026-05-08 19:26:58 +02:00
SuperDooper c9384f6424 Bump version to 1.1.4-beta.4 2026-05-08 19:26:57 +02:00
SuperDooper e4693fc18f Fix fetchUserEmail return type and capabilities subscript 2026-05-08 19:26:48 +02:00
SuperDooper 84a8592bae Windows beta release win-v0.0.1-beta.35 2026-05-08 17:24:24 +00:00
SuperDooper bdd2038364 Update release notes for 1.1.4-beta.3 2026-05-08 19:22:51 +02:00
SuperDooper fd61face77 Bump version to 1.1.4-beta.3 2026-05-08 19:22:50 +02:00
SuperDooper 1f58a81a26 Parse plan label from bootstrap on every refresh via fetchUserEmail 2026-05-08 19:22:41 +02:00
github-actions[bot] 7f6da51860 Beta release v1.1.4-beta.2 2026-05-08 17:20:51 +00:00
SuperDooper b49ac23863 Update release notes for 1.1.4-beta.2 2026-05-08 19:19:50 +02:00
SuperDooper 01d268c3e5 Bump version to 1.1.4-beta.2 2026-05-08 19:19:49 +02:00
SuperDooper 871d5245ac Parse plan label from capabilities array instead of plan_type 2026-05-08 19:14:53 +02:00
SuperDooper 0524f9b791 Parse plan label from bootstrap API instead of hardcoding 2026-05-08 19:09:06 +02:00
github-actions[bot] d68d564351 Beta release v1.1.4-beta.1 2026-05-08 15:12:07 +00:00
SuperDooper d55bad01db Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:27 +02:00
SuperDooper 7bc72e240b Revert workflow to git push now that ruleset is fixed 2026-05-08 17:09:26 +02:00
SuperDooper d77ed23d09 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:06 +02:00
SuperDooper 6136b0ded0 Fix: use GitHub API to update files instead of git push to protected main 2026-05-08 17:06:00 +02:00
SuperDooper aba7168448 Windows beta release win-v0.0.1-beta.33 2026-05-08 15:03:42 +00:00
SuperDooper a8c2083397 fix: remove hardcoded orgId from cookie primer URL 2026-05-08 16:54:48 +02:00
SuperDooper ad23446ed4 chore: release notes for v1.1.4-beta.1 2026-05-08 16:52:17 +02:00
SuperDooper d8b75d279c chore: bump version to 1.1.4-beta.1 2026-05-08 16:52:16 +02:00
SuperDooper 2658ff7f6a Windows beta release win-v0.0.1-beta.32 2026-05-08 14:51:18 +00:00
SuperDooper e10d79e5a6 fix: fetch org ID dynamically from bootstrap instead of hardcoding it 2026-05-08 16:49:44 +02:00
SuperDooper 88daa27630 Windows beta release win-v0.0.1-beta.31 2026-05-08 14:47:27 +00:00
SuperDooper b7c4958a74 Windows beta release win-v0.0.1-beta.30 2026-05-08 14:38:16 +00:00
SuperDooper f46b13d967 Windows beta release win-v0.0.1-beta.29 2026-05-08 14:30:44 +00:00
SuperDooper c449882ec6 Windows beta release win-v0.0.1-beta.28 2026-05-08 14:26:00 +00:00
SuperDooper 3c1a2ef1be Windows beta release win-v0.0.1-beta.27 2026-05-08 14:17:00 +00:00
SuperDooper c238db963b Windows beta release win-v0.0.1-beta.25 2026-05-08 14:07:31 +00:00
SuperDooper 63c9a8582d Windows beta release win-v0.0.1-beta.24 2026-05-08 14:02:59 +00:00
SuperDooper 6f256b3308 Windows beta release win-v0.0.1-beta.23 2026-05-08 13:51:22 +00:00
SuperDooper 2761b31a12 Windows beta release win-v0.0.1-beta.22 2026-05-08 13:31:58 +00:00
SuperDooper f9d8abebd5 Windows beta release win-v0.0.1-beta.20 2026-05-08 12:55:50 +00:00
SuperDooper 66ee233b1b Windows beta release win-v0.0.1-beta.19 2026-05-08 12:41:51 +00:00
SuperDooper a12c664bde Windows beta release win-v0.0.1-beta.18 2026-05-08 12:28:14 +00:00
SuperDooper c7251d8785 Windows beta release win-v0.0.1-beta.17 2026-05-08 12:16:57 +00:00
SuperDooper 7a95b100ef Windows beta release win-v0.0.1-beta.16 2026-05-08 12:12:23 +00:00
SuperDooper fb98c69c34 Windows beta release win-v0.0.1-beta.15 2026-05-08 12:06:07 +00:00
SuperDooper 5629c6b61e Windows beta release win-v0.0.1-beta.14 2026-05-08 11:58:49 +00:00
SuperDooper 4709f5b0f5 Windows beta release win-v0.0.1-beta.12 2026-05-08 11:49:10 +00:00
SuperDooper 5f8aeaa6e9 Windows beta release win-v0.0.1-beta.11 2026-05-08 11:35:50 +00:00
SuperDooper 27f1f45109 Windows beta release win-v0.0.1-beta.10 2026-05-08 11:29:15 +00:00
SuperDooper 7a5512cc28 Windows beta release win-v0.0.1-beta.9 2026-05-08 11:17:01 +00:00
SuperDooper 8c89ce4636 Windows beta release win-v0.0.1-beta.8 2026-05-08 11:08:41 +00:00
SuperDooper 3a964dc2f8 Windows beta release win-v0.0.1-beta.7 2026-05-08 11:00:23 +00:00
SuperDooper b553d3922a docs: reorder screenshot grid 2026-05-08 11:38:54 +02:00
SuperDooper 61e8455e5e docs: update screenshot captions and add release notes screenshot 2026-05-08 11:37:44 +02:00
SuperDooper c4b9d7454f docs: add release notes screenshot 2026-05-08 11:37:35 +02:00
SuperDooper 75aeb03332 docs: update banner screenshot 2026-05-08 11:37:34 +02:00
SuperDooper f5203a0299 docs: update notification screenshot 2026-05-08 11:37:33 +02:00
SuperDooper fe27e0d4c0 revert: restore version-beta.json to 1.1.3-beta.5 2026-05-08 11:36:33 +02:00
SuperDooper fb5e1ece25 test: fake beta version for screenshot 2026-05-08 11:31:45 +02:00
SuperDooper 71666224f1 chore: remove outdated settings-v2 screenshot 2026-05-08 11:31:02 +02:00
SuperDooper bd693cdf94 chore: remove outdated main-v2 screenshot 2026-05-08 11:31:01 +02:00
SuperDooper d24a354e1c docs: update README to use v3 screenshots 2026-05-08 11:30:50 +02:00
SuperDooper 9e47041715 docs: add settings screenshot v3 2026-05-08 11:30:36 +02:00
SuperDooper 22151f0af4 docs: add main window screenshot v3 2026-05-08 11:30:35 +02:00
SuperDooper 3508fa1a94 docs: update settings screenshot 2026-05-08 11:29:50 +02:00
SuperDooper b2fcafd3f0 docs: update main window screenshot 2026-05-08 11:29:48 +02:00
SuperDooper 45215de350 docs: add contributing guidelines 2026-05-08 11:25:46 +02:00
SuperDooper f969da8006 chore: add feature request issue template 2026-05-08 11:23:46 +02:00
SuperDooper 7f769414cc chore: add bug report issue template 2026-05-08 11:23:45 +02:00
github-actions[bot] db707340bb Release v1.1.3 2026-05-08 09:20:27 +00:00
SuperDooper be6bb1495d docs: release notes for 1.1.3 stable 2026-05-08 11:19:38 +02:00
SuperDooper 06755de91d chore: bump to 1.1.3 stable (build 40) 2026-05-08 11:19:38 +02:00
SuperDooper 2855155361 fix: populate notes in version-beta.json for 1.1.3-beta.5 2026-05-08 10:01:37 +02:00
SuperDooper ecfb1aa44d docs: fix empty release notes 2026-05-08 10:01:37 +02:00
github-actions[bot] 8b35e5df56 Beta release v1.1.3-beta.5 2026-05-08 07:58:08 +00:00
SuperDooper 794a6cdb65 docs: update release notes for 1.1.3-beta.5 2026-05-08 09:57:06 +02:00
SuperDooper e613ace503 chore: bump to 1.1.3-beta.5 (build 39) 2026-05-08 09:57:05 +02:00
SuperDooper 38d7683843 fix: fill popover background during resize animation 2026-05-08 09:56:41 +02:00
github-actions[bot] 802cf14a71 Beta release v1.1.3-beta.4 2026-05-08 07:53:11 +00:00
SuperDooper ae736a7e4a docs: update release notes for 1.1.3-beta.4 2026-05-08 09:52:28 +02:00
SuperDooper a1bb186af2 chore: bump to 1.1.3-beta.4 (build 38) 2026-05-08 09:52:27 +02:00
SuperDooper e93a4e6df5 fix: auto-resize popover to match SwiftUI content height 2026-05-08 09:52:04 +02:00
github-actions[bot] a3fbce3f0e Beta release v1.1.3-beta.3 2026-05-08 07:35:57 +00:00
SuperDooper 4b4280630c docs: update release notes for 1.1.3-beta.3 2026-05-08 09:35:01 +02:00
SuperDooper 90acbcb968 chore: bump to 1.1.3-beta.3 (build 37) 2026-05-08 09:34:52 +02:00
SuperDooper f2f2be1305 fix: render sparkline with a single data point (flat line) 2026-05-08 09:34:38 +02:00
SuperDooper f17694622c fix: persist burn history across launches; show sparkline from first refresh 2026-05-08 09:34:33 +02:00
github-actions[bot] aa10f77776 Beta release v1.1.3-beta.2 2026-05-08 07:22:39 +00:00
SuperDooper cb00ddc806 docs: update release notes for 1.1.3-beta.2 2026-05-08 09:21:48 +02:00
SuperDooper 1f357b9123 chore: bump to 1.1.3-beta.2 (build 36) 2026-05-08 09:21:42 +02:00
SuperDooper 9dee674ff0 fix: update banner label to '5 Hour Limit' and make banner tappable 2026-05-08 09:19:16 +02:00
github-actions[bot] 0025690a74 Beta release v1.1.3-beta.1 2026-05-07 21:31:40 +00:00
james 7b03909c55 Add rebase before push in both workflows to handle concurrent commits 2026-05-07 23:30:18 +02:00
james c3ea2be809 Fix YAML syntax in stable workflow — indent Python, use env var for version 2026-05-07 23:28:42 +02:00
james 6f6a7361fe Fix YAML syntax in beta workflow — indent Python code, use env var for badge 2026-05-07 23:28:13 +02:00
james d9d65da6b7 Fix stable workflow: use if condition to skip beta tags, fix YAML syntax in README step 2026-05-07 23:26:42 +02:00
james c18054a810 Compact update banner — remove release notes, fix scroll issue; start 1.1.3 beta 2026-05-07 23:24:52 +02:00
james 032951882f Add beta badge to README, managed by workflows 2026-05-07 23:17:02 +02:00
github-actions[bot] ed186af2c6 Release v1.1.2 2026-05-07 21:15:23 +00:00
james d3dabc7843 v1.1.2 stable release 2026-05-07 23:14:32 +02:00
github-actions[bot] 987bd36137 Beta release v1.1.2-beta.7 2026-05-07 21:07:31 +00:00
james cfcb74f32b Bump to 1.1.2-beta.7 2026-05-07 23:06:46 +02:00
github-actions[bot] 273e10566e Beta release v1.1.2-beta.6 2026-05-07 21:04:20 +00:00
james 76a8f433a7 Bump to 1.1.2-beta.6 2026-05-07 23:03:11 +02:00
github-actions[bot] 65a171f296 Beta release v1.1.2-beta.5 2026-05-07 20:59:40 +00:00
james 26312290ac Move update badge to own line to prevent name/version truncation; bump to 1.1.2-beta.5 2026-05-07 22:58:19 +02:00
github-actions[bot] 63cbd70f38 Beta release v1.1.2-beta.4 2026-05-07 20:56:53 +00:00
james 235ab81efb Fix crash in isNewer when latestVersion is empty; bump to 1.1.2-beta.4 2026-05-07 22:56:12 +02:00
github-actions[bot] 12d7855ceb Beta release v1.1.2-beta.3 2026-05-07 20:52:28 +00:00
james d1ab5206bd Set bundle version to 1.1.2-beta.3 2026-05-07 22:50:59 +02:00
github-actions[bot] 997e820be8 Beta release v1.1.2-beta.2 2026-05-07 20:46:59 +00:00
james 7fc78d5a83 Set bundle version to 1.1.2-beta.2 2026-05-07 22:46:06 +02:00
james 818d4aa6c2 Set bundle version to 1.1.2-beta.1 2026-05-07 22:45:39 +02:00
github-actions[bot] 6aa74fcabc Beta release v1.1.2-beta.1 2026-05-07 20:43:45 +00:00
james 2a01d6f5b8 Handle semver pre-release versions in isNewer; restore full version string in beta workflow 2026-05-07 22:42:35 +02:00
github-actions[bot] af920eda83 Beta release v1.1.2-beta.1 2026-05-07 20:41:57 +00:00
james 6b6802eaea Fix beta version format in version-beta.json — strip pre-release suffix for isNewer compatibility 2026-05-07 22:40:34 +02:00
github-actions[bot] e9d29db2ee Beta release v1.1.2-beta.1 2026-05-07 20:36:45 +00:00
james 9be5069aac v1.1.2-beta.1 — remove redundant window labels from card rows 2026-05-07 22:36:02 +02:00
james cc9e2aaba1 Add beta release workflow, restrict stable to non-prerelease tags 2026-05-07 22:33:47 +02:00
github-actions[bot] 19bb6459ed Release v1.1.1 2026-05-07 20:28:16 +00:00
James Bone abf05b04fc v1.1.1 — clearer limit labels and login window fix 2026-05-07 22:25:26 +02:00
github-actions[bot] 849e94c942 Release v1.1.0 2026-05-07 16:34:12 +00:00
superdooper86 d76c7496bb Release v1.1.0 2026-05-07 18:33:04 +02:00
github-actions[bot] 45a3925f65 Release v1.0.12 2026-05-07 16:29:13 +00:00
superdooper86 113e91583d Release v1.0.12 2026-05-07 18:28:11 +02:00
github-actions[bot] c60fb93e1a Release v1.0.11 2026-05-07 16:24:21 +00:00
superdooper86 b16c24cb44 Release v1.0.11 — update indicator in menubar, undismissable banner
- Menubar shows ↑ Update alongside limits when an update is available
- Removed × dismiss button from update banner; it stays until installed
2026-05-07 18:23:26 +02:00
superdooper86 89a79c6f13 Temporary: bump version.json to 1.1.1 for update testing 2026-05-07 18:14:08 +02:00
github-actions[bot] aa529835f7 Release v1.0.10 2026-05-07 16:13:13 +00:00
superdooper86 b5c3cc6379 Rerelease as v1.0.10 (was incorrectly versioned as v1.1.0) 2026-05-07 18:11:45 +02:00
github-actions[bot] c8adaf1baf Release v1.1.0 2026-05-07 16:02:51 +00:00
superdooper86 3e99e1417e Release v1.1.0 — always open to main window
Post popoverWillOpen notification on show; ContentView resets showSettings
on receive so reopening always lands on the main window, not Settings.
2026-05-07 18:02:04 +02:00
github-actions[bot] e41d3d0c0f Release v1.0.9 2026-05-07 15:56:24 +00:00
23 changed files with 553 additions and 172 deletions
+30
View File
@@ -0,0 +1,30 @@
---
name: Bug report
about: Report something that isn't working correctly
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behaviour:
1.
2.
3.
**Expected behaviour**
What you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment**
- macOS version: [e.g. Sequoia 15.4]
- ClaudeChecker version: [e.g. 1.1.3]
- Claude plan: [e.g. Pro, Max]
**Additional context**
Any other context about the problem here.
+22
View File
@@ -0,0 +1,22 @@
---
name: Feature request
about: Suggest an idea or improvement
title: ''
labels: enhancement
assignees: ''
---
**What problem does this solve?**
A clear and concise description of the problem or limitation you're running into.
**Describe the solution you'd like**
What you'd like to happen.
**Alternatives considered**
Any other approaches you've thought about.
**Screenshots or mockups**
If applicable, add visuals to help explain your idea.
**Additional context**
Any other context here.
+79
View File
@@ -0,0 +1,79 @@
name: Release Beta
on:
push:
tags:
- 'v*-beta*'
jobs:
release-beta:
runs-on: macos-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Get version from tag
id: version
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Build
run: |
xcodebuild \
-project ClaudeChecker.xcodeproj \
-scheme ClaudeChecker \
-configuration Release \
-derivedDataPath build \
clean build
- name: Zip app
run: |
ditto -c -k --keepParent \
build/Build/Products/Release/ClaudeChecker.app \
ClaudeChecker.zip
- name: Create GitHub pre-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
--title "$GITHUB_REF_NAME" \
--prerelease \
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Beta release ${{ steps.version.outputs.version }}')"
- name: Update version-beta.json
run: |
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "Beta release ${{ steps.version.outputs.version }}")
jq -n \
--arg version "${{ steps.version.outputs.version }}" \
--arg url "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/ClaudeChecker.zip" \
--arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' > version-beta.json
- name: Update beta badge in README
run: |
shields_version=$(echo "${{ steps.version.outputs.version }}" | sed 's/-/--/g')
tag="${{ github.ref_name }}"
export BADGE="[![Beta](https://img.shields.io/badge/beta-${shields_version}-orange?style=flat)](https://github.com/${{ github.repository }}/releases/tag/${tag}) <!-- BETA_BADGE -->"
python3 -c "
import os
badge = os.environ['BADGE']
lines = open('README.md').readlines()
out = []
for line in lines:
out.append(badge + chr(10) if '<!-- BETA_BADGE -->' in line else line)
open('README.md', 'w').writelines(out)
"
- name: Commit version-beta.json and README
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version-beta.json README.md
git commit -m "Beta release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main
+19 -3
View File
@@ -7,6 +7,7 @@ on:
jobs: jobs:
release: release:
if: ${{ !contains(github.ref_name, '-') }}
runs-on: macos-latest runs-on: macos-latest
permissions: permissions:
contents: write contents: write
@@ -65,10 +66,24 @@ jobs:
../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true ../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
- name: Update README badge - name: Update README badge
env:
NEW_VERSION: ${{ steps.version.outputs.version }}
run: | run: |
sed -i '' \ python3 -c "
's/version-[0-9][^-]*-orange/version-${{ steps.version.outputs.version }}-orange/' \ import os, re
README.md v = os.environ['NEW_VERSION']
content = open('README.md').read()
content = re.sub(r'version-[0-9][^-]*-orange', 'version-' + v + '-orange', content)
lines = content.splitlines(keepends=True)
out = []
for line in lines:
if '<!-- BETA_BADGE -->' in line:
continue
out.append(line)
if 'license-MIT-blue' in line:
out.append('<!-- BETA_BADGE -->' + chr(10))
open('README.md', 'w').writelines(out)
"
- name: Commit version.json and README - name: Commit version.json and README
run: | run: |
@@ -76,6 +91,7 @@ jobs:
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.json README.md git add version.json README.md
git commit -m "Release ${{ github.ref_name }}" git commit -m "Release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main git push origin HEAD:main
- name: Update Homebrew tap - name: Update Homebrew tap
+19
View File
@@ -0,0 +1,19 @@
# Contributing
Thanks for your interest in ClaudeChecker!
## Reporting bugs & requesting features
Please use the [issue templates](https://github.com/superdooper86/claudechecker/issues/new/choose) — they keep reports consistent and easy to act on.
## Pull requests
- Open an issue first to discuss anything non-trivial. This avoids wasted effort if the direction isn't a fit.
- Keep changes focused — one fix or feature per PR.
- Follow the existing Swift code style in the repo.
- There's no guarantee PRs will be merged; this is a personal project maintained in spare time.
## Notes
- This app targets macOS 13+
- No external dependencies — keep it that way
+22 -4
View File
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default() config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config) cookiePrimerView = WKWebView(frame: .zero, configuration: config)
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!)) cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
// Status item // Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -40,14 +40,17 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Popover // Popover
popover = NSPopover() popover = NSPopover()
popover?.contentSize = NSSize(width: 480, height: 640)
popover?.behavior = .transient popover?.behavior = .transient
popover?.animates = true popover?.animates = true
popover?.contentViewController = NSHostingController( let hc = NSHostingController(
rootView: ContentView() rootView: ContentView()
.environmentObject(usageViewModel) .environmentObject(usageViewModel)
.environmentObject(updateManager) .environmentObject(updateManager)
) )
hc.sizingOptions = .preferredContentSize
hc.view.wantsLayer = true
hc.view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
popover?.contentViewController = hc
// React to limits or menubar toggle changes // React to limits or menubar toggle changes
usageViewModel.$limits usageViewModel.$limits
@@ -60,6 +63,11 @@ class AppDelegate: NSObject, NSApplicationDelegate {
.sink { [weak self] _ in self?.updateStatusItem() } .sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables) .store(in: &cancellables)
updateManager.$updateAvailable
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
// Refresh timer // Refresh timer
scheduleTimer(interval: usageViewModel.refreshInterval) scheduleTimer(interval: usageViewModel.refreshInterval)
@@ -166,7 +174,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "" let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? ""
button.image = nil button.image = nil
button.attributedTitle = NSAttributedString(string: "") button.attributedTitle = NSAttributedString(string: "")
button.title = "\(fhStr) \(sdStr)" let updatePrefix = updateManager.updateAvailable ? "↑ Update " : ""
button.title = "\(updatePrefix)\(fhStr) \(sdStr)"
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
} else if updateManager.updateAvailable {
button.image = nil
button.attributedTitle = NSAttributedString(string: "")
button.title = "↑ Update"
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium) button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
} else { } else {
button.title = "" button.title = ""
@@ -199,6 +213,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc func showPopover() { @objc func showPopover() {
guard let button = statusItem?.button, let popover else { return } guard let button = statusItem?.button, let popover else { return }
if !popover.isShown { if !popover.isShown {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
@@ -209,6 +224,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if popover.isShown { if popover.isShown {
popover.performClose(nil) popover.performClose(nil)
} else { } else {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
@@ -223,3 +239,5 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
+60 -47
View File
@@ -40,20 +40,35 @@ struct ContentView: View {
.environmentObject(updater) .environmentObject(updater)
.transition(.move(edge: .trailing).combined(with: .opacity)) .transition(.move(edge: .trailing).combined(with: .opacity))
} else { } else {
ScrollView(.vertical, showsIndicators: false) { VStack(spacing: 0) {
VStack(spacing: 0) {
// Cards grid // Cards grid
if vm.limits.isEmpty { if vm.limits.isEmpty {
EmptyStateView() EmptyStateView()
.padding(.vertical, 40) .padding(.vertical, 40)
} else { } else {
VStack(spacing: 1) { VStack(spacing: 8) {
ForEach(vm.limits) { limit in ForEach(vm.limits) { limit in
LimitCard(limit: limit) VStack(spacing: 0) {
HStack {
Text(limit.window == .fiveHour ? "5 Hour Limit" : "7 Day Limit")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
.textCase(.uppercase)
Spacer()
}
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 4)
LimitCard(limit: limit)
.background(Color(nsColor: .controlBackgroundColor))
.cornerRadius(8)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.08), lineWidth: 0.5))
.padding(.horizontal, 8)
}
} }
} }
.background(Color.primary.opacity(0.05)) .padding(.bottom, 4)
.padding(.vertical, 1)
// Extra credits row // Extra credits row
if let extra = vm.extraUsage, extra.isEnabled { if let extra = vm.extraUsage, extra.isEnabled {
@@ -80,7 +95,6 @@ struct ContentView: View {
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }
}
} }
.transition(.opacity) .transition(.opacity)
@@ -94,13 +108,16 @@ struct ContentView: View {
} }
.frame(width: 480) .frame(width: 480)
.animation(.easeInOut(duration: 0.2), value: showSettings) .animation(.easeInOut(duration: 0.2), value: showSettings)
.onReceive(NotificationCenter.default.publisher(for: .popoverWillOpen)) { _ in
showSettings = false
}
.onReceive(NotificationCenter.default.publisher(for: .openUpdateSheet)) { _ in .onReceive(NotificationCenter.default.publisher(for: .openUpdateSheet)) { _ in
showSettings = false showSettings = false
showUpdateSheet = true showUpdateSheet = true
} }
.sheet(isPresented: $showLogin) { .sheet(isPresented: $showLogin) {
LoginSheetView(isPresented: $showLogin) { LoginSheetView(isPresented: $showLogin) { webView in
Task { await vm.refresh() } Task { await vm.adoptAndRefresh(webView) }
} }
} }
.sheet(isPresented: $showUpdateSheet) { .sheet(isPresented: $showUpdateSheet) {
@@ -193,7 +210,7 @@ struct LimitCard: View {
AgentIconView(agent: .claude, size: 14) AgentIconView(agent: .claude, size: 14)
Text("Claude") Text("Claude")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Text("· \(vm.planLabel) · \(limit.window.label)") Text("· \(vm.planLabel)")
.font(.system(size: 11)) .font(.system(size: 11))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Spacer() Spacer()
@@ -379,12 +396,11 @@ struct DiaryRow: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
HStack { HStack {
AgentIconView(agent: .claude, size: 12) Text("\(totalSaved) samples")
Text("Claude") .font(.system(size: 10.5))
.font(.system(size: 12, weight: .medium)) .foregroundColor(.secondary)
.foregroundColor(color)
Spacer() Spacer()
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h") Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
.font(.system(size: 10.5)) .font(.system(size: 10.5))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
@@ -410,7 +426,9 @@ struct SparklineView: View {
var body: some View { var body: some View {
GeometryReader { geo in GeometryReader { geo in
if data.count > 1 { let plotData = data.count == 1 ? [data[0], data[0]] : data
if plotData.count > 1 {
let data = plotData
let w = geo.size.width let w = geo.size.width
let h = geo.size.height let h = geo.size.height
let minV = data.min() ?? 0 let minV = data.min() ?? 0
@@ -770,25 +788,23 @@ struct SettingsView: View {
.padding(.vertical, 1) .padding(.vertical, 1)
.background(Color.primary.opacity(0.06)) .background(Color.primary.opacity(0.06))
.cornerRadius(4) .cornerRadius(4)
if updater.updateAvailable { }
Text("v\(updater.latestVersion) available") if updater.updateAvailable {
.font(.system(size: 10, weight: .medium)) Text("v\(updater.latestVersion) available")
.foregroundColor(.white) .font(.system(size: 10, weight: .medium))
.padding(.horizontal, 6) .foregroundColor(.white)
.padding(.vertical, 2) .padding(.horizontal, 6)
.background(Color.blue) .padding(.vertical, 2)
.cornerRadius(4) .background(Color.blue)
.fixedSize() .cornerRadius(4)
} else if updater.betaAvailable { } else if updater.betaAvailable {
Text("v\(updater.latestBetaVersion) available") Text("v\(updater.latestBetaVersion) available")
.font(.system(size: 10, weight: .medium)) .font(.system(size: 10, weight: .medium))
.foregroundColor(.white) .foregroundColor(.white)
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 2) .padding(.vertical, 2)
.background(Color.orange) .background(Color.orange)
.cornerRadius(4) .cornerRadius(4)
.fixedSize()
}
} }
Text("Built by superdooper86 & claude") Text("Built by superdooper86 & claude")
.font(.system(size: 11)) .font(.system(size: 11))
@@ -895,12 +911,12 @@ struct ErrorBanner: View {
struct EmptyStateView: View { struct EmptyStateView: View {
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "key.slash") Image(systemName: "person.crop.circle.badge.questionmark")
.font(.system(size: 28)) .font(.system(size: 28))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text("No API key configured") Text("Not signed in")
.font(.system(size: 13, weight: .medium)) .font(.system(size: 13, weight: .medium))
Text("Open Settings to add your Anthropic API key.") Text("Sign in to claude.ai to see your usage.")
.font(.system(size: 11.5)) .font(.system(size: 11.5))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@@ -1021,25 +1037,20 @@ struct UpdateBanner: View {
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
Text("Update available — v\(updater.latestVersion)") Text("Update available — v\(updater.latestVersion)")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 12, weight: .semibold))
if !updater.releaseNotes.isEmpty { Text("Tap Update to view what's new & install.")
ReleaseNotesText(text: updater.releaseNotes, font: .system(size: 11)) .font(.system(size: 11))
} .foregroundColor(.secondary)
} }
Spacer() Spacer()
Button("Update") { showSheet = true } Button("Update") { showSheet = true }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.small) .controlSize(.small)
Button { updater.updateAvailable = false } label: {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 10) .padding(.vertical, 10)
.background(Color.blue.opacity(0.08)) .background(Color.blue.opacity(0.08))
.overlay(Divider(), alignment: .bottom) .overlay(Divider(), alignment: .bottom)
.onTapGesture { showSheet = true }
} }
} }
@@ -1157,3 +1168,5 @@ struct UpdateSheet: View {
.frame(width: 380) .frame(width: 380)
} }
} }
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.9</string> <string>1.2.1-beta.17</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>28</string> <string>65</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+42 -22
View File
@@ -4,7 +4,7 @@ import WebKit
// MARK: - Login Web View // MARK: - Login Web View
struct LoginWebView: NSViewRepresentable { struct LoginWebView: NSViewRepresentable {
let onAuthenticated: () -> Void let onAuthenticated: (WKWebView) -> Void
func makeNSView(context: Context) -> WKWebView { func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration() let config = WKWebViewConfiguration()
@@ -12,6 +12,13 @@ struct LoginWebView: NSViewRepresentable {
let webView = WKWebView(frame: .zero, configuration: config) let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator webView.navigationDelegate = context.coordinator
context.coordinator.webView = webView
// KVO on url catches SPA pushState navigations that don't fire didFinish
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
coordinator?.checkCurrentURL(wv.url?.absoluteString)
}
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!)) webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
return webView return webView
} }
@@ -23,25 +30,31 @@ struct LoginWebView: NSViewRepresentable {
} }
class Coordinator: NSObject, WKNavigationDelegate { class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: () -> Void let onAuthenticated: (WKWebView) -> Void
weak var webView: WKWebView?
var didAuthenticate = false
var urlObservation: NSKeyValueObservation?
init(onAuthenticated: @escaping () -> Void) { init(onAuthenticated: @escaping (WKWebView) -> Void) {
self.onAuthenticated = onAuthenticated self.onAuthenticated = onAuthenticated
} }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { func checkCurrentURL(_ url: String?) {
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in guard !didAuthenticate, let url, let wv = webView else { return }
let hasSession = cookies.contains { // Ignore navigations to external OAuth providers (Google, etc.)
$0.domain.contains("claude.ai") && // only consider auth complete when we land back on claude.ai/anthropic.com
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token") guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
} if url.contains("/login") || url.contains("/auth") { return }
if hasSession { didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated() self.onAuthenticated(wv)
}
}
} }
} }
// Covers full cross-document navigations
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
checkCurrentURL(webView.url?.absoluteString)
}
} }
} }
@@ -49,7 +62,7 @@ struct LoginWebView: NSViewRepresentable {
struct LoginSheetView: View { struct LoginSheetView: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
let onDone: () -> Void let onDone: (WKWebView) -> Void
@State private var authenticated = false @State private var authenticated = false
var body: some View { var body: some View {
@@ -58,9 +71,15 @@ struct LoginSheetView: View {
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude") Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Spacer() Spacer()
Button("Cancel") { isPresented = false } if authenticated {
.buttonStyle(.bordered) Button("Done") { isPresented = false }
.controlSize(.small) .buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Button("Cancel") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 10) .padding(.vertical, 10)
@@ -68,12 +87,13 @@ struct LoginSheetView: View {
Divider() Divider()
LoginWebView { LoginWebView { webView in
authenticated = true authenticated = true
// Auto-dismiss and refresh after brief delay // Adopt the authenticated WebView immediately (before sheet tears it down),
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { // then auto-dismiss after a moment so the user sees confirmation.
onDone(webView)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
isPresented = false isPresented = false
onDone()
} }
} }
} }
+23 -5
View File
@@ -318,13 +318,31 @@ class UpdateManager: ObservableObject {
} }
private func isNewer(_ version: String, than current: String) -> Bool { private func isNewer(_ version: String, than current: String) -> Bool {
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } } func parse(_ v: String) -> (base: [Int], pre: [Int]?) {
let a = toInts(version), b = toInts(current) guard !v.isEmpty else { return ([], nil) }
for i in 0..<max(a.count, b.count) { let halves = v.split(separator: "-", maxSplits: 1)
let av = i < a.count ? a[i] : 0 let base = halves[0].split(separator: ".").compactMap { Int($0) }
let bv = i < b.count ? b[i] : 0 let pre = halves.count > 1 ? String(halves[1]).split(separator: ".").compactMap { Int($0) } : nil
return (base, pre)
}
let a = parse(version), b = parse(current)
// Compare base version numbers
for i in 0..<max(a.base.count, b.base.count) {
let av = i < a.base.count ? a.base[i] : 0
let bv = i < b.base.count ? b.base[i] : 0
if av != bv { return av > bv } if av != bv { return av > bv }
} }
// Base equal: stable beats pre-release
if a.pre == nil && b.pre != nil { return true }
if a.pre != nil && b.pre == nil { return false }
// Both pre-release: compare pre-release numbers
if let ap = a.pre, let bp = b.pre {
for i in 0..<max(ap.count, bp.count) {
let av = i < ap.count ? ap[i] : 0
let bv = i < bp.count ? bp[i] : 0
if av != bv { return av > bv }
}
}
return false return false
} }
} }
+199 -74
View File
@@ -3,7 +3,6 @@ import WebKit
@MainActor @MainActor
class UsageViewModel: ObservableObject { class UsageViewModel: ObservableObject {
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
@Published var limits: [AgentLimit] = [] @Published var limits: [AgentLimit] = []
@Published var isLoading = false @Published var isLoading = false
@Published var lastUpdated: Date? @Published var lastUpdated: Date?
@@ -28,24 +27,125 @@ class UsageViewModel: ObservableObject {
private var burnHistoryStore: [String: [Double]] = [:] private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24 private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:] private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:] private var firedThresholds: [String: Set<Int>] = [:]
// Background WKWebView for API calls via callAsyncJavaScript.
// Hosted in a hidden NSWindow without a window WebKit suspends the WebView
// and JS execution stops working, breaking callAsyncJavaScript.
private var apiWebView: WKWebView?
private var apiDelegate: APIWebViewDelegate?
private var apiWindow: NSWindow?
private var apiWebViewLoaded = false
private var apiReadyContinuations: [CheckedContinuation<Void, Never>] = []
init() { init() {
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
let saved = UserDefaults.standard.double(forKey: "refresh_interval") let saved = UserDefaults.standard.double(forKey: "refresh_interval")
refreshInterval = saved > 0 ? saved : 60 refreshInterval = saved > 0 ? saved : 60
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
if let saved = UserDefaults.standard.object(forKey: "burn_history") as? [String: [Double]] {
burnHistoryStore = saved
}
loadPlaceholderData() loadPlaceholderData()
setupAPIWebView()
Task { await checkInitialSignInState() }
}
// MARK: - Background API WebView
private func setupAPIWebView() {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
let del = APIWebViewDelegate()
del.onNavigationEnd = { [weak self] in
guard let self else { return }
self.apiWebViewLoaded = true
self.resumeAPIReadyContinuations()
}
wv.navigationDelegate = del
apiWebView = wv
apiDelegate = del
// A WKWebView with no window is suspended by macOS JS execution won't run.
// Hosting it in a 1×1 transparent window keeps WebKit's process alive.
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1, height: 1),
styleMask: .borderless,
backing: .buffered,
defer: false)
window.alphaValue = 0.0
window.ignoresMouseEvents = true
window.isReleasedWhenClosed = false
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
window.contentView?.addSubview(wv)
window.orderFrontRegardless()
apiWindow = window
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
}
private func resumeAPIReadyContinuations() {
let pending = apiReadyContinuations
apiReadyContinuations.removeAll()
pending.forEach { $0.resume() }
}
private func waitForAPIWebViewReady() async {
guard !apiWebViewLoaded else { return }
await withCheckedContinuation { cont in
apiReadyContinuations.append(cont)
}
}
// Called after login: adopts the proven-authenticated login WebView for all API calls.
// This avoids the background WebView potentially missing auth tokens that only exist
// in the login WebView's JS context (localStorage, Service Workers, etc.).
func adoptAndRefresh(_ loginWebView: WKWebView) async {
loginWebView.removeFromSuperview()
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
apiWindow?.contentView?.addSubview(loginWebView)
apiWebView = loginWebView
apiWebViewLoaded = true
resumeAPIReadyContinuations()
try? await Task.sleep(nanoseconds: 400_000_000)
await refresh()
}
// Executes a same-origin fetch inside the background WebView's page context.
// This includes all credentials the page has (cookies, localStorage tokens, etc.),
// which URLSession cannot access hence using callAsyncJavaScript instead.
private func webViewFetch(_ path: String) async throws -> (statusCode: Int, body: String) {
guard let wv = apiWebView else { throw AppError.detail("no webview") }
await waitForAPIWebViewReady()
let js = "const r = await fetch(path, {credentials:'include'}); return {s: r.status, b: await r.text()};"
do {
let result = try await wv.callAsyncJavaScript(js, arguments: ["path": path], in: nil, in: .page)
guard let d = result as? [String: Any],
let s = (d["s"] as? NSNumber)?.intValue,
let b = d["b"] as? String else {
throw AppError.detail("bad result: \(String(describing: result).prefix(120))")
}
return (s, b)
} catch let e as AppError { throw e }
catch {
let loc = wv.url?.absoluteString ?? "?"
throw AppError.detail("JS err @ \(loc): \(error.localizedDescription.prefix(100))")
}
}
private func checkInitialSignInState() async {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if !cookies.isEmpty { isSignedIn = true }
} }
func signOut() async { func signOut() async {
let store = WKWebsiteDataStore.default() let store = WKWebsiteDataStore.default()
let types = WKWebsiteDataStore.allWebsiteDataTypes() let types = WKWebsiteDataStore.allWebsiteDataTypes()
let records = await store.dataRecords(ofTypes: types) let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") } let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
await store.removeData(ofTypes: types, for: claudeRecords) await store.removeData(ofTypes: types, for: claudeRecords)
UserDefaults.standard.removeObject(forKey: "claude_org_id")
isSignedIn = false isSignedIn = false
isNotAuthenticated = true isNotAuthenticated = true
lastUpdated = nil lastUpdated = nil
@@ -54,6 +154,8 @@ class UsageViewModel: ObservableObject {
extraUsage = nil extraUsage = nil
prepaidCredits = nil prepaidCredits = nil
overageSpendLimit = nil overageSpendLimit = nil
apiWebViewLoaded = false
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
} }
func refresh() async { func refresh() async {
@@ -63,18 +165,29 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false } defer { isLoading = false }
do { do {
let orgId = cachedOrgId! let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId) let orgId: String
async let overageFetch = fetchOverageSpendLimit(orgId: orgId) if let id = fetchedOrgId {
async let emailFetch = fetchUserEmail() UserDefaults.standard.set(id, forKey: "claude_org_id")
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch) orgId = id
if let email { userEmail = email } } else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
orgId = cached
} else {
throw AppError.notAuthenticated
}
if let email = fetchedEmail { userEmail = email }
if let plan = fetchedPlan { planLabel = plan }
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
limits = buildLimits(from: usage) limits = buildLimits(from: usage)
extraUsage = usage.extraUsage extraUsage = usage.extraUsage
prepaidCredits = prepaid prepaidCredits = prepaid
overageSpendLimit = overage overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date() lastUpdated = Date()
isSignedIn = true isSignedIn = true
@@ -84,11 +197,13 @@ class UsageViewModel: ObservableObject {
history.append(limits[i].usedPercent) history.append(limits[i].usedPercent)
if history.count > maxHistorySamples { history.removeFirst() } if history.count > maxHistorySamples { history.removeFirst() }
burnHistoryStore[key] = history burnHistoryStore[key] = history
if history.count > 1 { limits[i].burnHistory = history } limits[i].burnHistory = history
} }
UserDefaults.standard.set(burnHistoryStore, forKey: "burn_history")
checkLimitNotifications(for: limits) checkLimitNotifications(for: limits)
} catch AppError.notAuthenticated { } catch AppError.notAuthenticated {
isNotAuthenticated = true isNotAuthenticated = true
isSignedIn = false
errorMessage = "Not signed in" errorMessage = "Not signed in"
} catch let error as DecodingError { } catch let error as DecodingError {
switch error { switch error {
@@ -106,70 +221,67 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - Bootstrap
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await webViewFetch("/api/bootstrap")
if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard status == 200 else { throw AppError.networkError }
guard let data = body.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return (nil, nil, nil)
}
let account = json["account"] as? [String: Any]
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
let firstOrg = memberships?.first?["organization"] as? [String: Any]
var orgId: String? = firstOrg?["uuid"] as? String
if orgId == nil {
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
}
if orgId == nil {
if let (orgsStatus, orgsBody) = try? await webViewFetch("/api/organizations"),
orgsStatus == 200,
let orgsData = orgsBody.data(using: .utf8),
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
orgId = orgs.first?["uuid"] as? String
}
}
let email = account?["email_address"] as? String
var planLabel: String? = nil
if let caps = firstOrg?["capabilities"] as? [String],
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
let name = String(cap.dropFirst("claude_".count))
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
}
return (orgId, email, planLabel)
}
// MARK: - Fetch usage // MARK: - Fetch usage
private func fetchUsage(orgId: String) async throws -> UsageResponse { private func fetchUsage(orgId: String) async throws -> UsageResponse {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")! let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
var req = URLRequest(url: url) if status == 401 || status == 403 { throw AppError.notAuthenticated }
req.setValue("application/json", forHTTPHeaderField: "accept") guard status == 200 else { throw AppError.networkError }
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError }
return try JSONDecoder().decode(UsageResponse.self, from: data) return try JSONDecoder().decode(UsageResponse.self, from: data)
} }
private func fetchUserEmail() async throws -> String? {
let url = URL(string: "https://claude.ai/api/bootstrap")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let account = json["account"] as? [String: Any],
let email = account["email_address"] as? String else { return nil }
return email
}
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? { private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")! guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
var req = URLRequest(url: url) status == 200,
req.setValue("application/json", forHTTPHeaderField: "accept") let data = body.data(using: .utf8) else { return nil }
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data) return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
} }
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? { private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")! guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
var req = URLRequest(url: url) status == 200,
req.setValue("application/json", forHTTPHeaderField: "accept") let data = body.data(using: .utf8) else { return nil }
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data) return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
} }
@@ -241,14 +353,11 @@ class UsageViewModel: ObservableObject {
private func checkLimitNotifications(for limits: [AgentLimit]) { private func checkLimitNotifications(for limits: [AgentLimit]) {
let thresholds = [80, 95] let thresholds = [80, 95]
for limit in limits { for limit in limits {
let key = limit.window.rawValue let key = limit.window.rawValue
let curr = limit.usedPercent let curr = limit.usedPercent
let prev = previousPercents[key] let prev = previousPercents[key]
var fired = firedThresholds[key] ?? [] var fired = firedThresholds[key] ?? []
// Threshold warnings (80% and 95%)
for t in thresholds { for t in thresholds {
let threshold = Double(t) let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue } guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
@@ -259,8 +368,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName, "percent": t] userInfo: ["windowName": limit.window.displayName, "percent": t]
) )
} }
// Reset detection: was high, now low
if let prev, prev > 50, curr < 20 { if let prev, prev > 50, curr < 20 {
fired.removeAll() fired.removeAll()
NotificationCenter.default.post( NotificationCenter.default.post(
@@ -269,7 +376,6 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName] userInfo: ["windowName": limit.window.displayName]
) )
} }
firedThresholds[key] = fired firedThresholds[key] = fired
previousPercents[key] = curr previousPercents[key] = curr
} }
@@ -292,13 +398,31 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - API WebView Delegate
private class APIWebViewDelegate: NSObject, WKNavigationDelegate {
var onNavigationEnd: (() -> Void)?
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
onNavigationEnd?()
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?()
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
onNavigationEnd?()
}
}
enum AppError: LocalizedError { enum AppError: LocalizedError {
case notAuthenticated case notAuthenticated
case networkError case networkError
case detail(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first." case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data." case .networkError: return "Network error fetching usage data."
case .detail(let msg): return msg
} }
} }
} }
@@ -307,6 +431,7 @@ extension Notification.Name {
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged") static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
static let closePopover = Notification.Name("closePopover") static let closePopover = Notification.Name("closePopover")
static let showPopover = Notification.Name("showPopover") static let showPopover = Notification.Name("showPopover")
static let popoverWillOpen = Notification.Name("popoverWillOpen")
static let updateDetected = Notification.Name("updateDetected") static let updateDetected = Notification.Name("updateDetected")
static let limitWarning = Notification.Name("limitWarning") static let limitWarning = Notification.Name("limitWarning")
static let limitReset = Notification.Name("limitReset") static let limitReset = Notification.Name("limitReset")
+10 -9
View File
@@ -8,8 +8,9 @@
[![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/) [![macOS](https://img.shields.io/badge/macOS-13.0+-000000?style=flat&logo=apple&logoColor=white)](https://www.apple.com/macos/)
[![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org) [![Swift](https://img.shields.io/badge/Swift-5.9-F05138?style=flat&logo=swift&logoColor=white)](https://swift.org)
[![Version](https://img.shields.io/badge/version-1.0.8-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases) [![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.16-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.16) <!-- BETA_BADGE -->
</div> </div>
@@ -29,21 +30,21 @@ No API key required. It reads directly from your `claude.ai` session using a bui
<table> <table>
<tr> <tr>
<td align="center"><img src="screenshots/main-v2.png" width="280" alt="Main window" /><br/><sub>Main window</sub></td> <td align="center"><img src="screenshots/main-v3.png" width="280" alt="Main window" /><br/><sub>Main window</sub></td>
<td align="center"><img src="screenshots/settings-v2.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td> <td align="center"><img src="screenshots/settings-v3.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
</tr> </tr>
<tr> <tr>
<td align="center"><img src="screenshots/update-banner.png" width="280" alt="Update banner" /><br/><sub>Update available</sub></td> <td align="center"><img src="screenshots/update-banner.png" width="280" alt="Update available" /><br/><sub>Update available</sub></td>
<td align="center"><img src="screenshots/notification.png" width="280" alt="Update notification" /><br/><sub>System notification</sub></td> <td align="center"><img src="screenshots/release-notes.png" width="280" alt="Release notes" /><br/><sub>Release notes</sub></td>
</tr>
<tr>
<td align="center"><img src="screenshots/notification.png" width="280" alt="Update notification" /><br/><sub>Update notification</sub></td>
<td align="center"><img src="screenshots/notif-reset.png" width="280" alt="Limit reset" /><br/><sub>Limit reset</sub></td>
</tr> </tr>
<tr> <tr>
<td align="center"><img src="screenshots/notif-limit-80.png" width="280" alt="80% limit warning" /><br/><sub>80% limit warning</sub></td> <td align="center"><img src="screenshots/notif-limit-80.png" width="280" alt="80% limit warning" /><br/><sub>80% limit warning</sub></td>
<td align="center"><img src="screenshots/notif-limit-95.png" width="280" alt="95% limit warning" /><br/><sub>95% limit warning</sub></td> <td align="center"><img src="screenshots/notif-limit-95.png" width="280" alt="95% limit warning" /><br/><sub>95% limit warning</sub></td>
</tr> </tr>
<tr>
<td align="center"><img src="screenshots/notif-reset.png" width="280" alt="Limit reset" /><br/><sub>Limit reset</sub></td>
<td></td>
</tr>
</table> </table>
<br/> <br/>
+13 -3
View File
@@ -1,4 +1,14 @@
## What's new in v1.0.9 ## What's new in v1.2.1-beta.17
### Fixed update notification popup ### Bug fixes
The notification popup now correctly shows **"Tap to view release notes & update now!"** instead of the raw markdown release notes text. - Fixed "Not signed in" after login by adopting the login WebView directly for API calls — the login WebView is proven-authenticated (user just completed sign-in in it), so reusing it eliminates the problem where a separately-loaded background WebView might not have the full auth context (localStorage tokens, Service Worker state) that claude.ai requires
- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging
## What's new in v1.2.1
### Bug fixes
- Fixed the root cause of "Not signed in" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.
- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.
- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)
- Added `/api/organizations` as a final fallback for org ID resolution
- Fixed Settings incorrectly showing "Signed in" after a failed refresh
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

+5
View File
@@ -0,0 +1,5 @@
{
"version": "1.2.1-beta.16",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.16/ClaudeChecker.zip",
"notes": "## What's new in v1.2.1\n\n### Bug fixes\n- Fixed the root cause of \"Not signed in\" after being clearly signed in: claude.ai's auth requires credentials beyond plain HTTP cookies (localStorage tokens, Service Worker state, etc.) that URLSession cannot access. All API calls now run via callAsyncJavaScript inside a background WKWebView, using the same fetch path the page itself uses — credentials are included automatically.\n- Fixed WebKit suspending the background WKWebView: a WKWebView with no window is throttled/suspended by macOS, preventing JS execution. The background WebView is now anchored in a transparent 1×1 NSWindow, keeping it active.\n- Fixed login window auto-closing before sign-in completes — login window loads `/login` and only detects auth when back on claude.ai (not on OAuth provider redirects)\n- Added `/api/organizations` as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
}
+5
View File
@@ -0,0 +1,5 @@
{
"version": "0.0.1-beta.49",
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.49/ClaudeChecker-Windows.zip",
"notes": "## What's new in beta.36\r\n\r\n### Bug fixes\r\n- Settings no longer incorrectly shows \"Not signed in\" when limits are working\r\n- Session Diary now shows correctly after the first successful refresh\r\n- Extra Usage Credits section now restored from cache on startup\r\n- Plan name, overage, and prepaid credits are now cached and shown immediately on launch\r\n- App now loads cached state instantly on startup before the background refresh completes"
}
+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.0.8", "version": "1.2.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.0.8/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
"notes": "## What's new in v1.0.8\n\n### Release notes now render correctly\nFixed markdown rendering in the update banner and update sheet — headers, **bold text**, and bullet points now display properly instead of showing raw `##` and `**` characters." "notes": "## What's new in v1.2.0\n\n### Bug fixes\n- App now works for all users — org ID is fetched dynamically from the API instead of being hardcoded\n- Plan name (e.g. Pro, Max) now updates correctly on every refresh\n\n### Improvements\n- Plan name is read from the API rather than hardcoded\n- Bootstrap API call consolidated — org ID, email, and plan name fetched in a single request per refresh\n- Main panel no longer scrolls — popover auto-sizes to fit content\n- Session Diary card redesigned — sample count and avg burn rate shown left/right above the sparkline, Claude header removed"
} }