Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,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)
|
||||||
@@ -429,7 +429,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
|
||||||
@@ -1038,9 +1040,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 +1053,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 +1171,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.7</string>
|
<string>1.1.4-beta.5</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>35</string>
|
<string>45</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -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,15 +27,16 @@ 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>] = [:]
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
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") }
|
||||||
await store.removeData(ofTypes: types, for: claudeRecords)
|
await store.removeData(ofTypes: types, for: claudeRecords)
|
||||||
|
UserDefaults.standard.removeObject(forKey: "claude_org_id")
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
lastUpdated = nil
|
lastUpdated = nil
|
||||||
@@ -63,18 +64,29 @@ class UsageViewModel: ObservableObject {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let orgId = cachedOrgId!
|
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
|
||||||
async let usageFetch = fetchUsage(orgId: orgId)
|
|
||||||
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
let orgId: String
|
||||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
if let id = fetchedOrgId {
|
||||||
async let emailFetch = fetchUserEmail()
|
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
||||||
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
orgId = id
|
||||||
if let email { userEmail = email }
|
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
||||||
|
orgId = cached
|
||||||
|
} else {
|
||||||
|
throw AppError.notAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
if let email = fetchedEmail { userEmail = email }
|
||||||
|
if let plan = fetchedPlan { planLabel = plan }
|
||||||
|
|
||||||
|
async let usageFetch = fetchUsage(orgId: orgId)
|
||||||
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||||
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
|
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
||||||
limits = buildLimits(from: usage)
|
limits = buildLimits(from: usage)
|
||||||
extraUsage = usage.extraUsage
|
extraUsage = usage.extraUsage
|
||||||
prepaidCredits = prepaid
|
prepaidCredits = prepaid
|
||||||
overageSpendLimit = overage
|
overageSpendLimit = overage
|
||||||
planLabel = "Pro"
|
|
||||||
lastUpdated = Date()
|
lastUpdated = Date()
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
|
|
||||||
@@ -84,8 +96,9 @@ 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
|
||||||
@@ -106,54 +119,71 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Bootstrap (org ID + email + plan label in one call)
|
||||||
|
|
||||||
|
private func fetchBootstrap() async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
||||||
|
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
guard let cookie = await claudeCookieHeader() else { throw AppError.notAuthenticated }
|
||||||
|
req.setValue(cookie, 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 }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return (nil, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
let account = json["account"] as? [String: Any]
|
||||||
|
// memberships may live under account or at root (older API shape)
|
||||||
|
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
|
||||||
|
let firstOrg = memberships?.first?["organization"] as? [String: Any]
|
||||||
|
|
||||||
|
// org ID — primary path then flat-list fallback
|
||||||
|
var orgId: String? = firstOrg?["uuid"] as? String
|
||||||
|
if orgId == nil {
|
||||||
|
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
||||||
|
}
|
||||||
|
|
||||||
|
let email = account?["email_address"] as? String
|
||||||
|
|
||||||
|
// plan label from capabilities e.g. "claude_pro" -> "Pro"
|
||||||
|
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 claudeCookieHeader() async -> String? {
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
return HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"]
|
||||||
|
}
|
||||||
|
|
||||||
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 url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||||||
// 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)
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||||
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
||||||
guard http.statusCode == 200 else { throw AppError.networkError }
|
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")!
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||||||
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)
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
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)
|
||||||
@@ -163,11 +193,7 @@ class UsageViewModel: ObservableObject {
|
|||||||
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
||||||
var req = URLRequest(url: url)
|
var req = URLRequest(url: url)
|
||||||
req.setValue("application/json", forHTTPHeaderField: "accept")
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
||||||
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)
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
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 +267,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 +282,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 +290,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.1.4-beta.4) <!-- 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,4 @@
|
|||||||
## What's new in v1.1.2
|
## What's new in v1.1.4-beta.5
|
||||||
|
|
||||||
### Cleaner card headers
|
### Improvements
|
||||||
Removed the redundant "5h" / "7d" window labels from each card row — the section headers already make it clear which limit is which.
|
- Bootstrap API call consolidated — org ID, email, and plan name now fetched in a single request per 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.6",
|
"version": "1.1.4-beta.4",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.2-beta.6/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.4-beta.4/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.1.4-beta.4\n\n### Bug fix\n- Plan name now updates correctly on every refresh (was only set on first launch)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1-beta.35",
|
||||||
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/win-v0.0.1-beta.35/ClaudeChecker-Windows.zip",
|
||||||
|
"notes": "## What's new in beta.10\r\n\r\n### Bug fixes\r\n- Limits now update correctly on each refresh cycle\r\n- Session expiry is detected and prompts re-authentication instead of silently showing stale data\r\n- Plan name (e.g. Pro, Max) is now read from the API instead of being hardcoded"
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.1.1",
|
"version": "1.1.3",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.1/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.3/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.1.3\n\n### UI polish\n- Limit headers now read \"5 Hour Limit\" and \"7 Day Limit\"\n- Tapping anywhere on the blue update banner opens the update window\n\n### Session Diary fix\n- Burn history is persisted across app launches — the sparkline populates immediately on first open instead of requiring a manual refresh\n\n### Window sizing fixes\n- The popover now resizes correctly when switching between the main view and settings\n- Background colour no longer shows through during the resize animation"
|
||||||
}
|
}
|
||||||
|
|||||||