Açıklama Yok

ViewController.swift 100KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163
  1. //
  2. // ViewController.swift
  3. // zoom_app
  4. //
  5. // Created by Dev Mac 1 on 14/04/2026.
  6. //
  7. import Cocoa
  8. import CryptoKit
  9. import Network
  10. import WebKit
  11. class ViewController: NSViewController {
  12. private let googleOAuth = GoogleOAuthService.shared
  13. private let zoomOAuth = ZoomOAuthService.shared
  14. private let loginStateKey = "zoom_app.isLoggedIn"
  15. private let sidebarWidth: CGFloat = 78
  16. private let appBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  17. private let sidebarBackground = NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
  18. private let sidebarActiveBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  19. private let cardBackground = NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
  20. private let secondaryCardBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  21. private let appShellBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  22. private let contentShellBackground = NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  23. private let topStripBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  24. private let chromeUnifiedBackground = NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  25. private let searchPillBackground = NSColor.white.withAlphaComponent(0.06)
  26. private let meetingCardBackground = NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
  27. private let appShellCornerRadius: CGFloat = 20
  28. private let homeChromeHeaderHeight: CGFloat = 56
  29. private let nativeTrafficLightsLeading: CGFloat = 14
  30. private let nativeTrafficLightsTopInset: CGFloat = 20
  31. private let brandLeadingInset: CGFloat = 84
  32. private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
  33. private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
  34. private let primaryText = NSColor(calibratedWhite: 0.98, alpha: 1)
  35. private let secondaryText = NSColor(calibratedWhite: 0.78, alpha: 1)
  36. private let mutedText = NSColor(calibratedWhite: 0.66, alpha: 1)
  37. private let rootContainer = NSView()
  38. private var loginView: NSView?
  39. private var homeView: NSView?
  40. private weak var googleButton: NSButton?
  41. private weak var nextSignInButton: NSButton?
  42. private weak var zoomSocialButton: NSButton?
  43. private weak var timeLabel: NSTextField?
  44. private weak var dateLabel: NSTextField?
  45. private weak var emptyMeetingLabel: NSTextField?
  46. private weak var meetingsListStack: NSStackView?
  47. private weak var meetingsStatusLabel: NSTextField?
  48. private weak var meetingsScrollView: NSScrollView?
  49. private weak var homeSearchField: NSTextField?
  50. private weak var homeSearchPill: NSView?
  51. private var allScheduledMeetings: [ScheduledMeeting] = []
  52. private var searchTextObserver: NSObjectProtocol?
  53. private var searchShortcutMonitor: Any?
  54. private var searchOutsideClickMonitor: Any?
  55. private var clockTimer: Timer?
  56. private var meetingsRefreshTimer: Timer?
  57. private var isSigningIn = false
  58. private var isPromptingZoomCredentials = false
  59. private var isLoadingMeetings = false
  60. private var meetingsScrollObserver: NSObjectProtocol?
  61. private var lastMeetingsRefreshAt = Date.distantPast
  62. private var lastScrollEdgeRefreshAt = Date.distantPast
  63. private let meetingsRefreshInterval: TimeInterval = 8
  64. private let scrollRefreshCooldown: TimeInterval = 3
  65. private enum SidebarStyle {
  66. case login
  67. case home
  68. }
  69. override func viewDidLoad() {
  70. super.viewDidLoad()
  71. setupUI()
  72. }
  73. override func viewDidAppear() {
  74. super.viewDidAppear()
  75. if let window = view.window {
  76. window.setContentSize(NSSize(width: 1020, height: 690))
  77. window.backgroundColor = chromeUnifiedBackground
  78. // Use full-size content view so custom top chrome sits in the titlebar region.
  79. window.titleVisibility = .hidden
  80. window.titlebarAppearsTransparent = true
  81. window.isMovableByWindowBackground = true
  82. window.styleMask.insert(.fullSizeContentView)
  83. }
  84. alignNativeTrafficLights()
  85. if isUserLoggedIn() {
  86. showHomeView(profile: nil)
  87. } else {
  88. showLoginView()
  89. }
  90. }
  91. override func viewDidLayout() {
  92. super.viewDidLayout()
  93. alignNativeTrafficLights()
  94. }
  95. private func setupUI() {
  96. view.wantsLayer = true
  97. view.layer?.backgroundColor = appBackground.cgColor
  98. rootContainer.translatesAutoresizingMaskIntoConstraints = false
  99. view.addSubview(rootContainer)
  100. NSLayoutConstraint.activate([
  101. rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  102. rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  103. rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
  104. rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  105. ])
  106. }
  107. private func showLoginView() {
  108. clockTimer?.invalidate()
  109. meetingsRefreshTimer?.invalidate()
  110. meetingsRefreshTimer = nil
  111. clearMeetingsScrollObserver()
  112. removeSearchFieldObserver()
  113. removeSearchShortcutMonitor()
  114. homeSearchField = nil
  115. homeSearchPill = nil
  116. allScheduledMeetings = []
  117. homeView?.removeFromSuperview()
  118. homeView = nil
  119. isSigningIn = false
  120. nextSignInButton?.title = "Next"
  121. nextSignInButton?.isEnabled = true
  122. zoomSocialButton?.isEnabled = true
  123. if loginView == nil {
  124. loginView = makeLoginView()
  125. }
  126. guard let loginView else { return }
  127. attachToRoot(loginView)
  128. }
  129. private func showHomeView(profile: GoogleUserProfile?) {
  130. loginView?.removeFromSuperview()
  131. clearMeetingsScrollObserver()
  132. removeSearchFieldObserver()
  133. removeSearchShortcutMonitor()
  134. homeSearchField = nil
  135. homeSearchPill = nil
  136. homeView?.removeFromSuperview()
  137. homeView = makeHomeView(profile: profile)
  138. if let homeView { attachToRoot(homeView) }
  139. installSearchShortcutMonitor()
  140. persistLoggedInState(true)
  141. startClock()
  142. startMeetingsAutoRefresh()
  143. triggerMeetingsRefresh(force: true)
  144. }
  145. private func isUserLoggedIn() -> Bool {
  146. UserDefaults.standard.bool(forKey: loginStateKey)
  147. }
  148. private func persistLoggedInState(_ loggedIn: Bool) {
  149. UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
  150. }
  151. private func attachToRoot(_ subview: NSView) {
  152. subview.translatesAutoresizingMaskIntoConstraints = false
  153. if subview.superview != rootContainer {
  154. rootContainer.addSubview(subview)
  155. }
  156. NSLayoutConstraint.activate([
  157. subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
  158. subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
  159. subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
  160. subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
  161. ])
  162. }
  163. @objc private func googleLoginTapped() {
  164. guard isSigningIn == false else { return }
  165. isSigningIn = true
  166. googleButton?.title = "..."
  167. googleButton?.isEnabled = false
  168. Task {
  169. do {
  170. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  171. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  172. await MainActor.run {
  173. self.isSigningIn = false
  174. self.googleButton?.title = "G"
  175. self.googleButton?.isEnabled = true
  176. self.showHomeView(profile: profile)
  177. }
  178. } catch {
  179. await MainActor.run {
  180. self.isSigningIn = false
  181. self.googleButton?.title = "G"
  182. self.googleButton?.isEnabled = true
  183. self.showSimpleError("Google login failed", error: error)
  184. }
  185. }
  186. }
  187. }
  188. /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
  189. @objc private func zoomPrimarySignInTapped() {
  190. guard isSigningIn == false else { return }
  191. isSigningIn = true
  192. nextSignInButton?.title = "Signing in…"
  193. nextSignInButton?.isEnabled = false
  194. zoomSocialButton?.isEnabled = false
  195. googleButton?.isEnabled = false
  196. Task {
  197. do {
  198. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  199. guard configured else {
  200. await MainActor.run { self.resetLoginSigningInState() }
  201. return
  202. }
  203. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  204. let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
  205. let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
  206. await MainActor.run {
  207. self.resetLoginSigningInState()
  208. self.showHomeView(profile: profile)
  209. }
  210. } catch {
  211. await MainActor.run {
  212. self.resetLoginSigningInState()
  213. self.showSimpleError("Zoom sign-in failed", error: error)
  214. }
  215. }
  216. }
  217. }
  218. @objc private func scheduleMeetingWebTapped() {
  219. guard let url = URL(string: "https://zoom.us/meeting/schedule") else { return }
  220. let opened = NSWorkspace.shared.open(url)
  221. if opened == false {
  222. meetingsStatusLabel?.stringValue = "Unable to open Zoom schedule page."
  223. }
  224. }
  225. @objc private func logoutTapped() {
  226. meetingsRefreshTimer?.invalidate()
  227. meetingsRefreshTimer = nil
  228. clearMeetingsScrollObserver()
  229. googleOAuth.clearSavedTokens()
  230. zoomOAuth.clearSavedTokens()
  231. persistLoggedInState(false)
  232. showLoginView()
  233. }
  234. @objc private func topBarPlaceholderTapped() {
  235. // Reserved for future titlebar control actions.
  236. }
  237. private func startMeetingsAutoRefresh() {
  238. meetingsRefreshTimer?.invalidate()
  239. // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
  240. meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
  241. guard let self else { return }
  242. self.triggerMeetingsRefresh()
  243. }
  244. }
  245. private func triggerMeetingsRefresh(force: Bool = false) {
  246. let now = Date()
  247. if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
  248. return
  249. }
  250. lastMeetingsRefreshAt = now
  251. Task { await self.loadScheduledMeetings() }
  252. }
  253. private func clearMeetingsScrollObserver() {
  254. if let meetingsScrollObserver {
  255. NotificationCenter.default.removeObserver(meetingsScrollObserver)
  256. }
  257. meetingsScrollObserver = nil
  258. meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
  259. meetingsScrollView = nil
  260. }
  261. private func removeSearchFieldObserver() {
  262. if let searchTextObserver {
  263. NotificationCenter.default.removeObserver(searchTextObserver)
  264. }
  265. searchTextObserver = nil
  266. }
  267. private func installSearchShortcutMonitor() {
  268. removeSearchShortcutMonitor()
  269. searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  270. guard let self else { return event }
  271. guard event.modifierFlags.contains(.command),
  272. event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
  273. guard self.homeSearchField != nil else { return event }
  274. DispatchQueue.main.async {
  275. self.focusHomeSearchField()
  276. }
  277. return nil
  278. }
  279. searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  280. guard let self else { return event }
  281. guard let window = self.view.window, event.window === window else { return event }
  282. guard let field = self.homeSearchField else { return event }
  283. guard self.isSearchFieldActive(field, in: window) else { return event }
  284. let location = event.locationInWindow
  285. let pill = self.homeSearchPill ?? field
  286. let rectInWindow = pill.convert(pill.bounds, to: nil)
  287. if rectInWindow.contains(location) { return event }
  288. DispatchQueue.main.async {
  289. window.makeFirstResponder(nil)
  290. (field as? SearchPillTextField)?.forceClearFocusState()
  291. self.applySearchPillFocusBorder(focused: false)
  292. }
  293. return event
  294. }
  295. }
  296. private func removeSearchShortcutMonitor() {
  297. if let searchShortcutMonitor {
  298. NSEvent.removeMonitor(searchShortcutMonitor)
  299. }
  300. searchShortcutMonitor = nil
  301. if let searchOutsideClickMonitor {
  302. NSEvent.removeMonitor(searchOutsideClickMonitor)
  303. }
  304. searchOutsideClickMonitor = nil
  305. }
  306. private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
  307. guard let fr = window.firstResponder else { return false }
  308. if fr === field { return true }
  309. if let editor = field.currentEditor(), fr === editor { return true }
  310. return false
  311. }
  312. @MainActor
  313. private func applySearchPillFocusBorder(focused: Bool) {
  314. homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
  315. homeSearchPill?.layer?.borderColor = accentBlue.cgColor
  316. }
  317. @MainActor
  318. private func focusHomeSearchField() {
  319. guard let field = homeSearchField else { return }
  320. view.window?.makeFirstResponder(field)
  321. }
  322. private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
  323. clearMeetingsScrollObserver()
  324. meetingsScrollView = scrollView
  325. scrollView.contentView.postsBoundsChangedNotifications = true
  326. meetingsScrollObserver = NotificationCenter.default.addObserver(
  327. forName: NSView.boundsDidChangeNotification,
  328. object: scrollView.contentView,
  329. queue: .main
  330. ) { [weak self, weak scrollView] _ in
  331. guard let self, let scrollView else { return }
  332. self.refreshMeetingsIfScrolledToEdge(scrollView)
  333. }
  334. }
  335. private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
  336. guard let documentView = scrollView.documentView else { return }
  337. let visibleRect = scrollView.contentView.bounds
  338. let contentHeight = documentView.bounds.height
  339. let viewportHeight = visibleRect.height
  340. if contentHeight <= viewportHeight + 2 {
  341. return
  342. }
  343. let maxOffset = max(contentHeight - viewportHeight, 0)
  344. let y = visibleRect.origin.y
  345. let threshold: CGFloat = 12
  346. let reachedTop = y <= threshold
  347. let reachedBottom = y >= (maxOffset - threshold)
  348. guard reachedTop || reachedBottom else { return }
  349. let now = Date()
  350. if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
  351. return
  352. }
  353. lastScrollEdgeRefreshAt = now
  354. triggerMeetingsRefresh(force: true)
  355. }
  356. @MainActor
  357. private func resetLoginSigningInState() {
  358. isSigningIn = false
  359. nextSignInButton?.title = "Next"
  360. nextSignInButton?.isEnabled = true
  361. zoomSocialButton?.isEnabled = true
  362. googleButton?.isEnabled = true
  363. }
  364. /// Returns false if the user cancelled or left credentials empty.
  365. @MainActor
  366. private func ensureZoomOAuthClientConfigured() -> Bool {
  367. if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
  368. return true
  369. }
  370. return presentZoomOAuthCredentialPrompt()
  371. }
  372. private func showSimpleError(_ title: String, error: Error) {
  373. let alert = NSAlert()
  374. alert.alertStyle = .warning
  375. alert.messageText = title
  376. alert.informativeText = error.localizedDescription
  377. alert.runModal()
  378. }
  379. private struct ScheduledMeeting {
  380. let title: String
  381. let start: Date
  382. let end: Date?
  383. let host: String
  384. let source: String
  385. }
  386. @MainActor
  387. private func applyMeetings(_ meetings: [ScheduledMeeting]) {
  388. allScheduledMeetings = meetings
  389. applyFilteredMeetings()
  390. }
  391. @MainActor
  392. private func applyFilteredMeetings() {
  393. guard let stack = meetingsListStack else { return }
  394. stack.arrangedSubviews.forEach { view in
  395. stack.removeArrangedSubview(view)
  396. view.removeFromSuperview()
  397. }
  398. let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  399. let source = allScheduledMeetings
  400. let filtered: [ScheduledMeeting]
  401. if query.isEmpty {
  402. filtered = source
  403. } else {
  404. filtered = source.filter { meeting in
  405. meeting.title.lowercased().contains(query)
  406. || meeting.host.lowercased().contains(query)
  407. || meeting.source.lowercased().contains(query)
  408. }
  409. }
  410. let ordered = filtered.sorted(by: { $0.start < $1.start })
  411. if ordered.isEmpty {
  412. emptyMeetingLabel?.isHidden = false
  413. if source.isEmpty {
  414. meetingsStatusLabel?.stringValue = "No upcoming Zoom meetings found."
  415. emptyMeetingLabel?.stringValue = "No meetings scheduled for today."
  416. } else {
  417. meetingsStatusLabel?.stringValue = "Zoom meetings"
  418. emptyMeetingLabel?.stringValue = "No meetings match your search."
  419. }
  420. return
  421. }
  422. emptyMeetingLabel?.isHidden = true
  423. meetingsStatusLabel?.stringValue = "Zoom meetings"
  424. for meeting in ordered {
  425. stack.addArrangedSubview(makeMeetingRowCard(meeting))
  426. }
  427. }
  428. private func loadScheduledMeetings() async {
  429. if isLoadingMeetings { return }
  430. isLoadingMeetings = true
  431. defer { isLoadingMeetings = false }
  432. do {
  433. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  434. let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
  435. await MainActor.run {
  436. self.applyMeetings(zoomMeetings)
  437. }
  438. } catch {
  439. await MainActor.run {
  440. self.applyMeetings([])
  441. if case ZoomOAuthError.missingClientId = error {
  442. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  443. self.promptForZoomOAuthCredentialsIfNeeded()
  444. } else if case ZoomOAuthError.missingClientSecret = error {
  445. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  446. self.promptForZoomOAuthCredentialsIfNeeded()
  447. } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
  448. self.zoomOAuth.clearSavedTokens()
  449. self.meetingsStatusLabel?.stringValue = "Zoom OAuth scope missing. Add required scopes in Marketplace, click Add app now, then sign in again. (\(scopeMessage))"
  450. } else {
  451. self.meetingsStatusLabel?.stringValue = "Zoom API error: \(error.localizedDescription)"
  452. }
  453. }
  454. }
  455. }
  456. @MainActor
  457. private func presentZoomOAuthCredentialPrompt() -> Bool {
  458. let alert = NSAlert()
  459. alert.alertStyle = .informational
  460. alert.messageText = "Configure Zoom OAuth"
  461. alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically."
  462. let wrapper = NSStackView()
  463. wrapper.orientation = .vertical
  464. wrapper.spacing = 8
  465. wrapper.translatesAutoresizingMaskIntoConstraints = false
  466. let clientIdField = NSTextField()
  467. clientIdField.placeholderString = "Zoom Client ID"
  468. clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
  469. let clientSecretField = NSSecureTextField()
  470. clientSecretField.placeholderString = "Zoom Client Secret"
  471. clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
  472. [clientIdField, clientSecretField].forEach { field in
  473. field.translatesAutoresizingMaskIntoConstraints = false
  474. field.widthAnchor.constraint(equalToConstant: 420).isActive = true
  475. wrapper.addArrangedSubview(field)
  476. }
  477. alert.accessoryView = wrapper
  478. alert.addButton(withTitle: "Save")
  479. alert.addButton(withTitle: "Cancel")
  480. let result = alert.runModal()
  481. if result == .alertFirstButtonReturn {
  482. var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  483. var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  484. if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
  485. if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
  486. if clientId.isEmpty == false, clientSecret.isEmpty == false {
  487. zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
  488. return true
  489. }
  490. meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
  491. }
  492. return false
  493. }
  494. @MainActor
  495. private func promptForZoomOAuthCredentialsIfNeeded() {
  496. guard isPromptingZoomCredentials == false else { return }
  497. isPromptingZoomCredentials = true
  498. defer { isPromptingZoomCredentials = false }
  499. if presentZoomOAuthCredentialPrompt() {
  500. meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
  501. Task { await self.loadScheduledMeetings() }
  502. }
  503. }
  504. private struct ZoomUserMeResponse: Decodable {
  505. let first_name: String?
  506. let last_name: String?
  507. let display_name: String?
  508. let email: String?
  509. let pic_url: String?
  510. var displayName: String? {
  511. if let display_name, display_name.isEmpty == false { return display_name }
  512. let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
  513. return parts.isEmpty ? nil : parts.joined(separator: " ")
  514. }
  515. var pictureURL: String? {
  516. guard let pic_url, pic_url.isEmpty == false else { return nil }
  517. return pic_url
  518. }
  519. }
  520. private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
  521. let url = URL(string: "https://api.zoom.us/v2/users/me")!
  522. var request = URLRequest(url: url)
  523. request.httpMethod = "GET"
  524. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  525. let (data, response) = try await URLSession.shared.data(for: request)
  526. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  527. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
  528. }
  529. return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
  530. }
  531. private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
  532. struct ZoomMeeting: Decodable {
  533. let topic: String?
  534. let start_time: String?
  535. let duration: Int?
  536. let host_id: String?
  537. }
  538. struct ZoomMeetingsPage: Decodable {
  539. let meetings: [ZoomMeeting]
  540. let next_page_token: String?
  541. }
  542. let iso = ISO8601DateFormatter()
  543. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  544. let fallbackISO = ISO8601DateFormatter()
  545. fallbackISO.formatOptions = [.withInternetDateTime]
  546. func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
  547. raw.compactMap { meeting in
  548. guard let startRaw = meeting.start_time else { return nil }
  549. let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
  550. guard let start else { return nil }
  551. let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
  552. return ScheduledMeeting(
  553. title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
  554. start: start,
  555. end: end,
  556. host: meeting.host_id ?? "Zoom Host",
  557. source: "Zoom"
  558. )
  559. }
  560. }
  561. var allMeetings: [ZoomMeeting] = []
  562. var nextPageToken: String?
  563. repeat {
  564. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  565. var items: [URLQueryItem] = [
  566. URLQueryItem(name: "type", value: "scheduled"),
  567. URLQueryItem(name: "page_size", value: "30")
  568. ]
  569. if let nextPageToken, nextPageToken.isEmpty == false {
  570. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  571. }
  572. components.queryItems = items
  573. var request = URLRequest(url: components.url!)
  574. request.httpMethod = "GET"
  575. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  576. let (data, response) = try await URLSession.shared.data(for: request)
  577. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  578. let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
  579. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  580. throw ZoomOAuthError.missingRequiredScope(raw)
  581. }
  582. throw GoogleOAuthError.tokenExchangeFailed(raw)
  583. }
  584. let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
  585. allMeetings.append(contentsOf: decoded.meetings)
  586. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  587. nextPageToken = (token?.isEmpty == false) ? token : nil
  588. } while nextPageToken != nil
  589. return mapMeetings(allMeetings)
  590. }
  591. // MARK: - Login UI
  592. private func makeLoginView() -> NSView {
  593. let root = NSView()
  594. let sidebar = makeSidebar(items: ["Home", "Chat", "Phone", "Docs", "Whiteboards", "Clips", "More"], selected: "Home", style: .login)
  595. let content = NSView()
  596. root.addSubview(sidebar)
  597. root.addSubview(content)
  598. sidebar.translatesAutoresizingMaskIntoConstraints = false
  599. content.translatesAutoresizingMaskIntoConstraints = false
  600. NSLayoutConstraint.activate([
  601. sidebar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  602. sidebar.topAnchor.constraint(equalTo: root.topAnchor),
  603. sidebar.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  604. sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth),
  605. content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
  606. content.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  607. content.topAnchor.constraint(equalTo: root.topAnchor),
  608. content.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  609. ])
  610. let back = makeLabel("‹ Back", size: 32, color: accentBlue, weight: .regular, centered: false)
  611. let logo = makeLabel("zoom\nWorkplace", size: 24, color: primaryText, weight: .bold, centered: true)
  612. logo.maximumNumberOfLines = 2
  613. let domain = makeLabel("us05web.zoom.us", size: 16, color: primaryText, weight: .semibold, centered: true)
  614. let emailField = NSTextField()
  615. emailField.placeholderString = "Email or phone number"
  616. emailField.font = .systemFont(ofSize: 20, weight: .regular)
  617. emailField.textColor = .white
  618. emailField.wantsLayer = true
  619. emailField.layer?.cornerRadius = 10
  620. emailField.layer?.borderWidth = 1.5
  621. emailField.layer?.borderColor = accentBlue.cgColor
  622. emailField.layer?.backgroundColor = cardBackground.cgColor
  623. emailField.focusRingType = .none
  624. let nextButton = NSButton(title: "Next", target: self, action: #selector(zoomPrimarySignInTapped))
  625. nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
  626. nextButton.isBordered = false
  627. nextButton.wantsLayer = true
  628. nextButton.layer?.cornerRadius = 10
  629. nextButton.layer?.backgroundColor = cardBackground.cgColor
  630. nextButton.contentTintColor = primaryText
  631. let divider = NSBox()
  632. divider.boxType = .separator
  633. let socialText = makeLabel("or sign in with", size: 14, color: secondaryText, weight: .regular, centered: true)
  634. let sso = makeSocialButton(icon: "🔑", text: "SSO")
  635. let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
  636. let apple = makeSocialButton(icon: "", text: "Apple")
  637. let facebook = makeSocialButton(icon: "f", text: "Facebook")
  638. let zoomSocial = makeSocialButton(icon: "Z", text: "Zoom", action: #selector(zoomPrimarySignInTapped))
  639. self.googleButton = google.button
  640. self.nextSignInButton = nextButton
  641. self.zoomSocialButton = zoomSocial.button
  642. let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, zoomSocial.container])
  643. social.orientation = .horizontal
  644. social.spacing = 14
  645. social.distribution = .fillEqually
  646. let signup = makeLabel("Don't have an account? Sign up", size: 15, color: primaryText, weight: .regular, centered: true)
  647. let footer = makeLabel("Help Terms Privacy", size: 14, color: accentBlue, weight: .regular, centered: true)
  648. [back, logo, domain, emailField, nextButton, divider, socialText, social, signup, footer].forEach {
  649. $0.translatesAutoresizingMaskIntoConstraints = false
  650. content.addSubview($0)
  651. }
  652. NSLayoutConstraint.activate([
  653. back.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
  654. back.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 34),
  655. logo.topAnchor.constraint(equalTo: content.topAnchor, constant: 118),
  656. logo.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  657. domain.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: 12),
  658. domain.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  659. emailField.topAnchor.constraint(equalTo: domain.bottomAnchor, constant: 30),
  660. emailField.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  661. emailField.widthAnchor.constraint(equalToConstant: 520),
  662. emailField.heightAnchor.constraint(equalToConstant: 52),
  663. nextButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 20),
  664. nextButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  665. nextButton.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  666. nextButton.heightAnchor.constraint(equalToConstant: 52),
  667. divider.topAnchor.constraint(equalTo: nextButton.bottomAnchor, constant: 28),
  668. divider.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  669. divider.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  670. socialText.centerYAnchor.constraint(equalTo: divider.centerYAnchor),
  671. socialText.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  672. social.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 18),
  673. social.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  674. social.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  675. signup.topAnchor.constraint(equalTo: social.bottomAnchor, constant: 14),
  676. signup.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  677. footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
  678. footer.centerXAnchor.constraint(equalTo: content.centerXAnchor)
  679. ])
  680. return root
  681. }
  682. // MARK: - Home UI
  683. private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
  684. let root = NSView()
  685. let shell = NSView()
  686. shell.wantsLayer = true
  687. shell.layer?.backgroundColor = appShellBackground.cgColor
  688. shell.layer?.cornerRadius = appShellCornerRadius
  689. shell.layer?.borderWidth = 1
  690. shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  691. let chromeColumn = NSView()
  692. chromeColumn.wantsLayer = true
  693. chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  694. chromeColumn.layer?.cornerRadius = appShellCornerRadius
  695. chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
  696. chromeColumn.layer?.masksToBounds = true
  697. let chromeDivider = NSView()
  698. chromeDivider.wantsLayer = true
  699. chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
  700. let chromeHeader = NSView()
  701. chromeHeader.wantsLayer = true
  702. chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  703. let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler"], selected: "Home", style: .home)
  704. let content = NSView()
  705. content.wantsLayer = true
  706. content.layer?.backgroundColor = NSColor.clear.cgColor
  707. root.addSubview(shell)
  708. shell.addSubview(chromeColumn)
  709. shell.addSubview(content)
  710. chromeColumn.addSubview(chromeDivider)
  711. chromeColumn.addSubview(chromeHeader)
  712. chromeColumn.addSubview(sidebar)
  713. shell.translatesAutoresizingMaskIntoConstraints = false
  714. chromeColumn.translatesAutoresizingMaskIntoConstraints = false
  715. chromeDivider.translatesAutoresizingMaskIntoConstraints = false
  716. chromeHeader.translatesAutoresizingMaskIntoConstraints = false
  717. sidebar.translatesAutoresizingMaskIntoConstraints = false
  718. content.translatesAutoresizingMaskIntoConstraints = false
  719. NSLayoutConstraint.activate([
  720. shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  721. shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  722. shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
  723. shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  724. chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
  725. chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
  726. chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
  727. chromeColumn.widthAnchor.constraint(equalToConstant: 82),
  728. chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  729. chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  730. chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  731. chromeDivider.widthAnchor.constraint(equalToConstant: 1),
  732. chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  733. chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  734. chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  735. chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
  736. sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  737. sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  738. sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
  739. sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  740. content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  741. content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
  742. content.topAnchor.constraint(equalTo: shell.topAnchor),
  743. content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
  744. ])
  745. let brandStack = NSStackView()
  746. brandStack.orientation = .vertical
  747. brandStack.spacing = 0
  748. brandStack.alignment = .leading
  749. let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
  750. let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
  751. brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
  752. brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
  753. [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
  754. let topBar = NSView()
  755. topBar.wantsLayer = true
  756. topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  757. let topBarDivider = NSView()
  758. topBarDivider.wantsLayer = true
  759. topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
  760. let searchPill = NSView()
  761. searchPill.wantsLayer = true
  762. searchPill.layer?.backgroundColor = searchPillBackground.cgColor
  763. searchPill.layer?.cornerRadius = 10
  764. searchPill.layer?.borderWidth = 0
  765. let searchIcon = NSImageView()
  766. searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
  767. searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
  768. searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  769. searchIcon.imageScaling = .scaleProportionallyUpOrDown
  770. let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
  771. searchHintLabel.isHidden = false
  772. let searchField = SearchPillTextField()
  773. searchField.isBordered = false
  774. searchField.drawsBackground = false
  775. searchField.backgroundColor = .clear
  776. searchField.focusRingType = .none
  777. searchField.font = .systemFont(ofSize: 13, weight: .regular)
  778. searchField.textColor = primaryText
  779. searchField.alignment = .left
  780. searchField.placeholderString = nil
  781. if let cell = searchField.cell as? NSTextFieldCell {
  782. cell.isBezeled = false
  783. cell.isBordered = false
  784. cell.backgroundColor = .clear
  785. }
  786. let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
  787. guard let searchField, let searchHintLabel else { return }
  788. let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
  789. searchHintLabel.isHidden = shouldShow == false
  790. }
  791. searchField.onFocusChange = { [weak self] focused in
  792. self?.applySearchPillFocusBorder(focused: focused)
  793. updateSearchHintVisibility()
  794. }
  795. updateSearchHintVisibility()
  796. let backForwardCluster = NSStackView()
  797. backForwardCluster.orientation = .horizontal
  798. backForwardCluster.spacing = 4
  799. backForwardCluster.alignment = .centerY
  800. let backButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(topBarPlaceholderTapped), dimension: 14, pointSize: 7, toolTip: "Back")
  801. let forwardButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(topBarPlaceholderTapped), dimension: 14, pointSize: 7, toolTip: "Forward")
  802. [backButton, forwardButton].forEach { backForwardCluster.addArrangedSubview($0) }
  803. let leftTopBarCluster = NSStackView()
  804. leftTopBarCluster.orientation = .horizontal
  805. leftTopBarCluster.spacing = 0
  806. leftTopBarCluster.alignment = .centerY
  807. let historyButton = makeNavGlyphButton(symbol: "clock.arrow.circlepath", action: #selector(topBarPlaceholderTapped), toolTip: "History")
  808. let navHistoryGap = NSView()
  809. navHistoryGap.translatesAutoresizingMaskIntoConstraints = false
  810. navHistoryGap.widthAnchor.constraint(equalToConstant: 12).isActive = true
  811. [backForwardCluster, navHistoryGap, historyButton].forEach { leftTopBarCluster.addArrangedSubview($0) }
  812. let searchRow = NSStackView()
  813. searchRow.orientation = .horizontal
  814. searchRow.spacing = 14
  815. searchRow.alignment = .centerY
  816. [leftTopBarCluster, searchPill].forEach { searchRow.addArrangedSubview($0) }
  817. let rightTopBarCluster = NSStackView()
  818. rightTopBarCluster.orientation = .horizontal
  819. rightTopBarCluster.spacing = 10
  820. rightTopBarCluster.alignment = .centerY
  821. let upgradeToProButton = makeUpgradeToProButton(action: #selector(topBarPlaceholderTapped))
  822. let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
  823. profileChip.isBordered = false
  824. profileChip.wantsLayer = true
  825. profileChip.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
  826. profileChip.layer?.cornerRadius = 10
  827. profileChip.contentTintColor = primaryText
  828. profileChip.font = .systemFont(ofSize: 14, weight: .bold)
  829. profileChip.toolTip = "Profile (click to logout)"
  830. [upgradeToProButton, profileChip].forEach { rightTopBarCluster.addArrangedSubview($0) }
  831. let welcome = makeLabel("Home", size: 15, color: secondaryText, weight: .medium, centered: false)
  832. let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
  833. let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
  834. let actions = NSStackView(views: [
  835. makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
  836. makeActionTile(title: "Join", symbol: "plus", color: accentBlue),
  837. makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
  838. ])
  839. actions.orientation = .horizontal
  840. actions.spacing = 12
  841. actions.alignment = .centerY
  842. actions.distribution = .fillEqually
  843. let panel = NSView()
  844. panel.wantsLayer = true
  845. panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  846. panel.layer?.cornerRadius = 16
  847. panel.layer?.borderWidth = 1
  848. panel.layer?.borderColor = NSColor.white.withAlphaComponent(0.07).cgColor
  849. let todaysDateFormatter = DateFormatter()
  850. todaysDateFormatter.dateFormat = "EEEE, MMM d"
  851. let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 21, color: primaryText, weight: .semibold, centered: false)
  852. let meetingsStatus = makeLabel("Upcoming meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
  853. let noMeeting = makeLabel("No meetings scheduled for today.", size: 18, color: secondaryText, weight: .regular, centered: true)
  854. let meetingsScrollView = NSScrollView()
  855. meetingsScrollView.drawsBackground = false
  856. meetingsScrollView.hasVerticalScroller = true
  857. meetingsScrollView.hasHorizontalScroller = false
  858. meetingsScrollView.autohidesScrollers = true
  859. let meetingsDocument = NSView()
  860. let meetingsStack = NSStackView()
  861. meetingsStack.orientation = .vertical
  862. meetingsStack.spacing = 10
  863. meetingsStack.alignment = .leading
  864. let openRecordings = NSButton(title: "Open recordings", target: nil, action: nil)
  865. openRecordings.isBordered = false
  866. openRecordings.font = .systemFont(ofSize: 14, weight: .semibold)
  867. openRecordings.contentTintColor = primaryText
  868. openRecordings.wantsLayer = true
  869. openRecordings.layer?.backgroundColor = NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1).cgColor
  870. openRecordings.layer?.cornerRadius = 11
  871. openRecordings.layer?.borderWidth = 1
  872. openRecordings.layer?.borderColor = NSColor.white.withAlphaComponent(0.07).cgColor
  873. let contentColumn = NSView()
  874. contentColumn.translatesAutoresizingMaskIntoConstraints = false
  875. content.addSubview(topBar)
  876. content.addSubview(topBarDivider)
  877. content.addSubview(contentColumn)
  878. [brandStack, searchRow, backForwardCluster, leftTopBarCluster, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
  879. $0.translatesAutoresizingMaskIntoConstraints = false
  880. }
  881. [brandStack].forEach {
  882. shell.addSubview($0)
  883. }
  884. [searchRow, rightTopBarCluster].forEach {
  885. topBar.addSubview($0)
  886. }
  887. [searchIcon, searchField, searchHintLabel].forEach {
  888. searchPill.addSubview($0)
  889. }
  890. [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, noMeeting, meetingsScrollView, openRecordings].forEach {
  891. $0.translatesAutoresizingMaskIntoConstraints = false
  892. contentColumn.addSubview($0)
  893. }
  894. topBar.translatesAutoresizingMaskIntoConstraints = false
  895. topBarDivider.translatesAutoresizingMaskIntoConstraints = false
  896. meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
  897. meetingsStack.translatesAutoresizingMaskIntoConstraints = false
  898. meetingsScrollView.documentView = meetingsDocument
  899. meetingsDocument.addSubview(meetingsStack)
  900. let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
  901. searchRowCenterX.priority = .defaultHigh
  902. NSLayoutConstraint.activate([
  903. brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset),
  904. brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
  905. brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
  906. topBar.topAnchor.constraint(equalTo: content.topAnchor),
  907. topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  908. topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  909. topBar.heightAnchor.constraint(equalToConstant: 56),
  910. topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
  911. topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  912. topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  913. topBarDivider.heightAnchor.constraint(equalToConstant: 1),
  914. contentColumn.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
  915. contentColumn.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
  916. contentColumn.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
  917. contentColumn.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
  918. searchRowCenterX,
  919. searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  920. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
  921. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
  922. searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
  923. rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  924. rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  925. searchPill.heightAnchor.constraint(equalToConstant: 32),
  926. searchPill.widthAnchor.constraint(equalToConstant: 320),
  927. searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
  928. searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  929. searchIcon.widthAnchor.constraint(equalToConstant: 16),
  930. searchIcon.heightAnchor.constraint(equalToConstant: 16),
  931. searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
  932. searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  933. searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
  934. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  935. searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
  936. searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  937. profileChip.widthAnchor.constraint(equalToConstant: 34),
  938. profileChip.heightAnchor.constraint(equalToConstant: 34),
  939. welcome.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 18),
  940. welcome.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  941. timeTitle.topAnchor.constraint(equalTo: welcome.bottomAnchor, constant: 12),
  942. timeTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  943. dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
  944. dateTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  945. actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
  946. actions.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  947. actions.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 12),
  948. actions.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -12),
  949. actions.heightAnchor.constraint(equalToConstant: 100),
  950. panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
  951. panel.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  952. panel.widthAnchor.constraint(equalToConstant: 640),
  953. panel.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 6),
  954. panel.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -6),
  955. panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  956. panel.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor, constant: -14),
  957. panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
  958. panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
  959. meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  960. meetingsStatus.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
  961. noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  962. noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  963. noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
  964. meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
  965. meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  966. meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  967. meetingsScrollView.bottomAnchor.constraint(equalTo: openRecordings.topAnchor, constant: -14),
  968. meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
  969. meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
  970. meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
  971. meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
  972. meetingsStack.bottomAnchor.constraint(equalTo: meetingsDocument.bottomAnchor),
  973. openRecordings.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  974. openRecordings.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  975. openRecordings.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
  976. openRecordings.heightAnchor.constraint(equalToConstant: 40)
  977. ])
  978. timeLabel = timeTitle
  979. dateLabel = dateTitle
  980. meetingsListStack = meetingsStack
  981. meetingsStatusLabel = meetingsStatus
  982. emptyMeetingLabel = noMeeting
  983. observeMeetingsScrollEdges(in: meetingsScrollView)
  984. updateClock()
  985. homeSearchField = searchField
  986. homeSearchPill = searchPill
  987. searchTextObserver = NotificationCenter.default.addObserver(
  988. forName: NSControl.textDidChangeNotification,
  989. object: searchField,
  990. queue: .main
  991. ) { [weak self] _ in
  992. self?.applyFilteredMeetings()
  993. updateSearchHintVisibility()
  994. }
  995. return root
  996. }
  997. deinit {
  998. removeSearchFieldObserver()
  999. removeSearchShortcutMonitor()
  1000. }
  1001. private func startClock() {
  1002. clockTimer?.invalidate()
  1003. clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  1004. self?.updateClock()
  1005. }
  1006. updateClock()
  1007. }
  1008. private func updateClock() {
  1009. let now = Date()
  1010. let timeFormatter = DateFormatter()
  1011. timeFormatter.dateFormat = "h:mm a"
  1012. let dateFormatter = DateFormatter()
  1013. dateFormatter.dateFormat = "EEEE, d MMM"
  1014. timeLabel?.stringValue = timeFormatter.string(from: now)
  1015. dateLabel?.stringValue = dateFormatter.string(from: now)
  1016. }
  1017. // MARK: - Shared UI
  1018. private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .login) -> NSView {
  1019. let sidebar = NSView()
  1020. sidebar.wantsLayer = true
  1021. sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
  1022. let stack = NSStackView()
  1023. stack.orientation = .vertical
  1024. stack.spacing = style == .home ? 12 : 16
  1025. stack.alignment = .centerX
  1026. stack.distribution = .fill
  1027. // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
  1028. stack.setContentHuggingPriority(.required, for: .vertical)
  1029. stack.setContentCompressionResistancePriority(.required, for: .vertical)
  1030. stack.translatesAutoresizingMaskIntoConstraints = false
  1031. sidebar.addSubview(stack)
  1032. for item in items {
  1033. let row = NSView()
  1034. row.translatesAutoresizingMaskIntoConstraints = false
  1035. row.wantsLayer = true
  1036. let selectedRow = item == selected
  1037. row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  1038. row.layer?.cornerRadius = style == .home ? 12 : 10
  1039. row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
  1040. // Prevent rows from stretching/collapsing when the window resizes.
  1041. row.setContentHuggingPriority(.required, for: .vertical)
  1042. row.setContentCompressionResistancePriority(.required, for: .vertical)
  1043. if style == .home {
  1044. // Must be tall enough for icon (26) + paddings + label without clipping.
  1045. row.heightAnchor.constraint(equalToConstant: 66).isActive = true
  1046. }
  1047. if style == .home {
  1048. let iconContainer = NSView()
  1049. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  1050. row.addSubview(iconContainer)
  1051. let iconView = NSImageView()
  1052. iconView.translatesAutoresizingMaskIntoConstraints = false
  1053. iconView.contentTintColor = primaryText
  1054. iconView.imageScaling = .scaleProportionallyUpOrDown
  1055. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  1056. iconView.image = NSImage(systemSymbolName: sidebarSymbolName(for: item), accessibilityDescription: item)
  1057. iconContainer.addSubview(iconView)
  1058. let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  1059. label.translatesAutoresizingMaskIntoConstraints = false
  1060. row.addSubview(label)
  1061. NSLayoutConstraint.activate([
  1062. iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
  1063. iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  1064. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  1065. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  1066. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  1067. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  1068. iconView.widthAnchor.constraint(equalToConstant: 22),
  1069. iconView.heightAnchor.constraint(equalToConstant: 22),
  1070. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  1071. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  1072. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  1073. ])
  1074. if item == "Hub" {
  1075. let badge = makeSidebarBadge(text: "1")
  1076. badge.translatesAutoresizingMaskIntoConstraints = false
  1077. iconContainer.addSubview(badge)
  1078. NSLayoutConstraint.activate([
  1079. badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
  1080. badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
  1081. ])
  1082. } else if item == "More" {
  1083. let dot = NSView()
  1084. dot.translatesAutoresizingMaskIntoConstraints = false
  1085. dot.wantsLayer = true
  1086. dot.layer?.backgroundColor = NSColor.systemRed.cgColor
  1087. dot.layer?.cornerRadius = 4
  1088. row.addSubview(dot)
  1089. NSLayoutConstraint.activate([
  1090. dot.widthAnchor.constraint(equalToConstant: 8),
  1091. dot.heightAnchor.constraint(equalToConstant: 8),
  1092. dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  1093. dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
  1094. ])
  1095. }
  1096. } else {
  1097. let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
  1098. let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  1099. [icon, label].forEach {
  1100. $0.translatesAutoresizingMaskIntoConstraints = false
  1101. row.addSubview($0)
  1102. }
  1103. NSLayoutConstraint.activate([
  1104. icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  1105. icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  1106. label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
  1107. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  1108. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  1109. ])
  1110. }
  1111. stack.addArrangedSubview(row)
  1112. }
  1113. if style == .home {
  1114. let spacer = NSView()
  1115. spacer.translatesAutoresizingMaskIntoConstraints = false
  1116. // Keep sidebar icons at the same vertical positions even when the window grows.
  1117. // A flexible spacer will expand with height and push icons away from their default placement.
  1118. spacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
  1119. spacer.setContentHuggingPriority(.required, for: .vertical)
  1120. spacer.setContentCompressionResistancePriority(.required, for: .vertical)
  1121. stack.addArrangedSubview(spacer)
  1122. let settingsBadge = NSView()
  1123. settingsBadge.wantsLayer = true
  1124. settingsBadge.layer?.backgroundColor = NSColor.clear.cgColor
  1125. settingsBadge.layer?.cornerRadius = 12
  1126. settingsBadge.translatesAutoresizingMaskIntoConstraints = false
  1127. settingsBadge.widthAnchor.constraint(equalToConstant: 32).isActive = true
  1128. settingsBadge.heightAnchor.constraint(equalToConstant: 32).isActive = true
  1129. let gearIcon = NSImageView()
  1130. gearIcon.translatesAutoresizingMaskIntoConstraints = false
  1131. gearIcon.image = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
  1132. gearIcon.contentTintColor = primaryText
  1133. gearIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  1134. gearIcon.imageScaling = .scaleProportionallyUpOrDown
  1135. settingsBadge.addSubview(gearIcon)
  1136. NSLayoutConstraint.activate([
  1137. gearIcon.centerXAnchor.constraint(equalTo: settingsBadge.centerXAnchor),
  1138. gearIcon.centerYAnchor.constraint(equalTo: settingsBadge.centerYAnchor),
  1139. gearIcon.widthAnchor.constraint(equalToConstant: 22),
  1140. gearIcon.heightAnchor.constraint(equalToConstant: 22)
  1141. ])
  1142. stack.addArrangedSubview(settingsBadge)
  1143. }
  1144. NSLayoutConstraint.activate([
  1145. stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
  1146. stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
  1147. ])
  1148. if style == .home {
  1149. NSLayoutConstraint.activate([
  1150. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
  1151. ])
  1152. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  1153. } else {
  1154. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 18).isActive = true
  1155. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  1156. }
  1157. return sidebar
  1158. }
  1159. private func sidebarSymbolName(for item: String) -> String {
  1160. switch item {
  1161. case "Home":
  1162. return "house"
  1163. case "Meetings":
  1164. return "video"
  1165. case "Chat":
  1166. return "message"
  1167. case "Scheduler":
  1168. return "calendar.badge.clock"
  1169. case "Hub":
  1170. return "square.grid.3x3"
  1171. case "More":
  1172. return "ellipsis"
  1173. default:
  1174. return "circle"
  1175. }
  1176. }
  1177. private func makeSidebarBadge(text: String) -> NSView {
  1178. let badge = NSView()
  1179. badge.wantsLayer = true
  1180. badge.layer?.backgroundColor = NSColor.systemRed.cgColor
  1181. badge.layer?.cornerRadius = 12 / 2
  1182. badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
  1183. badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
  1184. let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
  1185. label.translatesAutoresizingMaskIntoConstraints = false
  1186. badge.addSubview(label)
  1187. NSLayoutConstraint.activate([
  1188. label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
  1189. label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
  1190. ])
  1191. return badge
  1192. }
  1193. @MainActor
  1194. private func alignNativeTrafficLights() {
  1195. guard let window = view.window else { return }
  1196. guard let closeButton = window.standardWindowButton(.closeButton),
  1197. let miniButton = window.standardWindowButton(.miniaturizeButton),
  1198. let zoomButton = window.standardWindowButton(.zoomButton) else { return }
  1199. guard let buttonContainer = closeButton.superview else { return }
  1200. let buttons = [closeButton, miniButton, zoomButton]
  1201. // Compute from top inset so moving "down" is stable in titlebar coordinates.
  1202. let containerHeight = buttonContainer.bounds.height
  1203. let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
  1204. var nextX = nativeTrafficLightsLeading
  1205. for button in buttons {
  1206. button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
  1207. nextX += button.frame.width + 8
  1208. }
  1209. }
  1210. private func makeUpgradeToProButton(action: Selector?) -> NSButton {
  1211. let title = "Upgrade to Pro"
  1212. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  1213. button.isBordered = false
  1214. button.focusRingType = .none
  1215. button.wantsLayer = true
  1216. button.layer?.backgroundColor = accentBlue.cgColor
  1217. button.layer?.cornerRadius = 14
  1218. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1219. button.attributedTitle = NSAttributedString(string: title, attributes: [
  1220. .foregroundColor: NSColor.white,
  1221. .font: font
  1222. ])
  1223. button.toolTip = title
  1224. button.translatesAutoresizingMaskIntoConstraints = false
  1225. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1226. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
  1227. return button
  1228. }
  1229. /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
  1230. private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
  1231. let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
  1232. button.isBordered = false
  1233. button.bezelStyle = .shadowlessSquare
  1234. button.focusRingType = .none
  1235. button.contentTintColor = NSColor(calibratedWhite: 0.84, alpha: 1)
  1236. if let toolTip {
  1237. button.toolTip = toolTip
  1238. }
  1239. let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
  1240. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
  1241. let image = base.withSymbolConfiguration(symbolConfig) {
  1242. image.isTemplate = true
  1243. button.image = image
  1244. }
  1245. button.imageScaling = .scaleProportionallyUpOrDown
  1246. button.imagePosition = .imageOnly
  1247. button.translatesAutoresizingMaskIntoConstraints = false
  1248. button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
  1249. button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
  1250. return button
  1251. }
  1252. private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
  1253. let root = NSView()
  1254. root.translatesAutoresizingMaskIntoConstraints = false
  1255. root.widthAnchor.constraint(equalToConstant: 88).isActive = true
  1256. root.heightAnchor.constraint(equalToConstant: 70).isActive = true
  1257. let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
  1258. iconButton.isBordered = false
  1259. iconButton.wantsLayer = true
  1260. iconButton.layer?.backgroundColor = color.cgColor
  1261. iconButton.layer?.cornerRadius = 15
  1262. iconButton.layer?.shadowOpacity = 0.2
  1263. iconButton.layer?.shadowRadius = 7
  1264. iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
  1265. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
  1266. if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  1267. let configured = baseImage.withSymbolConfiguration(symbolConfig) {
  1268. iconButton.image = configured
  1269. }
  1270. iconButton.contentTintColor = .white
  1271. iconButton.imageScaling = .scaleNone
  1272. let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
  1273. [iconButton, label].forEach {
  1274. $0.translatesAutoresizingMaskIntoConstraints = false
  1275. root.addSubview($0)
  1276. }
  1277. NSLayoutConstraint.activate([
  1278. iconButton.topAnchor.constraint(equalTo: root.topAnchor),
  1279. iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  1280. iconButton.widthAnchor.constraint(equalToConstant: 50),
  1281. iconButton.heightAnchor.constraint(equalToConstant: 50),
  1282. label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
  1283. label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  1284. label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  1285. ])
  1286. return root
  1287. }
  1288. private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
  1289. let card = NSView()
  1290. card.wantsLayer = true
  1291. card.layer?.backgroundColor = meetingCardBackground.cgColor
  1292. card.layer?.cornerRadius = 13
  1293. card.layer?.borderWidth = 1
  1294. card.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  1295. card.translatesAutoresizingMaskIntoConstraints = false
  1296. card.heightAnchor.constraint(equalToConstant: 116).isActive = true
  1297. let dateFormatter = DateFormatter()
  1298. dateFormatter.dateFormat = "EEE, MMM d"
  1299. let timeFormatter = DateFormatter()
  1300. timeFormatter.dateFormat = "h:mm a"
  1301. let startText = timeFormatter.string(from: meeting.start)
  1302. let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
  1303. let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
  1304. let title = makeLabel(meeting.title, size: 26, color: primaryText, weight: .regular, centered: false)
  1305. let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 14, color: secondaryText, weight: .regular, centered: false)
  1306. detail.maximumNumberOfLines = 2
  1307. let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 13, color: secondaryText, weight: .regular, centered: false)
  1308. [title, detail, host].forEach {
  1309. $0.translatesAutoresizingMaskIntoConstraints = false
  1310. card.addSubview($0)
  1311. }
  1312. NSLayoutConstraint.activate([
  1313. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
  1314. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  1315. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  1316. detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
  1317. detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1318. detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  1319. host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
  1320. host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1321. host.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  1322. ])
  1323. return card
  1324. }
  1325. private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
  1326. let label = NSTextField(labelWithString: text)
  1327. label.font = .systemFont(ofSize: size, weight: weight)
  1328. label.textColor = color
  1329. label.alignment = centered ? .center : .left
  1330. return label
  1331. }
  1332. private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
  1333. let wrapper = NSView()
  1334. let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
  1335. button.font = .systemFont(ofSize: 20, weight: .medium)
  1336. button.isBordered = false
  1337. button.wantsLayer = true
  1338. button.layer?.cornerRadius = 12
  1339. button.layer?.backgroundColor = cardBackground.cgColor
  1340. button.contentTintColor = primaryText
  1341. button.translatesAutoresizingMaskIntoConstraints = false
  1342. let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
  1343. label.translatesAutoresizingMaskIntoConstraints = false
  1344. wrapper.addSubview(button)
  1345. wrapper.addSubview(label)
  1346. NSLayoutConstraint.activate([
  1347. button.topAnchor.constraint(equalTo: wrapper.topAnchor),
  1348. button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  1349. button.widthAnchor.constraint(equalToConstant: 52),
  1350. button.heightAnchor.constraint(equalToConstant: 52),
  1351. label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
  1352. label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  1353. label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  1354. ])
  1355. return (wrapper, action == nil ? nil : button)
  1356. }
  1357. }
  1358. private final class SearchPillTextField: NSTextField {
  1359. var onFocusChange: ((Bool) -> Void)?
  1360. private(set) var isSearchFocused = false
  1361. func forceClearFocusState() {
  1362. isSearchFocused = false
  1363. onFocusChange?(false)
  1364. }
  1365. override func becomeFirstResponder() -> Bool {
  1366. let ok = super.becomeFirstResponder()
  1367. if ok {
  1368. isSearchFocused = true
  1369. onFocusChange?(true)
  1370. }
  1371. return ok
  1372. }
  1373. override func resignFirstResponder() -> Bool {
  1374. let ok = super.resignFirstResponder()
  1375. if ok {
  1376. isSearchFocused = false
  1377. onFocusChange?(false)
  1378. }
  1379. return ok
  1380. }
  1381. }
  1382. struct GoogleOAuthTokens: Codable, Equatable {
  1383. var accessToken: String
  1384. var refreshToken: String?
  1385. var expiresAt: Date
  1386. var scope: String?
  1387. var tokenType: String?
  1388. }
  1389. struct GoogleUserProfile: Codable, Equatable {
  1390. var name: String?
  1391. var email: String?
  1392. var picture: String?
  1393. }
  1394. struct ZoomOAuthTokens: Codable, Equatable {
  1395. var accessToken: String
  1396. var refreshToken: String?
  1397. var expiresAt: Date
  1398. var scope: String?
  1399. var tokenType: String?
  1400. }
  1401. enum ZoomOAuthError: Error {
  1402. case missingClientId
  1403. case missingClientSecret
  1404. case invalidCallbackURL
  1405. case missingAuthorizationCode
  1406. case tokenExchangeFailed(String)
  1407. case missingRequiredScope(String)
  1408. case unableToOpenBrowser
  1409. case authenticationTimedOut
  1410. }
  1411. final class ZoomOAuthTokenStore {
  1412. private let defaultsKey: String
  1413. private let defaults: UserDefaults
  1414. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  1415. account: String = "zoomOAuthTokens",
  1416. defaults: UserDefaults = .standard) {
  1417. self.defaultsKey = "\(service).\(account)"
  1418. self.defaults = defaults
  1419. }
  1420. func readTokens() throws -> ZoomOAuthTokens? {
  1421. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  1422. return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
  1423. }
  1424. func writeTokens(_ tokens: ZoomOAuthTokens) throws {
  1425. let data = try JSONEncoder().encode(tokens)
  1426. defaults.set(data, forKey: defaultsKey)
  1427. }
  1428. func clearTokens() {
  1429. defaults.removeObject(forKey: defaultsKey)
  1430. }
  1431. }
  1432. final class ZoomOAuthService: NSObject {
  1433. static let shared = ZoomOAuthService()
  1434. private let tokenStore = ZoomOAuthTokenStore()
  1435. private let clientIdDefaultsKey = "zoom.oauth.clientId"
  1436. private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
  1437. private let infoPlistClientIdKey = "ZoomOAuthClientId"
  1438. private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
  1439. // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
  1440. /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
  1441. private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
  1442. /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
  1443. private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
  1444. func setClientCredentials(clientId: String, clientSecret: String) {
  1445. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  1446. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  1447. }
  1448. func configuredClientId() -> String? {
  1449. if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
  1450. let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
  1451. if trimmed.isEmpty == false { return trimmed }
  1452. }
  1453. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
  1454. .trimmingCharacters(in: .whitespacesAndNewlines)
  1455. if let value, value.isEmpty == false { return value }
  1456. return bundledClientId.isEmpty ? nil : bundledClientId
  1457. }
  1458. func configuredClientSecret() -> String? {
  1459. if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
  1460. let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
  1461. if trimmed.isEmpty == false { return trimmed }
  1462. }
  1463. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
  1464. .trimmingCharacters(in: .whitespacesAndNewlines)
  1465. if let value, value.isEmpty == false { return value }
  1466. return bundledClientSecret.isEmpty ? nil : bundledClientSecret
  1467. }
  1468. func clearSavedTokens() {
  1469. tokenStore.clearTokens()
  1470. }
  1471. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  1472. if let tokens = try tokenStore.readTokens(),
  1473. tokens.expiresAt.timeIntervalSinceNow > 60,
  1474. tokenHasRequiredScope(tokens.scope) {
  1475. return tokens.accessToken
  1476. } else if var tokens = try tokenStore.readTokens(),
  1477. let refreshed = try await refreshTokens(tokens) {
  1478. tokens = refreshed
  1479. try tokenStore.writeTokens(tokens)
  1480. return tokens.accessToken
  1481. }
  1482. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  1483. try tokenStore.writeTokens(tokens)
  1484. return tokens.accessToken
  1485. }
  1486. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
  1487. _ = presentingWindow
  1488. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  1489. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  1490. let loopback = try await OAuthLoopbackServer.start()
  1491. defer { loopback.stop() }
  1492. let redirectURI = loopback.redirectURI
  1493. let state = UUID().uuidString
  1494. var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
  1495. // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
  1496. components.queryItems = [
  1497. URLQueryItem(name: "response_type", value: "code"),
  1498. URLQueryItem(name: "client_id", value: clientId),
  1499. URLQueryItem(name: "redirect_uri", value: redirectURI),
  1500. URLQueryItem(name: "state", value: state)
  1501. ]
  1502. guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
  1503. let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
  1504. guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
  1505. let callbackURL = try await loopback.waitForCallback()
  1506. let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  1507. guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
  1508. guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  1509. throw ZoomOAuthError.missingAuthorizationCode
  1510. }
  1511. return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
  1512. }
  1513. private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
  1514. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  1515. request.httpMethod = "POST"
  1516. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  1517. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  1518. request.httpBody = Self.formURLEncoded([
  1519. "grant_type": "authorization_code",
  1520. "code": code,
  1521. "redirect_uri": redirectURI
  1522. ])
  1523. let (data, response) = try await URLSession.shared.data(for: request)
  1524. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  1525. throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  1526. }
  1527. struct TokenResponse: Decodable {
  1528. let access_token: String
  1529. let refresh_token: String?
  1530. let expires_in: Double
  1531. let scope: String?
  1532. let token_type: String?
  1533. }
  1534. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  1535. return ZoomOAuthTokens(
  1536. accessToken: decoded.access_token,
  1537. refreshToken: decoded.refresh_token,
  1538. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  1539. scope: decoded.scope,
  1540. tokenType: decoded.token_type
  1541. )
  1542. }
  1543. private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
  1544. guard let refreshToken = tokens.refreshToken else { return nil }
  1545. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  1546. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  1547. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  1548. request.httpMethod = "POST"
  1549. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  1550. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  1551. request.httpBody = Self.formURLEncoded([
  1552. "grant_type": "refresh_token",
  1553. "refresh_token": refreshToken
  1554. ])
  1555. let (data, response) = try await URLSession.shared.data(for: request)
  1556. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  1557. return nil
  1558. }
  1559. struct RefreshResponse: Decodable {
  1560. let access_token: String
  1561. let refresh_token: String?
  1562. let expires_in: Double
  1563. let scope: String?
  1564. let token_type: String?
  1565. }
  1566. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  1567. return ZoomOAuthTokens(
  1568. accessToken: decoded.access_token,
  1569. refreshToken: decoded.refresh_token ?? refreshToken,
  1570. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  1571. scope: decoded.scope ?? tokens.scope,
  1572. tokenType: decoded.token_type ?? tokens.tokenType
  1573. )
  1574. }
  1575. private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
  1576. guard let scopeValue, scopeValue.isEmpty == false else { return false }
  1577. let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
  1578. return parts.contains { part in
  1579. part == "meeting:read"
  1580. || part == "meeting:read:admin"
  1581. || part.contains("meeting:read")
  1582. || part.contains("list_meetings")
  1583. || part.contains("list_user_meetings")
  1584. }
  1585. }
  1586. private static func basicAuth(clientId: String, clientSecret: String) -> String {
  1587. let joined = "\(clientId):\(clientSecret)"
  1588. return Data(joined.utf8).base64EncodedString()
  1589. }
  1590. private static func formURLEncoded(_ params: [String: String]) -> Data {
  1591. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  1592. let pairs = params.map { key, value in
  1593. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  1594. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  1595. return "\(k)=\(v)"
  1596. }.joined(separator: "&")
  1597. return Data(pairs.utf8)
  1598. }
  1599. }
  1600. enum GoogleOAuthError: Error {
  1601. case missingClientId
  1602. case missingClientSecret
  1603. case invalidCallbackURL
  1604. case missingAuthorizationCode
  1605. case tokenExchangeFailed(String)
  1606. case unableToOpenBrowser
  1607. case authenticationTimedOut
  1608. }
  1609. final class GoogleOAuthService: NSObject {
  1610. static let shared = GoogleOAuthService()
  1611. private var inAppOAuthWindowController: InAppOAuthWindowController?
  1612. private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
  1613. private let clientSecret = "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"
  1614. private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
  1615. private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
  1616. private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
  1617. func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
  1618. func clearSavedTokens() {
  1619. tokenStore.clearTokens()
  1620. }
  1621. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  1622. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  1623. request.httpMethod = "GET"
  1624. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  1625. let (data, response) = try await URLSession.shared.data(for: request)
  1626. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  1627. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  1628. }
  1629. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  1630. }
  1631. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  1632. if let tokens = try tokenStore.readTokens(),
  1633. tokens.expiresAt.timeIntervalSinceNow > 60,
  1634. tokenHasCalendarScope(tokens.scope) {
  1635. return tokens.accessToken
  1636. }
  1637. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  1638. try tokenStore.writeTokens(tokens)
  1639. return tokens.accessToken
  1640. }
  1641. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  1642. _ = presentingWindow
  1643. let codeVerifier = Self.randomURLSafeString(length: 64)
  1644. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  1645. let state = Self.randomURLSafeString(length: 32)
  1646. let loopback = try await OAuthLoopbackServer.start()
  1647. defer { loopback.stop() }
  1648. let redirectURI = loopback.redirectURI
  1649. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  1650. components.queryItems = [
  1651. URLQueryItem(name: "client_id", value: clientId),
  1652. URLQueryItem(name: "redirect_uri", value: redirectURI),
  1653. URLQueryItem(name: "response_type", value: "code"),
  1654. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  1655. URLQueryItem(name: "state", value: state),
  1656. URLQueryItem(name: "code_challenge", value: codeChallenge),
  1657. URLQueryItem(name: "code_challenge_method", value: "S256"),
  1658. URLQueryItem(name: "access_type", value: "offline"),
  1659. URLQueryItem(name: "prompt", value: "consent")
  1660. ]
  1661. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  1662. let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
  1663. guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
  1664. let callbackURL = try await loopback.waitForCallback()
  1665. let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  1666. guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
  1667. guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  1668. throw GoogleOAuthError.missingAuthorizationCode
  1669. }
  1670. return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
  1671. }
  1672. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
  1673. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  1674. request.httpMethod = "POST"
  1675. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  1676. request.httpBody = Self.formURLEncoded([
  1677. "client_id": clientId,
  1678. "client_secret": clientSecret,
  1679. "code": code,
  1680. "code_verifier": codeVerifier,
  1681. "redirect_uri": redirectURI,
  1682. "grant_type": "authorization_code"
  1683. ])
  1684. let (data, response) = try await URLSession.shared.data(for: request)
  1685. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  1686. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  1687. }
  1688. struct TokenResponse: Decodable {
  1689. let access_token: String
  1690. let expires_in: Double
  1691. let refresh_token: String?
  1692. let scope: String?
  1693. let token_type: String?
  1694. }
  1695. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  1696. return GoogleOAuthTokens(
  1697. accessToken: decoded.access_token,
  1698. refreshToken: decoded.refresh_token,
  1699. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  1700. scope: decoded.scope,
  1701. tokenType: decoded.token_type
  1702. )
  1703. }
  1704. private static func pkceChallenge(for verifier: String) -> String {
  1705. let digest = SHA256.hash(data: Data(verifier.utf8))
  1706. return Data(digest).base64URLEncodedString()
  1707. }
  1708. private static func randomURLSafeString(length: Int) -> String {
  1709. var bytes = [UInt8](repeating: 0, count: length)
  1710. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  1711. return Data(bytes).base64URLEncodedString()
  1712. }
  1713. private static func formURLEncoded(_ params: [String: String]) -> Data {
  1714. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  1715. let pairs = params.map { key, value in
  1716. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  1717. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  1718. return "\(k)=\(v)"
  1719. }.joined(separator: "&")
  1720. return Data(pairs.utf8)
  1721. }
  1722. private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
  1723. guard let scopeValue else { return false }
  1724. return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
  1725. }
  1726. }
  1727. private extension Data {
  1728. func base64URLEncodedString() -> String {
  1729. base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
  1730. }
  1731. }
  1732. final class KeychainTokenStore {
  1733. private let defaultsKey: String
  1734. private let defaults: UserDefaults
  1735. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  1736. account: String = "googleOAuthTokens",
  1737. defaults: UserDefaults = .standard) {
  1738. self.defaultsKey = "\(service).\(account)"
  1739. self.defaults = defaults
  1740. }
  1741. func readTokens() throws -> GoogleOAuthTokens? {
  1742. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  1743. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  1744. }
  1745. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  1746. let data = try JSONEncoder().encode(tokens)
  1747. defaults.set(data, forKey: defaultsKey)
  1748. }
  1749. func clearTokens() {
  1750. defaults.removeObject(forKey: defaultsKey)
  1751. }
  1752. }
  1753. private final class OAuthLoopbackServer {
  1754. /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
  1755. private static let loopbackOAuthPort: UInt16 = 8742
  1756. private let queue = DispatchQueue(label: "google.oauth.loopback.server")
  1757. private let listener: NWListener
  1758. private var readyContinuation: CheckedContinuation<Void, Error>?
  1759. private var callbackContinuation: CheckedContinuation<URL, Error>?
  1760. private var callbackURL: URL?
  1761. private init(listener: NWListener) {
  1762. self.listener = listener
  1763. }
  1764. static func start() async throws -> OAuthLoopbackServer {
  1765. guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
  1766. throw GoogleOAuthError.invalidCallbackURL
  1767. }
  1768. let listener = try NWListener(using: .tcp, on: port)
  1769. let server = OAuthLoopbackServer(listener: listener)
  1770. try await server.startListening()
  1771. return server
  1772. }
  1773. var redirectURI: String {
  1774. let port = listener.port?.rawValue ?? 0
  1775. return "http://127.0.0.1:\(port)/oauth2redirect"
  1776. }
  1777. func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
  1778. try await withThrowingTaskGroup(of: URL.self) { group in
  1779. group.addTask { [weak self] in
  1780. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  1781. return try await self.awaitCallback()
  1782. }
  1783. group.addTask {
  1784. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  1785. throw GoogleOAuthError.authenticationTimedOut
  1786. }
  1787. let url = try await group.next()!
  1788. group.cancelAll()
  1789. return url
  1790. }
  1791. }
  1792. func stop() {
  1793. listener.cancel()
  1794. }
  1795. private func startListening() async throws {
  1796. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  1797. queue.async {
  1798. self.readyContinuation = continuation
  1799. self.listener.stateUpdateHandler = { [weak self] state in
  1800. guard let self else { return }
  1801. switch state {
  1802. case .ready:
  1803. if let readyContinuation = self.readyContinuation {
  1804. self.readyContinuation = nil
  1805. readyContinuation.resume()
  1806. }
  1807. case .failed(let error):
  1808. if let readyContinuation = self.readyContinuation {
  1809. self.readyContinuation = nil
  1810. readyContinuation.resume(throwing: error)
  1811. }
  1812. case .cancelled:
  1813. if let readyContinuation = self.readyContinuation {
  1814. self.readyContinuation = nil
  1815. readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
  1816. }
  1817. default:
  1818. break
  1819. }
  1820. }
  1821. self.listener.newConnectionHandler = { [weak self] connection in
  1822. self?.handle(connection: connection)
  1823. }
  1824. self.listener.start(queue: self.queue)
  1825. }
  1826. }
  1827. }
  1828. private func awaitCallback() async throws -> URL {
  1829. if let callbackURL { return callbackURL }
  1830. return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  1831. queue.async { self.callbackContinuation = continuation }
  1832. }
  1833. }
  1834. private func handle(connection: NWConnection) {
  1835. connection.start(queue: queue)
  1836. connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
  1837. guard let self else { return }
  1838. let requestLine = data.flatMap { String(data: $0, encoding: .utf8) }?.split(separator: "\r\n").first.map(String.init)
  1839. var parsedURL: URL?
  1840. if let requestLine {
  1841. let parts = requestLine.split(separator: " ")
  1842. if parts.count >= 2 {
  1843. parsedURL = URL(string: "http://127.0.0.1\(parts[1])")
  1844. }
  1845. }
  1846. self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
  1847. if let parsedURL {
  1848. self.callbackURL = parsedURL
  1849. self.callbackContinuation?.resume(returning: parsedURL)
  1850. self.callbackContinuation = nil
  1851. self.listener.cancel()
  1852. }
  1853. connection.cancel()
  1854. }
  1855. }
  1856. private func sendHTTPResponse(connection: NWConnection, success: Bool) {
  1857. let body = success ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>" : "<html><body><h3>Authentication failed</h3></body></html>"
  1858. let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
  1859. connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
  1860. }
  1861. }
  1862. @MainActor
  1863. private final class OAuthWebViewContainerView: NSView {
  1864. private let webView: WKWebView
  1865. init(webView: WKWebView) {
  1866. self.webView = webView
  1867. super.init(frame: .zero)
  1868. addSubview(webView)
  1869. }
  1870. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  1871. override func layout() {
  1872. super.layout()
  1873. webView.frame = bounds
  1874. }
  1875. }
  1876. @MainActor
  1877. private final class InAppOAuthWindowController: NSWindowController {
  1878. private let webView: WKWebView
  1879. init() {
  1880. self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
  1881. let container = OAuthWebViewContainerView(webView: webView)
  1882. let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
  1883. window.title = "Google Sign-In"
  1884. window.contentView = container
  1885. super.init(window: window)
  1886. }
  1887. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  1888. func load(url: URL) { webView.load(URLRequest(url: url)) }
  1889. }
  1890. extension GoogleOAuthError: LocalizedError {
  1891. var errorDescription: String? {
  1892. switch self {
  1893. case .missingClientId: return "Missing Google OAuth Client ID."
  1894. case .missingClientSecret: return "Missing Google OAuth Client Secret."
  1895. case .invalidCallbackURL: return "Invalid OAuth callback URL."
  1896. case .missingAuthorizationCode: return "Google did not return an authorization code."
  1897. case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
  1898. case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
  1899. case .authenticationTimedOut: return "Google sign-in timed out."
  1900. }
  1901. }
  1902. }
  1903. extension ZoomOAuthError: LocalizedError {
  1904. var errorDescription: String? {
  1905. switch self {
  1906. case .missingClientId:
  1907. return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
  1908. case .missingClientSecret:
  1909. return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
  1910. case .invalidCallbackURL:
  1911. return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect (must match OAuthLoopbackServer.loopbackOAuthPort in this target)."
  1912. case .missingAuthorizationCode:
  1913. return "Zoom did not return an authorization code."
  1914. case .tokenExchangeFailed(let details):
  1915. return details
  1916. case .missingRequiredScope(let details):
  1917. return "The Zoom access token is missing required scopes. \(details)"
  1918. case .unableToOpenBrowser:
  1919. return "Could not open the system browser for Zoom sign-in."
  1920. case .authenticationTimedOut:
  1921. return "Zoom sign-in timed out waiting for the browser redirect."
  1922. }
  1923. }
  1924. }