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") + ] + ) + ] +)