ClaudeChecker v1.0.1

This commit is contained in:
superdooper86
2026-05-07 16:42:46 +02:00
commit ea77454c10
35 changed files with 3745 additions and 0 deletions
@@ -0,0 +1,68 @@
{
"images": [
{
"idiom": "mac",
"scale": "1x",
"size": "16x16",
"filename": "icon_16.png"
},
{
"idiom": "mac",
"scale": "2x",
"size": "16x16",
"filename": "[email protected]"
},
{
"idiom": "mac",
"scale": "1x",
"size": "32x32",
"filename": "icon_32.png"
},
{
"idiom": "mac",
"scale": "2x",
"size": "32x32",
"filename": "[email protected]"
},
{
"idiom": "mac",
"scale": "1x",
"size": "128x128",
"filename": "icon_128.png"
},
{
"idiom": "mac",
"scale": "2x",
"size": "128x128",
"filename": "[email protected]"
},
{
"idiom": "mac",
"scale": "1x",
"size": "256x256",
"filename": "icon_256.png"
},
{
"idiom": "mac",
"scale": "2x",
"size": "256x256",
"filename": "[email protected]"
},
{
"idiom": "mac",
"scale": "1x",
"size": "512x512",
"filename": "icon_512.png"
},
{
"idiom": "mac",
"scale": "2x",
"size": "512x512",
"filename": "[email protected]"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 764 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

@@ -0,0 +1,11 @@
{
"images": [
{
"filename": "MenubarTemplate.png",
"idiom": "universal",
"scale": "2x"
}
],
"info": { "author": "xcode", "version": 1 },
"properties": { "template-rendering-intent": "template" }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

+225
View File
@@ -0,0 +1,225 @@
import SwiftUI
import WebKit
import Combine
@main
struct ClaudeCheckerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
Settings { EmptyView() }
}
}
@MainActor
class AppDelegate: NSObject, NSApplicationDelegate {
var statusItem: NSStatusItem?
var popover: NSPopover?
var usageViewModel = UsageViewModel()
var updateManager = UpdateManager()
var refreshTimer: Timer?
var cookiePrimerView: WKWebView?
var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Hidden WKWebView to prime the shared cookie store
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai/api/organizations/\(UsageViewModel.orgId)/usage")!))
// Status item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem?.button {
setMenubarIcon(button: button)
button.action = #selector(handleClick)
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
button.target = self
}
// Popover
popover = NSPopover()
popover?.contentSize = NSSize(width: 480, height: 640)
popover?.behavior = .transient
popover?.animates = true
popover?.contentViewController = NSHostingController(
rootView: ContentView()
.environmentObject(usageViewModel)
.environmentObject(updateManager)
)
// React to limits or menubar toggle changes
usageViewModel.$limits
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
usageViewModel.$showInMenuBar
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.updateStatusItem() }
.store(in: &cancellables)
// Refresh timer
scheduleTimer(interval: usageViewModel.refreshInterval)
NotificationCenter.default.addObserver(forName: .showPopover, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor [weak self] in self?.showPopover() }
}
NotificationCenter.default.addObserver(forName: .updateDetected, object: nil, queue: .main) { [weak self] note in
Task { @MainActor [weak self] in
guard let self else { return }
let version = note.object as? String ?? ""
let notes = self.updateManager.releaseNotes
UpdateNotificationWindowController.showUpdate(
version: version,
notes: notes,
near: self.statusItem
)
}
}
NotificationCenter.default.addObserver(forName: .limitWarning, object: nil, queue: .main) { [weak self] note in
Task { @MainActor [weak self] in
guard let self, let info = note.userInfo,
let windowName = info["windowName"] as? String,
let percent = info["percent"] as? Int else { return }
UpdateNotificationWindowController.showLimitWarning(windowName: windowName, percent: percent, near: self.statusItem)
}
}
NotificationCenter.default.addObserver(forName: .limitReset, object: nil, queue: .main) { [weak self] note in
Task { @MainActor [weak self] in
guard let self, let windowName = note.userInfo?["windowName"] as? String else { return }
UpdateNotificationWindowController.showLimitReset(windowName: windowName, near: self.statusItem)
}
}
NotificationCenter.default.addObserver(forName: .closePopover, object: nil, queue: .main) { [weak self] _ in
Task { @MainActor [weak self] in
self?.popover?.performClose(nil)
}
}
NotificationCenter.default.addObserver(forName: .refreshIntervalChanged, object: nil, queue: .main) { [weak self] note in
if let interval = note.object as? TimeInterval {
Task { @MainActor [weak self] in
self?.scheduleTimer(interval: interval)
}
}
}
// Auto-show compact notification if relaunched after an update
let flagFile = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".claudechecker_just_updated")
if FileManager.default.fileExists(atPath: flagFile.path) {
try? FileManager.default.removeItem(at: flagFile)
updateManager.justUpdated = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
UpdateNotificationWindowController.show(
version: version,
notes: "ClaudeChecker is now up to date.",
near: self?.statusItem
)
}
}
// 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) {
// Use template image so macOS auto-adapts to dark/light menu bar
if let img = NSImage(named: "MenubarTemplate") {
img.isTemplate = true
img.size = NSSize(width: 18, height: 18)
button.image = img
button.imageScaling = .scaleProportionallyDown
}
}
func scheduleTimer(interval: TimeInterval) {
refreshTimer?.invalidate()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task {
await self?.usageViewModel.refresh()
await self?.updateManager.checkForUpdates()
}
}
}
func updateStatusItem() {
guard let button = statusItem?.button else { return }
let limits = usageViewModel.limits
let show = usageViewModel.showInMenuBar && limits.first?.isLive == true
if show {
let fh = limits.first(where: { $0.window == .fiveHour })
let sd = limits.first(where: { $0.window == .sevenDay })
let fhStr = fh.map { "\(Int($0.usedPercent.rounded()))%" } ?? ""
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? ""
button.image = nil
button.attributedTitle = NSAttributedString(string: "")
button.title = "\(fhStr) \(sdStr)"
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
} else {
button.title = ""
button.attributedTitle = NSAttributedString(string: "")
button.font = NSFont.systemFont(ofSize: 12)
setMenubarIcon(button: button)
}
}
@objc func handleClick() {
guard let event = NSApp.currentEvent else { return }
if event.type == .rightMouseUp {
let menu = NSMenu()
let showItem = NSMenuItem(title: "Show ClaudeChecker", action: #selector(showPopover), keyEquivalent: "")
showItem.target = self
menu.addItem(showItem)
menu.addItem(.separator())
let quitItem = NSMenuItem(title: "Quit", action: #selector(quitApp), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem?.menu = menu
statusItem?.button?.performClick(nil)
DispatchQueue.main.async { self.statusItem?.menu = nil }
} else {
togglePopover()
}
}
@objc func showPopover() {
guard let button = statusItem?.button, let popover else { return }
if !popover.isShown {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}
}
func togglePopover() {
guard let button = statusItem?.button, let popover else { return }
if popover.isShown {
popover.performClose(nil)
} else {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
NSApp.activate(ignoringOtherApps: true)
}
}
@objc func quitApp() {
NSApp.terminate(nil)
}
func applicationWillTerminate(_ notification: Notification) {
refreshTimer?.invalidate()
}
}
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ClaudeChecker</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.1</string>
<key>CFBundleVersion</key>
<string>20</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026. All rights reserved.</string>
<key>NSMainStoryboardFile</key>
<string></string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSExceptionDomains</key>
<dict>
<key>api.anthropic.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<false/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
</dict>
</plist>
+82
View File
@@ -0,0 +1,82 @@
import SwiftUI
import WebKit
// MARK: - Login Web View
struct LoginWebView: NSViewRepresentable {
let onAuthenticated: () -> Void
func makeNSView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let webView = WKWebView(frame: .zero, configuration: config)
webView.navigationDelegate = context.coordinator
webView.load(URLRequest(url: URL(string: "https://claude.ai/login")!))
return webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(onAuthenticated: onAuthenticated)
}
class Coordinator: NSObject, WKNavigationDelegate {
let onAuthenticated: () -> Void
init(onAuthenticated: @escaping () -> Void) {
self.onAuthenticated = onAuthenticated
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
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()
}
}
}
}
}
}
// MARK: - Login Sheet View
struct LoginSheetView: View {
@Binding var isPresented: Bool
let onDone: () -> Void
@State private var authenticated = false
var body: some View {
VStack(spacing: 0) {
HStack {
Text(authenticated ? "✓ Signed in — loading data…" : "Sign in to Claude")
.font(.system(size: 13, weight: .semibold))
Spacer()
Button("Cancel") { isPresented = false }
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(Color(nsColor: .windowBackgroundColor))
Divider()
LoginWebView {
authenticated = true
// Auto-dismiss and refresh after brief delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isPresented = false
onDone()
}
}
}
.frame(width: 460, height: 560)
}
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
// MARK: - Models
struct AgentLimit: Identifiable {
let id = UUID()
var agent: AgentType
var window: WindowType
var usedPercent: Double
var timeRemaining: String
var resetDate: Date
var projectedHit: ProjectedHit
var burnRate: Double
var burnHistory: [Double]
var isLive: Bool
var usageLabel: String {
switch usedPercent {
case ..<30: return "low"
case 30..<70: return "med"
default: return "high"
}
}
}
enum AgentType: String {
case claude = "Claude"
}
enum WindowType: String {
case fiveHour = "5h"
case sevenDay = "7d"
var label: String { rawValue }
var displayName: String {
switch self {
case .fiveHour: return "5-hour"
case .sevenDay: return "7-day"
}
}
}
enum ProjectedHit {
case afterReset
case at(Date)
var display: String {
switch self {
case .afterReset: return "after reset"
case .at(let date):
let fmt = DateFormatter()
let cal = Calendar.current
if cal.isDateInToday(date) { fmt.dateFormat = "'today' h:mm a" }
else if cal.isDateInTomorrow(date) { fmt.dateFormat = "'tmw' h:mm a" }
else { fmt.dateFormat = "MMM d, h:mm a" }
return fmt.string(from: date)
}
}
}
// MARK: - API Response /api/organizations/{id}/usage
struct UsageResponse: Decodable {
let fiveHour: UsageWindow?
let sevenDay: UsageWindow?
let extraUsage: ExtraUsage?
enum CodingKeys: String, CodingKey {
case fiveHour = "five_hour"
case sevenDay = "seven_day"
case extraUsage = "extra_usage"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
fiveHour = try c.decodeIfPresent(UsageWindow.self, forKey: .fiveHour)
sevenDay = try c.decodeIfPresent(UsageWindow.self, forKey: .sevenDay)
extraUsage = try c.decodeIfPresent(ExtraUsage.self, forKey: .extraUsage)
}
}
struct UsageWindow: Codable {
let utilization: Double
let resetsAt: String?
enum CodingKeys: String, CodingKey {
case utilization
case resetsAt = "resets_at"
}
}
struct ExtraUsage: Codable {
let isEnabled: Bool
let currency: String?
enum CodingKeys: String, CodingKey {
case isEnabled = "is_enabled"
case currency
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
isEnabled = (try? c.decodeIfPresent(Bool.self, forKey: .isEnabled)) ?? false
currency = try? c.decodeIfPresent(String.self, forKey: .currency)
}
}
struct PrepaidCredits: Decodable {
let amount: Double?
let currency: String?
enum CodingKeys: String, CodingKey { case amount, currency }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
amount = try? c.decodeIfPresent(Double.self, forKey: .amount)
currency = try? c.decodeIfPresent(String.self, forKey: .currency)
}
}
struct OverageSpendLimit: Decodable {
let isEnabled: Bool
let usedCredits: Double?
let monthlyCreditLimit: Double?
let currency: String?
enum CodingKeys: String, CodingKey {
case isEnabled = "is_enabled"
case usedCredits = "used_credits"
case monthlyCreditLimit = "monthly_credit_limit"
case currency
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
isEnabled = (try? c.decodeIfPresent(Bool.self, forKey: .isEnabled)) ?? false
usedCredits = try? c.decodeIfPresent(Double.self, forKey: .usedCredits)
monthlyCreditLimit = try? c.decodeIfPresent(Double.self, forKey: .monthlyCreditLimit)
currency = try? c.decodeIfPresent(String.self, forKey: .currency)
}
}
// (Bootstrap models removed org ID is configured directly)
+330
View File
@@ -0,0 +1,330 @@
import Foundation
import AppKit
// MARK: - Version Info
struct VersionInfo: Codable {
let version: String
let url: String
let notes: String?
}
enum UpdateError: LocalizedError {
case downloadFailed
case unzipFailed
case appNotFound
case replaceFailed(String)
var errorDescription: String? {
switch self {
case .downloadFailed: return "Download failed. Check your connection."
case .unzipFailed: return "Could not unpack the update."
case .appNotFound: return "Could not find ClaudeChecker.app in the update."
case .replaceFailed(let r): return "Install failed: \(r)"
}
}
}
// MARK: - Update Manager
@MainActor
class UpdateManager: ObservableObject {
static let versionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version.json"
static let betaVersionURL = "https://raw.githubusercontent.com/superdooper86/claudechecker/refs/heads/main/version-beta.json"
@Published var updateAvailable = false
@Published var latestVersion = ""
@Published var releaseNotes = ""
@Published var downloadURL = ""
@Published var betaAvailable = false
@Published var latestBetaVersion = ""
@Published var isDownloading = false
@Published var downloadProgress: Double = 0
@Published var statusMessage = ""
@Published var updateError: String? = nil
@Published var updateComplete = false
@Published var justUpdated = false
private var notifiedVersion: String = ""
@Published var betaChannel: Bool = false {
didSet {
UserDefaults.standard.set(betaChannel, forKey: "beta_channel")
}
}
var currentVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
}
init() {
betaChannel = UserDefaults.standard.bool(forKey: "beta_channel")
}
// MARK: - Check
func checkForUpdates() async {
print("[UpdateManager] Checking for updates... current=\(currentVersion)")
async let stableResult = fetchVersion(from: Self.versionURL)
async let betaResult = fetchVersion(from: Self.betaVersionURL)
if let stable = await stableResult {
print("[UpdateManager] Stable remote=\(stable.version), isNewer=\(isNewer(stable.version, than: currentVersion))")
if isNewer(stable.version, than: currentVersion) {
latestVersion = stable.version
releaseNotes = stable.notes ?? ""
downloadURL = stable.url
updateAvailable = true
print("[UpdateManager] updateAvailable set to true")
if notifiedVersion != stable.version {
notifiedVersion = stable.version
NotificationCenter.default.post(name: .updateDetected, object: stable.version)
}
} else {
updateAvailable = false
latestVersion = ""
}
} else {
print("[UpdateManager] Stable fetch returned nil")
updateAvailable = false
latestVersion = ""
}
if let beta = await betaResult {
print("[UpdateManager] Beta remote=\(beta.version)")
if isNewer(beta.version, than: currentVersion) {
latestBetaVersion = beta.version
betaAvailable = true
if betaChannel && isNewer(beta.version, than: latestVersion) {
latestVersion = beta.version
releaseNotes = beta.notes ?? ""
downloadURL = beta.url
updateAvailable = true
if notifiedVersion != beta.version {
notifiedVersion = beta.version
NotificationCenter.default.post(name: .updateDetected, object: beta.version)
}
}
} else {
betaAvailable = false
latestBetaVersion = ""
}
} else {
print("[UpdateManager] Beta fetch returned nil")
betaAvailable = false
latestBetaVersion = ""
}
}
private func fetchVersion(from urlString: String) async -> VersionInfo? {
// Add timestamp to bust GitHub's 5-minute CDN cache
let cacheBusted = urlString + "?t=\(Int(Date().timeIntervalSince1970))"
guard let url = URL(string: cacheBusted) else {
print("[UpdateManager] Invalid URL: \(urlString)")
return nil
}
do {
var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData)
request.timeoutInterval = 10
let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
print("[UpdateManager] Fetch \(urlString) -> HTTP \(status)")
guard status == 200 else { return nil }
let info = try JSONDecoder().decode(VersionInfo.self, from: data)
print("[UpdateManager] Decoded version: \(info.version)")
return info
} catch {
print("[UpdateManager] Fetch error: \(error)")
return nil
}
}
// MARK: - Download & Install
func downloadAndInstall() async {
guard let src = URL(string: downloadURL) else { return }
isDownloading = true
updateError = nil
updateComplete = false
do {
// 1. Set up temp dir
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("CCUpdate_\(UUID().uuidString)")
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let zipURL = tempDir.appendingPathComponent("ClaudeChecker.zip")
// 2. Download with progress
statusMessage = "Downloading…"
let (asyncBytes, response) = try await URLSession.shared.bytes(from: src)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw UpdateError.downloadFailed
}
let total = Double(response.expectedContentLength)
var received: Double = 0
var buffer = Data()
if total > 0 { buffer.reserveCapacity(Int(total)) }
for try await byte in asyncBytes {
buffer.append(byte)
received += 1
if Int(received) % 50_000 == 0 && total > 0 {
downloadProgress = (received / total) * 0.75
}
}
try buffer.write(to: zipURL)
downloadProgress = 0.75
// 3. Unzip
statusMessage = "Unpacking…"
let unzipDir = tempDir.appendingPathComponent("unzipped")
try FileManager.default.createDirectory(at: unzipDir, withIntermediateDirectories: true)
let unzip = Process()
unzip.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
unzip.arguments = ["-q", "-o", zipURL.path, "-d", unzipDir.path]
try unzip.run()
unzip.waitUntilExit()
guard unzip.terminationStatus == 0 else { throw UpdateError.unzipFailed }
downloadProgress = 0.85
// 4. Find the .app
guard let newApp = findApp(in: unzipDir) else { throw UpdateError.appNotFound }
// 5. Determine install target same location as currently running app
let currentApp = Bundle.main.bundleURL
let installTarget = currentApp
statusMessage = "Installing…"
downloadProgress = 0.9
// 6. Write installer script to home dir (persists after app quits)
let homeDir = FileManager.default.homeDirectoryForCurrentUser
let scriptURL = homeDir.appendingPathComponent(".claudechecker_update.sh")
let logURL = homeDir.appendingPathComponent(".claudechecker_update.log")
let newAppPath = newApp.path
let targetPath = installTarget.path
let script = """
#!/bin/bash
exec > "\(logURL.path)" 2>&1
echo "=== ClaudeChecker Updater $(date) ==="
echo "Source: \(newAppPath)"
echo "Target: \(targetPath)"
# Wait for the old ClaudeChecker process to fully exit
for i in $(seq 1 20); do
if ! pgrep -x "ClaudeChecker" > /dev/null 2>&1; then
echo "Process exited after ${i}s"
break
fi
sleep 1
done
if [ ! -d "\(newAppPath)" ]; then
echo "ERROR: source not found"; exit 1
fi
echo "Replacing app..."
rm -rf "\(targetPath)"
/usr/bin/ditto "\(newAppPath)" "\(targetPath)"
if [ ! -d "\(targetPath)" ]; then
echo "ERROR: ditto failed"; exit 1
fi
chmod -R 755 "\(targetPath)"
xattr -cr "\(targetPath)" 2>/dev/null || true
sleep 1
echo "Relaunching..."
# Write flag file so app knows it just updated
touch "$HOME/.claudechecker_just_updated"
open "\(targetPath)"
sleep 5
rm -rf "\(tempDir.path)"
rm -f "\(scriptURL.path)"
echo "Done."
"""
try script.write(to: scriptURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: NSNumber(value: Int16(0o755))],
ofItemAtPath: scriptURL.path)
downloadProgress = 1.0
statusMessage = "Installed! Relaunching…"
updateComplete = true
try await Task.sleep(nanoseconds: 800_000_000)
do {
let launcher = Process()
launcher.executableURL = URL(fileURLWithPath: "/bin/sh")
launcher.arguments = ["-c", "nohup /bin/bash '\(scriptURL.path)' &"]
launcher.standardInput = FileHandle.nullDevice
launcher.standardOutput = FileHandle.nullDevice
launcher.standardError = FileHandle.nullDevice
try launcher.run()
} catch {
let fallback = Process()
fallback.executableURL = URL(fileURLWithPath: "/usr/bin/open")
fallback.arguments = [scriptURL.path]
try? fallback.run()
}
// Terminate belt and braces approach
DispatchQueue.main.async {
NSApp.terminate(nil)
// If NSApp.terminate gets blocked by a delegate, exit() guarantees quit
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
exit(0)
}
}
} catch let e as UpdateError {
updateError = e.localizedDescription
isDownloading = false
statusMessage = ""
// Log path for debugging: ~/.claudechecker_update.log
} catch {
updateError = error.localizedDescription
isDownloading = false
statusMessage = ""
}
}
// MARK: - Helpers
private func findApp(in dir: URL) -> URL? {
// Check direct children first
if let contents = try? FileManager.default.contentsOfDirectory(
at: dir, includingPropertiesForKeys: [.isDirectoryKey]) {
if let app = contents.first(where: { $0.pathExtension == "app" }) { return app }
}
// Then recurse
guard let enumerator = FileManager.default.enumerator(
at: dir, includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]) else { return nil }
for case let url as URL in enumerator where url.pathExtension == "app" {
return url
}
return nil
}
private func isNewer(_ version: String, than current: String) -> Bool {
let toInts = { (v: String) in v.split(separator: ".").compactMap { Int($0) } }
let a = toInts(version), b = toInts(current)
for i in 0..<max(a.count, b.count) {
let av = i < a.count ? a[i] : 0
let bv = i < b.count ? b[i] : 0
if av != bv { return av > bv }
}
return false
}
}
@@ -0,0 +1,186 @@
import SwiftUI
import AppKit
// MARK: - Compact update notification window (shown on relaunch after update)
class UpdateNotificationWindowController: NSWindowController {
static var shared: UpdateNotificationWindowController?
static var sharedLimit: UpdateNotificationWindowController?
// Called after successful install + relaunch
static func show(version: String, notes: String, near statusItem: NSStatusItem?) {
showWindow(version: version, subtitle: notes.isEmpty ? "ClaudeChecker is up to date." : notes, near: statusItem, isUpdate: false)
}
// Called when a new update is detected while running
static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) {
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?) {
let icon = percent >= 95 ? "exclamationmark.circle.fill" : "exclamationmark.triangle.fill"
let color = percent >= 95 ? Color.red : Color.orange
let title = "Claude \(windowName) limit at \(percent)%"
let body = percent >= 95 ? "Almost out of quota." : "Approaching your limit."
showLimitWindow(title: title, subtitle: body, badgeIcon: icon, badgeColor: color, near: statusItem)
}
static func showLimitReset(windowName: String, near statusItem: NSStatusItem?) {
showLimitWindow(
title: "Claude \(windowName) limit reset",
subtitle: "Your quota has been reset.",
badgeIcon: "arrow.clockwise.circle.fill",
badgeColor: .green,
near: statusItem
)
}
private static func showLimitWindow(title: String, subtitle: String, badgeIcon: String, badgeColor: Color, near statusItem: NSStatusItem?) {
sharedLimit?.close()
let content = UpdateNotificationView(
version: "", subtitle: subtitle, isUpdate: false,
titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor
) {
sharedLimit?.close()
sharedLimit = nil
}
let controller = makeWindow(content: content)
position(window: controller.window!, near: statusItem, existingWindow: shared?.window)
sharedLimit = controller
controller.showWindow(nil)
autoDismiss(controller: controller, slot: .limit)
}
private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) {
shared?.close()
let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate) {
shared?.close()
shared = nil
}
let controller = makeWindow(content: content)
position(window: controller.window!, near: statusItem, existingWindow: nil)
shared = controller
controller.showWindow(nil)
autoDismiss(controller: controller, slot: .update)
}
private static func makeWindow<V: View>(content: V) -> UpdateNotificationWindowController {
let hostingView = NSHostingView(rootView: content)
hostingView.frame = NSRect(x: 0, y: 0, width: 340, height: 1000)
let height = max(80, hostingView.fittingSize.height)
let size = NSSize(width: 340, height: height)
let window = NSWindow(
contentRect: NSRect(origin: .zero, size: size),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
window.isOpaque = false
window.backgroundColor = .clear
window.level = .statusBar
window.hasShadow = false
hostingView.frame = NSRect(origin: .zero, size: size)
window.contentView = hostingView
return UpdateNotificationWindowController(window: window)
}
private static func position(window: NSWindow, near statusItem: NSStatusItem?, existingWindow: NSWindow?) {
if let button = statusItem?.button,
let buttonWindow = button.window,
let screen = buttonWindow.screen {
let buttonFrame = buttonWindow.convertToScreen(button.frame)
let x = buttonFrame.midX - 170
let h = window.frame.height
let yOffset: CGFloat = existingWindow.map { $0.frame.height + 8 } ?? 0
let y = buttonFrame.minY - h - 8 - yOffset
let clampedX = max(8, min(x, screen.frame.maxX - 348))
window.setFrameOrigin(NSPoint(x: clampedX, y: y))
} else {
window.center()
}
}
private enum NotificationSlot { case update, limit }
private static func autoDismiss(controller: UpdateNotificationWindowController, slot: NotificationSlot) {
let window = controller.window!
DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.3
window.animator().alphaValue = 0
} completionHandler: {
controller.close()
switch slot {
case .update: if shared === controller { shared = nil }
case .limit: if sharedLimit === controller { sharedLimit = nil }
}
}
}
}
}
// MARK: - Notification View
struct UpdateNotificationView: View {
let version: String
let subtitle: String
let isUpdate: Bool
var titleText: String? = nil
var badgeIcon: String? = nil
var badgeColor: Color? = nil
let onDismiss: () -> Void
private var effectiveTitle: String {
titleText ?? (isUpdate ? "Update available — v\(version)" : "Updated to v\(version)")
}
private var effectiveBadgeIcon: String {
badgeIcon ?? (isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill")
}
private var effectiveBadgeColor: Color {
badgeColor ?? (isUpdate ? .blue : .green)
}
var body: some View {
HStack(spacing: 12) {
ZStack(alignment: .bottomTrailing) {
Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
.resizable()
.frame(width: 44, height: 44)
.cornerRadius(10)
Image(systemName: effectiveBadgeIcon)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(effectiveBadgeColor)
.background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
.offset(x: 4, y: 4)
}
VStack(alignment: .leading, spacing: 3) {
Text(effectiveTitle)
.font(.system(size: 13, weight: .semibold))
Text(subtitle)
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
Button(action: onDismiss) {
Image(systemName: "xmark")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 14)
.padding(.vertical, 14)
.frame(width: 340, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 13)
.fill(Color(nsColor: .windowBackgroundColor))
.shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 6)
)
}
}
+313
View File
@@ -0,0 +1,313 @@
import SwiftUI
import WebKit
@MainActor
class UsageViewModel: ObservableObject {
static let orgId = "daf626a9-4924-4ff3-ba98-23b523062f8e"
@Published var limits: [AgentLimit] = []
@Published var isLoading = false
@Published var lastUpdated: Date?
@Published var errorMessage: String?
@Published var extraUsage: ExtraUsage?
@Published var prepaidCredits: PrepaidCredits?
@Published var overageSpendLimit: OverageSpendLimit?
@Published var planLabel: String = "Claude"
@Published var isNotAuthenticated: Bool = false
@Published var isSignedIn: Bool = false
@Published var userEmail: String = ""
@Published var triggerLogin: Bool = false
@Published var showInMenuBar: Bool = true {
didSet { UserDefaults.standard.set(showInMenuBar, forKey: "show_in_menubar") }
}
@Published var refreshInterval: TimeInterval = 60 {
didSet {
UserDefaults.standard.set(refreshInterval, forKey: "refresh_interval")
NotificationCenter.default.post(name: .refreshIntervalChanged, object: refreshInterval)
}
}
private var burnHistoryStore: [String: [Double]] = [:]
private let maxHistorySamples = 24
private var cachedOrgId: String?
private var previousPercents: [String: Double] = [:]
private var firedThresholds: [String: Set<Int>] = [:]
init() {
cachedOrgId = UserDefaults.standard.string(forKey: "claude_org_id") ?? Self.orgId
let saved = UserDefaults.standard.double(forKey: "refresh_interval")
refreshInterval = saved > 0 ? saved : 60
showInMenuBar = UserDefaults.standard.object(forKey: "show_in_menubar") as? Bool ?? true
loadPlaceholderData()
}
func signOut() async {
let store = WKWebsiteDataStore.default()
let types = WKWebsiteDataStore.allWebsiteDataTypes()
let records = await store.dataRecords(ofTypes: types)
let claudeRecords = records.filter { $0.displayName.contains("claude.ai") }
await store.removeData(ofTypes: types, for: claudeRecords)
isSignedIn = false
isNotAuthenticated = true
lastUpdated = nil
userEmail = ""
limits = []
extraUsage = nil
prepaidCredits = nil
overageSpendLimit = nil
}
func refresh() async {
isLoading = true
errorMessage = nil
isNotAuthenticated = false
defer { isLoading = false }
do {
let orgId = cachedOrgId!
async let usageFetch = fetchUsage(orgId: orgId)
async let prepaidFetch = fetchPrepaidCredits(orgId: orgId)
async let overageFetch = fetchOverageSpendLimit(orgId: orgId)
async let emailFetch = fetchUserEmail()
let (usage, prepaid, overage, email) = try await (usageFetch, prepaidFetch, overageFetch, emailFetch)
if let email { userEmail = email }
limits = buildLimits(from: usage)
extraUsage = usage.extraUsage
prepaidCredits = prepaid
overageSpendLimit = overage
planLabel = "Pro"
lastUpdated = Date()
isSignedIn = true
for i in limits.indices {
let key = limits[i].window.rawValue
var history = burnHistoryStore[key] ?? []
history.append(limits[i].usedPercent)
if history.count > maxHistorySamples { history.removeFirst() }
burnHistoryStore[key] = history
if history.count > 1 { limits[i].burnHistory = history }
}
checkLimitNotifications(for: limits)
} catch AppError.notAuthenticated {
isNotAuthenticated = true
errorMessage = "Not signed in"
} catch let error as DecodingError {
switch error {
case .keyNotFound(let key, let ctx):
errorMessage = "Missing key '\(key.stringValue)' at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
case .typeMismatch(let type, let ctx):
errorMessage = "Type mismatch (\(type)) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
case .valueNotFound(let type, let ctx):
errorMessage = "Value not found (\(type)) at \(ctx.codingPath.map(\.stringValue).joined(separator: "."))"
default:
errorMessage = error.localizedDescription
}
} catch {
errorMessage = error.localizedDescription
}
}
// MARK: - Fetch usage
private func fetchUsage(orgId: String) async throws -> UsageResponse {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/usage")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
// Inject cookies from WKWebsiteDataStore (shared with browser/Claude app)
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw AppError.networkError }
if http.statusCode == 401 || http.statusCode == 403 { throw AppError.notAuthenticated }
guard http.statusCode == 200 else { throw AppError.networkError }
return try JSONDecoder().decode(UsageResponse.self, from: data)
}
private func fetchUserEmail() async throws -> String? {
let url = URL(string: "https://claude.ai/api/bootstrap")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let account = json["account"] as? [String: Any],
let email = account["email_address"] as? String else { return nil }
return email
}
private func fetchPrepaidCredits(orgId: String) async throws -> PrepaidCredits? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/prepaid/credits")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(PrepaidCredits.self, from: data)
}
private func fetchOverageSpendLimit(orgId: String) async throws -> OverageSpendLimit? {
let url = URL(string: "https://claude.ai/api/organizations/\(orgId)/overage_spend_limit")!
var req = URLRequest(url: url)
req.setValue("application/json", forHTTPHeaderField: "accept")
let cookies = await WKWebsiteDataStore.default().httpCookieStore.allCookies()
let claudeCookies = cookies.filter { $0.domain.contains("claude.ai") }
if let header = HTTPCookie.requestHeaderFields(with: claudeCookies)["Cookie"] {
req.setValue(header, forHTTPHeaderField: "Cookie")
}
let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }
return try? JSONDecoder().decode(OverageSpendLimit.self, from: data)
}
// MARK: - Build limits
private func buildLimits(from usage: UsageResponse) -> [AgentLimit] {
let now = Date()
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
func parseDate(_ str: String?) -> Date {
guard let str else { return now.addingTimeInterval(3600) }
return iso.date(from: str) ?? now.addingTimeInterval(3600)
}
func timeLeft(until reset: Date) -> String {
let diff = max(0, reset.timeIntervalSince(now))
if diff == 0 { return "resetting..." }
let h = Int(diff / 3600)
let m = Int((diff.truncatingRemainder(dividingBy: 3600)) / 60)
if h >= 24 { return "\(h/24)d \(h%24)h" }
return "\(h)h \(m)m"
}
func projected(pct: Double, windowHours: Double, reset: Date) -> ProjectedHit {
guard pct > 0 else { return .afterReset }
let rate = pct / windowHours
guard rate > 0 else { return .afterReset }
let hit = now.addingTimeInterval(((100 - pct) / rate) * 3600)
return hit > reset ? .afterReset : .at(hit)
}
var result: [AgentLimit] = []
if let fh = usage.fiveHour {
let reset = parseDate(fh.resetsAt)
let pct = min(100, max(0, fh.utilization))
result.append(AgentLimit(
agent: .claude, window: .fiveHour,
usedPercent: pct,
timeRemaining: timeLeft(until: reset),
resetDate: reset,
projectedHit: projected(pct: pct, windowHours: 5, reset: reset),
burnRate: pct / 5.0,
burnHistory: burnHistoryStore["5h"] ?? [pct],
isLive: true
))
}
if let sd = usage.sevenDay {
let reset = parseDate(sd.resetsAt)
let pct = min(100, max(0, sd.utilization))
result.append(AgentLimit(
agent: .claude, window: .sevenDay,
usedPercent: pct,
timeRemaining: timeLeft(until: reset),
resetDate: reset,
projectedHit: projected(pct: pct, windowHours: 7*24, reset: reset),
burnRate: pct / (7*24.0),
burnHistory: burnHistoryStore["7d"] ?? [pct],
isLive: true
))
}
return result
}
// MARK: - Limit notifications
private func checkLimitNotifications(for limits: [AgentLimit]) {
let thresholds = [80, 95]
for limit in limits {
let key = limit.window.rawValue
let curr = limit.usedPercent
let prev = previousPercents[key]
var fired = firedThresholds[key] ?? []
// Threshold warnings (80% and 95%)
for t in thresholds {
let threshold = Double(t)
guard curr >= threshold, prev ?? threshold < threshold, !fired.contains(t) else { continue }
fired.insert(t)
NotificationCenter.default.post(
name: .limitWarning,
object: nil,
userInfo: ["windowName": limit.window.displayName, "percent": t]
)
}
// Reset detection: was high, now low
if let prev, prev > 50, curr < 20 {
fired.removeAll()
NotificationCenter.default.post(
name: .limitReset,
object: nil,
userInfo: ["windowName": limit.window.displayName]
)
}
firedThresholds[key] = fired
previousPercents[key] = curr
}
}
private func loadPlaceholderData() {
let now = Date()
limits = [
AgentLimit(agent: .claude, window: .fiveHour,
usedPercent: 0, timeRemaining: "",
resetDate: now.addingTimeInterval(3600),
projectedHit: .afterReset, burnRate: 0,
burnHistory: [], isLive: false),
AgentLimit(agent: .claude, window: .sevenDay,
usedPercent: 0, timeRemaining: "",
resetDate: now.addingTimeInterval(7*24*3600),
projectedHit: .afterReset, burnRate: 0,
burnHistory: [], isLive: false),
]
}
}
enum AppError: LocalizedError {
case notAuthenticated
case networkError
var errorDescription: String? {
switch self {
case .notAuthenticated: return "Not signed into claude.ai — open claude.ai in your browser first."
case .networkError: return "Network error fetching usage data."
}
}
}
extension Notification.Name {
static let refreshIntervalChanged = Notification.Name("refreshIntervalChanged")
static let closePopover = Notification.Name("closePopover")
static let showPopover = Notification.Name("showPopover")
static let updateDetected = Notification.Name("updateDetected")
static let limitWarning = Notification.Name("limitWarning")
static let limitReset = Notification.Name("limitReset")
}