Files
superdooper86 a338f4e5fe release 1.3.2: anchor primer WebView in hidden window for reliable startup fetch
Every launch now loads claude.ai in a real (off-screen, invisible) NSWindow so
WebKit doesn't throttle JS execution. The nav delegate fires adoptPrimerWebView
once the page loads, replacing the old 1.5 s sleep-then-refresh approach.
2026-05-11 14:01:07 +02:00

274 lines
11 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 cookiePrimerWindow: NSWindow?
var primerNavDelegate: PrimerNavDelegate?
var cancellables = Set<AnyCancellable>()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Background WebView that loads claude.ai on every launch.
// Anchored in a real (off-screen, invisible) NSWindow so WebKit doesn't throttle
// JS execution. When the page finishes loading the nav delegate calls
// adoptPrimerWebView, which sets it as the API WebView and runs the first fetch.
let config = WKWebViewConfiguration()
config.websiteDataStore = WKWebsiteDataStore.default()
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
let win = NSWindow(
contentRect: NSRect(x: -9999, y: -9999, width: 1, height: 1),
styleMask: [],
backing: .buffered,
defer: false
)
win.contentView = wv
win.alphaValue = 0
win.orderFront(nil)
cookiePrimerWindow = win
cookiePrimerView = wv
let delegate = PrimerNavDelegate { [weak self] in
guard let self, let wv = self.cookiePrimerView else { return }
Task { @MainActor [weak self] in
guard let self else { return }
await self.usageViewModel.adoptPrimerWebView(wv)
await self.updateManager.checkForUpdates()
}
}
primerNavDelegate = delegate
wv.navigationDelegate = delegate
wv.load(URLRequest(url: URL(string: "https://claude.ai")!))
// Status item
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
)
}
}
}
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()
}
}
// One-shot WKNavigationDelegate: fires onDone on first finish or error, then goes silent.
final class PrimerNavDelegate: NSObject, WKNavigationDelegate {
private var done = false
private let onDone: () -> Void
init(_ onDone: @escaping () -> Void) { self.onDone = onDone }
private func finish() { guard !done else { return }; done = true; onDone() }
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { finish() }
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { finish() }
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { finish() }
}