AppDelegate.swift 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
  1. //
  2. // AppDelegate.swift
  3. // zoom_app
  4. //
  5. // Created by Dev Mac 1 on 14/04/2026.
  6. //
  7. import Cocoa
  8. import UserNotifications
  9. @main
  10. class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
  11. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  12. private let loginStateKey = "zoom_app.isLoggedIn"
  13. private var statusItem: NSStatusItem?
  14. private var statusMenu: NSMenu?
  15. private var mainWindowController: NSWindowController?
  16. private weak var mainWindow: NSWindow?
  17. private var mainOriginalContentViewController: NSViewController?
  18. private enum MenuItemTag: Int {
  19. case openHome = 10
  20. case openMeetings = 11
  21. case openScheduler = 12
  22. case openWidgets = 13
  23. case openAiAssistant = 14
  24. case openSettings = 15
  25. case newMeeting = 20
  26. case joinMeeting = 21
  27. case scheduleMeeting = 22
  28. case stopRecording = 23
  29. case openTopMeeting = 40
  30. case auth = 30
  31. case quit = 99
  32. }
  33. private enum DefaultWindowSize {
  34. static let width: CGFloat = 1020
  35. static let height: CGFloat = 690
  36. }
  37. func applicationDidFinishLaunching(_ aNotification: Notification) {
  38. // Ensure we are a regular app so windows can appear reliably.
  39. // (Some configurations behave like an accessory/menubar app otherwise.)
  40. NSApp.setActivationPolicy(.regular)
  41. let darkEnabled = systemPrefersDarkMode()
  42. UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
  43. NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
  44. UNUserNotificationCenter.current().delegate = self
  45. configureStatusItem()
  46. showSplashThenMainWindow()
  47. }
  48. func applicationWillTerminate(_ aNotification: Notification) {
  49. // Insert code here to tear down your application
  50. }
  51. func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
  52. // If enabled, macOS can restore the last window frame (size/position) on next launch.
  53. // This app wants a consistent default launch size instead.
  54. return false
  55. }
  56. private func applyDefaultWindowSizeIfNeeded() {
  57. let targetSize = NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height)
  58. for window in NSApplication.shared.windows {
  59. // Only touch the main app window (not OAuth popups, alerts, etc.).
  60. guard window.contentViewController is ViewController else { continue }
  61. window.isRestorable = false
  62. window.setContentSize(targetSize)
  63. window.center()
  64. }
  65. }
  66. private func systemPrefersDarkMode() -> Bool {
  67. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  68. let style = global?["AppleInterfaceStyle"] as? String
  69. return style?.lowercased() == "dark"
  70. }
  71. private func showSplashThenMainWindow() {
  72. let (window, originalVC) = openMainWindowIfNeededAndReturnWindow()
  73. guard let window else { return }
  74. let splashVC = SplashViewController()
  75. mainOriginalContentViewController = originalVC
  76. mainWindow = window
  77. // Show main window immediately with splash content.
  78. window.contentViewController = splashVC
  79. window.isRestorable = false
  80. window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  81. window.center()
  82. NSApp.activate(ignoringOtherApps: true)
  83. window.makeKeyAndOrderFront(nil)
  84. Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
  85. guard let self else { return }
  86. self.restoreMainContentAfterSplash()
  87. }
  88. }
  89. @discardableResult
  90. private func openMainWindowIfNeededAndReturnWindow() -> (NSWindow?, NSViewController?) {
  91. if let existing = windowHostingViewController() {
  92. existing.isRestorable = false
  93. existing.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  94. existing.center()
  95. existing.makeKeyAndOrderFront(nil)
  96. return (existing, existing.contentViewController)
  97. }
  98. let storyboard = NSStoryboard(name: "Main", bundle: nil)
  99. if let controller = storyboard.instantiateInitialController() as? NSWindowController {
  100. mainWindowController = controller
  101. controller.showWindow(nil)
  102. if let window = controller.window {
  103. window.isRestorable = false
  104. window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  105. window.center()
  106. window.makeKeyAndOrderFront(nil)
  107. return (window, window.contentViewController)
  108. }
  109. }
  110. return (nil, nil)
  111. }
  112. private func restoreMainContentAfterSplash() {
  113. guard let window = mainWindow else { return }
  114. // If we captured the original VC (from storyboard), restore it.
  115. if let original = mainOriginalContentViewController {
  116. window.contentViewController = original
  117. }
  118. window.isRestorable = false
  119. window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  120. window.center()
  121. window.makeKeyAndOrderFront(nil)
  122. mainOriginalContentViewController = nil
  123. mainWindow = nil
  124. }
  125. private func configureStatusItem() {
  126. let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
  127. if let button = item.button {
  128. if let image = NSImage(named: "StatusBarIcon") {
  129. image.size = NSSize(width: 18, height: 18)
  130. image.isTemplate = false
  131. button.image = image
  132. button.imagePosition = .imageOnly
  133. button.toolTip = "Companion AI for Zoom"
  134. } else {
  135. button.title = "Z"
  136. }
  137. }
  138. let menu = NSMenu()
  139. menu.delegate = self
  140. item.menu = menu
  141. statusItem = item
  142. statusMenu = menu
  143. rebuildStatusMenu()
  144. }
  145. func menuWillOpen(_ menu: NSMenu) {
  146. if Thread.isMainThread {
  147. MainActor.assumeIsolated {
  148. mainViewController()?.menuBarSyncTopMeetingsForStatusMenu()
  149. }
  150. }
  151. rebuildStatusMenu()
  152. }
  153. private func rebuildStatusMenu() {
  154. guard let menu = statusMenu else { return }
  155. menu.removeAllItems()
  156. appendMenuItem("Open Home", tag: .openHome, to: menu)
  157. appendMenuItem("Open Meetings", tag: .openMeetings, to: menu)
  158. appendMenuItem("Open Scheduler", tag: .openScheduler, to: menu)
  159. appendMenuItem("Open Widgets", tag: .openWidgets, to: menu)
  160. appendMenuItem("Open AI Companion", tag: .openAiAssistant, to: menu)
  161. appendMenuItem("Open Settings", tag: .openSettings, to: menu)
  162. menu.addItem(.separator())
  163. appendMenuItem("New Meeting", tag: .newMeeting, to: menu)
  164. appendMenuItem("Join Meeting", tag: .joinMeeting, to: menu)
  165. appendMenuItem("Schedule Meeting", tag: .scheduleMeeting, to: menu)
  166. if appendTopMeetingsSection(to: menu) {
  167. menu.addItem(.separator())
  168. }
  169. if MeetingRecordingManager.shared.isRecording {
  170. menu.addItem(.separator())
  171. appendMenuItem("Stop Recording", tag: .stopRecording, to: menu)
  172. }
  173. menu.addItem(.separator())
  174. let authTitle = isUserLoggedInForMenu() ? "Sign Out" : "Sign In"
  175. appendMenuItem(authTitle, tag: .auth, to: menu)
  176. menu.addItem(.separator())
  177. appendMenuItem("Quit", tag: .quit, to: menu)
  178. }
  179. private func appendMenuItem(_ title: String, tag: MenuItemTag, to menu: NSMenu) {
  180. let item = NSMenuItem(title: title, action: #selector(handleStatusMenuAction(_:)), keyEquivalent: "")
  181. item.target = self
  182. item.tag = tag.rawValue
  183. menu.addItem(item)
  184. }
  185. @discardableResult
  186. private func appendTopMeetingsSection(to menu: NSMenu) -> Bool {
  187. let meetings = Array(WidgetMeetingStore.load().prefix(3))
  188. guard meetings.isEmpty == false else { return false }
  189. menu.addItem(.separator())
  190. let header = NSMenuItem(title: "Top meetings", action: nil, keyEquivalent: "")
  191. header.isEnabled = false
  192. menu.addItem(header)
  193. for meeting in meetings {
  194. let item = NSMenuItem(
  195. title: TopMeetingsStatusBarFormatter.menuLine(for: meeting),
  196. action: #selector(handleStatusMenuAction(_:)),
  197. keyEquivalent: ""
  198. )
  199. item.target = self
  200. item.tag = MenuItemTag.openTopMeeting.rawValue
  201. item.representedObject = meeting.joinLink
  202. menu.addItem(item)
  203. }
  204. return true
  205. }
  206. @objc
  207. private func handleStatusMenuAction(_ sender: NSMenuItem) {
  208. guard let action = MenuItemTag(rawValue: sender.tag) else { return }
  209. if action == .openTopMeeting {
  210. focusMainAppWindow()
  211. mainViewController()?.menuBarOpenTopMeeting(joinLink: sender.representedObject as? String)
  212. return
  213. }
  214. if action == .quit {
  215. NSApp.terminate(nil)
  216. return
  217. }
  218. focusMainAppWindow()
  219. guard let controller = mainViewController() else { return }
  220. switch action {
  221. case .openHome:
  222. controller.menuBarShowSection(.home)
  223. case .openMeetings:
  224. controller.menuBarShowSection(.meetings)
  225. case .openScheduler:
  226. controller.menuBarShowSection(.scheduler)
  227. case .openWidgets:
  228. controller.menuBarShowSection(.widgets)
  229. case .openAiAssistant:
  230. controller.menuBarShowSection(.aiCompanion)
  231. case .openSettings:
  232. controller.menuBarShowSection(.settings)
  233. case .newMeeting:
  234. controller.menuBarTriggerNewMeeting()
  235. case .joinMeeting:
  236. controller.menuBarTriggerJoinMeeting()
  237. case .scheduleMeeting:
  238. controller.menuBarTriggerScheduleMeeting()
  239. case .stopRecording:
  240. Task { @MainActor in
  241. do {
  242. try await MeetingRecordingManager.shared.stopRecording()
  243. } catch {
  244. let alert = NSAlert()
  245. alert.messageText = "Could not save recording"
  246. alert.informativeText = error.localizedDescription
  247. alert.alertStyle = .warning
  248. alert.runModal()
  249. }
  250. }
  251. case .auth:
  252. controller.menuBarTriggerAuthAction()
  253. case .openTopMeeting:
  254. break
  255. case .quit:
  256. break
  257. }
  258. }
  259. private func focusMainAppWindow() {
  260. NSApp.activate(ignoringOtherApps: true)
  261. if let window = windowHostingViewController() {
  262. window.makeKeyAndOrderFront(nil)
  263. } else {
  264. NSApp.windows.first?.makeKeyAndOrderFront(nil)
  265. }
  266. }
  267. private func windowHostingViewController() -> NSWindow? {
  268. NSApp.windows.first(where: { $0.contentViewController is ViewController })
  269. }
  270. private func mainViewController() -> ViewController? {
  271. if let controller = NSApp.keyWindow?.contentViewController as? ViewController {
  272. return controller
  273. }
  274. if let controller = NSApp.mainWindow?.contentViewController as? ViewController {
  275. return controller
  276. }
  277. if let controller = windowHostingViewController()?.contentViewController as? ViewController {
  278. return controller
  279. }
  280. return nil
  281. }
  282. private func isUserLoggedInForMenu() -> Bool {
  283. if let controller = mainViewController() {
  284. return controller.menuBarIsUserLoggedIn()
  285. }
  286. return UserDefaults.standard.bool(forKey: loginStateKey)
  287. }
  288. }
  289. // Shared with ViewController and widgets (public = visible across files with Member Import Visibility).
  290. public struct WidgetMeetingSnapshot: Codable, Identifiable {
  291. public let id: String
  292. public let title: String
  293. public let timeText: String
  294. public let joinLink: String?
  295. public var startTime: TimeInterval?
  296. public var endTime: TimeInterval?
  297. public var timeZoneIdentifier: String?
  298. public init(
  299. id: String,
  300. title: String,
  301. timeText: String,
  302. joinLink: String?,
  303. startTime: TimeInterval? = nil,
  304. endTime: TimeInterval? = nil,
  305. timeZoneIdentifier: String? = nil
  306. ) {
  307. self.id = id
  308. self.title = title
  309. self.timeText = timeText
  310. self.joinLink = joinLink
  311. self.startTime = startTime
  312. self.endTime = endTime
  313. self.timeZoneIdentifier = timeZoneIdentifier
  314. }
  315. }
  316. public enum WidgetMeetingStore {
  317. static let key = "zoom_app.widget.topMeetings"
  318. static func load() -> [WidgetMeetingSnapshot] {
  319. guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
  320. return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
  321. }
  322. static func save(_ meetings: [WidgetMeetingSnapshot]) {
  323. guard let data = try? JSONEncoder().encode(meetings) else { return }
  324. UserDefaults.standard.set(data, forKey: key)
  325. }
  326. }
  327. public enum TopMeetingsStatusBarFormatter {
  328. public static func menuLine(for meeting: WidgetMeetingSnapshot) -> String {
  329. let titlePart = truncatedTitle(meeting.title, maxLength: 10)
  330. guard let startInterval = meeting.startTime else {
  331. return "\(meeting.timeText) \(titlePart)"
  332. }
  333. let timeZone = meeting.timeZoneIdentifier.flatMap(TimeZone.init(identifier:)) ?? .current
  334. let start = Date(timeIntervalSince1970: startInterval)
  335. let end = meeting.endTime.map { Date(timeIntervalSince1970: $0) } ?? start.addingTimeInterval(3600)
  336. let dayFormatter = DateFormatter()
  337. dayFormatter.dateFormat = "EEE"
  338. dayFormatter.timeZone = timeZone
  339. let timeFormatter = DateFormatter()
  340. timeFormatter.dateFormat = "h:mm a"
  341. timeFormatter.timeZone = timeZone
  342. let dateFormatter = DateFormatter()
  343. dateFormatter.dateFormat = "d MMM"
  344. dateFormatter.timeZone = timeZone
  345. let day = dayFormatter.string(from: start)
  346. let startText = timeFormatter.string(from: start)
  347. let endText = timeFormatter.string(from: end)
  348. let dateText = dateFormatter.string(from: start)
  349. return "\(day), \(startText) - \(endText) \(dateText) \(titlePart)"
  350. }
  351. private static func truncatedTitle(_ title: String, maxLength: Int) -> String {
  352. let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
  353. guard trimmed.count > maxLength else { return trimmed }
  354. return String(trimmed.prefix(maxLength)) + "..."
  355. }
  356. }
  357. extension AppDelegate: UNUserNotificationCenterDelegate {
  358. func userNotificationCenter(
  359. _ center: UNUserNotificationCenter,
  360. didReceive response: UNNotificationResponse,
  361. withCompletionHandler completionHandler: @escaping () -> Void
  362. ) {
  363. NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
  364. NSApp.activate(ignoringOtherApps: true)
  365. completionHandler()
  366. }
  367. func userNotificationCenter(
  368. _ center: UNUserNotificationCenter,
  369. willPresent notification: UNNotification,
  370. withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
  371. ) {
  372. completionHandler([.banner, .sound])
  373. }
  374. }
  375. private final class SplashViewController: NSViewController {
  376. private struct Theme {
  377. let isDark: Bool
  378. let backgroundTop: NSColor
  379. let backgroundBottom: NSColor
  380. let titleColor: NSColor
  381. let subtitleColor: NSColor
  382. let statusColor: NSColor
  383. let trackColor: NSColor
  384. let fillColor: NSColor
  385. let iconBadgeFill: NSColor
  386. }
  387. private final class GradientBackgroundView: NSView {
  388. let gradientLayer = CAGradientLayer()
  389. override init(frame frameRect: NSRect) {
  390. super.init(frame: frameRect)
  391. wantsLayer = true
  392. layer = CALayer()
  393. gradientLayer.startPoint = CGPoint(x: 0.1, y: 1.0)
  394. gradientLayer.endPoint = CGPoint(x: 0.9, y: 0.0)
  395. gradientLayer.locations = [0.0, 1.0]
  396. layer?.addSublayer(gradientLayer)
  397. }
  398. @available(*, unavailable)
  399. required init?(coder: NSCoder) { nil }
  400. override func layout() {
  401. super.layout()
  402. gradientLayer.frame = bounds
  403. }
  404. }
  405. private var theme: Theme = SplashViewController.makeTheme(isDark: true)
  406. private var dotsTimer: Timer?
  407. private var dotsStep: Int = 0
  408. private var progressTimer: Timer?
  409. private var progressValue: Double = 0
  410. private weak var rootBackground: GradientBackgroundView?
  411. private weak var iconBadgeView: NSView?
  412. private weak var iconView: NSImageView?
  413. private weak var titleLabel: NSTextField?
  414. private weak var subtitleLabel: NSTextField?
  415. private weak var contentStack: NSStackView?
  416. private weak var statusLabel: NSTextField?
  417. private weak var percentLabel: NSTextField?
  418. private weak var progressIndicator: NSProgressIndicator?
  419. override func viewDidLoad() {
  420. super.viewDidLoad()
  421. applyTheme()
  422. }
  423. override func viewDidAppear() {
  424. super.viewDidAppear()
  425. startAnimations()
  426. startDots()
  427. startProgress()
  428. }
  429. override func viewWillDisappear() {
  430. super.viewWillDisappear()
  431. dotsTimer?.invalidate()
  432. dotsTimer = nil
  433. progressTimer?.invalidate()
  434. progressTimer = nil
  435. }
  436. override func loadView() {
  437. let background = GradientBackgroundView()
  438. let stack = NSStackView()
  439. stack.orientation = .vertical
  440. stack.alignment = .centerX
  441. stack.spacing = 10
  442. stack.translatesAutoresizingMaskIntoConstraints = false
  443. let iconBadge = NSView()
  444. iconBadge.translatesAutoresizingMaskIntoConstraints = false
  445. iconBadge.wantsLayer = true
  446. iconBadge.layer?.cornerRadius = 26
  447. iconBadge.layer?.masksToBounds = true
  448. let iconView = NSImageView()
  449. iconView.translatesAutoresizingMaskIntoConstraints = false
  450. iconView.imageScaling = .scaleProportionallyUpOrDown
  451. iconView.image = NSApp.applicationIconImage
  452. iconView.wantsLayer = true
  453. let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Companion AI for Zoom"
  454. let title = NSTextField(labelWithString: appName)
  455. title.font = .systemFont(ofSize: 30, weight: .semibold)
  456. let subtitle = NSTextField(labelWithString: "Preparing your meetings workspace.")
  457. subtitle.font = .systemFont(ofSize: 14, weight: .regular)
  458. subtitle.alignment = .center
  459. subtitle.maximumNumberOfLines = 2
  460. subtitle.lineBreakMode = .byWordWrapping
  461. let progressRow = NSStackView()
  462. progressRow.orientation = .horizontal
  463. progressRow.alignment = .centerY
  464. progressRow.spacing = 10
  465. progressRow.translatesAutoresizingMaskIntoConstraints = false
  466. let progress = NSProgressIndicator()
  467. progress.translatesAutoresizingMaskIntoConstraints = false
  468. progress.isIndeterminate = false
  469. progress.minValue = 0
  470. progress.maxValue = 100
  471. progress.doubleValue = 10
  472. progress.controlSize = .small
  473. progress.style = .bar
  474. let percent = NSTextField(labelWithString: "10%")
  475. percent.font = .systemFont(ofSize: 12, weight: .medium)
  476. percent.alignment = .right
  477. progressRow.addArrangedSubview(progress)
  478. progressRow.addArrangedSubview(percent)
  479. let status = NSTextField(labelWithString: "CONNECTING SERVICES")
  480. status.font = .systemFont(ofSize: 10.5, weight: .semibold)
  481. status.alignment = .center
  482. status.usesSingleLineMode = true
  483. status.setAccessibilityLabel("Splash status")
  484. iconBadge.addSubview(iconView)
  485. NSLayoutConstraint.activate([
  486. iconView.centerXAnchor.constraint(equalTo: iconBadge.centerXAnchor),
  487. iconView.centerYAnchor.constraint(equalTo: iconBadge.centerYAnchor),
  488. iconView.widthAnchor.constraint(equalToConstant: 78),
  489. iconView.heightAnchor.constraint(equalTo: iconView.widthAnchor)
  490. ])
  491. stack.addArrangedSubview(iconBadge)
  492. stack.addArrangedSubview(title)
  493. stack.addArrangedSubview(subtitle)
  494. stack.addArrangedSubview(progressRow)
  495. stack.addArrangedSubview(status)
  496. background.addSubview(stack)
  497. NSLayoutConstraint.activate([
  498. iconBadge.widthAnchor.constraint(equalToConstant: 104),
  499. iconBadge.heightAnchor.constraint(equalToConstant: 104),
  500. stack.centerXAnchor.constraint(equalTo: background.centerXAnchor),
  501. stack.centerYAnchor.constraint(equalTo: background.centerYAnchor),
  502. stack.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 28),
  503. stack.trailingAnchor.constraint(lessThanOrEqualTo: background.trailingAnchor, constant: -28),
  504. progress.widthAnchor.constraint(equalToConstant: 260),
  505. progress.heightAnchor.constraint(equalToConstant: 6),
  506. percent.widthAnchor.constraint(equalToConstant: 44)
  507. ])
  508. // Initial state for animation.
  509. stack.alphaValue = 0
  510. stack.setFrameOrigin(NSPoint(x: stack.frame.origin.x, y: stack.frame.origin.y - 8))
  511. self.rootBackground = background
  512. self.iconBadgeView = iconBadge
  513. self.iconView = iconView
  514. self.titleLabel = title
  515. self.subtitleLabel = subtitle
  516. self.contentStack = stack
  517. self.statusLabel = status
  518. self.percentLabel = percent
  519. self.progressIndicator = progress
  520. view = background
  521. }
  522. private func startAnimations() {
  523. guard let stack = contentStack, let iconView else { return }
  524. // Entrance animation: fade + slight rise + gentle icon scale.
  525. iconView.layer?.transform = CATransform3DMakeScale(0.92, 0.92, 1)
  526. NSAnimationContext.runAnimationGroup { context in
  527. context.duration = 0.55
  528. context.timingFunction = CAMediaTimingFunction(name: .easeOut)
  529. stack.animator().alphaValue = 1
  530. }
  531. let scale = CABasicAnimation(keyPath: "transform")
  532. scale.fromValue = CATransform3DMakeScale(0.92, 0.92, 1)
  533. scale.toValue = CATransform3DIdentity
  534. scale.duration = 0.55
  535. scale.timingFunction = CAMediaTimingFunction(name: .easeOut)
  536. iconView.layer?.add(scale, forKey: "splash.scaleIn")
  537. iconView.layer?.transform = CATransform3DIdentity
  538. // Subtle breathing pulse after entrance.
  539. DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { [weak self] in
  540. guard let self, let iconLayer = self.iconView?.layer else { return }
  541. let pulse = CABasicAnimation(keyPath: "transform")
  542. pulse.fromValue = CATransform3DIdentity
  543. pulse.toValue = CATransform3DMakeScale(1.03, 1.03, 1)
  544. pulse.duration = 0.9
  545. pulse.autoreverses = true
  546. pulse.repeatCount = .infinity
  547. pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  548. iconLayer.add(pulse, forKey: "splash.pulse")
  549. }
  550. }
  551. private func startDots() {
  552. dotsTimer?.invalidate()
  553. dotsStep = 0
  554. dotsTimer = Timer.scheduledTimer(withTimeInterval: 0.45, repeats: true) { [weak self] _ in
  555. guard let self, let subtitleLabel else { return }
  556. self.dotsStep = (self.dotsStep + 1) % 4
  557. let dots = String(repeating: ".", count: self.dotsStep)
  558. // Keep the main subtitle stable; add subtle activity via ellipsis.
  559. subtitleLabel.stringValue = "Preparing your meetings workspace" + dots
  560. }
  561. }
  562. private func startProgress() {
  563. progressTimer?.invalidate()
  564. progressValue = 10
  565. setProgressUI(value: progressValue)
  566. // Animate progress smoothly for the 3s splash duration.
  567. let tick: TimeInterval = 0.06
  568. let target: Double = 100
  569. let duration: Double = 3.0
  570. let step = (target - progressValue) / (duration / tick)
  571. progressTimer = Timer.scheduledTimer(withTimeInterval: tick, repeats: true) { [weak self] timer in
  572. guard let self else { timer.invalidate(); return }
  573. self.progressValue = min(target, self.progressValue + step)
  574. self.setProgressUI(value: self.progressValue)
  575. if self.progressValue >= target - 0.01 {
  576. timer.invalidate()
  577. }
  578. }
  579. }
  580. private func setProgressUI(value: Double) {
  581. progressIndicator?.doubleValue = value
  582. percentLabel?.stringValue = "\(Int(round(value)))%"
  583. }
  584. private func applyTheme() {
  585. let isDark = view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
  586. theme = Self.makeTheme(isDark: isDark)
  587. rootBackground?.gradientLayer.colors = [theme.backgroundTop.cgColor, theme.backgroundBottom.cgColor]
  588. // Icon badge styling.
  589. if let badge = iconBadgeView {
  590. badge.layer?.backgroundColor = theme.iconBadgeFill.cgColor
  591. }
  592. titleLabel?.textColor = theme.titleColor
  593. subtitleLabel?.textColor = theme.subtitleColor
  594. statusLabel?.textColor = theme.statusColor
  595. percentLabel?.textColor = theme.subtitleColor
  596. if let progress = progressIndicator {
  597. progress.appearance = NSAppearance(named: theme.isDark ? .darkAqua : .aqua)
  598. // Prefer tinting when available; fall back gracefully if not.
  599. if #available(macOS 10.14, *) {
  600. progress.contentFilters = []
  601. }
  602. }
  603. }
  604. private static func makeTheme(isDark: Bool) -> Theme {
  605. if isDark {
  606. return Theme(
  607. isDark: true,
  608. backgroundTop: NSColor(calibratedRed: 9 / 255, green: 9 / 255, blue: 10 / 255, alpha: 1),
  609. backgroundBottom: NSColor(calibratedRed: 16 / 255, green: 16 / 255, blue: 18 / 255, alpha: 1),
  610. titleColor: NSColor(calibratedWhite: 0.98, alpha: 1),
  611. subtitleColor: NSColor(calibratedWhite: 0.78, alpha: 1),
  612. statusColor: NSColor(calibratedWhite: 0.66, alpha: 1),
  613. trackColor: NSColor.white.withAlphaComponent(0.10),
  614. fillColor: NSColor(calibratedRed: 96 / 255, green: 215 / 255, blue: 255 / 255, alpha: 1),
  615. iconBadgeFill: NSColor.white.withAlphaComponent(0.08)
  616. )
  617. }
  618. return Theme(
  619. isDark: false,
  620. backgroundTop: NSColor(calibratedWhite: 0.98, alpha: 1),
  621. backgroundBottom: NSColor(calibratedWhite: 0.92, alpha: 1),
  622. titleColor: NSColor(calibratedWhite: 0.10, alpha: 1),
  623. subtitleColor: NSColor(calibratedWhite: 0.34, alpha: 1),
  624. statusColor: NSColor(calibratedWhite: 0.42, alpha: 1),
  625. trackColor: NSColor.black.withAlphaComponent(0.10),
  626. fillColor: NSColor(calibratedRed: 0.18, green: 0.52, blue: 0.96, alpha: 1),
  627. iconBadgeFill: NSColor.black.withAlphaComponent(0.04)
  628. )
  629. }
  630. }