AppDelegate.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573
  1. //
  2. // AppDelegate.swift
  3. // zoom_app
  4. //
  5. // Created by Dev Mac 1 on 14/04/2026.
  6. //
  7. import Cocoa
  8. @main
  9. class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
  10. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  11. private let loginStateKey = "zoom_app.isLoggedIn"
  12. private var statusItem: NSStatusItem?
  13. private var statusMenu: NSMenu?
  14. private var mainWindowController: NSWindowController?
  15. private weak var mainWindow: NSWindow?
  16. private var mainOriginalContentViewController: NSViewController?
  17. private enum MenuItemTag: Int {
  18. case openHome = 10
  19. case openMeetings = 11
  20. case openScheduler = 12
  21. case openWidgets = 13
  22. case openSettings = 14
  23. case newMeeting = 20
  24. case joinMeeting = 21
  25. case scheduleMeeting = 22
  26. case auth = 30
  27. case quit = 99
  28. }
  29. private enum DefaultWindowSize {
  30. static let width: CGFloat = 1020
  31. static let height: CGFloat = 690
  32. }
  33. func applicationDidFinishLaunching(_ aNotification: Notification) {
  34. // Ensure we are a regular app so windows can appear reliably.
  35. // (Some configurations behave like an accessory/menubar app otherwise.)
  36. NSApp.setActivationPolicy(.regular)
  37. let darkEnabled = systemPrefersDarkMode()
  38. UserDefaults.standard.set(darkEnabled, forKey: darkModeDefaultsKey)
  39. NSApp.appearance = NSAppearance(named: darkEnabled ? .darkAqua : .aqua)
  40. configureStatusItem()
  41. showSplashThenMainWindow()
  42. }
  43. func applicationWillTerminate(_ aNotification: Notification) {
  44. // Insert code here to tear down your application
  45. }
  46. func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
  47. // If enabled, macOS can restore the last window frame (size/position) on next launch.
  48. // This app wants a consistent default launch size instead.
  49. return false
  50. }
  51. private func applyDefaultWindowSizeIfNeeded() {
  52. let targetSize = NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height)
  53. for window in NSApplication.shared.windows {
  54. // Only touch the main app window (not OAuth popups, alerts, etc.).
  55. guard window.contentViewController is ViewController else { continue }
  56. window.isRestorable = false
  57. window.setContentSize(targetSize)
  58. window.center()
  59. }
  60. }
  61. private func systemPrefersDarkMode() -> Bool {
  62. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  63. let style = global?["AppleInterfaceStyle"] as? String
  64. return style?.lowercased() == "dark"
  65. }
  66. private func showSplashThenMainWindow() {
  67. let (window, originalVC) = openMainWindowIfNeededAndReturnWindow()
  68. guard let window else { return }
  69. let splashVC = SplashViewController()
  70. mainOriginalContentViewController = originalVC
  71. mainWindow = window
  72. // Show main window immediately with splash content.
  73. window.contentViewController = splashVC
  74. window.isRestorable = false
  75. window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  76. window.center()
  77. NSApp.activate(ignoringOtherApps: true)
  78. window.makeKeyAndOrderFront(nil)
  79. Timer.scheduledTimer(withTimeInterval: 3.0, repeats: false) { [weak self] _ in
  80. guard let self else { return }
  81. self.restoreMainContentAfterSplash()
  82. }
  83. }
  84. @discardableResult
  85. private func openMainWindowIfNeededAndReturnWindow() -> (NSWindow?, NSViewController?) {
  86. if let existing = windowHostingViewController() {
  87. existing.isRestorable = false
  88. existing.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  89. existing.center()
  90. existing.makeKeyAndOrderFront(nil)
  91. return (existing, existing.contentViewController)
  92. }
  93. let storyboard = NSStoryboard(name: "Main", bundle: nil)
  94. if let controller = storyboard.instantiateInitialController() as? NSWindowController {
  95. mainWindowController = controller
  96. controller.showWindow(nil)
  97. if let window = controller.window {
  98. window.isRestorable = false
  99. window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  100. window.center()
  101. window.makeKeyAndOrderFront(nil)
  102. return (window, window.contentViewController)
  103. }
  104. }
  105. return (nil, nil)
  106. }
  107. private func restoreMainContentAfterSplash() {
  108. guard let window = mainWindow else { return }
  109. // If we captured the original VC (from storyboard), restore it.
  110. if let original = mainOriginalContentViewController {
  111. window.contentViewController = original
  112. }
  113. window.isRestorable = false
  114. window.setContentSize(NSSize(width: DefaultWindowSize.width, height: DefaultWindowSize.height))
  115. window.center()
  116. window.makeKeyAndOrderFront(nil)
  117. mainOriginalContentViewController = nil
  118. mainWindow = nil
  119. }
  120. private func configureStatusItem() {
  121. let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
  122. if let button = item.button {
  123. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
  124. if let base = NSImage(systemSymbolName: "video.fill", accessibilityDescription: "Zoom App"),
  125. let image = base.withSymbolConfiguration(symbolConfig) {
  126. image.isTemplate = true
  127. button.image = image
  128. button.imagePosition = .imageOnly
  129. button.toolTip = "Zoom App"
  130. } else {
  131. button.title = "Z"
  132. }
  133. }
  134. let menu = NSMenu()
  135. menu.delegate = self
  136. item.menu = menu
  137. statusItem = item
  138. statusMenu = menu
  139. rebuildStatusMenu()
  140. }
  141. func menuWillOpen(_ menu: NSMenu) {
  142. rebuildStatusMenu()
  143. }
  144. private func rebuildStatusMenu() {
  145. guard let menu = statusMenu else { return }
  146. menu.removeAllItems()
  147. appendMenuItem("Open Home", tag: .openHome, to: menu)
  148. appendMenuItem("Open Meetings", tag: .openMeetings, to: menu)
  149. appendMenuItem("Open Scheduler", tag: .openScheduler, to: menu)
  150. appendMenuItem("Open Widgets", tag: .openWidgets, to: menu)
  151. appendMenuItem("Open Settings", tag: .openSettings, to: menu)
  152. menu.addItem(.separator())
  153. appendMenuItem("New Meeting", tag: .newMeeting, to: menu)
  154. appendMenuItem("Join Meeting", tag: .joinMeeting, to: menu)
  155. appendMenuItem("Schedule Meeting", tag: .scheduleMeeting, to: menu)
  156. menu.addItem(.separator())
  157. let authTitle = isUserLoggedInForMenu() ? "Sign Out" : "Sign In"
  158. appendMenuItem(authTitle, tag: .auth, to: menu)
  159. menu.addItem(.separator())
  160. appendMenuItem("Quit", tag: .quit, to: menu)
  161. }
  162. private func appendMenuItem(_ title: String, tag: MenuItemTag, to menu: NSMenu) {
  163. let item = NSMenuItem(title: title, action: #selector(handleStatusMenuAction(_:)), keyEquivalent: "")
  164. item.target = self
  165. item.tag = tag.rawValue
  166. menu.addItem(item)
  167. }
  168. @objc
  169. private func handleStatusMenuAction(_ sender: NSMenuItem) {
  170. guard let action = MenuItemTag(rawValue: sender.tag) else { return }
  171. if action == .quit {
  172. NSApp.terminate(nil)
  173. return
  174. }
  175. focusMainAppWindow()
  176. guard let controller = mainViewController() else { return }
  177. switch action {
  178. case .openHome:
  179. controller.menuBarShowSection(.home)
  180. case .openMeetings:
  181. controller.menuBarShowSection(.meetings)
  182. case .openScheduler:
  183. controller.menuBarShowSection(.scheduler)
  184. case .openWidgets:
  185. controller.menuBarShowSection(.widgets)
  186. case .openSettings:
  187. controller.menuBarShowSection(.settings)
  188. case .newMeeting:
  189. controller.menuBarTriggerNewMeeting()
  190. case .joinMeeting:
  191. controller.menuBarTriggerJoinMeeting()
  192. case .scheduleMeeting:
  193. controller.menuBarTriggerScheduleMeeting()
  194. case .auth:
  195. controller.menuBarTriggerAuthAction()
  196. case .quit:
  197. break
  198. }
  199. }
  200. private func focusMainAppWindow() {
  201. NSApp.activate(ignoringOtherApps: true)
  202. if let window = windowHostingViewController() {
  203. window.makeKeyAndOrderFront(nil)
  204. } else {
  205. NSApp.windows.first?.makeKeyAndOrderFront(nil)
  206. }
  207. }
  208. private func windowHostingViewController() -> NSWindow? {
  209. NSApp.windows.first(where: { $0.contentViewController is ViewController })
  210. }
  211. private func mainViewController() -> ViewController? {
  212. if let controller = NSApp.keyWindow?.contentViewController as? ViewController {
  213. return controller
  214. }
  215. if let controller = NSApp.mainWindow?.contentViewController as? ViewController {
  216. return controller
  217. }
  218. if let controller = windowHostingViewController()?.contentViewController as? ViewController {
  219. return controller
  220. }
  221. return nil
  222. }
  223. private func isUserLoggedInForMenu() -> Bool {
  224. if let controller = mainViewController() {
  225. return controller.menuBarIsUserLoggedIn()
  226. }
  227. return UserDefaults.standard.bool(forKey: loginStateKey)
  228. }
  229. }
  230. private final class SplashViewController: NSViewController {
  231. private struct Theme {
  232. let isDark: Bool
  233. let backgroundTop: NSColor
  234. let backgroundBottom: NSColor
  235. let titleColor: NSColor
  236. let subtitleColor: NSColor
  237. let statusColor: NSColor
  238. let trackColor: NSColor
  239. let fillColor: NSColor
  240. let iconBadgeFill: NSColor
  241. }
  242. private final class GradientBackgroundView: NSView {
  243. let gradientLayer = CAGradientLayer()
  244. override init(frame frameRect: NSRect) {
  245. super.init(frame: frameRect)
  246. wantsLayer = true
  247. layer = CALayer()
  248. gradientLayer.startPoint = CGPoint(x: 0.1, y: 1.0)
  249. gradientLayer.endPoint = CGPoint(x: 0.9, y: 0.0)
  250. gradientLayer.locations = [0.0, 1.0]
  251. layer?.addSublayer(gradientLayer)
  252. }
  253. @available(*, unavailable)
  254. required init?(coder: NSCoder) { nil }
  255. override func layout() {
  256. super.layout()
  257. gradientLayer.frame = bounds
  258. }
  259. }
  260. private var theme: Theme = SplashViewController.makeTheme(isDark: true)
  261. private var dotsTimer: Timer?
  262. private var dotsStep: Int = 0
  263. private var progressTimer: Timer?
  264. private var progressValue: Double = 0
  265. private weak var rootBackground: GradientBackgroundView?
  266. private weak var iconBadgeView: NSView?
  267. private weak var iconView: NSImageView?
  268. private weak var titleLabel: NSTextField?
  269. private weak var subtitleLabel: NSTextField?
  270. private weak var contentStack: NSStackView?
  271. private weak var statusLabel: NSTextField?
  272. private weak var percentLabel: NSTextField?
  273. private weak var progressIndicator: NSProgressIndicator?
  274. override func viewDidLoad() {
  275. super.viewDidLoad()
  276. applyTheme()
  277. }
  278. override func viewDidAppear() {
  279. super.viewDidAppear()
  280. startAnimations()
  281. startDots()
  282. startProgress()
  283. }
  284. override func viewWillDisappear() {
  285. super.viewWillDisappear()
  286. dotsTimer?.invalidate()
  287. dotsTimer = nil
  288. progressTimer?.invalidate()
  289. progressTimer = nil
  290. }
  291. override func loadView() {
  292. let background = GradientBackgroundView()
  293. let stack = NSStackView()
  294. stack.orientation = .vertical
  295. stack.alignment = .centerX
  296. stack.spacing = 10
  297. stack.translatesAutoresizingMaskIntoConstraints = false
  298. let iconBadge = NSView()
  299. iconBadge.translatesAutoresizingMaskIntoConstraints = false
  300. iconBadge.wantsLayer = true
  301. iconBadge.layer?.cornerRadius = 26
  302. iconBadge.layer?.masksToBounds = true
  303. let iconView = NSImageView()
  304. iconView.translatesAutoresizingMaskIntoConstraints = false
  305. iconView.imageScaling = .scaleProportionallyUpOrDown
  306. iconView.image = NSApp.applicationIconImage
  307. iconView.wantsLayer = true
  308. let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Zoom App"
  309. let title = NSTextField(labelWithString: appName)
  310. title.font = .systemFont(ofSize: 30, weight: .semibold)
  311. let subtitle = NSTextField(labelWithString: "Preparing your meetings workspace.")
  312. subtitle.font = .systemFont(ofSize: 14, weight: .regular)
  313. subtitle.alignment = .center
  314. subtitle.maximumNumberOfLines = 2
  315. subtitle.lineBreakMode = .byWordWrapping
  316. let progressRow = NSStackView()
  317. progressRow.orientation = .horizontal
  318. progressRow.alignment = .centerY
  319. progressRow.spacing = 10
  320. progressRow.translatesAutoresizingMaskIntoConstraints = false
  321. let progress = NSProgressIndicator()
  322. progress.translatesAutoresizingMaskIntoConstraints = false
  323. progress.isIndeterminate = false
  324. progress.minValue = 0
  325. progress.maxValue = 100
  326. progress.doubleValue = 10
  327. progress.controlSize = .small
  328. progress.style = .bar
  329. let percent = NSTextField(labelWithString: "10%")
  330. percent.font = .systemFont(ofSize: 12, weight: .medium)
  331. percent.alignment = .right
  332. progressRow.addArrangedSubview(progress)
  333. progressRow.addArrangedSubview(percent)
  334. let status = NSTextField(labelWithString: "CONNECTING SERVICES")
  335. status.font = .systemFont(ofSize: 10.5, weight: .semibold)
  336. status.alignment = .center
  337. status.usesSingleLineMode = true
  338. status.setAccessibilityLabel("Splash status")
  339. iconBadge.addSubview(iconView)
  340. NSLayoutConstraint.activate([
  341. iconView.centerXAnchor.constraint(equalTo: iconBadge.centerXAnchor),
  342. iconView.centerYAnchor.constraint(equalTo: iconBadge.centerYAnchor),
  343. iconView.widthAnchor.constraint(equalToConstant: 78),
  344. iconView.heightAnchor.constraint(equalTo: iconView.widthAnchor)
  345. ])
  346. stack.addArrangedSubview(iconBadge)
  347. stack.addArrangedSubview(title)
  348. stack.addArrangedSubview(subtitle)
  349. stack.addArrangedSubview(progressRow)
  350. stack.addArrangedSubview(status)
  351. background.addSubview(stack)
  352. NSLayoutConstraint.activate([
  353. iconBadge.widthAnchor.constraint(equalToConstant: 104),
  354. iconBadge.heightAnchor.constraint(equalToConstant: 104),
  355. stack.centerXAnchor.constraint(equalTo: background.centerXAnchor),
  356. stack.centerYAnchor.constraint(equalTo: background.centerYAnchor),
  357. stack.leadingAnchor.constraint(greaterThanOrEqualTo: background.leadingAnchor, constant: 28),
  358. stack.trailingAnchor.constraint(lessThanOrEqualTo: background.trailingAnchor, constant: -28),
  359. progress.widthAnchor.constraint(equalToConstant: 260),
  360. progress.heightAnchor.constraint(equalToConstant: 6),
  361. percent.widthAnchor.constraint(equalToConstant: 44)
  362. ])
  363. // Initial state for animation.
  364. stack.alphaValue = 0
  365. stack.setFrameOrigin(NSPoint(x: stack.frame.origin.x, y: stack.frame.origin.y - 8))
  366. self.rootBackground = background
  367. self.iconBadgeView = iconBadge
  368. self.iconView = iconView
  369. self.titleLabel = title
  370. self.subtitleLabel = subtitle
  371. self.contentStack = stack
  372. self.statusLabel = status
  373. self.percentLabel = percent
  374. self.progressIndicator = progress
  375. view = background
  376. }
  377. private func startAnimations() {
  378. guard let stack = contentStack, let iconView else { return }
  379. // Entrance animation: fade + slight rise + gentle icon scale.
  380. iconView.layer?.transform = CATransform3DMakeScale(0.92, 0.92, 1)
  381. NSAnimationContext.runAnimationGroup { context in
  382. context.duration = 0.55
  383. context.timingFunction = CAMediaTimingFunction(name: .easeOut)
  384. stack.animator().alphaValue = 1
  385. }
  386. let scale = CABasicAnimation(keyPath: "transform")
  387. scale.fromValue = CATransform3DMakeScale(0.92, 0.92, 1)
  388. scale.toValue = CATransform3DIdentity
  389. scale.duration = 0.55
  390. scale.timingFunction = CAMediaTimingFunction(name: .easeOut)
  391. iconView.layer?.add(scale, forKey: "splash.scaleIn")
  392. iconView.layer?.transform = CATransform3DIdentity
  393. // Subtle breathing pulse after entrance.
  394. DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { [weak self] in
  395. guard let self, let iconLayer = self.iconView?.layer else { return }
  396. let pulse = CABasicAnimation(keyPath: "transform")
  397. pulse.fromValue = CATransform3DIdentity
  398. pulse.toValue = CATransform3DMakeScale(1.03, 1.03, 1)
  399. pulse.duration = 0.9
  400. pulse.autoreverses = true
  401. pulse.repeatCount = .infinity
  402. pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  403. iconLayer.add(pulse, forKey: "splash.pulse")
  404. }
  405. }
  406. private func startDots() {
  407. dotsTimer?.invalidate()
  408. dotsStep = 0
  409. dotsTimer = Timer.scheduledTimer(withTimeInterval: 0.45, repeats: true) { [weak self] _ in
  410. guard let self, let subtitleLabel else { return }
  411. self.dotsStep = (self.dotsStep + 1) % 4
  412. let dots = String(repeating: ".", count: self.dotsStep)
  413. // Keep the main subtitle stable; add subtle activity via ellipsis.
  414. subtitleLabel.stringValue = "Preparing your meetings workspace" + dots
  415. }
  416. }
  417. private func startProgress() {
  418. progressTimer?.invalidate()
  419. progressValue = 10
  420. setProgressUI(value: progressValue)
  421. // Animate progress smoothly for the 3s splash duration.
  422. let tick: TimeInterval = 0.06
  423. let target: Double = 100
  424. let duration: Double = 3.0
  425. let step = (target - progressValue) / (duration / tick)
  426. progressTimer = Timer.scheduledTimer(withTimeInterval: tick, repeats: true) { [weak self] timer in
  427. guard let self else { timer.invalidate(); return }
  428. self.progressValue = min(target, self.progressValue + step)
  429. self.setProgressUI(value: self.progressValue)
  430. if self.progressValue >= target - 0.01 {
  431. timer.invalidate()
  432. }
  433. }
  434. }
  435. private func setProgressUI(value: Double) {
  436. progressIndicator?.doubleValue = value
  437. percentLabel?.stringValue = "\(Int(round(value)))%"
  438. }
  439. private func applyTheme() {
  440. let isDark = view.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
  441. theme = Self.makeTheme(isDark: isDark)
  442. rootBackground?.gradientLayer.colors = [theme.backgroundTop.cgColor, theme.backgroundBottom.cgColor]
  443. // Icon badge styling.
  444. if let badge = iconBadgeView {
  445. badge.layer?.backgroundColor = theme.iconBadgeFill.cgColor
  446. }
  447. titleLabel?.textColor = theme.titleColor
  448. subtitleLabel?.textColor = theme.subtitleColor
  449. statusLabel?.textColor = theme.statusColor
  450. percentLabel?.textColor = theme.subtitleColor
  451. if let progress = progressIndicator {
  452. progress.appearance = NSAppearance(named: theme.isDark ? .darkAqua : .aqua)
  453. // Prefer tinting when available; fall back gracefully if not.
  454. if #available(macOS 10.14, *) {
  455. progress.contentFilters = []
  456. }
  457. }
  458. }
  459. private static func makeTheme(isDark: Bool) -> Theme {
  460. if isDark {
  461. return Theme(
  462. isDark: true,
  463. backgroundTop: NSColor(calibratedRed: 9 / 255, green: 9 / 255, blue: 10 / 255, alpha: 1),
  464. backgroundBottom: NSColor(calibratedRed: 16 / 255, green: 16 / 255, blue: 18 / 255, alpha: 1),
  465. titleColor: NSColor(calibratedWhite: 0.98, alpha: 1),
  466. subtitleColor: NSColor(calibratedWhite: 0.78, alpha: 1),
  467. statusColor: NSColor(calibratedWhite: 0.66, alpha: 1),
  468. trackColor: NSColor.white.withAlphaComponent(0.10),
  469. fillColor: NSColor(calibratedRed: 96 / 255, green: 215 / 255, blue: 255 / 255, alpha: 1),
  470. iconBadgeFill: NSColor.white.withAlphaComponent(0.08)
  471. )
  472. }
  473. return Theme(
  474. isDark: false,
  475. backgroundTop: NSColor(calibratedWhite: 0.98, alpha: 1),
  476. backgroundBottom: NSColor(calibratedWhite: 0.92, alpha: 1),
  477. titleColor: NSColor(calibratedWhite: 0.10, alpha: 1),
  478. subtitleColor: NSColor(calibratedWhite: 0.34, alpha: 1),
  479. statusColor: NSColor(calibratedWhite: 0.42, alpha: 1),
  480. trackColor: NSColor.black.withAlphaComponent(0.10),
  481. fillColor: NSColor(calibratedRed: 0.18, green: 0.52, blue: 0.96, alpha: 1),
  482. iconBadgeFill: NSColor.black.withAlphaComponent(0.04)
  483. )
  484. }
  485. }