StatusBarController.swift 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. import AppKit
  2. @MainActor
  3. final class StatusBarController: NSObject {
  4. private let authService = GoogleOAuthService.shared
  5. private let statusItem: NSStatusItem
  6. private var observers: [NSObjectProtocol] = []
  7. private var hasPremiumAccess = false
  8. override init() {
  9. statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
  10. super.init()
  11. if let icon = NSImage(named: "StatusBarIcon") {
  12. // Template so the system tints like other menu bar icons (adapts light/dark menu bar).
  13. icon.isTemplate = true
  14. statusItem.button?.image = icon
  15. } else if let icon = NSImage(systemSymbolName: "calendar.badge.clock", accessibilityDescription: AppBrand.name) {
  16. icon.isTemplate = true
  17. statusItem.button?.image = icon
  18. } else {
  19. statusItem.button?.title = AppBrand.name
  20. }
  21. statusItem.button?.setAccessibilityLabel("Meetings status menu".localized)
  22. rebuildMenu()
  23. let center = NotificationCenter.default
  24. observers.append(center.addObserver(forName: .meetingsSnapshotUpdated, object: nil, queue: .main) { [weak self] _ in
  25. self?.rebuildMenu()
  26. })
  27. observers.append(center.addObserver(forName: .statusBarSignOutRequested, object: nil, queue: .main) { [weak self] _ in
  28. self?.rebuildMenu()
  29. })
  30. observers.append(center.addObserver(forName: .widgetSignInRequested, object: nil, queue: .main) { [weak self] _ in
  31. self?.rebuildMenu()
  32. })
  33. observers.append(center.addObserver(forName: .statusBarPremiumAccessChanged, object: nil, queue: .main) { [weak self] notification in
  34. guard let self else { return }
  35. if let hasPremium = notification.userInfo?["hasPremiumAccess"] as? Bool {
  36. self.hasPremiumAccess = hasPremium
  37. }
  38. self.rebuildMenu()
  39. })
  40. }
  41. deinit {
  42. observers.forEach { NotificationCenter.default.removeObserver($0) }
  43. }
  44. private func rebuildMenu() {
  45. let menu = NSMenu()
  46. let openAppItem = NSMenuItem(title: "Open App".localized, action: #selector(openAppClicked), keyEquivalent: "")
  47. openAppItem.target = self
  48. menu.addItem(openAppItem)
  49. menu.addItem(.separator())
  50. addOpenSidebarItem(title: "Join Meetings".localized, pageRaw: 0, to: menu)
  51. addOpenSidebarItem(title: "Schedule".localized, pageRaw: 1, to: menu)
  52. addOpenSidebarItem(title: "Calendar".localized, pageRaw: 2, to: menu)
  53. addOpenSidebarItem(title: "Widgets".localized, pageRaw: 3, to: menu)
  54. addOpenSidebarItem(title: "AI Companion".localized, pageRaw: 5, to: menu)
  55. addOpenSidebarItem(title: "Settings".localized, pageRaw: 4, to: menu)
  56. let signedIn = authService.loadTokens() != nil
  57. if signedIn && hasPremiumAccess {
  58. let topMeetings = WidgetMeetingStore.load().prefix(3).map { $0 }
  59. menu.addItem(.separator())
  60. let header = NSMenuItem(title: "Top meetings".localized, action: nil, keyEquivalent: "")
  61. header.isEnabled = false
  62. menu.addItem(header)
  63. if topMeetings.isEmpty {
  64. let empty = NSMenuItem(title: "No upcoming meetings".localized, action: nil, keyEquivalent: "")
  65. empty.isEnabled = false
  66. menu.addItem(empty)
  67. } else {
  68. for meeting in topMeetings {
  69. let titleText = truncatedMeetingTitle(meeting.title)
  70. let title = "\(meeting.timeText) \(titleText)"
  71. let item = NSMenuItem(title: title, action: #selector(meetingClicked(_:)), keyEquivalent: "")
  72. item.target = self
  73. item.representedObject = meeting.joinLink
  74. menu.addItem(item)
  75. }
  76. }
  77. }
  78. if signedIn {
  79. menu.addItem(.separator())
  80. let signOutItem = NSMenuItem(title: "Sign Out".localized, action: #selector(signOutClicked), keyEquivalent: "")
  81. signOutItem.target = self
  82. menu.addItem(signOutItem)
  83. } else {
  84. menu.addItem(.separator())
  85. let signInItem = NSMenuItem(title: "Sign In".localized, action: #selector(signInClicked), keyEquivalent: "")
  86. signInItem.target = self
  87. menu.addItem(signInItem)
  88. }
  89. menu.addItem(.separator())
  90. let quitItem = NSMenuItem(title: "Quit".localized, action: #selector(quitClicked), keyEquivalent: "q")
  91. quitItem.target = self
  92. menu.addItem(quitItem)
  93. statusItem.menu = menu
  94. }
  95. @objc private func openAppClicked() {
  96. bringAppToFront()
  97. }
  98. @objc private func signInClicked() {
  99. bringAppToFront()
  100. DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
  101. NotificationCenter.default.post(name: .widgetSignInRequested, object: nil)
  102. }
  103. }
  104. @objc private func signOutClicked() {
  105. bringAppToFront()
  106. DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
  107. NotificationCenter.default.post(name: .statusBarSignOutRequested, object: nil)
  108. }
  109. }
  110. @objc private func openSidebarPageClicked(_ sender: NSMenuItem) {
  111. guard let pageRaw = sender.representedObject as? Int else { return }
  112. bringAppToFront()
  113. DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
  114. NotificationCenter.default.post(name: .statusBarOpenSidebarPage, object: nil, userInfo: ["page": pageRaw])
  115. }
  116. }
  117. @objc private func meetingClicked(_ sender: NSMenuItem) {
  118. guard let link = sender.representedObject as? String,
  119. let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
  120. let routed = authService.urlForPreferredGoogleAccountIfPossible(url)
  121. NSWorkspace.shared.open(routed)
  122. }
  123. @objc private func quitClicked() {
  124. NSApp.terminate(nil)
  125. }
  126. private func bringAppToFront() {
  127. NSApp.unhide(nil)
  128. NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
  129. NSApp.activate(ignoringOtherApps: true)
  130. NSApp.arrangeInFront(nil)
  131. for window in NSApp.windows where window.contentViewController is ViewController {
  132. if window.isMiniaturized {
  133. window.deminiaturize(nil)
  134. }
  135. window.orderFrontRegardless()
  136. window.makeKeyAndOrderFront(nil)
  137. }
  138. }
  139. private func truncatedMeetingTitle(_ title: String) -> String {
  140. let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
  141. guard trimmed.count > 10 else { return trimmed }
  142. return "\(trimmed.prefix(10))..."
  143. }
  144. private func addOpenSidebarItem(title: String, pageRaw: Int, to menu: NSMenu) {
  145. let item = NSMenuItem(title: title, action: #selector(openSidebarPageClicked(_:)), keyEquivalent: "")
  146. item.target = self
  147. item.representedObject = pageRaw
  148. menu.addItem(item)
  149. }
  150. }