説明なし

ViewController.swift 86KB

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