| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739 |
- //
- // AppDelegate.swift
- // zoom_app
- //
- // Created by Dev Mac 1 on 14/04/2026.
- //
- import Cocoa
- import UserNotifications
- @main
- class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
- private let darkModeDefaultsKey = "settings.darkModeEnabled"
- private let loginStateKey = "zoom_app.isLoggedIn"
- private var statusItem: NSStatusItem?
- private var statusMenu: NSMenu?
- private var mainWindowController: NSWindowController?
- private weak var mainWindow: NSWindow?
- private var mainOriginalContentViewController: NSViewController?
- private enum MenuItemTag: Int {
- case openHome = 10
- case openMeetings = 11
- case openScheduler = 12
- case openWidgets = 13
- case openAiAssistant = 14
- case openSettings = 15
- case newMeeting = 20
- case joinMeeting = 21
- case scheduleMeeting = 22
- case stopRecording = 23
- case openTopMeeting = 40
- case auth = 30
- case quit = 99
- }
- private enum DefaultWindowSize {
- static let width: CGFloat = 1020
- static let height: CGFloat = 690
- }
-
-
- func applicationDidFinishLaunching(_ aNotification: Notification) {
- // Ensure we are a regular app so windows can appear reliably.
- // (Some configurations behave like an accessory/menubar app otherwise.)
- NSApp.setActivationPolicy(.regular)
- let darkEnabled = systemPrefersDarkMode()
- UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
- NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
- UNUserNotificationCenter.current().delegate = self
- configureStatusItem()
- showSplashThenMainWindow()
- }
- func applicationWillTerminate(_ aNotification: Notification) {
- // Insert code here to tear down your application
- }
- func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
- // If enabled, macOS can restore the last window frame (size/position) on next launch.
- // This app wants a consistent default launch size instead.
- return false
- }
- private func applyDefaultWindowSizeIfNeeded() {
- let targetSize = NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height)
- for window in NSApplication.shared.windows {
- // Only touch the main app window (not OAuth popups, alerts, etc.).
- guard window.contentViewController is ViewController else { continue }
- window.isRestorable = false
- window.setContentSize(targetSize)
- window.center()
- }
- }
- private func systemPrefersDarkMode() -> Bool {
- let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
- let style = global?["AppleInterfaceStyle"] as? String
- return style?.lowercased() == "dark"
- }
- private func showSplashThenMainWindow() {
- let (window, originalVC) = openMainWindowIfNeededAndReturnWindow()
- guard let window else { return }
- let splashVC = SplashViewController()
- mainOriginalContentViewController = originalVC
- mainWindow = window
- // Show main window immediately with splash content.
- window.contentViewController = splashVC
- window.isRestorable = false
- window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
- window.center()
- NSApp.activate(ignoringOtherApps: true)
- window.makeKeyAndOrderFront(nil)
- Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
- guard let self else { return }
- self.restoreMainContentAfterSplash()
- }
- }
- @discardableResult
- private func openMainWindowIfNeededAndReturnWindow() -> (NSWindow?, NSViewController?) {
- if let existing = windowHostingViewController() {
- existing.isRestorable = false
- existing.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
- existing.center()
- existing.makeKeyAndOrderFront(nil)
- return (existing, existing.contentViewController)
- }
- let storyboard = NSStoryboard(name: "Main", bundle: nil)
- if let controller = storyboard.instantiateInitialController() as? NSWindowController {
- mainWindowController = controller
- controller.showWindow(nil)
- if let window = controller.window {
- window.isRestorable = false
- window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
- window.center()
- window.makeKeyAndOrderFront(nil)
- return (window, window.contentViewController)
- }
- }
- return (nil, nil)
- }
- private func restoreMainContentAfterSplash() {
- guard let window = mainWindow else { return }
- // If we captured the original VC (from storyboard), restore it.
- if let original = mainOriginalContentViewController {
- window.contentViewController = original
- }
- window.isRestorable = false
- window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
- window.center()
- window.makeKeyAndOrderFront(nil)
- mainOriginalContentViewController = nil
- mainWindow = nil
- }
- private func configureStatusItem() {
- let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
- if let button = item.button {
- if let image = NSImage(named: "StatusBarIcon") {
- image.size = NSSize(width: 18, height: 18)
- image.isTemplate = false
- button.image = image
- button.imagePosition = .imageOnly
- button.toolTip = "Companion AI for Zoom"
- } else {
- button.title = "Z"
- }
- }
- let menu = NSMenu()
- menu.delegate = self
- item.menu = menu
- statusItem = item
- statusMenu = menu
- rebuildStatusMenu()
- }
- func menuWillOpen(_ menu: NSMenu) {
- if Thread.isMainThread {
- MainActor.assumeIsolated {
- mainViewController()?.menuBarSyncTopMeetingsForStatusMenu()
- }
- }
- rebuildStatusMenu()
- }
- private func rebuildStatusMenu() {
- guard let menu = statusMenu else { return }
- menu.removeAllItems()
- appendMenuItem("Open Home", tag: .openHome, to: menu)
- appendMenuItem("Open Meetings", tag: .openMeetings, to: menu)
- appendMenuItem("Open Scheduler", tag: .openScheduler, to: menu)
- appendMenuItem("Open Widgets", tag: .openWidgets, to: menu)
- appendMenuItem("Open AI Companion", tag: .openAiAssistant, to: menu)
- appendMenuItem("Open Settings", tag: .openSettings, to: menu)
- menu.addItem(.separator())
- appendMenuItem("New Meeting", tag: .newMeeting, to: menu)
- appendMenuItem("Join Meeting", tag: .joinMeeting, to: menu)
- appendMenuItem("Schedule Meeting", tag: .scheduleMeeting, to: menu)
- if appendTopMeetingsSection(to: menu) {
- menu.addItem(.separator())
- }
- if MeetingRecordingManager.shared.isRecording {
- menu.addItem(.separator())
- appendMenuItem("Stop Recording", tag: .stopRecording, to: menu)
- }
- menu.addItem(.separator())
- let authTitle = isUserLoggedInForMenu() ? "Sign Out" : "Sign In"
- appendMenuItem(authTitle, tag: .auth, to: menu)
- menu.addItem(.separator())
- appendMenuItem("Quit", tag: .quit, to: menu)
- }
- private func appendMenuItem(_ title: String, tag: MenuItemTag, to menu: NSMenu) {
- let item = NSMenuItem(title: title, action: #selector(handleStatusMenuAction(_:)), keyEquivalent: "")
- item.target = self
- item.tag = tag.rawValue
- menu.addItem(item)
- }
- @discardableResult
- private func appendTopMeetingsSection(to menu: NSMenu) -> Bool {
- let meetings = Array(WidgetMeetingStore.load().prefix(3))
- guard meetings.isEmpty == false else { return false }
- menu.addItem(.separator())
- let header = NSMenuItem(title: "Top meetings", action: nil, keyEquivalent: "")
- header.isEnabled = false
- menu.addItem(header)
- for meeting in meetings {
- let item = NSMenuItem(
- title: TopMeetingsStatusBarFormatter.menuLine(for: meeting),
- action: #selector(handleStatusMenuAction(_:)),
- keyEquivalent: ""
- )
- item.target = self
- item.tag = MenuItemTag.openTopMeeting.rawValue
- item.representedObject = meeting.joinLink
- menu.addItem(item)
- }
- return true
- }
- @objc
- private func handleStatusMenuAction(_ sender: NSMenuItem) {
- guard let action = MenuItemTag(rawValue: sender.tag) else { return }
- if action == .openTopMeeting {
- focusMainAppWindow()
- mainViewController()?.menuBarOpenTopMeeting(joinLink: sender.representedObject as? String)
- return
- }
- if action == .quit {
- NSApp.terminate(nil)
- return
- }
- focusMainAppWindow()
- guard let controller = mainViewController() else { return }
- switch action {
- case .openHome:
- controller.menuBarShowSection(.home)
- case .openMeetings:
- controller.menuBarShowSection(.meetings)
- case .openScheduler:
- controller.menuBarShowSection(.scheduler)
- case .openWidgets:
- controller.menuBarShowSection(.widgets)
- case .openAiAssistant:
- controller.menuBarShowSection(.aiCompanion)
- case .openSettings:
- controller.menuBarShowSection(.settings)
- case .newMeeting:
- controller.menuBarTriggerNewMeeting()
- case .joinMeeting:
- controller.menuBarTriggerJoinMeeting()
- case .scheduleMeeting:
- controller.menuBarTriggerScheduleMeeting()
- case .stopRecording:
- Task { @MainActor in
- do {
- try await MeetingRecordingManager.shared.stopRecording()
- } catch {
- let alert = NSAlert()
- alert.messageText = "Could not save recording"
- alert.informativeText = error.localizedDescription
- alert.alertStyle = .warning
- alert.runModal()
- }
- }
- case .auth:
- controller.menuBarTriggerAuthAction()
- case .openTopMeeting:
- break
- case .quit:
- break
- }
- }
- private func focusMainAppWindow() {
- NSApp.activate(ignoringOtherApps: true)
- if let window = windowHostingViewController() {
- window.makeKeyAndOrderFront(nil)
- } else {
- NSApp.windows.first?.makeKeyAndOrderFront(nil)
- }
- }
- private func windowHostingViewController() -> NSWindow? {
- NSApp.windows.first(where: { $0.contentViewController is ViewController })
- }
- private func mainViewController() -> ViewController? {
- if let controller = NSApp.keyWindow?.contentViewController as? ViewController {
- return controller
- }
- if let controller = NSApp.mainWindow?.contentViewController as? ViewController {
- return controller
- }
- if let controller = windowHostingViewController()?.contentViewController as? ViewController {
- return controller
- }
- return nil
- }
- private func isUserLoggedInForMenu() -> Bool {
- if let controller = mainViewController() {
- return controller.menuBarIsUserLoggedIn()
- }
- return UserDefaults.standard.bool(forKey: loginStateKey)
- }
- }
- // Shared with ViewController and widgets (public = visible across files with Member Import Visibility).
- public struct WidgetMeetingSnapshot: Codable, Identifiable {
- public let id: String
- public let title: String
- public let timeText: String
- public let joinLink: String?
- public var startTime: TimeInterval?
- public var endTime: TimeInterval?
- public var timeZoneIdentifier: String?
- public init(
- id: String,
- title: String,
- timeText: String,
- joinLink: String?,
- startTime: TimeInterval? = nil,
- endTime: TimeInterval? = nil,
- timeZoneIdentifier: String? = nil
- ) {
- self.id = id
- self.title = title
- self.timeText = timeText
- self.joinLink = joinLink
- self.startTime = startTime
- self.endTime = endTime
- self.timeZoneIdentifier = timeZoneIdentifier
- }
- }
- public enum WidgetMeetingStore {
- static let key = "zoom_app.widget.topMeetings"
- static func load() -> [WidgetMeetingSnapshot] {
- guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
- return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
- }
- static func save(_ meetings: [WidgetMeetingSnapshot]) {
- guard let data = try? JSONEncoder().encode(meetings) else { return }
- UserDefaults.standard.set(data, forKey: key)
- }
- }
- public enum TopMeetingsStatusBarFormatter {
- public static func menuLine(for meeting: WidgetMeetingSnapshot) -> String {
- let titlePart = truncatedTitle(meeting.title, maxLength: 10)
- guard let startInterval = meeting.startTime else {
- return "\(meeting.timeText) \(titlePart)"
- }
- let timeZone = meeting.timeZoneIdentifier.flatMap(TimeZone.init(identifier:)) ?? .current
- let start = Date(timeIntervalSince1970: startInterval)
- let end = meeting.endTime.map { Date(timeIntervalSince1970: $0) } ?? start.addingTimeInterval(3600)
- let dayFormatter = DateFormatter()
- dayFormatter.dateFormat = "EEE"
- dayFormatter.timeZone = timeZone
- let timeFormatter = DateFormatter()
- timeFormatter.dateFormat = "h:mm a"
- timeFormatter.timeZone = timeZone
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "d MMM"
- dateFormatter.timeZone = timeZone
- let day = dayFormatter.string(from: start)
- let startText = timeFormatter.string(from: start)
- let endText = timeFormatter.string(from: end)
- let dateText = dateFormatter.string(from: start)
- return "\(day), \(startText) - \(endText) \(dateText) \(titlePart)"
- }
- private static func truncatedTitle(_ title: String, maxLength: Int) -> String {
- let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.count > maxLength else { return trimmed }
- return String(trimmed.prefix(maxLength)) + "..."
- }
- }
- extension AppDelegate: UNUserNotificationCenterDelegate {
- func userNotificationCenter(
- _ center: UNUserNotificationCenter,
- didReceive response: UNNotificationResponse,
- withCompletionHandler completionHandler: @escaping () -> Void
- ) {
- NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
- NSApp.activate(ignoringOtherApps: true)
- completionHandler()
- }
- func userNotificationCenter(
- _ center: UNUserNotificationCenter,
- willPresent notification: UNNotification,
- withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
- ) {
- completionHandler([.banner, .sound])
- }
- }
- private final class SplashViewController: NSViewController {
- private struct Theme {
- let isDark: Bool
- let backgroundTop: NSColor
- let backgroundBottom: NSColor
- let titleColor: NSColor
- let subtitleColor: NSColor
- let statusColor: NSColor
- let trackColor: NSColor
- let fillColor: NSColor
- let iconBadgeFill: NSColor
- }
- private final class GradientBackgroundView: NSView {
- let gradientLayer = CAGradientLayer()
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- wantsLayer = true
- layer = CALayer()
- gradientLayer.startPoint = CGPoint(x: 0.1, y: 1.0)
- gradientLayer.endPoint = CGPoint(x: 0.9, y: 0.0)
- gradientLayer.locations = [0.0, 1.0]
- layer?.addSublayer(gradientLayer)
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) { nil }
- override func layout() {
- super.layout()
- gradientLayer.frame = bounds
- }
- }
- private var theme: Theme = SplashViewController.makeTheme(isDark: true)
- private var dotsTimer: Timer?
- private var dotsStep: Int = 0
- private var progressTimer: Timer?
- private var progressValue: Double = 0
- private weak var rootBackground: GradientBackgroundView?
- private weak var iconBadgeView: NSView?
- private weak var iconView: NSImageView?
- private weak var titleLabel: NSTextField?
- private weak var subtitleLabel: NSTextField?
- private weak var contentStack: NSStackView?
- private weak var statusLabel: NSTextField?
- private weak var percentLabel: NSTextField?
- private weak var progressIndicator: NSProgressIndicator?
- override func viewDidLoad() {
- super.viewDidLoad()
- applyTheme()
- }
- override func viewDidAppear() {
- super.viewDidAppear()
- startAnimations()
- startDots()
- startProgress()
- }
- override func viewWillDisappear() {
- super.viewWillDisappear()
- dotsTimer?.invalidate()
- dotsTimer = nil
- progressTimer?.invalidate()
- progressTimer = nil
- }
- override func loadView() {
- let background = GradientBackgroundView()
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.alignment = .centerX
- stack.spacing = 10
- stack.translatesAutoresizingMaskIntoConstraints = false
- let iconBadge = NSView()
- iconBadge.translatesAutoresizingMaskIntoConstraints = false
- iconBadge.wantsLayer = true
- iconBadge.layer?.cornerRadius = 26
- iconBadge.layer?.masksToBounds = true
- let iconView = NSImageView()
- iconView.translatesAutoresizingMaskIntoConstraints = false
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.image = NSApp.applicationIconImage
- iconView.wantsLayer = true
- let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Companion AI for Zoom"
- let title = NSTextField(labelWithString: appName)
- title.font = .systemFont(ofSize: 30, weight: .semibold)
- let subtitle = NSTextField(labelWithString: "Preparing your meetings workspace.")
- subtitle.font = .systemFont(ofSize: 14, weight: .regular)
- subtitle.alignment = .center
- subtitle.maximumNumberOfLines = 2
- subtitle.lineBreakMode = .byWordWrapping
- let progressRow = NSStackView()
- progressRow.orientation = .horizontal
- progressRow.alignment = .centerY
- progressRow.spacing = 10
- progressRow.translatesAutoresizingMaskIntoConstraints = false
- let progress = NSProgressIndicator()
- progress.translatesAutoresizingMaskIntoConstraints = false
- progress.isIndeterminate = false
- progress.minValue = 0
- progress.maxValue = 100
- progress.doubleValue = 10
- progress.controlSize = .small
- progress.style = .bar
- let percent = NSTextField(labelWithString: "10%")
- percent.font = .systemFont(ofSize: 12, weight: .medium)
- percent.alignment = .right
- progressRow.addArrangedSubview(progress)
- progressRow.addArrangedSubview(percent)
- let status = NSTextField(labelWithString: "CONNECTING SERVICES")
- status.font = .systemFont(ofSize: 10.5, weight: .semibold)
- status.alignment = .center
- status.usesSingleLineMode = true
- status.setAccessibilityLabel("Splash status")
- iconBadge.addSubview(iconView)
- NSLayoutConstraint.activate([
- iconView.centerXAnchor.constraint(equalTo: iconBadge.centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: iconBadge.centerYAnchor),
- iconView.widthAnchor.constraint(equalToConstant: 78),
- iconView.heightAnchor.constraint(equalTo: iconView.widthAnchor)
- ])
- stack.addArrangedSubview(iconBadge)
- stack.addArrangedSubview(title)
- stack.addArrangedSubview(subtitle)
- stack.addArrangedSubview(progressRow)
- stack.addArrangedSubview(status)
- background.addSubview(stack)
- NSLayoutConstraint.activate([
- iconBadge.widthAnchor.constraint(equalToConstant: 104),
- iconBadge.heightAnchor.constraint(equalToConstant: 104),
- stack.centerXAnchor.constraint(equalTo: background.centerXAnchor),
- stack.centerYAnchor.constraint(equalTo: background.centerYAnchor),
- stack.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 28),
- stack.trailingAnchor.constraint(lessThanOrEqualTo: background.trailingAnchor, constant: -28),
- progress.widthAnchor.constraint(equalToConstant: 260),
- progress.heightAnchor.constraint(equalToConstant: 6),
- percent.widthAnchor.constraint(equalToConstant: 44)
- ])
- // Initial state for animation.
- stack.alphaValue = 0
- stack.setFrameOrigin(NSPoint(x: stack.frame.origin.x, y: stack.frame.origin.y - 8))
- self.rootBackground = background
- self.iconBadgeView = iconBadge
- self.iconView = iconView
- self.titleLabel = title
- self.subtitleLabel = subtitle
- self.contentStack = stack
- self.statusLabel = status
- self.percentLabel = percent
- self.progressIndicator = progress
- view = background
- }
- private func startAnimations() {
- guard let stack = contentStack, let iconView else { return }
- // Entrance animation: fade + slight rise + gentle icon scale.
- iconView.layer?.transform = CATransform3DMakeScale(0.92, 0.92, 1)
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.55
- context.timingFunction = CAMediaTimingFunction(name: .easeOut)
- stack.animator().alphaValue = 1
- }
- let scale = CABasicAnimation(keyPath: "transform")
- scale.fromValue = CATransform3DMakeScale(0.92, 0.92, 1)
- scale.toValue = CATransform3DIdentity
- scale.duration = 0.55
- scale.timingFunction = CAMediaTimingFunction(name: .easeOut)
- iconView.layer?.add(scale, forKey: "splash.scaleIn")
- iconView.layer?.transform = CATransform3DIdentity
- // Subtle breathing pulse after entrance.
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { [weak self] in
- guard let self, let iconLayer = self.iconView?.layer else { return }
- let pulse = CABasicAnimation(keyPath: "transform")
- pulse.fromValue = CATransform3DIdentity
- pulse.toValue = CATransform3DMakeScale(1.03, 1.03, 1)
- pulse.duration = 0.9
- pulse.autoreverses = true
- pulse.repeatCount = .infinity
- pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
- iconLayer.add(pulse, forKey: "splash.pulse")
- }
- }
- private func startDots() {
- dotsTimer?.invalidate()
- dotsStep = 0
- dotsTimer = Timer.scheduledTimer(withTimeInterval: 0.45, repeats: true) { [weak self] _ in
- guard let self, let subtitleLabel else { return }
- self.dotsStep = (self.dotsStep + 1) % 4
- let dots = String(repeating: ".", count: self.dotsStep)
- // Keep the main subtitle stable; add subtle activity via ellipsis.
- subtitleLabel.stringValue = "Preparing your meetings workspace" + dots
- }
- }
- private func startProgress() {
- progressTimer?.invalidate()
- progressValue = 10
- setProgressUI(value: progressValue)
- // Animate progress smoothly for the 3s splash duration.
- let tick: TimeInterval = 0.06
- let target: Double = 100
- let duration: Double = 3.0
- let step = (target - progressValue) / (duration / tick)
- progressTimer = Timer.scheduledTimer(withTimeInterval: tick, repeats: true) { [weak self] timer in
- guard let self else { timer.invalidate(); return }
- self.progressValue = min(target, self.progressValue + step)
- self.setProgressUI(value: self.progressValue)
- if self.progressValue >= target - 0.01 {
- timer.invalidate()
- }
- }
- }
- private func setProgressUI(value: Double) {
- progressIndicator?.doubleValue = value
- percentLabel?.stringValue = "\(Int(round(value)))%"
- }
- private func applyTheme() {
- let isDark = view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- theme = Self.makeTheme(isDark: isDark)
- rootBackground?.gradientLayer.colors = [theme.backgroundTop.cgColor, theme.backgroundBottom.cgColor]
- // Icon badge styling.
- if let badge = iconBadgeView {
- badge.layer?.backgroundColor = theme.iconBadgeFill.cgColor
- }
- titleLabel?.textColor = theme.titleColor
- subtitleLabel?.textColor = theme.subtitleColor
- statusLabel?.textColor = theme.statusColor
- percentLabel?.textColor = theme.subtitleColor
- if let progress = progressIndicator {
- progress.appearance = NSAppearance(named: theme.isDark ? .darkAqua : .aqua)
- // Prefer tinting when available; fall back gracefully if not.
- if #available(macOS 10.14, *) {
- progress.contentFilters = []
- }
- }
- }
- private static func makeTheme(isDark: Bool) -> Theme {
- if isDark {
- return Theme(
- isDark: true,
- backgroundTop: NSColor(calibratedRed: 9 / 255, green: 9 / 255, blue: 10 / 255, alpha: 1),
- backgroundBottom: NSColor(calibratedRed: 16 / 255, green: 16 / 255, blue: 18 / 255, alpha: 1),
- titleColor: NSColor(calibratedWhite: 0.98, alpha: 1),
- subtitleColor: NSColor(calibratedWhite: 0.78, alpha: 1),
- statusColor: NSColor(calibratedWhite: 0.66, alpha: 1),
- trackColor: NSColor.white.withAlphaComponent(0.10),
- fillColor: NSColor(calibratedRed: 96 / 255, green: 215 / 255, blue: 255 / 255, alpha: 1),
- iconBadgeFill: NSColor.white.withAlphaComponent(0.08)
- )
- }
- return Theme(
- isDark: false,
- backgroundTop: NSColor(calibratedWhite: 0.98, alpha: 1),
- backgroundBottom: NSColor(calibratedWhite: 0.92, alpha: 1),
- titleColor: NSColor(calibratedWhite: 0.10, alpha: 1),
- subtitleColor: NSColor(calibratedWhite: 0.34, alpha: 1),
- statusColor: NSColor(calibratedWhite: 0.42, alpha: 1),
- trackColor: NSColor.black.withAlphaComponent(0.10),
- fillColor: NSColor(calibratedRed: 0.18, green: 0.52, blue: 0.96, alpha: 1),
- iconBadgeFill: NSColor.black.withAlphaComponent(0.04)
- )
- }
- }
|