import SwiftUI import AppKit // MARK: - Compact update notification window (shown on relaunch after update) class UpdateNotificationWindowController: NSWindowController { static var shared: UpdateNotificationWindowController? static var sharedLimit: UpdateNotificationWindowController? // Called after successful install + relaunch static func show(version: String, notes: String, near statusItem: NSStatusItem?) { showWindow(version: version, subtitle: notes.isEmpty ? "ClaudeChecker is up to date." : notes, near: statusItem, isUpdate: false) } // Called when a new update is detected while running static func showUpdate(version: String, notes: String, near statusItem: NSStatusItem?) { showWindow(version: version, subtitle: "Tap to view release notes & update now!", near: statusItem, isUpdate: true) } static func showLimitWarning(windowName: String, percent: Int, near statusItem: NSStatusItem?) { let icon = percent >= 95 ? "exclamationmark.circle.fill" : "exclamationmark.triangle.fill" let color = percent >= 95 ? Color.red : Color.orange let title = "Claude \(windowName) limit at \(percent)%" let body = percent >= 95 ? "Almost out of quota." : "Approaching your limit." showLimitWindow(title: title, subtitle: body, badgeIcon: icon, badgeColor: color, near: statusItem) } static func showLimitReset(windowName: String, near statusItem: NSStatusItem?) { showLimitWindow( title: "Claude \(windowName) limit reset", subtitle: "Your quota has been reset.", badgeIcon: "arrow.clockwise.circle.fill", badgeColor: .green, near: statusItem ) } private static func showLimitWindow(title: String, subtitle: String, badgeIcon: String, badgeColor: Color, near statusItem: NSStatusItem?) { sharedLimit?.close() let content = UpdateNotificationView( version: "", subtitle: subtitle, isUpdate: false, titleText: title, badgeIcon: badgeIcon, badgeColor: badgeColor, onTap: { sharedLimit?.close() sharedLimit = nil NotificationCenter.default.post(name: .showPopover, object: nil) } ) { sharedLimit?.close() sharedLimit = nil } let controller = makeWindow(content: content) position(window: controller.window!, near: statusItem, existingWindow: shared?.window) sharedLimit = controller controller.showWindow(nil) autoDismiss(controller: controller, slot: .limit) } private static func showWindow(version: String, subtitle: String, near statusItem: NSStatusItem?, isUpdate: Bool) { shared?.close() let content = UpdateNotificationView(version: version, subtitle: subtitle, isUpdate: isUpdate, onTap: { shared?.close() shared = nil NotificationCenter.default.post(name: .showPopover, object: nil) if isUpdate { NotificationCenter.default.post(name: .openUpdateSheet, object: nil) } } ) { shared?.close() shared = nil } let controller = makeWindow(content: content) position(window: controller.window!, near: statusItem, existingWindow: nil) shared = controller controller.showWindow(nil) autoDismiss(controller: controller, slot: .update) } private static func makeWindow(content: V) -> UpdateNotificationWindowController { let hostingView = NSHostingView(rootView: content) hostingView.frame = NSRect(x: 0, y: 0, width: 340, height: 1000) let height = max(80, hostingView.fittingSize.height) let size = NSSize(width: 340, height: height) let window = NSWindow( contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false ) window.isOpaque = false window.backgroundColor = .clear window.level = .statusBar window.hasShadow = false hostingView.frame = NSRect(origin: .zero, size: size) window.contentView = hostingView return UpdateNotificationWindowController(window: window) } private static func position(window: NSWindow, near statusItem: NSStatusItem?, existingWindow: NSWindow?) { if let button = statusItem?.button, let buttonWindow = button.window, let screen = buttonWindow.screen { let buttonFrame = buttonWindow.convertToScreen(button.frame) let x = buttonFrame.midX - 170 let h = window.frame.height let yOffset: CGFloat = existingWindow.map { $0.frame.height + 8 } ?? 0 let y = buttonFrame.minY - h - 8 - yOffset let clampedX = max(8, min(x, screen.frame.maxX - 348)) window.setFrameOrigin(NSPoint(x: clampedX, y: y)) } else { window.center() } } private enum NotificationSlot { case update, limit } private static func autoDismiss(controller: UpdateNotificationWindowController, slot: NotificationSlot) { let window = controller.window! DispatchQueue.main.asyncAfter(deadline: .now() + 8) { NSAnimationContext.runAnimationGroup { ctx in ctx.duration = 0.3 window.animator().alphaValue = 0 } completionHandler: { controller.close() switch slot { case .update: if shared === controller { shared = nil } case .limit: if sharedLimit === controller { sharedLimit = nil } } } } } } // MARK: - Notification View struct UpdateNotificationView: View { let version: String let subtitle: String let isUpdate: Bool var titleText: String? = nil var badgeIcon: String? = nil var badgeColor: Color? = nil var onTap: (() -> Void)? = nil let onDismiss: () -> Void private var effectiveTitle: String { titleText ?? (isUpdate ? "Update available — v\(version)" : "Updated to v\(version) ✓") } private var effectiveBadgeIcon: String { badgeIcon ?? (isUpdate ? "arrow.down.circle.fill" : "checkmark.circle.fill") } private var effectiveBadgeColor: Color { badgeColor ?? (isUpdate ? .blue : .green) } var body: some View { ZStack(alignment: .topTrailing) { Button(action: { onTap?() }) { HStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { Image(nsImage: NSImage(named: "AppIcon") ?? NSImage()) .resizable() .frame(width: 44, height: 44) .cornerRadius(10) Image(systemName: effectiveBadgeIcon) .font(.system(size: 16, weight: .semibold)) .foregroundColor(effectiveBadgeColor) .background(Color(nsColor: .windowBackgroundColor).clipShape(Circle())) .offset(x: 4, y: 4) } VStack(alignment: .leading, spacing: 3) { Text(effectiveTitle) .font(.system(size: 13, weight: .semibold)) .foregroundColor(.primary) Text(subtitle) .font(.system(size: 11)) .foregroundColor(.secondary) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 24) } .padding(.horizontal, 14) .padding(.vertical, 14) .frame(width: 340, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(.plain) Button(action: onDismiss) { Image(systemName: "xmark") .font(.system(size: 10, weight: .medium)) .foregroundColor(.secondary) } .buttonStyle(.plain) .padding(14) } .background( RoundedRectangle(cornerRadius: 13) .fill(Color(nsColor: .windowBackgroundColor)) .shadow(color: .black.opacity(0.25), radius: 16, x: 0, y: 6) ) } }