Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f48b53fd8e | ||
|
|
ccfee938b7 | ||
|
|
28d952bcbd | ||
|
|
9bc023d239 | ||
|
|
fd82177a7d | ||
|
|
112210a99b | ||
|
|
f4a83940b1 | ||
|
|
377888a427 | ||
|
|
e24113a78b | ||
|
|
3baab91f70 | ||
|
|
0aa8837b2f | ||
|
|
373ca1dc14 | ||
|
|
7a4c21768f | ||
|
|
7b26629dc7 | ||
|
|
e446ea97f9 | ||
|
|
36af325e2d | ||
|
|
e2ef8b6f2a | ||
|
|
7a4975fa3e | ||
|
|
c9912f8463 | ||
|
|
238614a94b | ||
|
|
b11507221f | ||
|
|
b6fef53264 | ||
|
|
ec6ae6621a | ||
|
|
105a7706fa | ||
|
|
a950392a6f | ||
|
|
8087cc029d | ||
|
|
11f9e0c9b6 | ||
|
|
cc835ee2b0 | ||
|
|
8e7328b2c5 | ||
|
|
709eb12382 | ||
|
|
e866324a48 | ||
|
|
a62584221b | ||
|
|
fc7fd19652 | ||
|
|
58b947e515 | ||
|
|
88252e4d59 | ||
|
|
ad2ff3a7b6 | ||
|
|
1a9d9cd22a | ||
|
|
b8826ace9b | ||
|
|
2a0de269ff | ||
|
|
ac7ffe81af | ||
|
|
f28f7b8a5a | ||
|
|
aaed64484c | ||
|
|
2e3ee350ca | ||
|
|
e939bdb88b | ||
|
|
6749c5f158 | ||
|
|
de71ec2794 | ||
|
|
ac17ce154f | ||
|
|
d2eac62897 | ||
|
|
8c64fc50ad | ||
|
|
b2c138f665 | ||
|
|
aa6c299e2a | ||
|
|
ecbae7d7ca | ||
|
|
37a039e1d3 | ||
|
|
8f3ead2f65 | ||
|
|
734670bb3f | ||
|
|
94430e00ba | ||
|
|
7b0368b001 | ||
|
|
4629aeffbc | ||
|
|
2cb4ddfe97 | ||
|
|
73b251a1e9 | ||
|
|
7bab6a0df0 | ||
|
|
db6380fa66 | ||
|
|
47a148fa67 | ||
|
|
85ac329d36 | ||
|
|
a0fb6eabcf | ||
|
|
b778d03323 | ||
|
|
13387888b2 | ||
|
|
44be1649a1 | ||
|
|
1160712e1b | ||
|
|
e4f55c80ee | ||
|
|
526752dc36 | ||
|
|
a2d983b555 | ||
|
|
0d807c2285 | ||
|
|
1c42433145 | ||
|
|
c0ac2e5a66 | ||
|
|
405e65f9f7 | ||
|
|
1c7433ebea | ||
|
|
a39ef800e8 | ||
|
|
76dec8b85d | ||
|
|
2f04f8581c | ||
|
|
788c066a1e | ||
|
|
31280d4ab2 | ||
|
|
7b1c5bbf8e | ||
|
|
f2ba44bbaa | ||
|
|
de1fffe0e3 | ||
|
|
e80f07d1b9 | ||
|
|
e9ca8a7efc | ||
|
|
63ef2005a4 | ||
|
|
5ad7bb26f0 | ||
|
|
132a2a8f8d | ||
|
|
debd1c1850 | ||
|
|
9bcbb2ae7d | ||
|
|
c9384f6424 | ||
|
|
e4693fc18f | ||
|
|
84a8592bae | ||
|
|
bdd2038364 | ||
|
|
fd61face77 | ||
|
|
1f58a81a26 | ||
|
|
7f6da51860 | ||
|
|
b49ac23863 | ||
|
|
01d268c3e5 | ||
|
|
871d5245ac | ||
|
|
0524f9b791 | ||
|
|
d68d564351 | ||
|
|
d55bad01db | ||
|
|
7bc72e240b | ||
|
|
d77ed23d09 | ||
|
|
6136b0ded0 | ||
|
|
aba7168448 | ||
|
|
a8c2083397 | ||
|
|
ad23446ed4 | ||
|
|
d8b75d279c | ||
|
|
2658ff7f6a | ||
|
|
e10d79e5a6 | ||
|
|
88daa27630 | ||
|
|
b7c4958a74 | ||
|
|
f46b13d967 | ||
|
|
c449882ec6 | ||
|
|
3c1a2ef1be | ||
|
|
c238db963b | ||
|
|
63c9a8582d | ||
|
|
6f256b3308 | ||
|
|
2761b31a12 | ||
|
|
f9d8abebd5 | ||
|
|
66ee233b1b | ||
|
|
a12c664bde | ||
|
|
c7251d8785 | ||
|
|
7a95b100ef | ||
|
|
fb98c69c34 | ||
|
|
5629c6b61e | ||
|
|
4709f5b0f5 | ||
|
|
5f8aeaa6e9 | ||
|
|
27f1f45109 | ||
|
|
7a5512cc28 | ||
|
|
8c89ce4636 | ||
|
|
3a964dc2f8 | ||
|
|
b553d3922a | ||
|
|
61e8455e5e | ||
|
|
c4b9d7454f | ||
|
|
75aeb03332 | ||
|
|
f5203a0299 | ||
|
|
fe27e0d4c0 | ||
|
|
fb5e1ece25 | ||
|
|
71666224f1 | ||
|
|
bd693cdf94 | ||
|
|
d24a354e1c | ||
|
|
9e47041715 | ||
|
|
22151f0af4 | ||
|
|
3508fa1a94 | ||
|
|
b2fcafd3f0 | ||
|
|
45215de350 | ||
|
|
f969da8006 | ||
|
|
7f769414cc | ||
|
|
db707340bb | ||
|
|
be6bb1495d | ||
|
|
06755de91d | ||
|
|
2855155361 | ||
|
|
ecfb1aa44d | ||
|
|
8b35e5df56 | ||
|
|
794a6cdb65 | ||
|
|
e613ace503 | ||
|
|
38d7683843 | ||
|
|
802cf14a71 | ||
|
|
ae736a7e4a | ||
|
|
a1bb186af2 | ||
|
|
e93a4e6df5 | ||
|
|
a3fbce3f0e | ||
|
|
4b4280630c | ||
|
|
90acbcb968 | ||
|
|
f2f2be1305 | ||
|
|
f17694622c | ||
|
|
aa10f77776 | ||
|
|
cb00ddc806 | ||
|
|
1f357b9123 | ||
|
|
9dee674ff0 | ||
|
|
0025690a74 | ||
|
|
7b03909c55 | ||
|
|
c3ea2be809 | ||
|
|
6f6a7361fe | ||
|
|
d9d65da6b7 | ||
|
|
c18054a810 | ||
|
|
032951882f | ||
|
|
ed186af2c6 | ||
|
|
d3dabc7843 | ||
|
|
987bd36137 | ||
|
|
cfcb74f32b | ||
|
|
273e10566e | ||
|
|
76a8f433a7 | ||
|
|
65a171f296 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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="[](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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -912,12 +911,12 @@ struct ErrorBanner: View {
|
|||||||
struct EmptyStateView: View {
|
struct EmptyStateView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "key.slash")
|
Image(systemName: "person.crop.circle.badge.questionmark")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("No API key configured")
|
Text("Not signed in")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
Text("Open Settings to add your Anthropic API key.")
|
Text("Sign in to claude.ai to see your usage.")
|
||||||
.font(.system(size: 11.5))
|
.font(.system(size: 11.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -1038,9 +1037,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 }
|
||||||
@@ -1051,6 +1050,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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1168,3 +1168,5 @@ struct UpdateSheet: View {
|
|||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.5</string>
|
<string>1.2.1-beta.18</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>35</string>
|
<string>66</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,85 @@ class UsageViewModel: ObservableObject {
|
|||||||
|
|
||||||
private var burnHistoryStore: [String: [Double]] = [:]
|
private var burnHistoryStore: [String: [Double]] = [:]
|
||||||
private let maxHistorySamples = 24
|
private let maxHistorySamples = 24
|
||||||
private var cachedOrgId: String?
|
|
||||||
private var previousPercents: [String: Double] = [:]
|
private var previousPercents: [String: Double] = [:]
|
||||||
private var firedThresholds: [String: Set<Int>] = [:]
|
private var firedThresholds: [String: Set<Int>] = [:]
|
||||||
|
|
||||||
|
// Background WKWebView for API calls via 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() {
|
init() {
|
||||||
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
|
|
||||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||||
refreshInterval = saved > 0 ? saved : 60
|
refreshInterval = saved > 0 ? saved : 60
|
||||||
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
||||||
|
if let saved = UserDefaults.standard.object(forKey: "burn_history") as? [String: [Double]] {
|
||||||
|
burnHistoryStore = saved
|
||||||
|
}
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
|
setupAPIWebView()
|
||||||
|
Task { await checkInitialSignInState() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background API WebView
|
||||||
|
|
||||||
|
private func setupAPIWebView() {
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
|
let wv = WKWebView(frame: NSRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
||||||
|
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 -> (statusCode: Int, body: 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 {
|
func signOut() async {
|
||||||
let store = WKWebsiteDataStore.default()
|
let store = WKWebsiteDataStore.default()
|
||||||
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||||
let records = await store.dataRecords(ofTypes: types)
|
let records = await store.dataRecords(ofTypes: types)
|
||||||
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
|
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
|
||||||
await store.removeData(ofTypes: types, for: claudeRecords)
|
await store.removeData(ofTypes: types, for: claudeRecords)
|
||||||
|
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
lastUpdated = nil
|
lastUpdated = nil
|
||||||
@@ -54,6 +114,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
extraUsage = nil
|
extraUsage = nil
|
||||||
prepaidCredits = nil
|
prepaidCredits = nil
|
||||||
overageSpendLimit = nil
|
overageSpendLimit = nil
|
||||||
|
apiWebView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
||||||
}
|
}
|
||||||
|
|
||||||
func refresh() async {
|
func refresh() async {
|
||||||
@@ -63,18 +124,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 }
|
||||||
|
|
||||||
|
// 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)
|
||||||
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 +156,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 +180,71 @@ 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
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
let (status, body) = try await webViewFetch("/api/organizations/\(orgId)/usage")
|
||||||
var req = URLRequest(url: url)
|
if status == 401 || status == 403 { throw AppError.notAuthenticated }
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
guard status == 200 else { throw AppError.networkError }
|
||||||
|
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
||||||
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
|
||||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
|
||||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
|
||||||
|
|
||||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchUserEmail() async throws -> String? {
|
|
||||||
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
|
||||||
var req = URLRequest(url: url)
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
||||||
let account = json["account"] as? [String: Any],
|
|
||||||
let email = account["email_address"] as? String else { return nil }
|
|
||||||
return email
|
|
||||||
}
|
|
||||||
|
|
||||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
||||||
var req = URLRequest(url: url)
|
status == 200,
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
guard let (status, body) = try? await webViewFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
||||||
var req = URLRequest(url: url)
|
status == 200,
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
let data = body.data(using: .utf8) else { return nil }
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
|
||||||
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
|
||||||
req.setValue(header, forHTTPHeaderField: "Cookie")
|
|
||||||
}
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
|
||||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,14 +316,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 +331,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 +339,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 +361,68 @@ 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 {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
|
|
||||||
[](https://www.apple.com/macos/)
|
[](https://www.apple.com/macos/)
|
||||||
[](https://swift.org)
|
[](https://swift.org)
|
||||||
[](https://github.com/superdooper86/claudechecker/releases)
|
[](https://github.com/superdooper86/claudechecker/releases)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
|
[](https://github.com/superdooper86/claudechecker/releases/tag/v1.2.1-beta.17) <!-- 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/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.2-beta.4",
|
"version": "1.2.1-beta.17",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.2-beta.4/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.17/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.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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||