244 lines
9.5 KiB
Swift
244 lines
9.5 KiB
Swift
import SwiftUI
|
|
import WebKit
|
|
import Combine
|
|
|
|
@main
|
|
struct ClaudeCheckerApp: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
|
var body: some Scene {
|
|
Settings { EmptyView() }
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
|
var statusItem: NSStatusItem?
|
|
var popover: NSPopover?
|
|
var usageViewModel = UsageViewModel()
|
|
var updateManager = UpdateManager()
|
|
var refreshTimer: Timer?
|
|
var cookiePrimerView: WKWebView?
|
|
var cancellables = Set<AnyCancellable>()
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
NSApp.setActivationPolicy(.accessory)
|
|
|
|
// Hidden WKWebView to prime the shared cookie store
|
|
let config = WKWebViewConfiguration()
|
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
|
cookiePrimerView = WKWebView(frame: .zero, configuration: config)
|
|
cookiePrimerView?.load(URLRequest(url: URL(string: "https://claude.ai")!))
|
|
|
|
// 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?.behavior = .transient
|
|
popover?.animates = true
|
|
let hc = NSHostingController(
|
|
rootView: ContentView()
|
|
.environmentObject(usageViewModel)
|
|
.environmentObject(updateManager)
|
|
)
|
|
hc.sizingOptions = .preferredContentSize
|
|
hc.view.wantsLayer = true
|
|
hc.view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
|
popover?.contentViewController = hc
|
|
|
|
// React to limits or menubar toggle changes
|
|
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)
|
|
|
|
updateManager.$updateAvailable
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in self?.updateStatusItem() }
|
|
.store(in: &cancellables)
|
|
|
|
// Refresh timer
|
|
scheduleTimer(interval: usageViewModel.refreshInterval)
|
|
|
|
NotificationCenter.default.addObserver(forName: .showPopover, object: nil, queue: .main) { [weak self] _ in
|
|
Task { @MainActor [weak self] in self?.showPopover() }
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: .updateDetected, object: nil, queue: .main) { [weak self] note in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
let version = note.object as? String ?? ""
|
|
let notes = self.updateManager.releaseNotes
|
|
UpdateNotificationWindowController.showUpdate(
|
|
version: version,
|
|
notes: notes,
|
|
near: self.statusItem
|
|
)
|
|
}
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: .limitWarning, object: nil, queue: .main) { [weak self] note in
|
|
Task { @MainActor [weak self] in
|
|
guard let self, let info = note.userInfo,
|
|
let windowName = info["windowName"] as? String,
|
|
let percent = info["percent"] as? Int else { return }
|
|
UpdateNotificationWindowController.showLimitWarning(windowName: windowName, percent: percent, near: self.statusItem)
|
|
}
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: .limitReset, object: nil, queue: .main) { [weak self] note in
|
|
Task { @MainActor [weak self] in
|
|
guard let self, let windowName = note.userInfo?["windowName"] as? String else { return }
|
|
UpdateNotificationWindowController.showLimitReset(windowName: windowName, near: self.statusItem)
|
|
}
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: .closePopover, object: nil, queue: .main) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.popover?.performClose(nil)
|
|
}
|
|
}
|
|
|
|
NotificationCenter.default.addObserver(forName: .refreshIntervalChanged, object: nil, queue: .main) { [weak self] note in
|
|
if let interval = note.object as? TimeInterval {
|
|
Task { @MainActor [weak self] in
|
|
self?.scheduleTimer(interval: interval)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-show compact notification if relaunched after an update
|
|
let flagFile = FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".claudechecker_just_updated")
|
|
if FileManager.default.fileExists(atPath: flagFile.path) {
|
|
try? FileManager.default.removeItem(at: flagFile)
|
|
updateManager.justUpdated = true
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
|
UpdateNotificationWindowController.show(
|
|
version: version,
|
|
notes: "ClaudeChecker is now up to date.",
|
|
near: self?.statusItem
|
|
)
|
|
}
|
|
}
|
|
|
|
// Initial fetch after cookie primer + update check
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
|
await usageViewModel.refresh()
|
|
await updateManager.checkForUpdates()
|
|
}
|
|
}
|
|
|
|
private func setMenubarIcon(button: NSStatusBarButton) {
|
|
// Use template image so macOS auto-adapts to dark/light menu bar
|
|
if let img = NSImage(named: "MenubarTemplate") {
|
|
img.isTemplate = true
|
|
img.size = NSSize(width: 18, height: 18)
|
|
button.image = img
|
|
button.imageScaling = .scaleProportionallyDown
|
|
}
|
|
}
|
|
|
|
func scheduleTimer(interval: TimeInterval) {
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
Task {
|
|
await self?.usageViewModel.refresh()
|
|
await self?.updateManager.checkForUpdates()
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateStatusItem() {
|
|
guard let button = statusItem?.button else { return }
|
|
let limits = usageViewModel.limits
|
|
let show = usageViewModel.showInMenuBar && limits.first?.isLive == true
|
|
|
|
if show {
|
|
let fh = limits.first(where: { $0.window == .fiveHour })
|
|
let sd = limits.first(where: { $0.window == .sevenDay })
|
|
let fhStr = fh.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
|
let sdStr = sd.map { "\(Int($0.usedPercent.rounded()))%" } ?? "—"
|
|
button.image = nil
|
|
button.attributedTitle = NSAttributedString(string: "")
|
|
let updatePrefix = updateManager.updateAvailable ? "↑ Update " : "◔ "
|
|
button.title = "\(updatePrefix)\(fhStr) \(sdStr)"
|
|
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
|
} else if updateManager.updateAvailable {
|
|
button.image = nil
|
|
button.attributedTitle = NSAttributedString(string: "")
|
|
button.title = "↑ Update"
|
|
button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium)
|
|
} 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 {
|
|
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
|
|
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 {
|
|
NotificationCenter.default.post(name: .popoverWillOpen, object: nil)
|
|
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()
|
|
}
|
|
|
|
}
|
|
|
|
|