Compare commits

..
Author SHA1 Message Date
superdooper86 4378463600 beta.22: manual cookie join, anthropic-client-platform header, ephemeral session, expose usage 403 body 2026-05-11 12:52:46 +02:00
github-actions[bot] 2cfd7d56a1 Beta release v1.2.1-beta.21 2026-05-11 10:41:34 +00:00
SuperDooper 84bdfbd786 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:50 +02:00
SuperDooper beba7586f9 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:48 +02:00
SuperDooper a4dff4bde9 beta.21: add DiagnosticsView.swift 2026-05-11 12:40:47 +02:00
SuperDooper d0b33e8f43 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:38 +02:00
SuperDooper 6a5a3c9cd1 beta.21: diagnostics view, cookie polling, detailed error messages 2026-05-11 12:40:36 +02:00
github-actions[bot] 15e225439b Beta release v1.2.1-beta.20 2026-05-11 10:33:30 +00:00
SuperDooper 91829b0ed9 beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:34 +02:00
SuperDooper caffc996ff beta.20: URLSession with browser headers (sec-fetch-*, Chrome UA, Origin/Referer) 2026-05-11 12:32:33 +02:00
github-actions[bot] cd13a6d124 Beta release v1.2.1-beta.19 2026-05-11 10:22:52 +00:00
SuperDooper 566258e9c8 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:14 +02:00
SuperDooper dc2a8e2307 beta.19: fix tuple label mismatch in APIFetchDelegate 2026-05-11 12:22:13 +02:00
SuperDooper f48b53fd8e beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:22 +02:00
SuperDooper ccfee938b7 beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:21 +02:00
SuperDooper 28d952bcbd beta.18: navigate-and-read API fetch replaces callAsyncJavaScript 2026-05-11 12:20:19 +02:00
github-actions[bot] 9bc023d239 Beta release v1.2.1-beta.17 2026-05-11 10:07:03 +00:00
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
24 changed files with 674 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.
+18 -2
View File
@@ -54,10 +54,26 @@ jobs:
--arg notes "$notes" \ --arg notes "$notes" \
'{version: $version, url: $url, notes: $notes}' > version-beta.json '{version: $version, url: $url, notes: $notes}' > version-beta.json
- name: Commit 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: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
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-beta.json git add version-beta.json README.md
git commit -m "Beta release ${{ github.ref_name }}" git commit -m "Beta release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main git push origin HEAD:main
+19 -4
View File
@@ -4,10 +4,10 @@ on:
push: push:
tags: tags:
- 'v*' - 'v*'
- '!v*-*'
jobs: jobs:
release: release:
if: ${{ !contains(github.ref_name, '-') }}
runs-on: macos-latest runs-on: macos-latest
permissions: permissions:
contents: write contents: write
@@ -66,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: |
@@ -77,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
+20
View File
@@ -10,6 +10,13 @@
<string>77</string> <string>77</string>
<key>objects</key> <key>objects</key>
<dict> <dict>
<key>AAAAAA009</key>
<dict>
<key>isa</key>
<string>PBXBuildFile</string>
<key>fileRef</key>
<string>AAAAAA109</string>
</dict>
<key>AAAAAA007</key> <key>AAAAAA007</key>
<dict> <dict>
<key>isa</key> <key>isa</key>
@@ -88,6 +95,17 @@
<key>sourceTree</key> <key>sourceTree</key>
<string>&lt;group&gt;</string> <string>&lt;group&gt;</string>
</dict> </dict>
<key>AAAAAA109</key>
<dict>
<key>isa</key>
<string>PBXFileReference</string>
<key>lastKnownFileType</key>
<string>sourcecode.swift</string>
<key>path</key>
<string>DiagnosticsView.swift</string>
<key>sourceTree</key>
<string>&lt;group&gt;</string>
</dict>
<key>AAAAAA101</key> <key>AAAAAA101</key>
<dict> <dict>
<key>isa</key> <key>isa</key>
@@ -179,6 +197,7 @@
<string>AAAAAA106</string> <string>AAAAAA106</string>
<string>AAAAAA107</string> <string>AAAAAA107</string>
<string>AAAAAA108</string> <string>AAAAAA108</string>
<string>AAAAAA109</string>
</array> </array>
<key>path</key> <key>path</key>
<string>ClaudeChecker</string> <string>ClaudeChecker</string>
@@ -292,6 +311,7 @@
<string>AAAAAA005</string> <string>AAAAAA005</string>
<string>AAAAAA007</string> <string>AAAAAA007</string>
<string>AAAAAA008</string> <string>AAAAAA008</string>
<string>AAAAAA009</string>
</array> </array>
<key>runOnlyForDeploymentPostprocessing</key> <key>runOnlyForDeploymentPostprocessing</key>
<string>0</string> <string>0</string>
+8 -3
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
@@ -236,3 +239,5 @@ class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
+55 -37
View File
@@ -40,8 +40,7 @@ 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()
@@ -51,7 +50,7 @@ struct ContentView: View {
ForEach(vm.limits) { limit in ForEach(vm.limits) { limit in
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
Text(limit.window == .fiveHour ? "5hr Limit" : "7 Day Limit") Text(limit.window == .fiveHour ? "5 Hour Limit" : "7 Day Limit")
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary) .foregroundColor(.secondary)
.textCase(.uppercase) .textCase(.uppercase)
@@ -96,7 +95,6 @@ struct ContentView: View {
.padding(.bottom, 8) .padding(.bottom, 8)
} }
} }
}
} }
.transition(.opacity) .transition(.opacity)
@@ -118,8 +116,8 @@ struct ContentView: View {
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) {
@@ -398,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)
} }
@@ -429,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
@@ -585,6 +584,7 @@ struct SettingsView: View {
@Binding var showSettings: Bool @Binding var showSettings: Bool
@Binding var showUpdateSheet: Bool @Binding var showUpdateSheet: Bool
@State private var checkingForUpdates = false @State private var checkingForUpdates = false
@State private var showDiagnostics = false
let refreshOptions: [(label: String, seconds: TimeInterval)] = [ let refreshOptions: [(label: String, seconds: TimeInterval)] = [
("1 min", 60), ("1 min", 60),
@@ -771,6 +771,23 @@ struct SettingsView: View {
Divider() Divider()
// Diagnostics
HStack {
Label("Diagnostics", systemImage: "stethoscope")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
Spacer()
Button("Open") { showDiagnostics = true }
.buttonStyle(.bordered)
.controlSize(.small)
}
.sheet(isPresented: $showDiagnostics) {
DiagnosticsView(isPresented: $showDiagnostics)
.environmentObject(vm)
}
Divider()
// About // About
HStack(spacing: 12) { HStack(spacing: 12) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
@@ -789,25 +806,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))
@@ -914,12 +929,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)
@@ -1040,9 +1055,9 @@ 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 }
@@ -1053,6 +1068,7 @@ struct UpdateBanner: View {
.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 }
} }
} }
@@ -1170,3 +1186,5 @@ struct UpdateSheet: View {
.frame(width: 380) .frame(width: 380)
} }
} }
+226
View File
@@ -0,0 +1,226 @@
import SwiftUI
import WebKit
struct DiagnosticsView: View {
@EnvironmentObject var vm: UsageViewModel
@Binding var isPresented: Bool
@State private var liveAllCookies: [HTTPCookie] = []
@State private var loadingCookies = false
@State private var copied = false
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Diagnostics")
.font(.system(size: 13, weight: .semibold))
Spacer()
Button("Copy All") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(fullDiagText, forType: .string)
copied = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
}
.buttonStyle(.bordered)
.controlSize(.small)
if copied {
Text("Copied!")
.font(.system(size: 11))
.foregroundColor(.green)
}
Button("Close") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color(nsColor: .windowBackgroundColor))
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
// App info
DiagSection(title: "App") {
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
DiagRow("Error", vm.errorMessage ?? "(none)")
}
Divider()
// Last request
DiagSection(title: "Last Request") {
DiagRow("Path", vm.diagLastPath.isEmpty ? "(none)" : vm.diagLastPath)
DiagRow("Status", vm.diagLastStatus == 0 ? "(none)" : "\(vm.diagLastStatus)")
DiagRow("Error", vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError)
if let t = vm.diagLastFetch {
DiagRow("Time", t.formatted(date: .omitted, time: .standard))
}
}
Divider()
// Response body
if !vm.diagLastBody.isEmpty {
DiagSection(title: "Response Body (first 500 chars)") {
Text(vm.diagLastBody)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(Color.primary.opacity(0.04))
.cornerRadius(5)
}
Divider()
}
// Cookie summary
DiagSection(title: "Cookie Store") {
DiagRow("Total cookies", "\(vm.diagCookieCount)")
DiagRow("claude.ai cookies", "\(vm.diagClaudeCookieCount)")
DiagRow("All domains", vm.diagCookieDomains.isEmpty
? "(none — never fetched)"
: vm.diagCookieDomains.joined(separator: ", "))
}
Divider()
// Live cookie list
DiagSection(title: "Live Cookie Names (WKWebsiteDataStore.default)") {
if loadingCookies {
ProgressView().scaleEffect(0.7)
} else if liveAllCookies.isEmpty {
Text("No cookies found")
.font(.system(size: 11))
.foregroundColor(.secondary)
} else {
let claudeCookies = liveAllCookies.filter {
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
}
let otherCookies = liveAllCookies.filter {
!$0.domain.contains("claude.ai") && !$0.domain.contains("anthropic.com")
}
if !claudeCookies.isEmpty {
Text("claude.ai / anthropic.com (\(claudeCookies.count))")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.orange)
ForEach(claudeCookies, id: \.name) { c in
Text("\(c.domain) \(c.name)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(.secondary)
}
}
if !otherCookies.isEmpty {
Text("Other domains (\(otherCookies.count))")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
.padding(.top, 4)
ForEach(otherCookies.prefix(10), id: \.name) { c in
Text("\(c.domain) \(c.name)")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(Color.secondary.opacity(0.6))
}
if otherCookies.count > 10 {
Text("… and \(otherCookies.count - 10) more")
.font(.system(size: 10))
.foregroundColor(Color.secondary.opacity(0.5))
}
}
}
Button("Refresh cookies") { loadLiveCookies() }
.buttonStyle(.bordered)
.controlSize(.small)
.padding(.top, 4)
}
}
.padding(14)
}
}
.frame(width: 480, height: 560)
.onAppear { loadLiveCookies() }
}
private func loadLiveCookies() {
loadingCookies = true
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
DispatchQueue.main.async {
liveAllCookies = cookies.sorted { $0.domain < $1.domain }
loadingCookies = false
}
}
}
private var fullDiagText: String {
var lines: [String] = []
lines.append("=== ClaudeChecker Diagnostics ===")
lines.append("Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
lines.append("Error: \(vm.errorMessage ?? "(none)")")
lines.append("")
lines.append("Last path: \(vm.diagLastPath)")
lines.append("Last status: \(vm.diagLastStatus)")
lines.append("Last error: \(vm.diagLastError)")
lines.append("Total cookies: \(vm.diagCookieCount)")
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
lines.append("")
lines.append("Response body:")
lines.append(vm.diagLastBody)
lines.append("")
lines.append("Live cookies:")
for c in liveAllCookies {
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
}
return lines.joined(separator: "\n")
}
}
// MARK: - Helpers
private struct DiagSection<Content: View>: View {
let title: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.secondary)
.textCase(.uppercase)
content()
}
}
}
private struct DiagRow: View {
let label: String
let value: String
init(_ label: String, _ value: String) {
self.label = label
self.value = value
}
var body: some View {
HStack(alignment: .top, spacing: 8) {
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)
.frame(width: 100, alignment: .leading)
Text(value)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.primary)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
+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.1.2-beta.4</string> <string>1.2.1-beta.22</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>35</string> <string>70</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>13.0</string> <string>13.0</string>
<key>LSUIElement</key> <key>LSUIElement</key>
+35 -30
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,31 +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 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?) {
guard !didAuthenticate else { return } guard !didAuthenticate, let url, let wv = webView else { return }
// Don't fire on the login/auth pages themselves // Ignore navigations to external OAuth providers (Google, etc.)
if let url = webView.url?.absoluteString, // only consider auth complete when we land back on claude.ai/anthropic.com
url.contains("/login") || url.contains("/auth") { return } guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
if url.contains("/login") || url.contains("/auth") { return }
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in didAuthenticate = true
let hasSession = cookies.contains { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
$0.domain.contains("claude.ai") && self.onAuthenticated(wv)
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
}
guard hasSession, !self.didAuthenticate else { return }
self.didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated()
}
} }
} }
// Covers full cross-document navigations
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
checkCurrentURL(webView.url?.absoluteString)
}
} }
} }
@@ -55,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 {
@@ -65,12 +72,9 @@ struct LoginSheetView: View {
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 13, weight: .semibold))
Spacer() Spacer()
if authenticated { if authenticated {
Button("Done") { Button("Done") { isPresented = false }
isPresented = false .buttonStyle(.borderedProminent)
onDone() .controlSize(.small)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else { } else {
Button("Cancel") { isPresented = false } Button("Cancel") { isPresented = false }
.buttonStyle(.bordered) .buttonStyle(.bordered)
@@ -83,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()
} }
} }
} }
+180 -76
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,117 @@ 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>] = [:]
// Diagnostics populated on every urlFetch call
@Published var diagCookieCount: Int = 0
@Published var diagClaudeCookieCount: Int = 0
@Published var diagCookieDomains: [String] = []
@Published var diagLastPath: String = ""
@Published var diagLastStatus: Int = 0
@Published var diagLastBody: String = ""
@Published var diagLastError: String = ""
@Published var diagLastFetch: Date? = nil
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()
Task { await checkInitialSignInState() }
}
// Called after login: poll until session cookies appear (they may commit slightly
// after the auth redirect fires), then refresh.
func adoptAndRefresh(_ loginWebView: WKWebView) async {
for _ in 0..<30 {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
try? await Task.sleep(nanoseconds: 300_000_000)
}
await refresh()
}
// Fetches an API path via URLSession with browser-like headers.
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
// to avoid 401 matching how the Windows version (HttpClient) handles this.
private func urlFetch(_ path: String) async throws -> (Int, String) {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter {
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
}
// Record cookie diagnostics
diagCookieCount = cookies.count
diagClaudeCookieCount = claudeCookies.count
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
diagLastPath = path
diagLastFetch = Date()
diagLastError = ""
guard !claudeCookies.isEmpty else {
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
diagLastError = msg
throw AppError.detail(msg)
}
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
req.httpShouldHandleCookies = false
// Build cookie header manually (same format as Windows HttpClient) to avoid
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
req.setValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent")
req.setValue(
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
forHTTPHeaderField: "sec-ch-ua")
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
let session = URLSession(configuration: .ephemeral)
do {
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
let body = String(data: data, encoding: .utf8) ?? ""
diagLastStatus = http.statusCode
diagLastBody = String(body.prefix(500))
if http.statusCode != 200 {
diagLastError = "HTTP \(http.statusCode)"
}
return (http.statusCode, body)
} catch let e as AppError { throw e }
catch {
let msg = error.localizedDescription
diagLastError = msg
throw AppError.detail(msg)
}
}
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
@@ -63,18 +155,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 +187,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 +211,72 @@ class UsageViewModel: ObservableObject {
} }
} }
// MARK: - Bootstrap
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
let (status, body) = try await urlFetch("/api/bootstrap")
if status == 401 || status == 403 {
throw AppError.detail("HTTP \(status)\(body.prefix(120))")
}
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
}
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 urlFetch("/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 urlFetch("/api/organizations/\(orgId)/usage")
var req = URLRequest(url: url) if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
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 urlFetch("/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 urlFetch("/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 +348,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 +363,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 +371,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 +393,16 @@ class UsageViewModel: ObservableObject {
} }
} }
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
} }
} }
} }
+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.1.1-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.21-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.21) <!-- 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/>
+19 -3
View File
@@ -1,4 +1,20 @@
## What's new in v1.1.2 ## What's new in v1.2.1-beta.18
### Cleaner card headers ### Bug fixes
Removed the redundant "5h" / "7d" window labels from each card row — the section headers already make it clear which limit is which. - Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.
- API calls are now sequential to share a single WebView for all navigations
- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser
## What's new in v1.2.1-beta.17
### Bug fixes
- Fixed "Not signed in" after login by adopting the login WebView directly for API calls
- Added diagnostic error messages for JS errors and unexpected responses
## What's new in v1.2.1
### Bug fixes
- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow
- Fixed login window auto-closing before sign-in completes
- 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

+3 -3
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.1.2-beta.3", "version": "1.2.1-beta.21",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.2-beta.3/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.21/ClaudeChecker.zip",
"notes": "## What's new in v1.1.2\n\n### Cleaner card headers\nRemoved the redundant \"5h\" / \"7d\" window labels from each card row — the section headers already make it clear which limit is which." "notes": "## What's new in v1.2.1-beta.18\n\n### Bug fixes\n- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.\n- API calls are now sequential to share a single WebView for all navigations\n- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser\n\n## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls\n- Added diagnostic error messages for JS errors and unexpected responses\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow\n- Fixed login window auto-closing before sign-in completes\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.1.1", "version": "1.2.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.1/ClaudeChecker.zip", "url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
"notes": "## What's new in v1.1.1\n\n### Clearer limit labels\nThe 5hr and 7 Day usage cards now have clear section headers so it's immediately obvious which limit is which.\n\n### Login window fix\nThe sign-in sheet now reliably closes after a successful login. A **Done** button also appears as a fallback once authentication is detected — no more being stuck with only a Cancel button." "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"
} }