diff --git a/ClaudeChecker.xcodeproj/project.pbxproj b/ClaudeChecker.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..8e181cf
--- /dev/null
+++ b/ClaudeChecker.xcodeproj/project.pbxproj
@@ -0,0 +1,345 @@
+
+
+
+
+ archiveVersion
+ 1
+ classes
+
+ objectVersion
+ 77
+ objects
+
+ AAAAAA007
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA107
+
+ AAAAAA008
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA108
+
+ AAAAAA001
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA101
+
+ AAAAAA002
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA102
+
+ AAAAAA003
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA103
+
+ AAAAAA004
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA104
+
+ AAAAAA005
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA105
+
+ AAAAAA006
+
+ isa
+ PBXBuildFile
+ fileRef
+ AAAAAA106
+
+ AAAAAA107
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ sourcecode.swift
+ path
+ UpdateManager.swift
+ sourceTree
+ <group>
+
+ AAAAAA108
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ sourcecode.swift
+ path
+ UpdateNotificationWindow.swift
+ sourceTree
+ <group>
+
+ AAAAAA101
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ sourcecode.swift
+ path
+ ClaudeCheckerApp.swift
+ sourceTree
+ <group>
+
+ AAAAAA102
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ sourcecode.swift
+ path
+ ContentView.swift
+ sourceTree
+ <group>
+
+ AAAAAA103
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ sourcecode.swift
+ path
+ Models.swift
+ sourceTree
+ <group>
+
+ AAAAAA104
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ sourcecode.swift
+ path
+ UsageViewModel.swift
+ sourceTree
+ <group>
+
+ AAAAAA105
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ sourcecode.swift
+ path
+ LoginView.swift
+ sourceTree
+ <group>
+
+ AAAAAA106
+
+ isa
+ PBXFileReference
+ lastKnownFileType
+ folder.assetcatalog
+ path
+ Assets.xcassets
+ sourceTree
+ <group>
+
+ AAAAAA200
+
+ isa
+ PBXGroup
+ children
+
+ AAAAAA300
+
+ sourceTree
+ <group>
+
+ AAAAAA300
+
+ isa
+ PBXGroup
+ children
+
+ AAAAAA101
+ AAAAAA102
+ AAAAAA103
+ AAAAAA104
+ AAAAAA105
+ AAAAAA106
+ AAAAAA107
+ AAAAAA108
+
+ path
+ ClaudeChecker
+ sourceTree
+ <group>
+
+ AAAAAA400
+
+ isa
+ PBXNativeTarget
+ buildConfigurationList
+ AAAAAA500
+ buildPhases
+
+ AAAAAA600
+ AAAAAA650
+
+ buildRules
+
+ dependencies
+
+ name
+ ClaudeChecker
+ productName
+ ClaudeChecker
+ productType
+ com.apple.product-type.application
+
+ AAAAAA500
+
+ isa
+ XCConfigurationList
+ buildConfigurations
+
+ AAAAAA501
+ AAAAAA502
+
+ defaultConfigurationIsVisible
+ 0
+ defaultConfigurationName
+ Release
+
+ AAAAAA501
+
+ isa
+ XCBuildConfiguration
+ buildSettings
+
+ SWIFT_VERSION
+ 5.9
+ MACOSX_DEPLOYMENT_TARGET
+ 13.0
+ PRODUCT_NAME
+ ClaudeChecker
+ PRODUCT_BUNDLE_IDENTIFIER
+ com.claudechecker.app
+ INFOPLIST_FILE
+ ClaudeChecker/Info.plist
+ ALWAYS_SEARCH_USER_PATHS
+ NO
+ USE_HEADERMAP
+ NO
+ SWIFT_EMIT_LOC_STRINGS
+ YES
+ ASSETCATALOG_COMPILER_APPICON_NAME
+ AppIcon
+
+ name
+ Debug
+
+ AAAAAA502
+
+ isa
+ XCBuildConfiguration
+ buildSettings
+
+ SWIFT_VERSION
+ 5.9
+ MACOSX_DEPLOYMENT_TARGET
+ 13.0
+ PRODUCT_NAME
+ ClaudeChecker
+ PRODUCT_BUNDLE_IDENTIFIER
+ com.claudechecker.app
+ INFOPLIST_FILE
+ ClaudeChecker/Info.plist
+ ALWAYS_SEARCH_USER_PATHS
+ NO
+ USE_HEADERMAP
+ NO
+ SWIFT_EMIT_LOC_STRINGS
+ YES
+ ASSETCATALOG_COMPILER_APPICON_NAME
+ AppIcon
+
+ name
+ Release
+
+ AAAAAA600
+
+ isa
+ PBXSourcesBuildPhase
+ buildActionMask
+ 2147483647
+ files
+
+ AAAAAA001
+ AAAAAA002
+ AAAAAA003
+ AAAAAA004
+ AAAAAA005
+ AAAAAA007
+ AAAAAA008
+
+ runOnlyForDeploymentPostprocessing
+ 0
+
+ AAAAAA650
+
+ isa
+ PBXResourcesBuildPhase
+ buildActionMask
+ 2147483647
+ files
+
+ AAAAAA006
+
+ runOnlyForDeploymentPostprocessing
+ 0
+
+ AAAAAA700
+
+ isa
+ PBXProject
+ buildConfigurationList
+ AAAAAA800
+ compatibilityVersion
+ Xcode 15.0
+ mainGroup
+ AAAAAA200
+ targets
+
+ AAAAAA400
+
+
+ AAAAAA800
+
+ isa
+ XCConfigurationList
+ buildConfigurations
+
+ AAAAAA501
+ AAAAAA502
+
+ defaultConfigurationIsVisible
+ 0
+ defaultConfigurationName
+ Release
+
+
+ rootObject
+ AAAAAA700
+
+
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/Contents.json b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..8938466
--- /dev/null
+++ b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,68 @@
+{
+ "images": [
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "16x16",
+ "filename": "icon_16.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "16x16",
+ "filename": "icon_16@2x.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "32x32",
+ "filename": "icon_32.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "32x32",
+ "filename": "icon_32@2x.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "128x128",
+ "filename": "icon_128.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "128x128",
+ "filename": "icon_128@2x.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "256x256",
+ "filename": "icon_256.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "256x256",
+ "filename": "icon_256@2x.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "512x512",
+ "filename": "icon_512.png"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "512x512",
+ "filename": "icon_512@2x.png"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
\ No newline at end of file
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128.png
new file mode 100644
index 0000000..f1b00ac
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png
new file mode 100644
index 0000000..9049af1
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_128@2x.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_16.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_16.png
new file mode 100644
index 0000000..7ea1776
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_16.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png
new file mode 100644
index 0000000..4352356
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_16@2x.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_256.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_256.png
new file mode 100644
index 0000000..9049af1
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_256.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png
new file mode 100644
index 0000000..74527c8
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_256@2x.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_32.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_32.png
new file mode 100644
index 0000000..4352356
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_32.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png
new file mode 100644
index 0000000..bfc2805
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_32@2x.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_512.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_512.png
new file mode 100644
index 0000000..74527c8
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_512.png differ
diff --git a/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png
new file mode 100644
index 0000000..e60aac0
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/AppIcon.appiconset/icon_512@2x.png differ
diff --git a/ClaudeChecker/Assets.xcassets/MenubarTemplate.imageset/Contents.json b/ClaudeChecker/Assets.xcassets/MenubarTemplate.imageset/Contents.json
new file mode 100644
index 0000000..9d304e2
--- /dev/null
+++ b/ClaudeChecker/Assets.xcassets/MenubarTemplate.imageset/Contents.json
@@ -0,0 +1,11 @@
+{
+ "images": [
+ {
+ "filename": "MenubarTemplate.png",
+ "idiom": "universal",
+ "scale": "2x"
+ }
+ ],
+ "info": { "author": "xcode", "version": 1 },
+ "properties": { "template-rendering-intent": "template" }
+}
diff --git a/ClaudeChecker/Assets.xcassets/MenubarTemplate.imageset/MenubarTemplate.png b/ClaudeChecker/Assets.xcassets/MenubarTemplate.imageset/MenubarTemplate.png
new file mode 100644
index 0000000..5782dc0
Binary files /dev/null and b/ClaudeChecker/Assets.xcassets/MenubarTemplate.imageset/MenubarTemplate.png differ
diff --git a/ClaudeChecker/ClaudeCheckerApp.swift b/ClaudeChecker/ClaudeCheckerApp.swift
new file mode 100644
index 0000000..3dabb7d
--- /dev/null
+++ b/ClaudeChecker/ClaudeCheckerApp.swift
@@ -0,0 +1,208 @@
+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()
+
+ 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: 500)
+ 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: .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()
+ }
+}
diff --git a/ClaudeChecker/ContentView.swift b/ClaudeChecker/ContentView.swift
new file mode 100644
index 0000000..2648754
--- /dev/null
+++ b/ClaudeChecker/ContentView.swift
@@ -0,0 +1,1043 @@
+import SwiftUI
+
+struct ContentView: View {
+ @EnvironmentObject var vm: UsageViewModel
+ @EnvironmentObject var updater: UpdateManager
+ @State private var showSettings = false
+ @State private var showLogin = false
+ @State private var showUpdateSheet = false
+
+ var claudeLimits: [AgentLimit] { vm.limits }
+
+ var body: some View {
+ ZStack {
+ Color(nsColor: .windowBackgroundColor)
+ .opacity(0.97)
+
+ VStack(spacing: 0) {
+ // Header
+ PanelHeader(showSettings: $showSettings)
+ .environmentObject(vm)
+
+ Divider().opacity(0.4)
+
+ // Update success banner — shown after auto-relaunch
+ if updater.justUpdated && !showSettings {
+ UpdateSuccessBanner(version: updater.currentVersion) {
+ updater.justUpdated = false
+ }
+ }
+
+ // Update available banner
+ if updater.updateAvailable && !showSettings {
+ UpdateBanner(showSheet: $showUpdateSheet)
+ .environmentObject(updater)
+ }
+
+ if showSettings {
+ SettingsView(showSettings: $showSettings, showUpdateSheet: $showUpdateSheet)
+ .environmentObject(vm)
+ .environmentObject(updater)
+ .transition(.move(edge: .trailing).combined(with: .opacity))
+ } else {
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(spacing: 0) {
+ // Cards grid
+ if vm.limits.isEmpty {
+ EmptyStateView()
+ .padding(.vertical, 40)
+ } else {
+ VStack(spacing: 1) {
+ ForEach(vm.limits) { limit in
+ LimitCard(limit: limit)
+ }
+ }
+ .background(Color.primary.opacity(0.05))
+ .padding(.vertical, 1)
+
+ // Extra credits row
+ if let extra = vm.extraUsage, extra.isEnabled {
+ ExtraUsageRow(extra: extra)
+ .padding(.horizontal, 16)
+ .padding(.top, 12)
+ }
+
+ // Session Diary
+ DiarySection(limits: vm.limits)
+ .padding(.horizontal, 16)
+ .padding(.top, 14)
+ .padding(.bottom, 8)
+ }
+
+ if let err = vm.errorMessage {
+ if vm.isNotAuthenticated {
+ SignInBanner { showLogin = true }
+ .padding(.horizontal, 16)
+ .padding(.bottom, 8)
+ } else {
+ ErrorBanner(message: err)
+ .padding(.horizontal, 16)
+ .padding(.bottom, 8)
+ }
+ }
+ }
+ }
+ .transition(.opacity)
+
+ Divider().opacity(0.3)
+
+ // Footer
+ FooterBar()
+ .environmentObject(vm)
+ }
+ }
+ }
+ .frame(width: 480)
+ .animation(.easeInOut(duration: 0.2), value: showSettings)
+ .sheet(isPresented: $showLogin) {
+ LoginSheetView(isPresented: $showLogin) {
+ Task { await vm.refresh() }
+ }
+ }
+ .sheet(isPresented: $showUpdateSheet) {
+ UpdateSheet(isPresented: $showUpdateSheet)
+ .environmentObject(updater)
+ }
+ .onChange(of: vm.triggerLogin) { triggered in
+ if triggered {
+ showLogin = true
+ vm.triggerLogin = false
+ }
+ }
+ .task {
+ await updater.checkForUpdates()
+ }
+ }
+}
+
+// MARK: - Panel Header
+
+struct PanelHeader: View {
+ @EnvironmentObject var vm: UsageViewModel
+ @Binding var showSettings: Bool
+
+ var body: some View {
+ HStack(spacing: 10) {
+ // App icon
+ Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
+ .resizable()
+ .frame(width: 24, height: 24)
+ .cornerRadius(6)
+
+ Text("ClaudeChecker")
+ .font(.system(size: 14, weight: .semibold))
+
+ Spacer()
+
+ HeaderButton(icon: "gearshape") { showSettings.toggle() }
+ .background(showSettings ? Color.primary.opacity(0.08) : Color.clear)
+ .cornerRadius(6)
+
+ HeaderButton(icon: "xmark") {
+ NotificationCenter.default.post(name: .closePopover, object: nil)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 11)
+ }
+}
+
+struct HeaderButton: View {
+ let icon: String
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ Image(systemName: icon)
+ .font(.system(size: 11.5, weight: .regular))
+ .foregroundColor(.secondary)
+ .frame(width: 26, height: 26)
+ .background(Color.primary.opacity(0.001))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color.primary.opacity(0.1), lineWidth: 0.5)
+ )
+ }
+}
+
+// MARK: - Limit Card
+
+struct LimitCard: View {
+ let limit: AgentLimit
+ @EnvironmentObject var vm: UsageViewModel
+
+ var accentColor: Color { .orange }
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 16) {
+
+ // Gauge
+ GaugeView(percent: limit.usedPercent, color: accentColor)
+ .frame(width: 76, height: 76)
+
+ // Centre info block
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 6) {
+ AgentIconView(agent: .claude, size: 14)
+ Text("Claude")
+ .font(.system(size: 13, weight: .semibold))
+ Text("· \(vm.planLabel) · \(limit.window.label)")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ Spacer()
+ if limit.isLive { LiveBadge() }
+ Text("Usg: \(limit.usageLabel)")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.primary.opacity(0.06))
+ .cornerRadius(4)
+ .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.primary.opacity(0.1), lineWidth: 0.5))
+ }
+
+ ProgressBar(value: limit.usedPercent / 100, color: accentColor)
+ .frame(height: 4)
+
+ HStack(spacing: 16) {
+ HStack(spacing: 4) {
+ Image(systemName: "clock")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ Text(limit.timeRemaining)
+ .font(.system(size: 12, weight: .medium).monospacedDigit())
+ }
+ Text("Resets \(limit.resetDate.formatted(date: .abbreviated, time: .shortened))")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ Spacer()
+ HStack(spacing: 3) {
+ Image(systemName: "chart.line.uptrend.xyaxis")
+ .font(.system(size: 9))
+ .foregroundColor(.secondary)
+ Text(limit.projectedHit.display)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(Color(nsColor: .controlBackgroundColor))
+ }
+}
+
+// MARK: - Gauge View
+
+struct GaugeView: View {
+ let percent: Double
+ let color: Color
+
+ var body: some View {
+ ZStack {
+ // Track
+ Circle()
+ .trim(from: 0.125, to: 0.875)
+ .stroke(Color.primary.opacity(0.07), style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ .rotationEffect(.degrees(90))
+
+ // Fill
+ Circle()
+ .trim(from: 0.125, to: 0.125 + 0.75 * (percent / 100))
+ .stroke(color, style: StrokeStyle(lineWidth: 6, lineCap: .round))
+ .rotationEffect(.degrees(90))
+ .animation(.easeInOut(duration: 0.8), value: percent)
+
+ // Label
+ VStack(spacing: 1) {
+ Text("\(Int(percent.rounded()))%")
+ .font(.system(size: 17, weight: .bold, design: .monospaced))
+ .minimumScaleFactor(0.7)
+ Text("Updated\nnow")
+ .font(.system(size: 7.5))
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ }
+ }
+ }
+}
+
+// MARK: - Progress Bar
+
+struct ProgressBar: View {
+ let value: Double // 0.0–1.0
+ let color: Color
+
+ var body: some View {
+ GeometryReader { geo in
+ ZStack(alignment: .leading) {
+ RoundedRectangle(cornerRadius: 2)
+ .fill(Color.primary.opacity(0.07))
+ RoundedRectangle(cornerRadius: 2)
+ .fill(color)
+ .frame(width: geo.size.width * CGFloat(min(1, max(0, value))))
+ .animation(.easeInOut(duration: 0.6), value: value)
+ }
+ }
+ }
+}
+
+// MARK: - Live Badge
+
+struct LiveBadge: View {
+ @State private var pulse = false
+
+ var body: some View {
+ HStack(spacing: 4) {
+ Circle()
+ .fill(Color.green)
+ .frame(width: 5, height: 5)
+ .opacity(pulse ? 0.4 : 1.0)
+ .animation(.easeInOut(duration: 1.2).repeatForever(), value: pulse)
+ .onAppear { pulse = true }
+ Text("Live")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.green)
+ }
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.green.opacity(0.1))
+ .cornerRadius(4)
+ .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color.green.opacity(0.2), lineWidth: 0.5))
+ }
+}
+
+// MARK: - Agent Icon
+
+struct AgentIconView: View {
+ let agent: AgentType
+ let size: CGFloat
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .fill(Color.orange.opacity(0.15))
+ .frame(width: size * 1.4, height: size * 1.4)
+ Text("✦")
+ .font(.system(size: size * 0.7))
+ .foregroundColor(.orange)
+ }
+ }
+}
+
+// MARK: - Session Diary
+
+struct DiarySection: View {
+ let limits: [AgentLimit]
+
+ var sparkData: [Double] { limits.first(where: { $0.window == .fiveHour })?.burnHistory ?? [] }
+ var burnRate: Double { limits.first(where: { $0.window == .fiveHour })?.burnRate ?? 0 }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .firstTextBaseline, spacing: 6) {
+ Text("Session Diary")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(.secondary)
+ Text("quota samples · 5h avg burn rate")
+ .font(.system(size: 10.5))
+ .foregroundColor(Color.secondary.opacity(0.6))
+ }
+
+ DiaryRow(
+ totalSaved: sparkData.count,
+ windowSaved: sparkData.count,
+ burnRate: burnRate,
+ sparkData: sparkData,
+ color: .orange
+ )
+ }
+ }
+}
+
+struct DiaryRow: View {
+ let totalSaved: Int
+ let windowSaved: Int
+ let burnRate: Double
+ let sparkData: [Double]
+ let color: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 5) {
+ HStack {
+ AgentIconView(agent: .claude, size: 12)
+ Text("Claude")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(color)
+ Spacer()
+ Text("\(totalSaved) samples · Avg Burn Rate: +\(String(format: "%.1f", burnRate))%/h")
+ .font(.system(size: 10.5))
+ .foregroundColor(.secondary)
+ }
+
+ SparklineView(data: sparkData, color: color)
+ .frame(height: 28)
+ .cornerRadius(3)
+ }
+ }
+}
+
+// MARK: - Sparkline
+
+struct SparklineView: View {
+ let data: [Double]
+ let color: Color
+
+ private func pt(i: Int, w: CGFloat, h: CGFloat, minV: Double, range: Double) -> CGPoint {
+ let x = CGFloat(i) / CGFloat(data.count - 1) * w
+ let y = h - CGFloat((data[i] - minV) / range) * (h - 6) - 3
+ return CGPoint(x: x, y: y)
+ }
+
+ var body: some View {
+ GeometryReader { geo in
+ if data.count > 1 {
+ let w = geo.size.width
+ let h = geo.size.height
+ let minV = data.min() ?? 0
+ let maxV = data.max() ?? 1
+ let range = max(maxV - minV, 1)
+
+ ZStack {
+ Path { path in
+ path.move(to: CGPoint(x: 0, y: h))
+ for i in 0.. 80 ? .red : pct > 50 ? .orange : .green }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Image(systemName: "creditcard")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ Text("Extra Usage Credits")
+ .font(.system(size: 12, weight: .semibold))
+ .foregroundColor(.secondary)
+ Spacer()
+ Text("\(extra.currency) \(String(format: "%.2f", extra.usedCredits / 100)) / \(String(format: "%.2f", extra.monthlyLimit / 100))")
+ .font(.system(size: 11.5, weight: .medium).monospacedDigit())
+ .foregroundColor(color)
+ }
+ ProgressBar(value: pct / 100, color: color)
+ .frame(height: 4)
+ }
+ .padding(12)
+ .background(Color.primary.opacity(0.04))
+ .cornerRadius(8)
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.primary.opacity(0.08), lineWidth: 0.5))
+ }
+}
+
+// MARK: - Settings View
+
+struct SettingsView: View {
+ @EnvironmentObject var vm: UsageViewModel
+ @EnvironmentObject var updater: UpdateManager
+ @Binding var showSettings: Bool
+ @Binding var showUpdateSheet: Bool
+ @State private var checkingForUpdates = false
+
+ let refreshOptions: [(label: String, seconds: TimeInterval)] = [
+ ("1 min", 60),
+ ("2 min", 120),
+ ("3 min", 180),
+ ("4 min", 240),
+ ("5 min", 300),
+ ("10 min", 600),
+ ]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack {
+ Button(action: { showSettings = false }) {
+ HStack(spacing: 4) {
+ Image(systemName: "chevron.left")
+ Text("Back")
+ }
+ .font(.system(size: 12))
+ .foregroundColor(.accentColor)
+ }
+ .buttonStyle(.plain)
+ Spacer()
+ }
+
+ Text("Settings")
+ .font(.system(size: 16, weight: .semibold))
+
+ // Auth section
+ VStack(alignment: .leading, spacing: 6) {
+ Label("Authentication", systemImage: "person.crop.circle")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+
+ Text("ClaudeChecker signs into claude.ai inside a built-in browser window and reads your quota data directly from the API. Your session is stored locally and persists across restarts.")
+ .font(.system(size: 11.5))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Button(action: {
+ showSettings = false
+ vm.triggerLogin = true
+ }) {
+ Label("Sign in again", systemImage: "arrow.right.circle")
+ .font(.system(size: 12))
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+
+ Divider()
+
+ // Beta channel
+ VStack(alignment: .leading, spacing: 8) {
+ Label("Update channel", systemImage: "dot.radiowaves.left.and.right")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+
+ Toggle(isOn: $updater.betaChannel) {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Text("Beta updates")
+ .font(.system(size: 12))
+ Text("BETA")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundColor(.white)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 2)
+ .background(Color.orange)
+ .cornerRadius(4)
+ if updater.betaAvailable && !updater.betaChannel {
+ Text("v\(updater.latestBetaVersion) available")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.orange)
+ }
+ }
+ Text("Receive early access builds — may be less stable")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ }
+ .toggleStyle(.switch)
+ .onChange(of: updater.betaChannel) { _ in
+ Task { await updater.checkForUpdates() }
+ }
+ }
+
+ Divider()
+
+ // Menubar display
+ VStack(alignment: .leading, spacing: 8) {
+ Label("Menubar display", systemImage: "menubar.rectangle")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+
+ Toggle(isOn: $vm.showInMenuBar) {
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Show percentages in menubar")
+ .font(.system(size: 12))
+ Text("Displays 5h and 7d usage inline next to the icon")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ }
+ .toggleStyle(.switch)
+ }
+
+ Divider()
+
+ // Refresh interval
+ VStack(alignment: .leading, spacing: 8) {
+ Label("Auto-refresh interval", systemImage: "clock.arrow.circlepath")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+
+ Picker("", selection: $vm.refreshInterval) {
+ ForEach(refreshOptions, id: \.seconds) { option in
+ Text(option.label).tag(option.seconds)
+ }
+ }
+ .pickerStyle(.segmented)
+ .labelsHidden()
+ }
+
+ Divider()
+
+ // Endpoints info
+ VStack(alignment: .leading, spacing: 4) {
+ Label("Data source", systemImage: "network")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("• /api/organizations/{id}/usage")
+ Text("• /api/organizations/{id}/rate_limits")
+ }
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundColor(Color.secondary.opacity(0.7))
+ }
+
+ Spacer()
+
+ Divider()
+
+ // About
+ HStack(spacing: 12) {
+ Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
+ .resizable()
+ .frame(width: 36, height: 36)
+ .cornerRadius(8)
+
+ VStack(alignment: .leading, spacing: 3) {
+ HStack(spacing: 6) {
+ Text("ClaudeChecker")
+ .font(.system(size: 12, weight: .semibold))
+ Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")")
+ .font(.system(size: 11, weight: .medium, design: .monospaced))
+ .foregroundColor(.secondary)
+ .padding(.horizontal, 5)
+ .padding(.vertical, 1)
+ .background(Color.primary.opacity(0.06))
+ .cornerRadius(4)
+ if updater.updateAvailable {
+ Text("v\(updater.latestVersion) available")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.blue)
+ .cornerRadius(4)
+ } else if updater.betaAvailable {
+ Text("v\(updater.latestBetaVersion) beta")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(Color.orange)
+ .cornerRadius(4)
+ }
+ }
+ Text("Built by James Bone & Claude")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ Button(action: {
+ if updater.updateAvailable {
+ showSettings = false
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
+ showUpdateSheet = true
+ }
+ } else if updater.betaAvailable && !updater.betaChannel {
+ // Tease: explain they need to enable beta
+ } else {
+ checkingForUpdates = true
+ Task {
+ await updater.checkForUpdates()
+ checkingForUpdates = false
+ }
+ }
+ }) {
+ if checkingForUpdates {
+ ProgressView().scaleEffect(0.6).frame(width: 16, height: 16)
+ } else if updater.updateAvailable {
+ Text("Install")
+ .font(.system(size: 11))
+ } else if updater.betaAvailable && !updater.betaChannel {
+ Text("Enable beta to install")
+ .font(.system(size: 10))
+ .foregroundColor(.orange)
+ } else {
+ Text("Check")
+ .font(.system(size: 11))
+ }
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ .disabled(updater.betaAvailable && !updater.updateAvailable && !updater.betaChannel)
+ }
+ .padding(.top, 4)
+ }
+ .padding(16)
+ }
+}
+
+// MARK: - Sign In Banner
+
+struct SignInBanner: View {
+ let onSignIn: () -> Void
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "person.crop.circle.badge.exclamationmark")
+ .foregroundColor(.orange)
+ .font(.system(size: 14))
+ VStack(alignment: .leading, spacing: 2) {
+ Text("Not signed in")
+ .font(.system(size: 12, weight: .medium))
+ Text("Sign in to claude.ai to see your usage.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ Button("Sign In") { onSignIn() }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 10)
+ .background(Color.orange.opacity(0.08))
+ .cornerRadius(8)
+ .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.orange.opacity(0.2), lineWidth: 0.5))
+ }
+}
+
+// MARK: - Error Banner
+
+struct ErrorBanner: View {
+ let message: String
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ .font(.system(size: 12))
+ Text(message)
+ .font(.system(size: 11.5))
+ .foregroundColor(.secondary)
+ Spacer()
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 8)
+ .background(Color.orange.opacity(0.08))
+ .cornerRadius(6)
+ .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.orange.opacity(0.2), lineWidth: 0.5))
+ }
+}
+
+// MARK: - Empty State
+
+struct EmptyStateView: View {
+ var body: some View {
+ VStack(spacing: 10) {
+ Image(systemName: "key.slash")
+ .font(.system(size: 28))
+ .foregroundColor(.secondary)
+ Text("No API key configured")
+ .font(.system(size: 13, weight: .medium))
+ Text("Open Settings to add your Anthropic API key.")
+ .font(.system(size: 11.5))
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
+
+// MARK: - Color Extension
+
+extension Color {
+ init(hex: String) {
+ let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+ var int: UInt64 = 0
+ Scanner(string: hex).scanHexInt64(&int)
+ let r = Double((int >> 16) & 0xFF) / 255
+ let g = Double((int >> 8) & 0xFF) / 255
+ let b = Double(int & 0xFF) / 255
+ self.init(red: r, green: g, blue: b)
+ }
+}
+
+// MARK: - Update Success Banner
+
+struct UpdateSuccessBanner: View {
+ let version: String
+ let onDismiss: () -> Void
+ @State private var timeRemaining = 10
+ @State private var timer: Timer? = nil
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundColor(.green)
+ .font(.system(size: 16))
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Updated to v\(version) successfully!")
+ .font(.system(size: 12, weight: .semibold))
+ Text("ClaudeChecker is now up to date.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ Text("\(timeRemaining)s")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(Color.secondary.opacity(0.5))
+ Button(action: {
+ timer?.invalidate()
+ onDismiss()
+ }) {
+ Image(systemName: "xmark")
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(Color.green.opacity(0.08))
+ .overlay(Divider(), alignment: .bottom)
+ .onAppear {
+ timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
+ if timeRemaining > 1 {
+ timeRemaining -= 1
+ } else {
+ timer?.invalidate()
+ onDismiss()
+ }
+ }
+ }
+ .onDisappear {
+ timer?.invalidate()
+ }
+ }
+}
+
+// MARK: - Update Banner
+
+struct UpdateBanner: View {
+ @EnvironmentObject var updater: UpdateManager
+ @Binding var showSheet: Bool
+
+ var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "arrow.down.circle.fill")
+ .foregroundColor(.blue)
+ .font(.system(size: 16))
+ VStack(alignment: .leading, spacing: 1) {
+ Text("Update available — v\(updater.latestVersion)")
+ .font(.system(size: 12, weight: .semibold))
+ if !updater.releaseNotes.isEmpty {
+ Text(updater.releaseNotes)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ Spacer()
+ Button("Update") { showSheet = true }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.small)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 10)
+ .background(Color.blue.opacity(0.08))
+ .overlay(Divider(), alignment: .bottom)
+ }
+}
+
+// MARK: - Update Sheet
+
+struct UpdateSheet: View {
+ @EnvironmentObject var updater: UpdateManager
+ @Binding var isPresented: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ HStack {
+ Text("Update ClaudeChecker")
+ .font(.system(size: 14, weight: .semibold))
+ Spacer()
+ if !updater.isDownloading && !updater.updateComplete {
+ Button("Not Now") { isPresented = false }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+
+ Divider()
+
+ VStack(spacing: 20) {
+ // Icon + version info
+ HStack(spacing: 14) {
+ Image(nsImage: NSImage(named: "AppIcon") ?? NSImage())
+ .resizable()
+ .frame(width: 48, height: 48)
+ .cornerRadius(11)
+
+ VStack(alignment: .leading, spacing: 4) {
+ HStack(spacing: 8) {
+ Text("v\(updater.currentVersion)")
+ .font(.system(size: 12, design: .monospaced))
+ .foregroundColor(.secondary)
+ Image(systemName: "arrow.right")
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ Text("v\(updater.latestVersion)")
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(.blue)
+ }
+ if !updater.releaseNotes.isEmpty {
+ Text(updater.releaseNotes)
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ }
+ }
+ Spacer()
+ }
+
+ if updater.updateComplete {
+ // Done state
+ VStack(spacing: 8) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 32))
+ .foregroundColor(.green)
+ Text(updater.statusMessage)
+ .font(.system(size: 13, weight: .medium))
+ }
+ } else if updater.isDownloading {
+ // Progress state
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text(updater.statusMessage)
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
+ Spacer()
+ Text("\(Int(updater.downloadProgress * 100))%")
+ .font(.system(size: 12, weight: .medium, design: .monospaced))
+ .foregroundColor(.secondary)
+ }
+ ProgressView(value: updater.downloadProgress)
+ .progressViewStyle(.linear)
+ .tint(.blue)
+ }
+ } else {
+ // Ready to install
+ VStack(spacing: 10) {
+ if let err = updater.updateError {
+ HStack(spacing: 6) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundColor(.orange)
+ Text(err)
+ .font(.system(size: 11.5))
+ .foregroundColor(.secondary)
+ }
+ }
+ Text("ClaudeChecker will download the update, replace itself, and relaunch automatically.")
+ .font(.system(size: 11.5))
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Button(action: {
+ Task { await updater.downloadAndInstall() }
+ }) {
+ HStack {
+ Image(systemName: "arrow.down.circle.fill")
+ Text(updater.updateError != nil ? "Try Again" : "Install Update")
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ }
+ }
+ }
+ .padding(20)
+ }
+ .frame(width: 380)
+ }
+}
diff --git a/ClaudeChecker/Info.plist b/ClaudeChecker/Info.plist
new file mode 100644
index 0000000..310f72d
--- /dev/null
+++ b/ClaudeChecker/Info.plist
@@ -0,0 +1,47 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ ClaudeChecker
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.6
+ CFBundleVersion
+ 6
+ LSMinimumSystemVersion
+ 13.0
+ LSUIElement
+
+ NSHumanReadableCopyright
+ Copyright © 2026. All rights reserved.
+ NSMainStoryboardFile
+
+ NSPrincipalClass
+ NSApplication
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSExceptionDomains
+
+ api.anthropic.com
+
+ NSExceptionAllowsInsecureHTTPLoads
+
+ NSIncludesSubdomains
+
+
+
+
+
+
diff --git a/ClaudeChecker/LoginView.swift b/ClaudeChecker/LoginView.swift
new file mode 100644
index 0000000..2f2b8dc
--- /dev/null
+++ b/ClaudeChecker/LoginView.swift
@@ -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)
+ }
+}
diff --git a/ClaudeChecker/Models.swift b/ClaudeChecker/Models.swift
new file mode 100644
index 0000000..1b49aa1
--- /dev/null
+++ b/ClaudeChecker/Models.swift
@@ -0,0 +1,99 @@
+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 }
+}
+
+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 monthlyLimit: Double
+ let usedCredits: Double
+ let utilization: Double
+ let currency: String
+ enum CodingKeys: String, CodingKey {
+ case isEnabled = "is_enabled"
+ case monthlyLimit = "monthly_limit"
+ case usedCredits = "used_credits"
+ case utilization
+ case currency
+ }
+}
+
+// (Bootstrap models removed — org ID is configured directly)
diff --git a/ClaudeChecker/UpdateManager.swift b/ClaudeChecker/UpdateManager.swift
new file mode 100644
index 0000000..64980ff
--- /dev/null
+++ b/ClaudeChecker/UpdateManager.swift
@@ -0,0 +1,315 @@
+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
+
+ @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) {
+ let wasAvailable = updateAvailable
+ latestVersion = stable.version
+ releaseNotes = stable.notes ?? ""
+ downloadURL = stable.url
+ updateAvailable = true
+ print("[UpdateManager] updateAvailable set to true")
+ // Only notify once — when first detected
+ if !wasAvailable {
+ NotificationCenter.default.post(name: .updateDetected, object: stable.version)
+ }
+ }
+ } else {
+ print("[UpdateManager] Stable fetch returned nil")
+ }
+
+ 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
+ }
+ }
+ } else {
+ print("[UpdateManager] Beta fetch returned nil")
+ }
+ }
+
+ 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.. bv }
+ }
+ return false
+ }
+}
diff --git a/ClaudeChecker/UpdateNotificationWindow.swift b/ClaudeChecker/UpdateNotificationWindow.swift
new file mode 100644
index 0000000..d125881
--- /dev/null
+++ b/ClaudeChecker/UpdateNotificationWindow.swift
@@ -0,0 +1,120 @@
+import SwiftUI
+import AppKit
+
+// MARK: - Compact update notification window (shown on relaunch after update)
+
+class UpdateNotificationWindowController: NSWindowController {
+ static var shared: 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)
+ }
+
+ 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 hosting = NSHostingController(rootView: content)
+
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 340, height: 80),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.level = .statusBar
+ window.hasShadow = false
+ window.contentViewController = hosting
+
+ 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 y = buttonFrame.minY - 80 - 8
+ let clampedX = max(8, min(x, screen.frame.maxX - 348))
+ window.setFrameOrigin(NSPoint(x: clampedX, y: y))
+ } else {
+ window.center()
+ }
+
+ let controller = UpdateNotificationWindowController(window: window)
+ shared = controller
+ controller.showWindow(nil)
+
+ // Auto-dismiss after 8 seconds
+ DispatchQueue.main.asyncAfter(deadline: .now() + 8) {
+ NSAnimationContext.runAnimationGroup { ctx in
+ ctx.duration = 0.3
+ window.animator().alphaValue = 0
+ } completionHandler: {
+ controller.close()
+ if shared === controller { shared = nil }
+ }
+ }
+ }
+}
+
+// MARK: - Notification View
+
+struct UpdateNotificationView: View {
+ let version: String
+ let subtitle: String
+ let isUpdate: Bool
+ let onDismiss: () -> Void
+
+ 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: isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(isUpdate ? .blue : .green)
+ .background(Color(nsColor: .windowBackgroundColor).clipShape(Circle()))
+ .offset(x: 4, y: 4)
+ }
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(isUpdate ? "Update available — v\(version)" : "Updated to v\(version) ✓")
+ .font(.system(size: 13, weight: .semibold))
+ Text(subtitle)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ 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, height: 80)
+ .background(
+ RoundedRectangle(cornerRadius: 13)
+ .fill(Color(nsColor: .windowBackgroundColor))
+ .shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 6)
+ )
+ }
+}
diff --git a/ClaudeChecker/UsageViewModel.swift b/ClaudeChecker/UsageViewModel.swift
new file mode 100644
index 0000000..92aa1fe
--- /dev/null
+++ b/ClaudeChecker/UsageViewModel.swift
@@ -0,0 +1,197 @@
+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 planLabel: String = "Claude"
+ @Published var isNotAuthenticated: Bool = false
+ @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?
+
+ 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 refresh() async {
+ isLoading = true
+ errorMessage = nil
+ isNotAuthenticated = false
+ defer { isLoading = false }
+
+ do {
+ let orgId = cachedOrgId!
+ let usage = try await fetchUsage(orgId: orgId)
+ limits = buildLimits(from: usage)
+ extraUsage = usage.extraUsage
+ planLabel = "Pro"
+ lastUpdated = Date()
+
+ 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 }
+ }
+ } 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)
+ }
+
+ // 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
+ }
+
+ 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")
+}
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..baa7509
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,19 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "ClaudeChecker",
+ platforms: [.macOS(.v13)],
+ products: [
+ .executable(name: "ClaudeChecker", targets: ["ClaudeChecker"])
+ ],
+ targets: [
+ .executableTarget(
+ name: "ClaudeChecker",
+ path: "ClaudeChecker",
+ resources: [
+ .process("Info.plist")
+ ]
+ )
+ ]
+)