Compare commits

..
50 Commits
Author SHA1 Message Date
superdooper86 02440c5340 Add fake beta version 3.1.0-beta for testing 2026-05-07 16:06:23 +02:00
superdooper86 6fe3e9722b v3.0.0 — built-by credit fix, version bump 2026-05-07 16:03:23 +02:00
superdooper86 f5114a31f8 README: add notification details, split update section, remove internal beta notes 2026-05-07 15:59:28 +02:00
superdooper86 4f260a3d4b v2.0.10 — limit notifications, fix positioning, add notification screenshots 2026-05-07 15:57:06 +02:00
superdooper86 3d1bae3d0a v2.0.9 — limit notifications using custom floating window (same style as update notifications) 2026-05-07 15:44:38 +02:00
superdooper86 b95102cb1b v2.0.9 — limit notifications at 80%/95% and on reset 2026-05-07 15:40:13 +02:00
superdooper86 2b48114996 Rename screenshots to bust CDN cache 2026-05-07 15:29:47 +02:00
superdooper86 6a4bb73c4f v2.0.8 — Fix data source endpoints in settings, update screenshots 2026-05-07 15:28:08 +02:00
superdooper86 d2815d65ce v2.0.7 — Show account email in settings auth section 2026-05-07 15:21:35 +02:00
superdooper86 a1c3c0b314 v2.0.6 — Auth section shows sign-in status, re-authenticate and sign out buttons 2026-05-07 15:17:11 +02:00
superdooper86 d3ec3f5dd7 revert: restore v2.0.5 after testing 2026-05-07 14:47:04 +02:00
superdooper86 5fee0af18b temp: bump fake version to 9.9.10 2026-05-07 14:37:39 +02:00
superdooper86 4edca2962c temp: fake update for testing 2026-05-07 14:33:51 +02:00
superdooper86 ef2cdd8bf3 v2.0.5 — Fixed window size on open, update banner now clears when up to date 2026-05-07 14:28:33 +02:00
superdooper86 2da3eeaf72 Improve screenshots layout with grid table 2026-05-07 14:24:36 +02:00
superdooper86 d229581de1 Add screenshots to README 2026-05-07 14:21:26 +02:00
superdooper86 169cfd8950 revert: restore v2.0.4 after screenshot 2026-05-07 14:17:37 +02:00
superdooper86 a5218fa44f temp: bump version for screenshot 2026-05-07 14:16:08 +02:00
superdooper86 1f7afab61c Update Data Sources section with new billing endpoints 2026-05-07 14:06:46 +02:00
superdooper86 1507c6490f v2.0.4 — Show spent, limit and balance from dedicated billing endpoints 2026-05-07 14:04:19 +02:00
superdooper86 ccf3468621 v2.0.3 — Clean up extra usage display, remove non-existent balance field 2026-05-07 13:56:31 +02:00
superdooper86 3199b228a8 v2.0.2 — Show spent, limit, and balance in extra usage section 2026-05-07 13:46:36 +02:00
superdooper86 255f3f6cd5 v2.0.2 — Fix unlimited monthly limit display in extra usage section 2026-05-07 13:44:05 +02:00
superdooper86 773fcb1a46 Bump to v2.0.1 2026-05-07 13:39:18 +02:00
superdooper86 464884e88f Fix crash when extra_usage fields are null 2026-05-07 13:36:23 +02:00
superdooper86 edd8d42c8d Bump CFBundleShortVersionString to 2.0 2026-05-07 13:33:16 +02:00
superdooper86 e123b48812 Bump to v2.0 2026-05-07 13:22:06 +02:00
superdooper86 603d405c87 Fix icon URL in README to raw GitHub path 2026-05-07 13:19:25 +02:00
superdooper86 f976a90875 Add Xcode project source for v1.6 2026-05-07 13:18:00 +02:00
SuperDooper c79bd41504 Bump version to 1.6 and update download URL 2026-05-07 12:53:37 +02:00
SuperDooper dfbd276616 Remove instructions from README
Removed instructions
2026-05-07 12:38:27 +02:00
SuperDooper dbf14bdd70 Update contributors in README.md 2026-05-07 12:37:08 +02:00
SuperDooper 6c894c32a4 Create README.md for ClaudeChecker app
Add README for ClaudeChecker macOS app with features and installation instructions.
2026-05-07 12:36:35 +02:00
SuperDooper 345150e894 Update version to 1.5 with notification fixes 2026-05-07 12:32:57 +02:00
SuperDooper d724622533 Downgrade version from 1.5 to 1.4 2026-05-07 12:21:37 +02:00
SuperDooper 8ecb5f3670 Bump version to 1.5 and update release notes 2026-05-07 12:17:08 +02:00
SuperDooper cf6f9184e3 Remove beta version information from JSON 2026-05-07 12:16:49 +02:00
SuperDooper d734fec836 Update version to 1.4 and change download URL 2026-05-07 12:08:18 +02:00
SuperDooper b6542e099a Update version to 1.3 and change download URL 2026-05-07 11:56:24 +02:00
SuperDooper c4e798b484 Update download URL in version.json 2026-05-07 11:47:15 +02:00
SuperDooper 0dd1ac3a8f Update version.json for release 1.2
Updated the version URL and release notes for version 1.2.
2026-05-07 11:45:28 +02:00
SuperDooper 2d8a3629da Rename version-beta to version-beta.json 2026-05-07 11:35:10 +02:00
SuperDooper e07af519a6 Rename version to version.json 2026-05-07 11:35:00 +02:00
SuperDooper 6b1f85dbaf Update version to 1.2 2026-05-07 11:31:12 +02:00
SuperDooper 74fe785898 Add version information for beta release 2026-05-07 11:26:21 +02:00
SuperDooper 3de4e09876 Create version-beta 2026-05-07 11:24:20 +02:00
SuperDooper 43dbb155f5 Delete version-beta.json 2026-05-07 11:24:08 +02:00
SuperDooper 79209eee6f Create version-beta.json 2026-05-07 11:23:50 +02:00
SuperDooper d9598a70ea Add version file with release information 2026-05-07 10:59:12 +02:00
SuperDooper c8cae1827f Initial commit 2026-05-07 10:58:23 +02:00
24 changed files with 208 additions and 766 deletions
-30
View File
@@ -1,30 +0,0 @@
---
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
@@ -1,22 +0,0 @@
---
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
@@ -1,79 +0,0 @@
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
-111
View File
@@ -1,111 +0,0 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
if: ${{ !contains(github.ref_name, '-') }}
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: Compute SHA256
id: sha
run: echo "sha256=$(shasum -a 256 ClaudeChecker.zip | awk '{print $1}')" >> $GITHUB_OUTPUT
- name: Create GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "$GITHUB_REF_NAME" ClaudeChecker.zip \
--title "$GITHUB_REF_NAME" \
--notes "$(cat RELEASE_NOTES.md 2>/dev/null || echo 'Release ${{ steps.version.outputs.version }}')"
- name: Update version.json
run: |
notes=$(cat RELEASE_NOTES.md 2>/dev/null || echo "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.json
- name: Update Homebrew cask
run: |
sed -i '' \
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
../homebrew-tap/Casks/claudechecker.rb 2>/dev/null || true
- name: Update README badge
env:
NEW_VERSION: ${{ steps.version.outputs.version }}
run: |
python3 -c "
import os, re
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
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add version.json README.md
git commit -m "Release ${{ github.ref_name }}"
git pull --rebase origin main
git push origin HEAD:main
- name: Update Homebrew tap
env:
TAP_TOKEN: ${{ secrets.TAP_TOKEN }}
run: |
git clone https://x-access-token:${{ secrets.TAP_TOKEN }}@github.com/${{ github.repository_owner }}/homebrew-tap /tmp/homebrew-tap
sed -i '' \
-e 's/version "[^"]*"/version "${{ steps.version.outputs.version }}"/' \
-e 's/sha256 "[^"]*"/sha256 "${{ steps.sha.outputs.sha256 }}"/' \
/tmp/homebrew-tap/Casks/claudechecker.rb
cd /tmp/homebrew-tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Casks/claudechecker.rb
git commit -m "Update claudechecker to ${{ github.ref_name }}"
git push
-19
View File
@@ -1,19 +0,0 @@
# 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
+4 -22
View File
@@ -27,7 +27,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
// Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -40,17 +40,14 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Popover
popover = NSPopover()
popover?.contentSize = NSSize(width: 480, height: 640)
popover?.behavior = .transient
popover?.animates = true
let hc = NSHostingController(
popover?.contentViewController = NSHostingController(
rootView: ContentView()
.environmentObject(usageViewModel)
.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
usageViewModel.$limits
@@ -63,11 +60,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
updateManager.$updateAvailable
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
// Refresh timer
scheduleTimer(interval: usageViewModel.refreshInterval)
@@ -174,13 +166,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? ""
button.image = nil
button.attributedTitle = NSAttributedString(string: "")
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.title = "\(fhStr) \(sdStr)"
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
} else {
button.title = ""
@@ -213,7 +199,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc func showPopover() {
guard let button = statusItem?.button, let popover else { return }
if !popover.isShown {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}
@@ -224,7 +209,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
if popover.isShown {
popover.performClose(nil)
} else {
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}
@@ -239,5 +223,3 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}
}
+45 -95
View File
@@ -40,35 +40,20 @@ struct ContentView: View {
.environmentObject(updater)
.transition(.move(edge: .trailing).combined(with: .opacity))
} else {
VStack(spacing: 0) {
ScrollView(.vertical, showsIndicators: false) {
VStack(spacing: 0) {
// Cards grid
if vm.limits.isEmpty {
EmptyStateView()
.padding(.vertical, 40)
} else {
VStack(spacing: 8) {
VStack(spacing: 1) {
ForEach(vm.limits) { limit in
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)
}
LimitCard(limit: limit)
}
}
.padding(.bottom, 4)
.background(Color.primary.opacity(0.05))
.padding(.vertical, 1)
// Extra credits row
if let extra = vm.extraUsage, extra.isEnabled {
@@ -95,6 +80,7 @@ struct ContentView: View {
.padding(.bottom, 8)
}
}
}
}
.transition(.opacity)
@@ -108,16 +94,9 @@ struct ContentView: View {
}
.frame(width: 480)
.animation(.easeInOut(duration: 0.2), value: showSettings)
.onReceive(NotificationCenter.default.publisher(for: .popoverWillOpen)) { _ in
showSettings = false
}
.onReceive(NotificationCenter.default.publisher(for: .openUpdateSheet)) { _ in
showSettings = false
showUpdateSheet = true
}
.sheet(isPresented: $showLogin) {
LoginSheetView(isPresented: $showLogin) { webView in
Task { await vm.adoptAndRefresh(webView) }
LoginSheetView(isPresented: $showLogin) {
Task { await vm.refresh() }
}
}
.sheet(isPresented: $showUpdateSheet) {
@@ -210,7 +189,7 @@ struct LimitCard: View {
AgentIconView(agent: .claude, size: 14)
Text("Claude")
.font(.system(size: 13, weight: .semibold))
Text("· \(vm.planLabel)")
Text("· \(vm.planLabel) · \(limit.window.label)")
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
@@ -396,11 +375,12 @@ struct DiaryRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 5) {
HStack {
Text("\(totalSaved) samples")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
AgentIconView(agent: .claude, size: 12)
Text("Claude")
.font(.system(size: 12, weight: .medium))
.foregroundColor(color)
Spacer()
Text("Avg burn rate: \(String(format: "%.1f", burnRate))%/h")
Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h")
.font(.system(size: 10.5))
.foregroundColor(.secondary)
}
@@ -426,9 +406,7 @@ struct SparklineView: View {
var body: some View {
GeometryReader { geo in
let plotData = data.count == 1 ? [data[0], data[0]] : data
if plotData.count > 1 {
let data = plotData
if data.count > 1 {
let w = geo.size.width
let h = geo.size.height
let minV = data.min() ?? 0
@@ -788,23 +766,23 @@ struct SettingsView: View {
.padding(.vertical, 1)
.background(Color.primary.opacity(0.06))
.cornerRadius(4)
}
if updater.updateAvailable {
Text("v\(updater.latestVersion) available")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue)
.cornerRadius(4)
} else if updater.betaAvailable {
Text("v\(updater.latestBetaVersion) available")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.cornerRadius(4)
if updater.updateAvailable {
Text("v\(updater.latestVersion) available")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue)
.cornerRadius(4)
} else if updater.betaAvailable {
Text("v\(updater.latestBetaVersion) beta")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange)
.cornerRadius(4)
}
}
Text("Built by superdooper86 & claude")
.font(.system(size: 11))
@@ -911,12 +889,12 @@ struct ErrorBanner: View {
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 10) {
Image(systemName: "person.crop.circle.badge.questionmark")
Image(systemName: "key.slash")
.font(.system(size: 28))
.foregroundColor(.secondary)
Text("Not signed in")
Text("No API key configured")
.font(.system(size: 13, weight: .medium))
Text("Sign in to claude.ai to see your usage.")
Text("Open Settings to add your Anthropic API key.")
.font(.system(size: 11.5))
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
@@ -993,36 +971,6 @@ struct UpdateSuccessBanner: View {
}
}
// MARK: - Markdown notes renderer
struct ReleaseNotesText: View {
let text: String
var font: Font = .system(size: 12)
var body: some View {
let processed = text
.split(separator: "\n", omittingEmptySubsequences: false)
.map { line -> String in
let s = String(line)
if s.hasPrefix("### ") { return "**\(s.dropFirst(4))**" }
if s.hasPrefix("## ") { return "**\(s.dropFirst(3))**" }
if s.hasPrefix("# ") { return "**\(s.dropFirst(2))**" }
if s.hasPrefix("- ") { return "\(s.dropFirst(2))" }
return s
}
.joined(separator: "\n")
if let attr = try? AttributedString(markdown: processed,
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
Text(attr).font(font).foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
} else {
Text(text).font(font).foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// MARK: - Update Banner
struct UpdateBanner: View {
@@ -1037,9 +985,12 @@ struct UpdateBanner: View {
VStack(alignment: .leading, spacing: 1) {
Text("Update available — v\(updater.latestVersion)")
.font(.system(size: 12, weight: .semibold))
Text("Tap Update to view what's new & install.")
.font(.system(size: 11))
.foregroundColor(.secondary)
if !updater.releaseNotes.isEmpty {
Text(updater.releaseNotes)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Spacer()
Button("Update") { showSheet = true }
@@ -1050,7 +1001,6 @@ struct UpdateBanner: View {
.padding(.vertical, 10)
.background(Color.blue.opacity(0.08))
.overlay(Divider(), alignment: .bottom)
.onTapGesture { showSheet = true }
}
}
@@ -1099,7 +1049,9 @@ struct UpdateSheet: View {
.foregroundColor(.blue)
}
if !updater.releaseNotes.isEmpty {
ReleaseNotesText(text: updater.releaseNotes)
Text(updater.releaseNotes)
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
Spacer()
@@ -1168,5 +1120,3 @@ struct UpdateSheet: View {
.frame(width: 380)
}
}
+2 -2
View File
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.2.1-beta.19</string>
<string>3.0.0</string>
<key>CFBundleVersion</key>
<string>67</string>
<string>18</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
+22 -42
View File
@@ -4,7 +4,7 @@ import WebKit
// MARK: - Login Web View
struct LoginWebView: NSViewRepresentable {
let onAuthenticated: (WKWebView) -> Void
let onAuthenticated: () -> Void
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
@@ -12,13 +12,6 @@ struct LoginWebView: NSViewRepresentable {
let webView = WKWebView(frame: .zero, configuration: config)
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")!))
return webView
}
@@ -30,30 +23,24 @@ struct LoginWebView: NSViewRepresentable {
}
class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: (WKWebView) -> Void
weak var webView: WKWebView?
var didAuthenticate = false
var urlObservation: NSKeyValueObservation?
let onAuthenticated: () -> Void
init(onAuthenticated: @escaping (WKWebView) -> Void) {
init(onAuthenticated: @escaping () -> Void) {
self.onAuthenticated = onAuthenticated
}
func checkCurrentURL(_ url: String?) {
guard !didAuthenticate, let url, let wv = webView else { return }
// Ignore navigations to external OAuth providers (Google, etc.)
// only consider auth complete when we land back on claude.ai/anthropic.com
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
if url.contains("/login") || url.contains("/auth") { return }
didAuthenticate = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated(wv)
}
}
// Covers full cross-document navigations
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
checkCurrentURL(webView.url?.absoluteString)
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
let hasSession = cookies.contains {
$0.domain.contains("claude.ai") &&
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
}
if hasSession {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.onAuthenticated()
}
}
}
}
}
}
@@ -62,7 +49,7 @@ struct LoginWebView: NSViewRepresentable {
struct LoginSheetView: View {
@Binding var isPresented: Bool
let onDone: (WKWebView) -> Void
let onDone: () -> Void
@State private var authenticated = false
var body: some View {
@@ -71,15 +58,9 @@ struct LoginSheetView: View {
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
.font(.system(size: 13, weight: .semibold))
Spacer()
if authenticated {
Button("Done") { isPresented = false }
.buttonStyle(.borderedProminent)
.controlSize(.small)
} else {
Button("Cancel") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
Button("Cancel") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
@@ -87,13 +68,12 @@ struct LoginSheetView: View {
Divider()
LoginWebView { webView in
LoginWebView {
authenticated = true
// Adopt the authenticated WebView immediately (before sheet tears it down),
// then auto-dismiss after a moment so the user sees confirmation.
onDone(webView)
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
// Auto-dismiss and refresh after brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isPresented = false
onDone()
}
}
}
+8 -38
View File
@@ -46,8 +46,6 @@ class UpdateManager: ObservableObject {
@Published var updateComplete = false
@Published var justUpdated = false
private var notifiedVersion: String = ""
@Published var betaChannel: Bool = false {
didSet {
UserDefaults.standard.set(betaChannel, forKey: "beta_channel")
@@ -72,13 +70,14 @@ class UpdateManager: ObservableObject {
if let stable = await stableResult {
print("[UpdateManager] Stable remote=\(stable.version), isNewer=\(isNewer(stable.version, than: currentVersion))")
if isNewer(stable.version, than: currentVersion) {
let wasAvailable = updateAvailable
latestVersion = stable.version
releaseNotes = stable.notes ?? ""
downloadURL = stable.url
updateAvailable = true
print("[UpdateManager] updateAvailable set to true")
if notifiedVersion != stable.version {
notifiedVersion = stable.version
// Only notify once when first detected
if !wasAvailable {
NotificationCenter.default.post(name: .updateDetected, object: stable.version)
}
} else {
@@ -87,8 +86,6 @@ class UpdateManager: ObservableObject {
}
} else {
print("[UpdateManager] Stable fetch returned nil")
updateAvailable = false
latestVersion = ""
}
if let beta = await betaResult {
@@ -101,19 +98,10 @@ class UpdateManager: ObservableObject {
releaseNotes = beta.notes ?? ""
downloadURL = beta.url
updateAvailable = true
if notifiedVersion != beta.version {
notifiedVersion = beta.version
NotificationCenter.default.post(name: .updateDetected, object: beta.version)
}
}
} else {
betaAvailable = false
latestBetaVersion = ""
}
} else {
print("[UpdateManager] Beta fetch returned nil")
betaAvailable = false
latestBetaVersion = ""
}
}
@@ -318,31 +306,13 @@ class UpdateManager: ObservableObject {
}
private func isNewer(_ version: String, than current: String) -> Bool {
func parse(_ v: String) -> (base: [Int], pre: [Int]?) {
guard !v.isEmpty else { return ([], nil) }
let halves = v.split(separator: "-", maxSplits: 1)
let base = halves[0].split(separator: ".").compactMap { Int($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
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } }
let a = toInts(version), b = toInts(current)
for i in 0..<max(a.count, b.count) {
let av = i < a.count ? a[i] : 0
let bv = i < b.count ? b[i] : 0
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
}
}
+28 -51
View File
@@ -14,7 +14,7 @@ class UpdateNotificationWindowController: NSWindowController {
// Called when a new update is detected while running
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
showWindow(version: version, subtitle: "Tap to view release notes & update now!", near: statusItem, isUpdate: true)
showWindow(version: version, subtitle: notes.isEmpty ? "Tap to update now." : notes, near: statusItem, isUpdate: true)
}
static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) {
@@ -39,12 +39,7 @@ class UpdateNotificationWindowController: NSWindowController {
sharedLimit?.close()
let content = UpdateNotificationView(
version: "", subtitle: subtitle, isUpdate: false,
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor,
onTap: {
sharedLimit?.close()
sharedLimit = nil
NotificationCenter.default.post(name: .showPopover, object: nil)
}
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor
) {
sharedLimit?.close()
sharedLimit = nil
@@ -58,16 +53,7 @@ class UpdateNotificationWindowController: NSWindowController {
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
shared?.close()
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate,
onTap: {
shared?.close()
shared = nil
NotificationCenter.default.post(name: .showPopover, object: nil)
if isUpdate {
NotificationCenter.default.post(name: .openUpdateSheet, object: nil)
}
}
) {
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) {
shared?.close()
shared = nil
}
@@ -143,7 +129,6 @@ struct UpdateNotificationView: View {
var titleText: String? = nil
var badgeIcon: String? = nil
var badgeColor: Color? = nil
var onTap: (() -> Void)? = nil
let onDismiss: () -> Void
private var effectiveTitle: String {
@@ -157,40 +142,30 @@ struct UpdateNotificationView: View {
}
var body: some View {
ZStack(alignment: .topTrailing) {
Button(action: { onTap?() }) {
HStack(spacing: 12) {
ZStack(alignment: .bottomTrailing) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 44, height: 44)
.cornerRadius(10)
HStack(spacing: 12) {
ZStack(alignment: .bottomTrailing) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 44, height: 44)
.cornerRadius(10)
Image(systemName: effectiveBadgeIcon)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(effectiveBadgeColor)
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
.offset(x: 4, y: 4)
}
VStack(alignment: .leading, spacing: 3) {
Text(effectiveTitle)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.primary)
Text(subtitle)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 24)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(width: 340, alignment: .leading)
.contentShape(Rectangle())
Image(systemName: effectiveBadgeIcon)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(effectiveBadgeColor)
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
.offset(x: 4, y: 4)
}
.buttonStyle(.plain)
VStack(alignment: .leading, spacing: 3) {
Text(effectiveTitle)
.font(.system(size: 13, weight: .semibold))
Text(subtitle)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Button(action: onDismiss) {
Image(systemName: "xmark")
@@ -198,8 +173,10 @@ struct UpdateNotificationView: View {
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.padding(14)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(width: 340, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 13)
.fill(Color(nsColor: .windowBackgroundColor))
+76 -202
View File
@@ -3,6 +3,7 @@ import WebKit
@MainActor
class UsageViewModel: ObservableObject {
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
@Published var limits: [AgentLimit] = []
@Published var isLoading = false
@Published var lastUpdated: Date?
@@ -27,85 +28,24 @@ class UsageViewModel: ObservableObject {
private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:]
// Background WKWebView for API calls via WebKit navigation.
// Hosted in a hidden NSWindow to keep the WebKit process active.
private var apiWebView: WKWebView?
private var apiWindow: NSWindow?
private var currentFetchDelegate: APIFetchDelegate?
init() {
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
refreshInterval = saved > 0 ? saved : 60
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()
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)
apiWebView = wv
// Hosting in a 1×1 transparent window keeps the WebKit process active.
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
}
// Called after login: adopt the login WebView (proven authenticated) and refresh.
func adoptAndRefresh(_ loginWebView: WKWebView) async {
loginWebView.removeFromSuperview()
loginWebView.frame = NSRect(x: 0, y: 0, width: 1, height: 1)
apiWindow?.contentView?.addSubview(loginWebView)
apiWebView = loginWebView
try? await Task.sleep(nanoseconds: 400_000_000)
await refresh()
}
// Fetches an API path by navigating the WebView to the URL and reading the response.
// Navigation lets WebKit send full browser headers and cookies automatically
// more reliable than callAsyncJavaScript fetch, which bypasses SPA auth interceptors.
private func webViewFetch(_ path: String) async throws -> (Int, String) {
guard let wv = apiWebView else { throw AppError.detail("no api webview") }
return try await withCheckedThrowingContinuation { cont in
let delegate = APIFetchDelegate(continuation: cont)
currentFetchDelegate = delegate
wv.navigationDelegate = delegate
wv.load(URLRequest(url: URL(string: "https://claude.ai\(path)")!))
}
}
private func checkInitialSignInState() async {
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
if !cookies.isEmpty { isSignedIn = true }
}
func signOut() async {
let store = WKWebsiteDataStore.default()
let types = WKWebsiteDataStore.allWebsiteDataTypes()
let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
await store.removeData(ofTypes: types, for: claudeRecords)
UserDefaults.standard.removeObject(forKey: "claude_org_id")
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
@@ -114,7 +54,6 @@ class UsageViewModel: ObservableObject {
extraUsage = nil
prepaidCredits = nil
overageSpendLimit = nil
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
}
func refresh() async {
@@ -124,29 +63,18 @@ class UsageViewModel: ObservableObject {
defer { isLoading = false }
do {
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
let orgId: String
if let id = fetchedOrgId {
UserDefaults.standard.set(id, forKey: "claude_org_id")
orgId = id
} 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 }
// Sequential each call navigates the shared WebView to the next API URL.
let usage = try await fetchUsage(orgId: orgId)
let prepaid = try? await fetchPrepaidCredits(orgId: orgId)
let overage = try? await fetchOverageSpendLimit(orgId: orgId)
let orgId = cachedOrgId!
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
async let emailFetch = fetchUserEmail()
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
if let email { userEmail = email }
limits = buildLimits(from: usage)
extraUsage = usage.extraUsage
prepaidCredits = prepaid
extraUsage = usage.extraUsage
prepaidCredits = prepaid
overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date()
isSignedIn = true
@@ -156,13 +84,11 @@ class UsageViewModel: ObservableObject {
history.append(limits[i].usedPercent)
if history.count > maxHistorySamples { history.removeFirst() }
burnHistoryStore[key] = history
limits[i].burnHistory = history
if history.count > 1 { limits[i].burnHistory = history }
}
UserDefaults.standard.set(burnHistoryStore, forKey: "burn_history")
checkLimitNotifications(for: limits)
} catch AppError.notAuthenticated {
isNotAuthenticated = true
isSignedIn = false
errorMessage = "Not signed in"
} catch let error as DecodingError {
switch error {
@@ -180,71 +106,70 @@ 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.detail("bootstrap \(status): \(body.prefix(80))") }
// If WebKit followed a redirect to the login page we get HTML instead of JSON.
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
throw AppError.notAuthenticated
}
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
private func fetchUsage(orgId: String) async throws -> UsageResponse {
let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
if status == 401 || status == 403 { throw AppError.notAuthenticated }
guard status == 200 else { throw AppError.networkError }
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
// 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)
}
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? {
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
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 }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
}
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
status == 200,
let data = body.data(using: .utf8) else { return nil }
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
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 }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
}
@@ -316,11 +241,14 @@ class UsageViewModel: ObservableObject {
private func checkLimitNotifications(for limits: [AgentLimit]) {
let thresholds = [80, 95]
for limit in limits {
let key = limit.window.rawValue
let curr = limit.usedPercent
let prev = previousPercents[key]
var fired = firedThresholds[key] ?? []
// Threshold warnings (80% and 95%)
for t in thresholds {
let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
@@ -331,6 +259,8 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName, "percent": t]
)
}
// Reset detection: was high, now low
if let prev, prev > 50, curr < 20 {
fired.removeAll()
NotificationCenter.default.post(
@@ -339,6 +269,7 @@ class UsageViewModel: ObservableObject {
userInfo: ["windowName": limit.window.displayName]
)
}
firedThresholds[key] = fired
previousPercents[key] = curr
}
@@ -361,68 +292,13 @@ class UsageViewModel: ObservableObject {
}
}
// MARK: - API Fetch Delegate
// Captures the HTTP status code and response body from a WebView navigation.
// Used by webViewFetch to turn a navigation into an async (statusCode, body) result.
private class APIFetchDelegate: NSObject, WKNavigationDelegate {
private let continuation: CheckedContinuation<(Int, String), Error>
private var capturedStatus = 0
private var finished = false
init(continuation: CheckedContinuation<(Int, String), Error>) {
self.continuation = continuation
}
private func complete(_ result: Result<(Int, String), Error>) {
guard !finished else { return }
finished = true
continuation.resume(with: result)
}
func webView(_ webView: WKWebView, decidePolicyFor response: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
if let http = response.response as? HTTPURLResponse {
capturedStatus = http.statusCode
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let status = capturedStatus
// Detect auth redirect: if WebKit followed a 302 to /login, finalURL changes.
let finalURL = webView.url?.absoluteString ?? ""
if finalURL.contains("/login") || finalURL.contains("/auth") {
complete(.success((401, "redirected:\(finalURL)")))
return
}
webView.evaluateJavaScript("document.body.innerText ?? ''") { [weak self] result, error in
if let body = result as? String {
self?.complete(.success((status, body)))
} else {
self?.complete(.failure(AppError.detail("body read: \(error?.localizedDescription ?? "nil")")))
}
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
complete(.failure(AppError.detail("nav: \(error.localizedDescription.prefix(80))")))
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
complete(.failure(AppError.detail("prov: \(error.localizedDescription.prefix(80))")))
}
}
enum AppError: LocalizedError {
case notAuthenticated
case networkError
case detail(String)
var errorDescription: String? {
switch self {
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data."
case .detail(let msg): return msg
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data."
}
}
}
@@ -431,9 +307,7 @@ extension Notification.Name {
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
static let closePopover = Notification.Name("closePopover")
static let showPopover = Notification.Name("showPopover")
static let popoverWillOpen = Notification.Name("popoverWillOpen")
static let updateDetected = Notification.Name("updateDetected")
static let limitWarning = Notification.Name("limitWarning")
static let limitReset = Notification.Name("limitReset")
static let openUpdateSheet = Notification.Name("openUpdateSheet")
}
+17 -22
View File
@@ -8,9 +8,8 @@
[![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)
[![Version](https://img.shields.io/badge/version-1.2.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![Version](https://img.shields.io/badge/version-3.0.0-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases)
[![License](https://img.shields.io/badge/license-MIT-blue?style=flat)](LICENSE)
[![Beta](https://img.shields.io/badge/beta-1.2.1--beta.17-orange?style=flat)](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- BETA_BADGE -->
</div>
@@ -30,21 +29,21 @@ No API key required. It reads directly from your `claude.ai` session using a bui
<table>
<tr>
<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-v3.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
<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/settings-v2.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
</tr>
<tr>
<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/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>
<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/notification.png" width="280" alt="Update notification" /><br/><sub>System notification</sub></td>
</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-95.png" width="280" alt="95% limit warning" /><br/><sub>95% limit warning</sub></td>
</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>
<br/>
@@ -114,29 +113,25 @@ No API key required. It reads directly from your `claude.ai` session using a bui
## Requirements
- macOS 13 Ventura or later
- Xcode 15+ (to build from source)
- A claude.ai account
---
## Installation
### Homebrew (easiest)
```bash
brew tap superdooper86/tap
brew install --cask claudechecker
```
### Download
### Download (recommended)
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
2. Download `ClaudeChecker.zip` from the latest release
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
4. Open from Applications — it will appear in your menubar
> **macOS security prompt?** If macOS blocks the app on first launch, run this in Terminal:
> ```bash
> xattr -cr /Applications/ClaudeChecker.app
> ```
> This is not needed when installing via Homebrew.
### Build from source
```bash
git clone https://github.com/superdooper86/claudechecker.git
open ClaudeChecker/ClaudeChecker.xcodeproj
```
Then in Xcode: set your Team under Signing & Capabilities, then **⌘R** to run.
---
-20
View File
@@ -1,20 +0,0 @@
## What's new in v1.2.1-beta.18
### Bug fixes
- 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.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

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.2.1-beta.17",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/ClaudeChecker.zip",
"notes": "## 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 — 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\n- Added diagnostic error messages: JS errors, WebView URL, and unexpected response types are now surfaced in the error banner to aid future debugging\n\n## 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"
"version": "3.1.0-beta",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v3.0.0/ClaudeChecker.zip",
"notes": "Beta test build."
}
-5
View File
@@ -1,5 +0,0 @@
{
"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.2.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
"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"
"version": "3.0.0",
"url": "https://github.com/superdooper86/claudechecker/releases/download/v3.0.0/ClaudeChecker.zip",
"notes": "Limit notifications for 80% and 95% usage, and on quota reset."
}