Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c72a7923e3 | ||
|
|
42e36372db | ||
|
|
ce1b5c0a50 | ||
|
|
30b64683e0 | ||
|
|
02440c5340 | ||
|
|
6fe3e9722b | ||
|
|
f5114a31f8 | ||
|
|
4f260a3d4b | ||
|
|
3d1bae3d0a | ||
|
|
b95102cb1b | ||
|
|
2b48114996 | ||
|
|
6a4bb73c4f | ||
|
|
d2815d65ce | ||
|
|
a1c3c0b314 | ||
|
|
d3ec3f5dd7 | ||
|
|
5fee0af18b | ||
|
|
4edca2962c | ||
|
|
ef2cdd8bf3 | ||
|
|
2da3eeaf72 | ||
|
|
d229581de1 | ||
|
|
169cfd8950 | ||
|
|
a5218fa44f | ||
|
|
1f7afab61c | ||
|
|
1507c6490f | ||
|
|
ccf3468621 | ||
|
|
3199b228a8 | ||
|
|
255f3f6cd5 | ||
|
|
773fcb1a46 | ||
|
|
464884e88f | ||
|
|
edd8d42c8d | ||
|
|
e123b48812 | ||
|
|
603d405c87 | ||
|
|
f976a90875 | ||
|
|
c79bd41504 | ||
|
|
dfbd276616 | ||
|
|
dbf14bdd70 | ||
|
|
6c894c32a4 | ||
|
|
345150e894 | ||
|
|
d724622533 | ||
|
|
8ecb5f3670 | ||
|
|
cf6f9184e3 | ||
|
|
d734fec836 | ||
|
|
b6542e099a | ||
|
|
c4e798b484 | ||
|
|
0dd1ac3a8f | ||
|
|
2d8a3629da | ||
|
|
e07af519a6 | ||
|
|
6b1f85dbaf | ||
|
|
74fe785898 | ||
|
|
3de4e09876 | ||
|
|
43dbb155f5 | ||
|
|
79209eee6f | ||
|
|
d9598a70ea | ||
|
|
c8cae1827f |
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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="[](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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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")!))
|
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
|
||||||
|
|
||||||
// Status item
|
// Status item
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
@@ -40,17 +40,14 @@ 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
|
||||||
let hc = NSHostingController(
|
popover?.contentViewController = 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
|
||||||
@@ -63,11 +60,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
.sink { [weak self] _ in self?.updateStatusItem() }
|
.sink { [weak self] _ in self?.updateStatusItem() }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
updateManager.$updateAvailable
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in self?.updateStatusItem() }
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Refresh timer
|
// Refresh timer
|
||||||
scheduleTimer(interval: usageViewModel.refreshInterval)
|
scheduleTimer(interval: usageViewModel.refreshInterval)
|
||||||
|
|
||||||
@@ -174,13 +166,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
||||||
button.image = nil
|
button.image = nil
|
||||||
button.attributedTitle = NSAttributedString(string: "")
|
button.attributedTitle = NSAttributedString(string: "")
|
||||||
let updatePrefix = updateManager.updateAvailable ? "↑ Update " : "◔ "
|
button.title = "◔ \(fhStr) \(sdStr)"
|
||||||
button.title = "\(updatePrefix)\(fhStr) \(sdStr)"
|
|
||||||
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
|
||||||
} else if updateManager.updateAvailable {
|
|
||||||
button.image = nil
|
|
||||||
button.attributedTitle = NSAttributedString(string: "")
|
|
||||||
button.title = "↑ Update"
|
|
||||||
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
||||||
} else {
|
} else {
|
||||||
button.title = ""
|
button.title = ""
|
||||||
@@ -213,7 +199,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
@objc func showPopover() {
|
@objc func showPopover() {
|
||||||
guard let button = statusItem?.button, let popover else { return }
|
guard let button = statusItem?.button, let popover else { return }
|
||||||
if !popover.isShown {
|
if !popover.isShown {
|
||||||
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
|
|
||||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
@@ -224,7 +209,6 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
if popover.isShown {
|
if popover.isShown {
|
||||||
popover.performClose(nil)
|
popover.performClose(nil)
|
||||||
} else {
|
} else {
|
||||||
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
|
|
||||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
@@ -239,5 +223,3 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,35 +40,20 @@ struct ContentView: View {
|
|||||||
.environmentObject(updater)
|
.environmentObject(updater)
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 0) {
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
// Cards grid
|
// Cards grid
|
||||||
if vm.limits.isEmpty {
|
if vm.limits.isEmpty {
|
||||||
EmptyStateView()
|
EmptyStateView()
|
||||||
.padding(.vertical, 40)
|
.padding(.vertical, 40)
|
||||||
} else {
|
} else {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: 1) {
|
||||||
ForEach(vm.limits) { limit in
|
ForEach(vm.limits) { limit in
|
||||||
VStack(spacing: 0) {
|
LimitCard(limit: limit)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.bottom, 4)
|
.background(Color.primary.opacity(0.05))
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
|
||||||
// Extra credits row
|
// Extra credits row
|
||||||
if let extra = vm.extraUsage, extra.isEnabled {
|
if let extra = vm.extraUsage, extra.isEnabled {
|
||||||
@@ -95,6 +80,7 @@ struct ContentView: View {
|
|||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
|
||||||
@@ -108,13 +94,6 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 480)
|
.frame(width: 480)
|
||||||
.animation(.easeInOut(duration: 0.2), value: showSettings)
|
.animation(.easeInOut(duration: 0.2), value: showSettings)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .popoverWillOpen)) { _ in
|
|
||||||
showSettings = false
|
|
||||||
}
|
|
||||||
.onReceive(NotificationCenter.default.publisher(for: .openUpdateSheet)) { _ in
|
|
||||||
showSettings = false
|
|
||||||
showUpdateSheet = true
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showLogin) {
|
.sheet(isPresented: $showLogin) {
|
||||||
LoginSheetView(isPresented: $showLogin) {
|
LoginSheetView(isPresented: $showLogin) {
|
||||||
Task { await vm.refresh() }
|
Task { await vm.refresh() }
|
||||||
@@ -210,7 +189,7 @@ struct LimitCard: View {
|
|||||||
AgentIconView(agent: .claude, size: 14)
|
AgentIconView(agent: .claude, size: 14)
|
||||||
Text("Claude")
|
Text("Claude")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
Text("· \(vm.planLabel)")
|
Text("· \(vm.planLabel) · \(limit.window.label)")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -396,11 +375,12 @@ struct DiaryRow: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("\(totalSaved) samples")
|
AgentIconView(agent: .claude, size: 12)
|
||||||
.font(.system(size: 10.5))
|
Text("Claude")
|
||||||
.foregroundColor(.secondary)
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(color)
|
||||||
Spacer()
|
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))
|
.font(.system(size: 10.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
@@ -426,9 +406,7 @@ struct SparklineView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let plotData = data.count == 1 ? [data[0], data[0]] : data
|
if data.count > 1 {
|
||||||
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
|
||||||
@@ -788,23 +766,25 @@ struct SettingsView: View {
|
|||||||
.padding(.vertical, 1)
|
.padding(.vertical, 1)
|
||||||
.background(Color.primary.opacity(0.06))
|
.background(Color.primary.opacity(0.06))
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
}
|
if updater.updateAvailable {
|
||||||
if updater.updateAvailable {
|
Text("v\(updater.latestVersion) available")
|
||||||
Text("v\(updater.latestVersion) available")
|
.font(.system(size: 10, weight: .medium))
|
||||||
.font(.system(size: 10, weight: .medium))
|
.foregroundColor(.white)
|
||||||
.foregroundColor(.white)
|
.padding(.horizontal, 6)
|
||||||
.padding(.horizontal, 6)
|
.padding(.vertical, 2)
|
||||||
.padding(.vertical, 2)
|
.background(Color.blue)
|
||||||
.background(Color.blue)
|
.cornerRadius(4)
|
||||||
.cornerRadius(4)
|
.fixedSize()
|
||||||
} else if updater.betaAvailable {
|
} else if updater.betaAvailable {
|
||||||
Text("v\(updater.latestBetaVersion) available")
|
Text("v\(updater.latestBetaVersion) available")
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Color.orange)
|
.background(Color.orange)
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
|
.fixedSize()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text("Built by superdooper86 & claude")
|
Text("Built by superdooper86 & claude")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
@@ -993,36 +973,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
|
// MARK: - Update Banner
|
||||||
|
|
||||||
struct UpdateBanner: View {
|
struct UpdateBanner: View {
|
||||||
@@ -1037,9 +987,12 @@ 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))
|
||||||
Text("Tap Update to view what's new & install.")
|
if !updater.releaseNotes.isEmpty {
|
||||||
.font(.system(size: 11))
|
Text(updater.releaseNotes)
|
||||||
.foregroundColor(.secondary)
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Update") { showSheet = true }
|
Button("Update") { showSheet = true }
|
||||||
@@ -1050,7 +1003,6 @@ 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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1099,7 +1051,9 @@ struct UpdateSheet: View {
|
|||||||
.foregroundColor(.blue)
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
if !updater.releaseNotes.isEmpty {
|
if !updater.releaseNotes.isEmpty {
|
||||||
ReleaseNotesText(text: updater.releaseNotes)
|
Text(updater.releaseNotes)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -1168,5 +1122,3 @@ 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.2.1-beta.1</string>
|
<string>1.0.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>49</string>
|
<string>19</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -24,27 +24,21 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
|
|
||||||
class Coordinator: NSObject, WKNavigationDelegate {
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
let onAuthenticated: () -> Void
|
let onAuthenticated: () -> Void
|
||||||
var didAuthenticate = false
|
|
||||||
|
|
||||||
init(onAuthenticated: @escaping () -> Void) {
|
init(onAuthenticated: @escaping () -> Void) {
|
||||||
self.onAuthenticated = onAuthenticated
|
self.onAuthenticated = onAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
guard !didAuthenticate else { return }
|
|
||||||
// Don't fire on the login/auth pages themselves
|
|
||||||
if let url = webView.url?.absoluteString,
|
|
||||||
url.contains("/login") || url.contains("/auth") { return }
|
|
||||||
|
|
||||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||||
let hasSession = cookies.contains {
|
let hasSession = cookies.contains {
|
||||||
$0.domain.contains("claude.ai") &&
|
$0.domain.contains("claude.ai") &&
|
||||||
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
||||||
}
|
}
|
||||||
guard hasSession, !self.didAuthenticate else { return }
|
if hasSession {
|
||||||
self.didAuthenticate = true
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
self.onAuthenticated()
|
||||||
self.onAuthenticated()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -64,18 +58,9 @@ struct LoginSheetView: View {
|
|||||||
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
|
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
if authenticated {
|
Button("Cancel") { isPresented = false }
|
||||||
Button("Done") {
|
.buttonStyle(.bordered)
|
||||||
isPresented = false
|
|
||||||
onDone()
|
|
||||||
}
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
} else {
|
|
||||||
Button("Cancel") { isPresented = false }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
|
|||||||
@@ -87,8 +87,6 @@ class UpdateManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print("[UpdateManager] Stable fetch returned nil")
|
print("[UpdateManager] Stable fetch returned nil")
|
||||||
updateAvailable = false
|
|
||||||
latestVersion = ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let beta = await betaResult {
|
if let beta = await betaResult {
|
||||||
@@ -318,31 +316,13 @@ class UpdateManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isNewer(_ version: String, than current: String) -> Bool {
|
private func isNewer(_ version: String, than current: String) -> Bool {
|
||||||
func parse(_ v: String) -> (base: [Int], pre: [Int]?) {
|
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } }
|
||||||
guard !v.isEmpty else { return ([], nil) }
|
let a = toInts(version), b = toInts(current)
|
||||||
let halves = v.split(separator: "-", maxSplits: 1)
|
for i in 0..<max(a.count, b.count) {
|
||||||
let base = halves[0].split(separator: ".").compactMap { Int($0) }
|
let av = i < a.count ? a[i] : 0
|
||||||
let pre = halves.count > 1 ? String(halves[1]).split(separator: ".").compactMap { Int($0) } : nil
|
let bv = i < b.count ? b[i] : 0
|
||||||
return (base, pre)
|
|
||||||
}
|
|
||||||
let a = parse(version), b = parse(current)
|
|
||||||
// Compare base version numbers
|
|
||||||
for i in 0..<max(a.base.count, b.base.count) {
|
|
||||||
let av = i < a.base.count ? a.base[i] : 0
|
|
||||||
let bv = i < b.base.count ? b.base[i] : 0
|
|
||||||
if av != bv { return av > bv }
|
if av != bv { return av > bv }
|
||||||
}
|
}
|
||||||
// Base equal: stable beats pre-release
|
|
||||||
if a.pre == nil && b.pre != nil { return true }
|
|
||||||
if a.pre != nil && b.pre == nil { return false }
|
|
||||||
// Both pre-release: compare pre-release numbers
|
|
||||||
if let ap = a.pre, let bp = b.pre {
|
|
||||||
for i in 0..<max(ap.count, bp.count) {
|
|
||||||
let av = i < ap.count ? ap[i] : 0
|
|
||||||
let bv = i < bp.count ? bp[i] : 0
|
|
||||||
if av != bv { return av > bv }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class UpdateNotificationWindowController: NSWindowController {
|
|||||||
|
|
||||||
// Called when a new update is detected while running
|
// Called when a new update is detected while running
|
||||||
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
|
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?) {
|
static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) {
|
||||||
@@ -39,12 +39,7 @@ class UpdateNotificationWindowController: NSWindowController {
|
|||||||
sharedLimit?.close()
|
sharedLimit?.close()
|
||||||
let content = UpdateNotificationView(
|
let content = UpdateNotificationView(
|
||||||
version: "", subtitle: subtitle, isUpdate: false,
|
version: "", subtitle: subtitle, isUpdate: false,
|
||||||
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor,
|
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor
|
||||||
onTap: {
|
|
||||||
sharedLimit?.close()
|
|
||||||
sharedLimit = nil
|
|
||||||
NotificationCenter.default.post(name: .showPopover, object: nil)
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
sharedLimit?.close()
|
sharedLimit?.close()
|
||||||
sharedLimit = nil
|
sharedLimit = nil
|
||||||
@@ -58,16 +53,7 @@ class UpdateNotificationWindowController: NSWindowController {
|
|||||||
|
|
||||||
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
|
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
|
||||||
shared?.close()
|
shared?.close()
|
||||||
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate,
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
shared?.close()
|
shared?.close()
|
||||||
shared = nil
|
shared = nil
|
||||||
}
|
}
|
||||||
@@ -143,7 +129,6 @@ struct UpdateNotificationView: View {
|
|||||||
var titleText: String? = nil
|
var titleText: String? = nil
|
||||||
var badgeIcon: String? = nil
|
var badgeIcon: String? = nil
|
||||||
var badgeColor: Color? = nil
|
var badgeColor: Color? = nil
|
||||||
var onTap: (() -> Void)? = nil
|
|
||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
private var effectiveTitle: String {
|
private var effectiveTitle: String {
|
||||||
@@ -157,40 +142,30 @@ struct UpdateNotificationView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
HStack(spacing: 12) {
|
||||||
Button(action: { onTap?() }) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
HStack(spacing: 12) {
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
ZStack(alignment: .bottomTrailing) {
|
.resizable()
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
.frame(width: 44, height: 44)
|
||||||
.resizable()
|
.cornerRadius(10)
|
||||||
.frame(width: 44, height: 44)
|
|
||||||
.cornerRadius(10)
|
|
||||||
|
|
||||||
Image(systemName: effectiveBadgeIcon)
|
Image(systemName: effectiveBadgeIcon)
|
||||||
.font(.system(size: 16, weight: .semibold))
|
.font(.system(size: 16, weight: .semibold))
|
||||||
.foregroundColor(effectiveBadgeColor)
|
.foregroundColor(effectiveBadgeColor)
|
||||||
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
|
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
|
||||||
.offset(x: 4, y: 4)
|
.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())
|
|
||||||
}
|
}
|
||||||
.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) {
|
Button(action: onDismiss) {
|
||||||
Image(systemName: "xmark")
|
Image(systemName: "xmark")
|
||||||
@@ -198,8 +173,10 @@ struct UpdateNotificationView: View {
|
|||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(14)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.frame(width: 340, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: 13)
|
RoundedRectangle(cornerRadius: 13)
|
||||||
.fill(Color(nsColor: .windowBackgroundColor))
|
.fill(Color(nsColor: .windowBackgroundColor))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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?
|
||||||
@@ -27,27 +28,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()
|
||||||
Task { await checkInitialSignInState() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkInitialSignInState() async {
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let hasSession = cookies.contains {
|
|
||||||
$0.domain.contains("claude.ai") &&
|
|
||||||
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
|
||||||
}
|
|
||||||
if hasSession { isSignedIn = true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
@@ -56,7 +46,6 @@ 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
|
||||||
@@ -74,29 +63,18 @@ class UsageViewModel: ObservableObject {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap()
|
let orgId = cachedOrgId!
|
||||||
|
async let usageFetch = fetchUsage(orgId: orgId)
|
||||||
let orgId: String
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||||
if let id = fetchedOrgId {
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
async let emailFetch = fetchUserEmail()
|
||||||
orgId = id
|
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
||||||
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
if let email { userEmail = email }
|
||||||
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
|
||||||
|
|
||||||
@@ -106,9 +84,8 @@ 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
|
||||||
limits[i].burnHistory = history
|
if history.count > 1 { 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
|
||||||
@@ -129,82 +106,54 @@ 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 then dedicated endpoint
|
|
||||||
var orgId: String? = firstOrg?["uuid"] as? String
|
|
||||||
if orgId == nil {
|
|
||||||
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
|
||||||
}
|
|
||||||
if orgId == nil, let cookie = await claudeCookieHeader() {
|
|
||||||
// Final fallback: fetch /api/organizations directly
|
|
||||||
var orgsReq = URLRequest(url: URL(string: "https://claude.ai/api/organizations")!)
|
|
||||||
orgsReq.setValue("application/json", forHTTPHeaderField: "accept")
|
|
||||||
orgsReq.setValue(cookie, forHTTPHeaderField: "Cookie")
|
|
||||||
if let (orgsData, orgsResp) = try? await URLSession.shared.data(for: orgsReq),
|
|
||||||
let orgsHttp = orgsResp as? HTTPURLResponse, orgsHttp.statusCode == 200,
|
|
||||||
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
|
||||||
orgId = orgs.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")
|
||||||
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
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, 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)
|
||||||
@@ -214,7 +163,11 @@ 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")
|
||||||
if let cookie = await claudeCookieHeader() { req.setValue(cookie, forHTTPHeaderField: "Cookie") }
|
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, 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)
|
||||||
@@ -288,11 +241,14 @@ 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 }
|
||||||
@@ -303,6 +259,8 @@ 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(
|
||||||
@@ -311,6 +269,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -348,9 +307,7 @@ extension Notification.Name {
|
|||||||
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
|
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
|
||||||
static let closePopover = Notification.Name("closePopover")
|
static let closePopover = Notification.Name("closePopover")
|
||||||
static let showPopover = Notification.Name("showPopover")
|
static let showPopover = Notification.Name("showPopover")
|
||||||
static let popoverWillOpen = Notification.Name("popoverWillOpen")
|
|
||||||
static let updateDetected = Notification.Name("updateDetected")
|
static let updateDetected = Notification.Name("updateDetected")
|
||||||
static let limitWarning = Notification.Name("limitWarning")
|
static let limitWarning = Notification.Name("limitWarning")
|
||||||
static let limitReset = Notification.Name("limitReset")
|
static let limitReset = Notification.Name("limitReset")
|
||||||
static let openUpdateSheet = Notification.Name("openUpdateSheet")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,8 @@
|
|||||||
|
|
||||||
[](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)
|
||||||
<!-- BETA_BADGE -->
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -30,21 +29,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-v3.png" width="280" alt="Main window" /><br/><sub>Main window</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-v3.png" width="280" alt="Settings" /><br/><sub>Settings</sub></td>
|
<td align="center"><img src="screenshots/settings-v2.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 available" /><br/><sub>Update available</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/release-notes.png" width="280" alt="Release notes" /><br/><sub>Release notes</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/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/>
|
||||||
@@ -114,29 +113,25 @@ No API key required. It reads directly from your `claude.ai` session using a bui
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 13 Ventura or later
|
- macOS 13 Ventura or later
|
||||||
|
- Xcode 15+ (to build from source)
|
||||||
- A claude.ai account
|
- A claude.ai account
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Homebrew (easiest)
|
### Download (recommended)
|
||||||
```bash
|
|
||||||
brew tap superdooper86/tap
|
|
||||||
brew install --cask claudechecker
|
|
||||||
```
|
|
||||||
|
|
||||||
### Download
|
|
||||||
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
|
1. Go to [Releases](https://github.com/superdooper86/claudechecker/releases)
|
||||||
2. Download `ClaudeChecker.zip` from the latest release
|
2. Download `ClaudeChecker.zip` from the latest release
|
||||||
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
|
3. Unzip and drag `ClaudeChecker.app` to `/Applications`
|
||||||
4. Open from Applications — it will appear in your menubar
|
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:
|
### Build from source
|
||||||
> ```bash
|
```bash
|
||||||
> xattr -cr /Applications/ClaudeChecker.app
|
git clone https://github.com/superdooper86/claudechecker.git
|
||||||
> ```
|
open ClaudeChecker/ClaudeChecker.xcodeproj
|
||||||
> This is not needed when installing via Homebrew.
|
```
|
||||||
|
Then in Xcode: set your Team under Signing & Capabilities, then **⌘R** to run.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
## What's new in v1.2.1
|
|
||||||
|
|
||||||
### Bug fixes
|
|
||||||
- Fixed "Not signed in" showing incorrectly on launch when the session was already active
|
|
||||||
- Sign-in state is now detected immediately from stored cookies on startup, before the first data refresh completes
|
|
||||||
- Added `/api/organizations` as a final fallback for org ID resolution when the bootstrap API response doesn't include it
|
|
||||||
- Session cookie detection now checks both `sessionKey` and `__Secure-next-auth.session-token` cookie names
|
|
||||||
|
After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "1.1.4-beta.7",
|
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.1.4-beta.7/ClaudeChecker.zip",
|
|
||||||
"notes": "## What's new in v1.1.4-beta.7\n\n### Bug fix\n- Main panel no longer scrolls — popover now auto-sizes to fit content"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.0",
|
"version": "1.0.0",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.0/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.0.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"
|
"notes": "Initial release."
|
||||||
}
|
}
|
||||||
|
|||||||