Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
|
||||||
@@ -10,13 +10,6 @@
|
|||||||
<string>77</string>
|
<string>77</string>
|
||||||
<key>objects</key>
|
<key>objects</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>AAAAAA009</key>
|
|
||||||
<dict>
|
|
||||||
<key>isa</key>
|
|
||||||
<string>PBXBuildFile</string>
|
|
||||||
<key>fileRef</key>
|
|
||||||
<string>AAAAAA109</string>
|
|
||||||
</dict>
|
|
||||||
<key>AAAAAA007</key>
|
<key>AAAAAA007</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -95,17 +88,6 @@
|
|||||||
<key>sourceTree</key>
|
<key>sourceTree</key>
|
||||||
<string><group></string>
|
<string><group></string>
|
||||||
</dict>
|
</dict>
|
||||||
<key>AAAAAA109</key>
|
|
||||||
<dict>
|
|
||||||
<key>isa</key>
|
|
||||||
<string>PBXFileReference</string>
|
|
||||||
<key>lastKnownFileType</key>
|
|
||||||
<string>sourcecode.swift</string>
|
|
||||||
<key>path</key>
|
|
||||||
<string>DiagnosticsView.swift</string>
|
|
||||||
<key>sourceTree</key>
|
|
||||||
<string><group></string>
|
|
||||||
</dict>
|
|
||||||
<key>AAAAAA101</key>
|
<key>AAAAAA101</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>isa</key>
|
<key>isa</key>
|
||||||
@@ -197,7 +179,6 @@
|
|||||||
<string>AAAAAA106</string>
|
<string>AAAAAA106</string>
|
||||||
<string>AAAAAA107</string>
|
<string>AAAAAA107</string>
|
||||||
<string>AAAAAA108</string>
|
<string>AAAAAA108</string>
|
||||||
<string>AAAAAA109</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>path</key>
|
<key>path</key>
|
||||||
<string>ClaudeChecker</string>
|
<string>ClaudeChecker</string>
|
||||||
@@ -311,7 +292,6 @@
|
|||||||
<string>AAAAAA005</string>
|
<string>AAAAAA005</string>
|
||||||
<string>AAAAAA007</string>
|
<string>AAAAAA007</string>
|
||||||
<string>AAAAAA008</string>
|
<string>AAAAAA008</string>
|
||||||
<string>AAAAAA009</string>
|
|
||||||
</array>
|
</array>
|
||||||
<key>runOnlyForDeploymentPostprocessing</key>
|
<key>runOnlyForDeploymentPostprocessing</key>
|
||||||
<string>0</string>
|
<string>0</string>
|
||||||
|
|||||||
@@ -18,43 +18,16 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
var updateManager = UpdateManager()
|
var updateManager = UpdateManager()
|
||||||
var refreshTimer: Timer?
|
var refreshTimer: Timer?
|
||||||
var cookiePrimerView: WKWebView?
|
var cookiePrimerView: WKWebView?
|
||||||
var cookiePrimerWindow: NSWindow?
|
|
||||||
var primerNavDelegate: PrimerNavDelegate?
|
|
||||||
var cancellables = Set<AnyCancellable>()
|
var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
NSApp.setActivationPolicy(.accessory)
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
|
||||||
// Background WebView that loads claude.ai on every launch.
|
// Hidden WKWebView to prime the shared cookie store
|
||||||
// Anchored in a real (off-screen, invisible) NSWindow so WebKit doesn't throttle
|
|
||||||
// JS execution. When the page finishes loading the nav delegate calls
|
|
||||||
// adoptPrimerWebView, which sets it as the API WebView and runs the first fetch.
|
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
|
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
|
||||||
let win = NSWindow(
|
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
|
||||||
contentRect: NSRect(x: -9999, y: -9999, width: 1, height: 1),
|
|
||||||
styleMask: [],
|
|
||||||
backing: .buffered,
|
|
||||||
defer: false
|
|
||||||
)
|
|
||||||
win.contentView = wv
|
|
||||||
win.alphaValue = 0
|
|
||||||
win.orderFront(nil)
|
|
||||||
cookiePrimerWindow = win
|
|
||||||
cookiePrimerView = wv
|
|
||||||
|
|
||||||
let delegate = PrimerNavDelegate { [weak self] in
|
|
||||||
guard let self, let wv = self.cookiePrimerView else { return }
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
await self.usageViewModel.adoptPrimerWebView(wv)
|
|
||||||
await self.updateManager.checkForUpdates()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
primerNavDelegate = delegate
|
|
||||||
wv.navigationDelegate = delegate
|
|
||||||
wv.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)
|
||||||
@@ -67,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
|
||||||
@@ -90,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)
|
||||||
|
|
||||||
@@ -161,6 +126,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initial fetch after cookie primer + update check
|
||||||
|
Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
await usageViewModel.refresh()
|
||||||
|
await updateManager.checkForUpdates()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setMenubarIcon(button: NSStatusBarButton) {
|
private func setMenubarIcon(button: NSStatusBarButton) {
|
||||||
@@ -195,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 = ""
|
||||||
@@ -234,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)
|
||||||
}
|
}
|
||||||
@@ -245,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)
|
||||||
}
|
}
|
||||||
@@ -260,14 +223,3 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-shot WKNavigationDelegate: fires onDone on first finish or error, then goes silent.
|
|
||||||
final class PrimerNavDelegate: NSObject, WKNavigationDelegate {
|
|
||||||
private var done = false
|
|
||||||
private let onDone: () -> Void
|
|
||||||
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
|
|
||||||
private func finish() { guard !done else { return }; done = true; onDone() }
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
|
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
|
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
VStack(spacing: 0) {
|
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) {
|
|
||||||
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)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.background(Color.primary.opacity(0.05))
|
||||||
.padding(.bottom, 4)
|
.padding(.vertical, 1)
|
||||||
|
|
||||||
// Extra credits row
|
// Extra credits row
|
||||||
if let extra = vm.extraUsage, extra.isEnabled {
|
if let extra = vm.extraUsage, extra.isEnabled {
|
||||||
@@ -96,6 +81,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
|
|
||||||
Divider().opacity(0.3)
|
Divider().opacity(0.3)
|
||||||
@@ -108,16 +94,9 @@ 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) { webView in
|
LoginSheetView(isPresented: $showLogin) {
|
||||||
Task { await vm.adoptAndRefresh(webView) }
|
Task { await vm.refresh() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showUpdateSheet) {
|
.sheet(isPresented: $showUpdateSheet) {
|
||||||
@@ -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
|
||||||
@@ -584,7 +562,6 @@ struct SettingsView: View {
|
|||||||
@Binding var showSettings: Bool
|
@Binding var showSettings: Bool
|
||||||
@Binding var showUpdateSheet: Bool
|
@Binding var showUpdateSheet: Bool
|
||||||
@State private var checkingForUpdates = false
|
@State private var checkingForUpdates = false
|
||||||
@State private var showDiagnostics = false
|
|
||||||
|
|
||||||
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
let refreshOptions: [(label: String, seconds: TimeInterval)] = [
|
||||||
("1 min", 60),
|
("1 min", 60),
|
||||||
@@ -771,23 +748,6 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
// Diagnostics
|
|
||||||
HStack {
|
|
||||||
Label("Diagnostics", systemImage: "stethoscope")
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Button("Open") { showDiagnostics = true }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showDiagnostics) {
|
|
||||||
DiagnosticsView(isPresented: $showDiagnostics)
|
|
||||||
.environmentObject(vm)
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// About
|
// About
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
@@ -806,7 +766,6 @@ 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))
|
||||||
@@ -816,7 +775,7 @@ struct SettingsView: View {
|
|||||||
.background(Color.blue)
|
.background(Color.blue)
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
} else if updater.betaAvailable {
|
} else if updater.betaAvailable {
|
||||||
Text("v\(updater.latestBetaVersion) available")
|
Text("v\(updater.latestBetaVersion) beta")
|
||||||
.font(.system(size: 10, weight: .medium))
|
.font(.system(size: 10, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
@@ -824,6 +783,7 @@ struct SettingsView: View {
|
|||||||
.background(Color.orange)
|
.background(Color.orange)
|
||||||
.cornerRadius(4)
|
.cornerRadius(4)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Text("Built by superdooper86 & claude")
|
Text("Built by superdooper86 & claude")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
@@ -929,12 +889,12 @@ struct ErrorBanner: View {
|
|||||||
struct EmptyStateView: View {
|
struct EmptyStateView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Image(systemName: "person.crop.circle.badge.questionmark")
|
Image(systemName: "key.slash")
|
||||||
.font(.system(size: 28))
|
.font(.system(size: 28))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
Text("Not signed in")
|
Text("No API key configured")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
Text("Sign in to claude.ai to see your usage.")
|
Text("Open Settings to add your Anthropic API key.")
|
||||||
.font(.system(size: 11.5))
|
.font(.system(size: 11.5))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@@ -1011,36 +971,6 @@ struct UpdateSuccessBanner: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Markdown notes renderer
|
|
||||||
|
|
||||||
struct ReleaseNotesText: View {
|
|
||||||
let text: String
|
|
||||||
var font: Font = .system(size: 12)
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let processed = text
|
|
||||||
.split(separator: "\n", omittingEmptySubsequences: false)
|
|
||||||
.map { line -> String in
|
|
||||||
let s = String(line)
|
|
||||||
if s.hasPrefix("### ") { return "**\(s.dropFirst(4))**" }
|
|
||||||
if s.hasPrefix("## ") { return "**\(s.dropFirst(3))**" }
|
|
||||||
if s.hasPrefix("# ") { return "**\(s.dropFirst(2))**" }
|
|
||||||
if s.hasPrefix("- ") { return "• \(s.dropFirst(2))" }
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
.joined(separator: "\n")
|
|
||||||
|
|
||||||
if let attr = try? AttributedString(markdown: processed,
|
|
||||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {
|
|
||||||
Text(attr).font(font).foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
} else {
|
|
||||||
Text(text).font(font).foregroundColor(.secondary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Update Banner
|
// MARK: - Update Banner
|
||||||
|
|
||||||
struct UpdateBanner: View {
|
struct UpdateBanner: View {
|
||||||
@@ -1055,9 +985,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 {
|
||||||
|
Text(updater.releaseNotes)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("Update") { showSheet = true }
|
Button("Update") { showSheet = true }
|
||||||
@@ -1068,7 +1001,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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1117,7 +1049,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()
|
||||||
@@ -1186,5 +1120,3 @@ struct UpdateSheet: View {
|
|||||||
.frame(width: 380)
|
.frame(width: 380)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,211 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import WebKit
|
|
||||||
|
|
||||||
struct DiagnosticsView: View {
|
|
||||||
@EnvironmentObject var vm: UsageViewModel
|
|
||||||
@Binding var isPresented: Bool
|
|
||||||
@State private var liveAllCookies: [HTTPCookie] = []
|
|
||||||
@State private var loadingCookies = false
|
|
||||||
@State private var copied = false
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
// Header
|
|
||||||
HStack {
|
|
||||||
Text("Diagnostics")
|
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
Spacer()
|
|
||||||
Button("Copy All") {
|
|
||||||
NSPasteboard.general.clearContents()
|
|
||||||
NSPasteboard.general.setString(fullDiagText, forType: .string)
|
|
||||||
copied = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { copied = false }
|
|
||||||
}
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
if copied {
|
|
||||||
Text("Copied!")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(.green)
|
|
||||||
}
|
|
||||||
Button("Close") { isPresented = false }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
ScrollView {
|
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
|
||||||
|
|
||||||
// App info
|
|
||||||
DiagSection(title: "App") {
|
|
||||||
DiagRow("Version", Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")
|
|
||||||
DiagRow("Build", Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?")
|
|
||||||
DiagRow("Signed in", vm.isSignedIn ? "Yes" : "No")
|
|
||||||
DiagRow("Email", vm.userEmail.isEmpty ? "(none)" : vm.userEmail)
|
|
||||||
DiagRow("Org ID", UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")
|
|
||||||
DiagRow("lastActiveOrg", vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)
|
|
||||||
DiagRow("Error", vm.errorMessage ?? "(none)")
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Last request
|
|
||||||
DiagSection(title: "Last Request") {
|
|
||||||
DiagRow("Path", vm.diagLastPath.isEmpty ? "(none)" : vm.diagLastPath)
|
|
||||||
DiagRow("Status", vm.diagLastStatus == 0 ? "(none)" : "\(vm.diagLastStatus)")
|
|
||||||
DiagRow("Error", vm.diagLastError.isEmpty ? "(none)" : vm.diagLastError)
|
|
||||||
if let t = vm.diagLastFetch {
|
|
||||||
DiagRow("Time", t.formatted(date: .omitted, time: .standard))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Cookie summary
|
|
||||||
DiagSection(title: "Cookie Store") {
|
|
||||||
DiagRow("Total cookies", "\(vm.diagCookieCount)")
|
|
||||||
DiagRow("claude.ai cookies", "\(vm.diagClaudeCookieCount)")
|
|
||||||
DiagRow("All domains", vm.diagCookieDomains.isEmpty
|
|
||||||
? "(none — never fetched)"
|
|
||||||
: vm.diagCookieDomains.joined(separator: ", "))
|
|
||||||
}
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
// Live cookie list
|
|
||||||
DiagSection(title: "Live Cookie Names (WKWebsiteDataStore.default)") {
|
|
||||||
if loadingCookies {
|
|
||||||
ProgressView().scaleEffect(0.7)
|
|
||||||
} else if liveAllCookies.isEmpty {
|
|
||||||
Text("No cookies found")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
} else {
|
|
||||||
let claudeCookies = liveAllCookies.filter {
|
|
||||||
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
|
||||||
}
|
|
||||||
let otherCookies = liveAllCookies.filter {
|
|
||||||
!$0.domain.contains("claude.ai") && !$0.domain.contains("anthropic.com")
|
|
||||||
}
|
|
||||||
if !claudeCookies.isEmpty {
|
|
||||||
Text("claude.ai / anthropic.com (\(claudeCookies.count))")
|
|
||||||
.font(.system(size: 10, weight: .medium))
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
ForEach(claudeCookies, id: \.name) { c in
|
|
||||||
Text("• \(c.domain) \(c.name)")
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !otherCookies.isEmpty {
|
|
||||||
Text("Other domains (\(otherCookies.count))")
|
|
||||||
.font(.system(size: 10, weight: .medium))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.padding(.top, 4)
|
|
||||||
ForEach(otherCookies.prefix(10), id: \.name) { c in
|
|
||||||
Text("• \(c.domain) \(c.name)")
|
|
||||||
.font(.system(size: 10, design: .monospaced))
|
|
||||||
.foregroundColor(Color.secondary.opacity(0.6))
|
|
||||||
}
|
|
||||||
if otherCookies.count > 10 {
|
|
||||||
Text("… and \(otherCookies.count - 10) more")
|
|
||||||
.font(.system(size: 10))
|
|
||||||
.foregroundColor(Color.secondary.opacity(0.5))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Button("Refresh cookies") { loadLiveCookies() }
|
|
||||||
.buttonStyle(.bordered)
|
|
||||||
.controlSize(.small)
|
|
||||||
.padding(.top, 4)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(14)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 480, height: 560)
|
|
||||||
.onAppear { loadLiveCookies() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadLiveCookies() {
|
|
||||||
loadingCookies = true
|
|
||||||
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
liveAllCookies = cookies.sorted { $0.domain < $1.domain }
|
|
||||||
loadingCookies = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var fullDiagText: String {
|
|
||||||
var lines: [String] = []
|
|
||||||
lines.append("=== ClaudeChecker Diagnostics ===")
|
|
||||||
lines.append("Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?") (\(Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "?"))")
|
|
||||||
lines.append("Signed in: \(vm.isSignedIn ? "Yes" : "No")")
|
|
||||||
lines.append("Email: \(vm.userEmail.isEmpty ? "(none)" : vm.userEmail)")
|
|
||||||
lines.append("Org ID: \(UserDefaults.standard.string(forKey: "claude_org_id") ?? "(none)")")
|
|
||||||
lines.append("lastActiveOrg: \(vm.diagLastActiveOrg.isEmpty ? "(not read)" : vm.diagLastActiveOrg)")
|
|
||||||
lines.append("Error: \(vm.errorMessage ?? "(none)")")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Last path: \(vm.diagLastPath)")
|
|
||||||
lines.append("Last status: \(vm.diagLastStatus)")
|
|
||||||
lines.append("Last error: \(vm.diagLastError)")
|
|
||||||
lines.append("Total cookies: \(vm.diagCookieCount)")
|
|
||||||
lines.append("Claude cookies: \(vm.diagClaudeCookieCount)")
|
|
||||||
lines.append("Domains: \(vm.diagCookieDomains.joined(separator: ", "))")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Live cookies:")
|
|
||||||
for c in liveAllCookies {
|
|
||||||
lines.append(" \(c.domain) \(c.name) httpOnly=\(c.isHTTPOnly) secure=\(c.isSecure)")
|
|
||||||
}
|
|
||||||
return lines.joined(separator: "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private struct DiagSection<Content: View>: View {
|
|
||||||
let title: String
|
|
||||||
@ViewBuilder let content: () -> Content
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text(title)
|
|
||||||
.font(.system(size: 11, weight: .semibold))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.textCase(.uppercase)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct DiagRow: View {
|
|
||||||
let label: String
|
|
||||||
let value: String
|
|
||||||
|
|
||||||
init(_ label: String, _ value: String) {
|
|
||||||
self.label = label
|
|
||||||
self.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .top, spacing: 8) {
|
|
||||||
Text(label)
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
.frame(width: 100, alignment: .leading)
|
|
||||||
Text(value)
|
|
||||||
.font(.system(size: 11, design: .monospaced))
|
|
||||||
.foregroundColor(.primary)
|
|
||||||
.textSelection(.enabled)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,9 +15,9 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.3.2</string>
|
<string>3.0.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>76</string>
|
<string>18</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import WebKit
|
|||||||
// MARK: - Login Web View
|
// MARK: - Login Web View
|
||||||
|
|
||||||
struct LoginWebView: NSViewRepresentable {
|
struct LoginWebView: NSViewRepresentable {
|
||||||
let onAuthenticated: (WKWebView) -> Void
|
let onAuthenticated: () -> Void
|
||||||
|
|
||||||
func makeNSView(context: Context) -> WKWebView {
|
func makeNSView(context: Context) -> WKWebView {
|
||||||
let config = WKWebViewConfiguration()
|
let config = WKWebViewConfiguration()
|
||||||
@@ -12,13 +12,6 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
|
|
||||||
let webView = WKWebView(frame: .zero, configuration: config)
|
let webView = WKWebView(frame: .zero, configuration: config)
|
||||||
webView.navigationDelegate = context.coordinator
|
webView.navigationDelegate = context.coordinator
|
||||||
context.coordinator.webView = webView
|
|
||||||
|
|
||||||
// KVO on url catches SPA pushState navigations that don't fire didFinish
|
|
||||||
context.coordinator.urlObservation = webView.observe(\.url, options: [.new]) { [weak coordinator = context.coordinator] wv, _ in
|
|
||||||
coordinator?.checkCurrentURL(wv.url?.absoluteString)
|
|
||||||
}
|
|
||||||
|
|
||||||
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
|
||||||
return webView
|
return webView
|
||||||
}
|
}
|
||||||
@@ -30,30 +23,24 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Coordinator: NSObject, WKNavigationDelegate {
|
class Coordinator: NSObject, WKNavigationDelegate {
|
||||||
let onAuthenticated: (WKWebView) -> Void
|
let onAuthenticated: () -> Void
|
||||||
weak var webView: WKWebView?
|
|
||||||
var didAuthenticate = false
|
|
||||||
var urlObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
init(onAuthenticated: @escaping (WKWebView) -> Void) {
|
init(onAuthenticated: @escaping () -> Void) {
|
||||||
self.onAuthenticated = onAuthenticated
|
self.onAuthenticated = onAuthenticated
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkCurrentURL(_ url: String?) {
|
|
||||||
guard !didAuthenticate, let url, let wv = webView else { return }
|
|
||||||
// Ignore navigations to external OAuth providers (Google, etc.) —
|
|
||||||
// only consider auth complete when we land back on claude.ai/anthropic.com
|
|
||||||
guard url.contains("claude.ai") || url.contains("anthropic.com") else { return }
|
|
||||||
if url.contains("/login") || url.contains("/auth") { return }
|
|
||||||
didAuthenticate = true
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
self.onAuthenticated(wv)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Covers full cross-document navigations
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
checkCurrentURL(webView.url?.absoluteString)
|
WKWebsiteDataStore.default().httpCookieStore.getAllCookies { cookies in
|
||||||
|
let hasSession = cookies.contains {
|
||||||
|
$0.domain.contains("claude.ai") &&
|
||||||
|
($0.name == "sessionKey" || $0.name == "__Secure-next-auth.session-token")
|
||||||
|
}
|
||||||
|
if hasSession {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self.onAuthenticated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +49,7 @@ struct LoginWebView: NSViewRepresentable {
|
|||||||
|
|
||||||
struct LoginSheetView: View {
|
struct LoginSheetView: View {
|
||||||
@Binding var isPresented: Bool
|
@Binding var isPresented: Bool
|
||||||
let onDone: (WKWebView) -> Void
|
let onDone: () -> Void
|
||||||
@State private var authenticated = false
|
@State private var authenticated = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -71,29 +58,22 @@ 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("Done") { isPresented = false }
|
|
||||||
.buttonStyle(.borderedProminent)
|
|
||||||
.controlSize(.small)
|
|
||||||
} else {
|
|
||||||
Button("Cancel") { isPresented = false }
|
Button("Cancel") { isPresented = false }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
LoginWebView { webView in
|
LoginWebView {
|
||||||
authenticated = true
|
authenticated = true
|
||||||
// Adopt the authenticated WebView immediately (before sheet tears it down),
|
// Auto-dismiss and refresh after brief delay
|
||||||
// then auto-dismiss after a moment so the user sees confirmation.
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||||
onDone(webView)
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
||||||
isPresented = false
|
isPresented = false
|
||||||
|
onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,6 @@ class UpdateManager: ObservableObject {
|
|||||||
@Published var updateComplete = false
|
@Published var updateComplete = false
|
||||||
@Published var justUpdated = false
|
@Published var justUpdated = false
|
||||||
|
|
||||||
private var notifiedVersion: String = ""
|
|
||||||
|
|
||||||
@Published var betaChannel: Bool = false {
|
@Published var betaChannel: Bool = false {
|
||||||
didSet {
|
didSet {
|
||||||
UserDefaults.standard.set(betaChannel, forKey: "beta_channel")
|
UserDefaults.standard.set(betaChannel, forKey: "beta_channel")
|
||||||
@@ -72,13 +70,14 @@ class UpdateManager: ObservableObject {
|
|||||||
if let stable = await stableResult {
|
if let stable = await stableResult {
|
||||||
print("[UpdateManager] Stable remote=\(stable.version), isNewer=\(isNewer(stable.version, than: currentVersion))")
|
print("[UpdateManager] Stable remote=\(stable.version), isNewer=\(isNewer(stable.version, than: currentVersion))")
|
||||||
if isNewer(stable.version, than: currentVersion) {
|
if isNewer(stable.version, than: currentVersion) {
|
||||||
|
let wasAvailable = updateAvailable
|
||||||
latestVersion = stable.version
|
latestVersion = stable.version
|
||||||
releaseNotes = stable.notes ?? ""
|
releaseNotes = stable.notes ?? ""
|
||||||
downloadURL = stable.url
|
downloadURL = stable.url
|
||||||
updateAvailable = true
|
updateAvailable = true
|
||||||
print("[UpdateManager] updateAvailable set to true")
|
print("[UpdateManager] updateAvailable set to true")
|
||||||
if notifiedVersion != stable.version {
|
// Only notify once — when first detected
|
||||||
notifiedVersion = stable.version
|
if !wasAvailable {
|
||||||
NotificationCenter.default.post(name: .updateDetected, object: stable.version)
|
NotificationCenter.default.post(name: .updateDetected, object: stable.version)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -87,8 +86,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 {
|
||||||
@@ -101,19 +98,10 @@ class UpdateManager: ObservableObject {
|
|||||||
releaseNotes = beta.notes ?? ""
|
releaseNotes = beta.notes ?? ""
|
||||||
downloadURL = beta.url
|
downloadURL = beta.url
|
||||||
updateAvailable = true
|
updateAvailable = true
|
||||||
if notifiedVersion != beta.version {
|
|
||||||
notifiedVersion = beta.version
|
|
||||||
NotificationCenter.default.post(name: .updateDetected, object: beta.version)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
betaAvailable = false
|
|
||||||
latestBetaVersion = ""
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
print("[UpdateManager] Beta fetch returned nil")
|
print("[UpdateManager] Beta fetch returned nil")
|
||||||
betaAvailable = false
|
|
||||||
latestBetaVersion = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,31 +306,13 @@ class UpdateManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isNewer(_ version: String, than current: String) -> Bool {
|
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,8 +142,6 @@ struct UpdateNotificationView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topTrailing) {
|
|
||||||
Button(action: { onTap?() }) {
|
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
|
||||||
@@ -176,21 +159,13 @@ struct UpdateNotificationView: View {
|
|||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text(effectiveTitle)
|
Text(effectiveTitle)
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 13, weight: .semibold))
|
||||||
.foregroundColor(.primary)
|
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(minLength: 24)
|
Spacer(minLength: 8)
|
||||||
}
|
|
||||||
.padding(.horizontal, 14)
|
|
||||||
.padding(.vertical, 14)
|
|
||||||
.frame(width: 340, alignment: .leading)
|
|
||||||
.contentShape(Rectangle())
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
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,170 +28,24 @@ 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>] = [:]
|
||||||
|
|
||||||
// WebView used for JS-based API calls (avoids URLSession 403 fingerprinting issues)
|
|
||||||
private var apiWebView: WKWebView?
|
|
||||||
|
|
||||||
// Diagnostics
|
|
||||||
@Published var diagCookieCount: Int = 0
|
|
||||||
@Published var diagClaudeCookieCount: Int = 0
|
|
||||||
@Published var diagCookieDomains: [String] = []
|
|
||||||
@Published var diagLastPath: String = ""
|
|
||||||
@Published var diagLastStatus: Int = 0
|
|
||||||
@Published var diagLastError: String = ""
|
|
||||||
@Published var diagLastActiveOrg: String = "" // lastActiveOrg cookie value from JS
|
|
||||||
@Published var diagLastFetch: Date? = nil
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
|
||||||
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
|
||||||
refreshInterval = saved > 0 ? saved : 60
|
refreshInterval = saved > 0 ? saved : 60
|
||||||
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
|
||||||
if let saved = UserDefaults.standard.object(forKey: "burn_history") as? [String: [Double]] {
|
|
||||||
burnHistoryStore = saved
|
|
||||||
}
|
|
||||||
loadPlaceholderData()
|
loadPlaceholderData()
|
||||||
Task { await checkInitialSignInState() }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called by AppDelegate once the primer WebView has loaded claude.ai.
|
|
||||||
// Sets it as the API WebView and triggers the first data fetch.
|
|
||||||
func adoptPrimerWebView(_ wv: WKWebView) async {
|
|
||||||
apiWebView = wv
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Called after login: store the login WebView (already on claude.ai) so jsFetch can use it.
|
|
||||||
func adoptAndRefresh(_ loginWebView: WKWebView) async {
|
|
||||||
for _ in 0..<30 {
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
if cookies.contains(where: { $0.domain.contains("claude.ai") }) { break }
|
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
}
|
|
||||||
apiWebView = loginWebView
|
|
||||||
await refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runs a fetch() inside the live claude.ai WebView — same browser context as the frontend,
|
|
||||||
// so no CORS/fingerprinting issues that URLSession hits.
|
|
||||||
private func jsFetch(_ path: String) async throws -> (Int, String) {
|
|
||||||
guard let wv = apiWebView else { throw AppError.networkError }
|
|
||||||
let js = """
|
|
||||||
const r = await fetch('\(path)', {
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Accept': 'application/json' }
|
|
||||||
});
|
|
||||||
const body = await r.text();
|
|
||||||
return {status: r.status, body: body};
|
|
||||||
"""
|
|
||||||
let result: Any? = try await withCheckedThrowingContinuation { cont in
|
|
||||||
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { res in
|
|
||||||
switch res {
|
|
||||||
case .success(let val): cont.resume(returning: val)
|
|
||||||
case .failure(let err): cont.resume(throwing: err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard let dict = result as? [String: Any],
|
|
||||||
let status = dict["status"] as? Int,
|
|
||||||
let body = dict["body"] as? String else {
|
|
||||||
throw AppError.networkError
|
|
||||||
}
|
|
||||||
diagLastPath = path
|
|
||||||
diagLastFetch = Date()
|
|
||||||
diagLastStatus = status
|
|
||||||
diagLastError = status != 200 ? "JS HTTP \(status)" : ""
|
|
||||||
return (status, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use JS fetch when apiWebView is ready on claude.ai, otherwise fall back to URLSession.
|
|
||||||
private func apiFetch(_ path: String) async throws -> (Int, String) {
|
|
||||||
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
|
||||||
return try await jsFetch(path)
|
|
||||||
}
|
|
||||||
return try await urlFetch(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetches an API path via URLSession with browser-like headers.
|
|
||||||
// Claude.ai's API requires sec-fetch-*, Origin, Referer, and a browser User-Agent
|
|
||||||
// to avoid 401 — matching how the Windows version (HttpClient) handles this.
|
|
||||||
private func urlFetch(_ path: String) async throws -> (Int, String) {
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
let claudeCookies = cookies.filter {
|
|
||||||
$0.domain.contains("claude.ai") || $0.domain.contains("anthropic.com")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record cookie diagnostics
|
|
||||||
diagCookieCount = cookies.count
|
|
||||||
diagClaudeCookieCount = claudeCookies.count
|
|
||||||
diagCookieDomains = Array(Set(cookies.map { $0.domain })).sorted()
|
|
||||||
diagLastPath = path
|
|
||||||
diagLastFetch = Date()
|
|
||||||
diagLastError = ""
|
|
||||||
|
|
||||||
guard !claudeCookies.isEmpty else {
|
|
||||||
let msg = "No claude.ai cookies (\(cookies.count) total in store)"
|
|
||||||
diagLastError = msg
|
|
||||||
throw AppError.detail(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
var req = URLRequest(url: URL(string: "https://claude.ai\(path)")!)
|
|
||||||
req.httpShouldHandleCookies = false
|
|
||||||
// Build cookie header manually (same format as Windows HttpClient) to avoid
|
|
||||||
// any encoding differences from HTTPCookie.requestHeaderFields(with:)
|
|
||||||
let cookieHeader = claudeCookies.map { "\($0.name)=\($0.value)" }.joined(separator: "; ")
|
|
||||||
req.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
|
|
||||||
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
||||||
req.setValue("https://claude.ai", forHTTPHeaderField: "Origin")
|
|
||||||
req.setValue("https://claude.ai/", forHTTPHeaderField: "Referer")
|
|
||||||
req.setValue("empty", forHTTPHeaderField: "sec-fetch-dest")
|
|
||||||
req.setValue("cors", forHTTPHeaderField: "sec-fetch-mode")
|
|
||||||
req.setValue("same-origin", forHTTPHeaderField: "sec-fetch-site")
|
|
||||||
req.setValue(
|
|
||||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
|
||||||
forHTTPHeaderField: "User-Agent")
|
|
||||||
req.setValue(
|
|
||||||
"\"Chromium\";v=\"124\", \"Google Chrome\";v=\"124\", \"Not-A.Brand\";v=\"99\"",
|
|
||||||
forHTTPHeaderField: "sec-ch-ua")
|
|
||||||
req.setValue("?0", forHTTPHeaderField: "sec-ch-ua-mobile")
|
|
||||||
req.setValue("\"macOS\"", forHTTPHeaderField: "sec-ch-ua-platform")
|
|
||||||
req.setValue("web", forHTTPHeaderField: "anthropic-client-platform")
|
|
||||||
|
|
||||||
let session = URLSession(configuration: .ephemeral)
|
|
||||||
do {
|
|
||||||
let (data, response) = try await session.data(for: req)
|
|
||||||
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
|
||||||
let body = String(data: data, encoding: .utf8) ?? ""
|
|
||||||
diagLastStatus = http.statusCode
|
|
||||||
if http.statusCode != 200 {
|
|
||||||
diagLastError = "HTTP \(http.statusCode)"
|
|
||||||
}
|
|
||||||
return (http.statusCode, body)
|
|
||||||
} catch let e as AppError { throw e }
|
|
||||||
catch {
|
|
||||||
let msg = error.localizedDescription
|
|
||||||
diagLastError = msg
|
|
||||||
throw AppError.detail(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkInitialSignInState() async {
|
|
||||||
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
|
||||||
guard cookies.contains(where: { $0.domain.contains("claude.ai") }) else { return }
|
|
||||||
isSignedIn = true
|
|
||||||
// AppDelegate's primer WebView will call adoptPrimerWebView once it loads,
|
|
||||||
// which triggers the first real data fetch.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func signOut() async {
|
func signOut() async {
|
||||||
let store = WKWebsiteDataStore.default()
|
let store = WKWebsiteDataStore.default()
|
||||||
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
let types = WKWebsiteDataStore.allWebsiteDataTypes()
|
||||||
let records = await store.dataRecords(ofTypes: types)
|
let records = await store.dataRecords(ofTypes: types)
|
||||||
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") || $0.displayName.contains("anthropic.com") }
|
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")
|
|
||||||
apiWebView = nil
|
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
isNotAuthenticated = true
|
isNotAuthenticated = true
|
||||||
lastUpdated = nil
|
lastUpdated = nil
|
||||||
@@ -208,50 +63,18 @@ class UsageViewModel: ObservableObject {
|
|||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Read lastActiveOrg cookie from JS — most reliable org source since it's
|
let orgId = cachedOrgId!
|
||||||
// set by the claude.ai frontend to whichever org is currently active.
|
|
||||||
var cookieOrgId: String? = nil
|
|
||||||
if let wv = apiWebView, wv.url?.host?.hasSuffix("claude.ai") == true {
|
|
||||||
let js = """
|
|
||||||
return document.cookie.split(';')
|
|
||||||
.map(c => c.trim().split('='))
|
|
||||||
.filter(p => p[0] === 'lastActiveOrg')
|
|
||||||
.map(p => p.slice(1).join('='))[0] || null;
|
|
||||||
"""
|
|
||||||
let raw: Any? = try? await withCheckedThrowingContinuation { cont in
|
|
||||||
wv.callAsyncJavaScript(js, arguments: [:], in: nil, in: .defaultClient) { r in
|
|
||||||
cont.resume(returning: (try? r.get()) ?? nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let v = raw as? String, !v.isEmpty {
|
|
||||||
cookieOrgId = v
|
|
||||||
diagLastActiveOrg = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (fetchedOrgId, fetchedEmail, fetchedPlan) = try await fetchBootstrap(preferredOrgId: cookieOrgId)
|
|
||||||
|
|
||||||
let orgId: String
|
|
||||||
if let id = cookieOrgId ?? fetchedOrgId {
|
|
||||||
UserDefaults.standard.set(id, forKey: "claude_org_id")
|
|
||||||
orgId = id
|
|
||||||
} else if let cached = UserDefaults.standard.string(forKey: "claude_org_id") {
|
|
||||||
orgId = cached
|
|
||||||
} else {
|
|
||||||
throw AppError.notAuthenticated
|
|
||||||
}
|
|
||||||
|
|
||||||
if let email = fetchedEmail { userEmail = email }
|
|
||||||
if let plan = fetchedPlan { planLabel = plan }
|
|
||||||
|
|
||||||
async let usageFetch = fetchUsage(orgId: orgId)
|
async let usageFetch = fetchUsage(orgId: orgId)
|
||||||
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
|
||||||
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
|
||||||
let (usage, prepaid, overage) = try await (usageFetch, prepaidFetch, overageFetch)
|
async let emailFetch = fetchUserEmail()
|
||||||
|
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
|
||||||
|
if let email { userEmail = email }
|
||||||
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
|
||||||
|
|
||||||
@@ -261,13 +84,11 @@ 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
|
||||||
isSignedIn = false
|
|
||||||
errorMessage = "Not signed in"
|
errorMessage = "Not signed in"
|
||||||
} catch let error as DecodingError {
|
} catch let error as DecodingError {
|
||||||
switch error {
|
switch error {
|
||||||
@@ -285,75 +106,70 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Bootstrap
|
|
||||||
|
|
||||||
private func fetchBootstrap(preferredOrgId: String? = nil) async throws -> (orgId: String?, email: String?, planLabel: String?) {
|
|
||||||
let (status, body) = try await apiFetch("/api/bootstrap")
|
|
||||||
if status == 401 || status == 403 {
|
|
||||||
throw AppError.detail("HTTP \(status) — \(body.prefix(120))")
|
|
||||||
}
|
|
||||||
guard status == 200 else { throw AppError.detail("HTTP \(status): \(body.prefix(80))") }
|
|
||||||
guard body.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("{") else {
|
|
||||||
throw AppError.detail("Non-JSON response (HTML?): \(body.prefix(80))")
|
|
||||||
}
|
|
||||||
guard let data = body.data(using: .utf8),
|
|
||||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
||||||
return (nil, nil, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let account = json["account"] as? [String: Any]
|
|
||||||
let memberships = (account?["memberships"] ?? json["memberships"]) as? [[String: Any]]
|
|
||||||
let allOrgs = memberships?.compactMap { $0["organization"] as? [String: Any] } ?? []
|
|
||||||
let firstOrg = allOrgs.first
|
|
||||||
|
|
||||||
var orgId: String? = firstOrg?["uuid"] as? String
|
|
||||||
if orgId == nil {
|
|
||||||
orgId = (json["organizations"] as? [[String: Any]])?.first?["uuid"] as? String
|
|
||||||
}
|
|
||||||
if orgId == nil {
|
|
||||||
if let (orgsStatus, orgsBody) = try? await apiFetch("/api/organizations"),
|
|
||||||
orgsStatus == 200,
|
|
||||||
let orgsData = orgsBody.data(using: .utf8),
|
|
||||||
let orgs = try? JSONSerialization.jsonObject(with: orgsData) as? [[String: Any]] {
|
|
||||||
orgId = orgs.first?["uuid"] as? String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let email = account?["email_address"] as? String
|
|
||||||
|
|
||||||
// Read capabilities from the preferred (active) org, falling back to first org.
|
|
||||||
let capOrg = preferredOrgId.flatMap { id in allOrgs.first(where: { $0["uuid"] as? String == id }) } ?? firstOrg
|
|
||||||
var planLabel: String? = nil
|
|
||||||
if let caps = capOrg?["capabilities"] as? [String],
|
|
||||||
let cap = caps.first(where: { $0.hasPrefix("claude_") }) {
|
|
||||||
let name = String(cap.dropFirst("claude_".count))
|
|
||||||
planLabel = name.prefix(1).uppercased() + name.dropFirst().lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
return (orgId, email, planLabel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Fetch usage
|
// MARK: - Fetch usage
|
||||||
|
|
||||||
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
private func fetchUsage(orgId: String) async throws -> UsageResponse {
|
||||||
let (status, body) = try await apiFetch("/api/organizations/\(orgId)/usage")
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
|
||||||
if status == 401 || status == 403 { throw AppError.detail("usage \(status): \(body.prefix(200))") }
|
var req = URLRequest(url: url)
|
||||||
guard status == 200 else { throw AppError.networkError }
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
guard let data = body.data(using: .utf8) else { throw AppError.networkError }
|
|
||||||
|
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
|
||||||
|
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
|
||||||
|
guard http.statusCode == 200 else { throw AppError.networkError }
|
||||||
|
|
||||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func fetchUserEmail() async throws -> String? {
|
||||||
|
let url = URL(string: "https://claude.ai/api/bootstrap")!
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let account = json["account"] as? [String: Any],
|
||||||
|
let email = account["email_address"] as? String else { return nil }
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
|
||||||
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
|
||||||
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/prepaid/credits"),
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
|
||||||
status == 200,
|
var req = URLRequest(url: url)
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
|
||||||
guard let (status, body) = try? await apiFetch("/api/organizations/\(orgId)/overage_spend_limit"),
|
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
|
||||||
status == 200,
|
var req = URLRequest(url: url)
|
||||||
let data = body.data(using: .utf8) else { return nil }
|
req.setValue("application/json", forHTTPHeaderField: "accept")
|
||||||
|
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
|
||||||
|
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
|
||||||
|
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
|
||||||
|
req.setValue(header, forHTTPHeaderField: "Cookie")
|
||||||
|
}
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: req)
|
||||||
|
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
|
||||||
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,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 }
|
||||||
@@ -440,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(
|
||||||
@@ -448,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
|
||||||
}
|
}
|
||||||
@@ -470,17 +292,13 @@ class UsageViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
enum AppError: LocalizedError {
|
enum AppError: LocalizedError {
|
||||||
case notAuthenticated
|
case notAuthenticated
|
||||||
case networkError
|
case networkError
|
||||||
case detail(String)
|
|
||||||
var errorDescription: String? {
|
var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
|
||||||
case .networkError: return "Network error fetching usage data."
|
case .networkError: return "Network error fetching usage data."
|
||||||
case .detail(let msg): return msg
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -489,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,4 +0,0 @@
|
|||||||
## What's new in v1.3.2
|
|
||||||
|
|
||||||
### Reliability
|
|
||||||
- Data now loads automatically on every app launch without requiring manual re-authentication. The background WebView is anchored in a hidden window so macOS no longer throttles its JavaScript execution.
|
|
||||||
|
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 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.1-beta.25",
|
"version": "3.1.0-beta",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.2.1-beta.25/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v3.0.0/ClaudeChecker.zip",
|
||||||
"notes": "## What's new in v1.2.1-beta.18\n\n### Bug fixes\n- Replaced callAsyncJavaScript fetch approach with WebKit navigation: instead of running `fetch()` in the page's JS context (which was returning 401 because it bypasses the SPA's auth interceptors), each API call now navigates the WebView to the API URL directly. WebKit sends full browser headers and cookies automatically at the HTTP layer, the same way a real browser navigation works. This is more reliable regardless of what server-side auth mechanism claude.ai uses.\n- API calls are now sequential to share a single WebView for all navigations\n- Added redirect detection: if WebKit follows a 302 to /login, the response is treated as an auth failure rather than returning HTML to the JSON parser\n\n## What's new in v1.2.1-beta.17\n\n### Bug fixes\n- Fixed \"Not signed in\" after login by adopting the login WebView directly for API calls\n- Added diagnostic error messages for JS errors and unexpected responses\n\n## What's new in v1.2.1\n\n### Bug fixes\n- Fixed WebKit suspending the background WKWebView: anchored in a transparent 1×1 NSWindow\n- Fixed login window auto-closing before sign-in completes\n- Added /api/organizations as a final fallback for org ID resolution\n- Fixed Settings incorrectly showing \"Signed in\" after a failed refresh"
|
"notes": "Beta test build."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.3.1",
|
"version": "3.0.0",
|
||||||
"url": "https://github.com/superdooper86/claudechecker/releases/download/v1.3.1/ClaudeChecker.zip",
|
"url": "https://github.com/superdooper86/claudechecker/releases/download/v3.0.0/ClaudeChecker.zip",
|
||||||
"notes": "## What's new in v1.3.1\n\n### Privacy\n- Diagnostics panel no longer displays API response bodies, which could contain account and financial data. Status codes, error messages, cookie names, and request paths are still shown."
|
"notes": "Limit notifications for 80% and 95% usage, and on quota reset."
|
||||||
}
|
}
|
||||||
|
|||||||