暂无描述

ViewController.swift 174KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817
  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 StoreKit
  11. import WebKit
  12. class ViewController: NSViewController {
  13. private let googleOAuth = GoogleOAuthService.shared
  14. private let zoomOAuth = ZoomOAuthService.shared
  15. private let loginStateKey = "zoom_app.isLoggedIn"
  16. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  17. private struct Palette {
  18. let isDarkMode: Bool
  19. var appBackground: NSColor {
  20. isDarkMode
  21. ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  22. : NSColor(calibratedWhite: 0.96, alpha: 1)
  23. }
  24. var sidebarBackground: NSColor {
  25. isDarkMode
  26. ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
  27. : NSColor(calibratedWhite: 0.94, alpha: 1)
  28. }
  29. var sidebarActiveBackground: NSColor {
  30. isDarkMode
  31. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  32. : NSColor(calibratedWhite: 0.86, alpha: 1)
  33. }
  34. var cardBackground: NSColor {
  35. isDarkMode
  36. ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
  37. : NSColor.white
  38. }
  39. var secondaryCardBackground: NSColor {
  40. isDarkMode
  41. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  42. // Slightly darker than pure white so cards are visible on Light background.
  43. : NSColor(calibratedWhite: 0.93, alpha: 1)
  44. }
  45. var appShellBackground: NSColor { appBackground }
  46. var contentShellBackground: NSColor { appBackground }
  47. var topStripBackground: NSColor { chromeUnifiedBackground }
  48. var chromeUnifiedBackground: NSColor {
  49. isDarkMode
  50. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  51. : NSColor(calibratedWhite: 0.92, alpha: 1)
  52. }
  53. var searchPillBackground: NSColor {
  54. isDarkMode
  55. ? NSColor.white.withAlphaComponent(0.06)
  56. : NSColor.black.withAlphaComponent(0.06)
  57. }
  58. var meetingCardBackground: NSColor {
  59. isDarkMode
  60. ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
  61. : NSColor(calibratedWhite: 0.98, alpha: 1)
  62. }
  63. var sectionCard: NSColor {
  64. isDarkMode
  65. ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
  66. : NSColor.white
  67. }
  68. var inputBackground: NSColor {
  69. isDarkMode
  70. ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
  71. : NSColor(calibratedWhite: 0.97, alpha: 1)
  72. }
  73. var inputBorder: NSColor {
  74. isDarkMode
  75. ? NSColor.white.withAlphaComponent(0.08)
  76. : NSColor.black.withAlphaComponent(0.10)
  77. }
  78. var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
  79. var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
  80. var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
  81. }
  82. private final class TopAlignedClipView: NSClipView {
  83. override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
  84. var rect = super.constrainBoundsRect(proposedBounds)
  85. rect.origin.x = 0
  86. return rect
  87. }
  88. }
  89. private final class HoverButton: NSButton {
  90. var normalColor: NSColor = .clear { didSet { applyBackground() } }
  91. var hoverColor: NSColor = .clear
  92. var onHoverChanged: ((Bool) -> Void)?
  93. private var tracking: NSTrackingArea?
  94. private var hovering = false { didSet { applyBackground() } }
  95. override func updateTrackingAreas() {
  96. super.updateTrackingAreas()
  97. if let tracking { removeTrackingArea(tracking) }
  98. let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
  99. addTrackingArea(area)
  100. tracking = area
  101. }
  102. override func mouseEntered(with event: NSEvent) {
  103. hovering = true
  104. onHoverChanged?(true)
  105. }
  106. override func mouseExited(with event: NSEvent) {
  107. hovering = false
  108. onHoverChanged?(false)
  109. }
  110. private func applyBackground() {
  111. wantsLayer = true
  112. layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
  113. }
  114. }
  115. private var palette = Palette(isDarkMode: true)
  116. private var darkModeEnabled: Bool {
  117. get {
  118. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  119. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  120. }
  121. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  122. }
  123. private let sidebarWidth: CGFloat = 78
  124. private var appBackground: NSColor { palette.appBackground }
  125. private var sidebarBackground: NSColor { palette.sidebarBackground }
  126. private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
  127. private var cardBackground: NSColor { palette.cardBackground }
  128. private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
  129. private var appShellBackground: NSColor { palette.appShellBackground }
  130. private var contentShellBackground: NSColor { palette.contentShellBackground }
  131. private var topStripBackground: NSColor { palette.topStripBackground }
  132. private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
  133. private var searchPillBackground: NSColor { palette.searchPillBackground }
  134. private var meetingCardBackground: NSColor { palette.meetingCardBackground }
  135. private let appShellCornerRadius: CGFloat = 20
  136. private let homeChromeHeaderHeight: CGFloat = 56
  137. private let nativeTrafficLightsLeading: CGFloat = 14
  138. private let nativeTrafficLightsTopInset: CGFloat = 20
  139. private let brandLeadingInset: CGFloat = 84
  140. private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
  141. private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
  142. private var primaryText: NSColor { palette.primaryText }
  143. private var secondaryText: NSColor { palette.secondaryText }
  144. private var mutedText: NSColor { palette.mutedText }
  145. private let rootContainer = NSView()
  146. private var loginView: NSView?
  147. private var homeView: NSView?
  148. private weak var googleButton: NSButton?
  149. private weak var nextSignInButton: NSButton?
  150. private weak var zoomSocialButton: NSButton?
  151. private weak var timeLabel: NSTextField?
  152. private weak var dateLabel: NSTextField?
  153. private weak var meetingsDayHeaderLabel: NSTextField?
  154. private weak var emptyMeetingLabel: NSTextField?
  155. private weak var meetingsListStack: NSStackView?
  156. private weak var meetingsStatusLabel: NSTextField?
  157. private weak var meetingsScrollView: NSScrollView?
  158. private weak var meetingsPrevDayButton: NSButton?
  159. private weak var meetingsNextDayButton: NSButton?
  160. private weak var meetingsTodayButton: NSButton?
  161. private weak var refreshMeetingsButton: NSButton?
  162. private weak var homeWelcomeLabel: NSTextField?
  163. private weak var homeTimeLabelView: NSTextField?
  164. private weak var homeDateLabelView: NSTextField?
  165. private weak var homeActionsRow: NSView?
  166. private weak var homeMeetingsPanel: NSView?
  167. private weak var homePlaceholderLabel: NSTextField?
  168. private weak var homeSearchField: NSTextField?
  169. private weak var homeSearchPill: NSView?
  170. private weak var homeSettingsView: NSView?
  171. private weak var settingsDarkModeSwitch: NSSwitch?
  172. private weak var settingsUpgradeButton: NSButton?
  173. private weak var settingsRestoreButton: NSButton?
  174. private weak var settingsGoogleActionButton: NSButton?
  175. private weak var topBarPremiumButton: NSButton?
  176. private var paywallWindow: NSWindow?
  177. private let paywallContentWidth: CGFloat = 520
  178. private var selectedPremiumPlan: PremiumPlan = .monthly
  179. private var paywallPlanViews: [PremiumPlan: NSView] = [:]
  180. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  181. private weak var paywallOfferLabel: NSTextField?
  182. private weak var paywallContinueLabel: NSTextField?
  183. private weak var paywallContinueButton: NSView?
  184. private var paywallPurchaseTask: Task<Void, Never>?
  185. private var paywallContinueEnabled = true
  186. private var allScheduledMeetings: [ScheduledMeeting] = []
  187. private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
  188. private var selectedHomeSidebarItem: String = "Home"
  189. private var homeSidebarRowViews: [String: NSView] = [:]
  190. private var homeSidebarIconViews: [String: NSImageView] = [:]
  191. private var homeSidebarLabels: [String: NSTextField] = [:]
  192. private var searchTextObserver: NSObjectProtocol?
  193. private var searchShortcutMonitor: Any?
  194. private var searchOutsideClickMonitor: Any?
  195. private var clockTimer: Timer?
  196. private var meetingsRefreshTimer: Timer?
  197. private var isSigningIn = false
  198. private var isPromptingZoomCredentials = false
  199. private var isLoadingMeetings = false
  200. private var meetingsScrollObserver: NSObjectProtocol?
  201. private var lastMeetingsRefreshAt = Date.distantPast
  202. private var lastScrollEdgeRefreshAt = Date.distantPast
  203. // Keep this conservative to avoid Zoom API rate limits.
  204. private let meetingsRefreshInterval: TimeInterval = 60
  205. private let scrollRefreshCooldown: TimeInterval = 3
  206. private var meetingsRateLimitedUntil: Date?
  207. private enum SidebarStyle {
  208. case login
  209. case home
  210. }
  211. private enum PremiumPlan: String, CaseIterable {
  212. case weekly = "com.mqldev.zoomapp.premium.weekly"
  213. case monthly = "com.mqldev.zoomapp.premium.monthly"
  214. case yearly = "com.mqldev.zoomapp.premium.yearly"
  215. case lifetime = "com.mqldev.zoomapp.premium.lifetime"
  216. var displayName: String {
  217. switch self {
  218. case .weekly: return "Premium Weekly"
  219. case .monthly: return "Premium Monthly"
  220. case .yearly: return "Premium Yearly"
  221. case .lifetime: return "Premium Lifetime"
  222. }
  223. }
  224. }
  225. private final class StoreKitCoordinator {
  226. enum PurchaseOutcome {
  227. case success
  228. case pending
  229. case cancelled
  230. case failed(String)
  231. }
  232. private(set) var productsByID: [String: Product] = [:]
  233. private(set) var activeProductIDs = Set<String>()
  234. var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
  235. private var transactionUpdatesTask: Task<Void, Never>?
  236. var onEntitlementsChanged: ((Bool) -> Void)?
  237. func start() async {
  238. await refreshProducts()
  239. await refreshEntitlements()
  240. observeTransactionUpdatesIfNeeded()
  241. }
  242. func refreshProducts() async {
  243. do {
  244. let products = try await Product.products(for: PremiumPlan.allCases.map(\.rawValue))
  245. productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
  246. } catch {
  247. productsByID = [:]
  248. }
  249. }
  250. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  251. guard let product = productsByID[plan.rawValue] else {
  252. await refreshProducts()
  253. guard let refreshed = productsByID[plan.rawValue] else {
  254. return .failed("Product not available. Check StoreKit configuration and product IDs.")
  255. }
  256. return await purchase(product: refreshed)
  257. }
  258. return await purchase(product: product)
  259. }
  260. func restorePurchases() async -> String {
  261. do {
  262. try await AppStore.sync()
  263. await refreshEntitlements()
  264. return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
  265. } catch {
  266. return "Restore failed. \(error.localizedDescription)"
  267. }
  268. }
  269. private func purchase(product: Product) async -> PurchaseOutcome {
  270. do {
  271. let result = try await product.purchase()
  272. switch result {
  273. case .success(let verification):
  274. guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
  275. await transaction.finish()
  276. await refreshEntitlements()
  277. return .success
  278. case .pending:
  279. return .pending
  280. case .userCancelled:
  281. return .cancelled
  282. @unknown default:
  283. return .failed("Unknown purchase state.")
  284. }
  285. } catch {
  286. return .failed(error.localizedDescription)
  287. }
  288. }
  289. private func observeTransactionUpdatesIfNeeded() {
  290. guard transactionUpdatesTask == nil else { return }
  291. transactionUpdatesTask = Task { [weak self] in
  292. for await update in Transaction.updates {
  293. guard case .verified(let transaction) = update else { continue }
  294. if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) {
  295. await self?.refreshEntitlements()
  296. }
  297. await transaction.finish()
  298. }
  299. }
  300. }
  301. @MainActor
  302. private func refreshEntitlements() async {
  303. let previousHasPremiumAccess = hasPremiumAccess
  304. let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
  305. var active = Set<String>()
  306. for await entitlement in Transaction.currentEntitlements {
  307. guard case .verified(let transaction) = entitlement else { continue }
  308. guard allIDs.contains(transaction.productID) else { continue }
  309. if Self.isTransactionActive(transaction) {
  310. active.insert(transaction.productID)
  311. }
  312. }
  313. // StoreKit testing can briefly report empty current entitlements even though a latest
  314. // verified transaction exists for a non-consumable. Merge in latest transactions.
  315. for productID in allIDs {
  316. guard let latest = await Transaction.latest(for: productID),
  317. case .verified(let transaction) = latest,
  318. Self.isTransactionActive(transaction) else { continue }
  319. active.insert(productID)
  320. }
  321. activeProductIDs = active
  322. let newHasPremiumAccess = hasPremiumAccess
  323. if newHasPremiumAccess != previousHasPremiumAccess {
  324. onEntitlementsChanged?(newHasPremiumAccess)
  325. }
  326. }
  327. private static func isTransactionActive(_ transaction: Transaction) -> Bool {
  328. if transaction.revocationDate != nil { return false }
  329. if let expirationDate = transaction.expirationDate {
  330. return expirationDate > Date()
  331. }
  332. return true
  333. }
  334. }
  335. private let storeKitCoordinator = StoreKitCoordinator()
  336. private var storeKitStartupTask: Task<Void, Never>?
  337. override func viewDidLoad() {
  338. super.viewDidLoad()
  339. palette = Palette(isDarkMode: darkModeEnabled)
  340. NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  341. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  342. setupUI()
  343. startStoreKit()
  344. }
  345. override func viewDidAppear() {
  346. super.viewDidAppear()
  347. if let window = view.window {
  348. window.setContentSize(NSSize(width: 1020, height: 690))
  349. applyWindowBackgroundForCurrentTheme(window)
  350. // Use full-size content view so custom top chrome sits in the titlebar region.
  351. window.titleVisibility = .hidden
  352. window.titlebarAppearsTransparent = true
  353. window.isMovableByWindowBackground = true
  354. window.styleMask.insert(.fullSizeContentView)
  355. }
  356. alignNativeTrafficLights()
  357. if isUserLoggedIn() {
  358. showHomeView(profile: nil)
  359. } else {
  360. showLoginView()
  361. }
  362. }
  363. override func viewDidLayout() {
  364. super.viewDidLayout()
  365. alignNativeTrafficLights()
  366. }
  367. private func setupUI() {
  368. view.wantsLayer = true
  369. view.layer?.backgroundColor = appBackground.cgColor
  370. rootContainer.translatesAutoresizingMaskIntoConstraints = false
  371. view.addSubview(rootContainer)
  372. NSLayoutConstraint.activate([
  373. rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  374. rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  375. rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
  376. rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  377. ])
  378. }
  379. private func showLoginView() {
  380. clockTimer?.invalidate()
  381. meetingsRefreshTimer?.invalidate()
  382. meetingsRefreshTimer = nil
  383. clearMeetingsScrollObserver()
  384. removeSearchFieldObserver()
  385. removeSearchShortcutMonitor()
  386. homeSearchField = nil
  387. homeSearchPill = nil
  388. allScheduledMeetings = []
  389. homeView?.removeFromSuperview()
  390. homeView = nil
  391. isSigningIn = false
  392. nextSignInButton?.title = "Next"
  393. nextSignInButton?.isEnabled = true
  394. zoomSocialButton?.isEnabled = true
  395. if loginView == nil {
  396. loginView = makeLoginView()
  397. }
  398. guard let loginView else { return }
  399. attachToRoot(loginView)
  400. }
  401. private func showHomeView(profile: GoogleUserProfile?) {
  402. loginView?.removeFromSuperview()
  403. clearMeetingsScrollObserver()
  404. removeSearchFieldObserver()
  405. removeSearchShortcutMonitor()
  406. homeSearchField = nil
  407. homeSearchPill = nil
  408. homeView?.removeFromSuperview()
  409. selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
  410. homeView = makeHomeView(profile: profile)
  411. if let homeView { attachToRoot(homeView) }
  412. installSearchShortcutMonitor()
  413. persistLoggedInState(true)
  414. startClock()
  415. startMeetingsAutoRefresh()
  416. triggerMeetingsRefresh(force: true)
  417. updateHomeSidebarHighlight()
  418. updateSelectedHomeSectionUI()
  419. }
  420. private func isUserLoggedIn() -> Bool {
  421. UserDefaults.standard.bool(forKey: loginStateKey)
  422. }
  423. private func persistLoggedInState(_ loggedIn: Bool) {
  424. UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
  425. }
  426. private func attachToRoot(_ subview: NSView) {
  427. subview.translatesAutoresizingMaskIntoConstraints = false
  428. if subview.superview != rootContainer {
  429. rootContainer.addSubview(subview)
  430. }
  431. NSLayoutConstraint.activate([
  432. subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
  433. subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
  434. subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
  435. subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
  436. ])
  437. }
  438. @objc private func googleLoginTapped() {
  439. guard isSigningIn == false else { return }
  440. isSigningIn = true
  441. googleButton?.title = "..."
  442. googleButton?.isEnabled = false
  443. Task {
  444. do {
  445. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  446. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  447. await MainActor.run {
  448. self.isSigningIn = false
  449. self.googleButton?.title = "G"
  450. self.googleButton?.isEnabled = true
  451. self.showHomeView(profile: profile)
  452. }
  453. } catch {
  454. await MainActor.run {
  455. self.isSigningIn = false
  456. self.googleButton?.title = "G"
  457. self.googleButton?.isEnabled = true
  458. self.showSimpleError("Google login failed", error: error)
  459. }
  460. }
  461. }
  462. }
  463. /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
  464. @objc private func zoomPrimarySignInTapped() {
  465. guard isSigningIn == false else { return }
  466. isSigningIn = true
  467. nextSignInButton?.title = "Signing in…"
  468. nextSignInButton?.isEnabled = false
  469. zoomSocialButton?.isEnabled = false
  470. googleButton?.isEnabled = false
  471. Task {
  472. do {
  473. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  474. guard configured else {
  475. await MainActor.run { self.resetLoginSigningInState() }
  476. return
  477. }
  478. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  479. let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
  480. let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
  481. await MainActor.run {
  482. self.resetLoginSigningInState()
  483. self.showHomeView(profile: profile)
  484. }
  485. } catch {
  486. await MainActor.run {
  487. self.resetLoginSigningInState()
  488. self.showSimpleError("Zoom sign-in failed", error: error)
  489. }
  490. }
  491. }
  492. }
  493. @objc private func scheduleMeetingWebTapped() {
  494. guard let url = URL(string: "https://zoom.us/meeting/schedule") else { return }
  495. let opened = NSWorkspace.shared.open(url)
  496. if opened == false {
  497. meetingsStatusLabel?.stringValue = "Unable to open Zoom schedule page."
  498. }
  499. }
  500. @objc private func logoutTapped() {
  501. meetingsRefreshTimer?.invalidate()
  502. meetingsRefreshTimer = nil
  503. clearMeetingsScrollObserver()
  504. googleOAuth.clearSavedTokens()
  505. zoomOAuth.clearSavedTokens()
  506. persistLoggedInState(false)
  507. showLoginView()
  508. }
  509. @objc private func topBarPlaceholderTapped() {
  510. // Reserved for future titlebar control actions.
  511. }
  512. @objc private func upgradeToProTapped() {
  513. if storeKitCoordinator.hasPremiumAccess {
  514. openManageSubscriptions()
  515. } else {
  516. showPaywall()
  517. }
  518. }
  519. private func openManageSubscriptions() {
  520. if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
  521. NSWorkspace.shared.open(url)
  522. }
  523. }
  524. @objc private func refreshMeetingsTapped() {
  525. Task { @MainActor in
  526. self.animateMeetingsRefresh()
  527. self.setMeetingsLoadingUI(true)
  528. }
  529. triggerMeetingsRefresh(force: true)
  530. }
  531. @MainActor
  532. private func setMeetingsLoadingUI(_ loading: Bool) {
  533. refreshMeetingsButton?.isEnabled = loading == false
  534. }
  535. @MainActor
  536. private func animateMeetingsRefresh() {
  537. guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
  538. let views = stack.arrangedSubviews
  539. let dimmed: CGFloat = 0.86
  540. NSAnimationContext.runAnimationGroup { context in
  541. context.duration = 0.07
  542. context.timingFunction = CAMediaTimingFunction(name: .linear)
  543. views.forEach { $0.animator().alphaValue = dimmed }
  544. } completionHandler: {
  545. NSAnimationContext.runAnimationGroup { context in
  546. context.duration = 0.08
  547. context.timingFunction = CAMediaTimingFunction(name: .linear)
  548. views.forEach { $0.animator().alphaValue = 1.0 }
  549. }
  550. }
  551. }
  552. private func startMeetingsAutoRefresh() {
  553. meetingsRefreshTimer?.invalidate()
  554. // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
  555. meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
  556. guard let self else { return }
  557. self.triggerMeetingsRefresh()
  558. }
  559. }
  560. private func triggerMeetingsRefresh(force: Bool = false) {
  561. let now = Date()
  562. if let until = meetingsRateLimitedUntil, now < until {
  563. return
  564. }
  565. if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
  566. return
  567. }
  568. lastMeetingsRefreshAt = now
  569. Task { await self.loadScheduledMeetings() }
  570. }
  571. private func clearMeetingsScrollObserver() {
  572. if let meetingsScrollObserver {
  573. NotificationCenter.default.removeObserver(meetingsScrollObserver)
  574. }
  575. meetingsScrollObserver = nil
  576. meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
  577. meetingsScrollView = nil
  578. }
  579. private func removeSearchFieldObserver() {
  580. if let searchTextObserver {
  581. NotificationCenter.default.removeObserver(searchTextObserver)
  582. }
  583. searchTextObserver = nil
  584. }
  585. private func installSearchShortcutMonitor() {
  586. removeSearchShortcutMonitor()
  587. searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  588. guard let self else { return event }
  589. guard event.modifierFlags.contains(.command),
  590. event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
  591. guard self.homeSearchField != nil else { return event }
  592. DispatchQueue.main.async {
  593. self.focusHomeSearchField()
  594. }
  595. return nil
  596. }
  597. searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  598. guard let self else { return event }
  599. guard let window = self.view.window, event.window === window else { return event }
  600. guard let field = self.homeSearchField else { return event }
  601. guard self.isSearchFieldActive(field, in: window) else { return event }
  602. let location = event.locationInWindow
  603. let pill = self.homeSearchPill ?? field
  604. let rectInWindow = pill.convert(pill.bounds, to: nil)
  605. if rectInWindow.contains(location) { return event }
  606. DispatchQueue.main.async {
  607. window.makeFirstResponder(nil)
  608. (field as? SearchPillTextField)?.forceClearFocusState()
  609. self.applySearchPillFocusBorder(focused: false)
  610. }
  611. return event
  612. }
  613. }
  614. private func removeSearchShortcutMonitor() {
  615. if let searchShortcutMonitor {
  616. NSEvent.removeMonitor(searchShortcutMonitor)
  617. }
  618. searchShortcutMonitor = nil
  619. if let searchOutsideClickMonitor {
  620. NSEvent.removeMonitor(searchOutsideClickMonitor)
  621. }
  622. searchOutsideClickMonitor = nil
  623. }
  624. private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
  625. guard let fr = window.firstResponder else { return false }
  626. if fr === field { return true }
  627. if let editor = field.currentEditor(), fr === editor { return true }
  628. return false
  629. }
  630. @MainActor
  631. private func applySearchPillFocusBorder(focused: Bool) {
  632. homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
  633. homeSearchPill?.layer?.borderColor = accentBlue.cgColor
  634. }
  635. @MainActor
  636. private func focusHomeSearchField() {
  637. guard let field = homeSearchField else { return }
  638. view.window?.makeFirstResponder(field)
  639. }
  640. private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
  641. clearMeetingsScrollObserver()
  642. meetingsScrollView = scrollView
  643. scrollView.contentView.postsBoundsChangedNotifications = true
  644. meetingsScrollObserver = NotificationCenter.default.addObserver(
  645. forName: NSView.boundsDidChangeNotification,
  646. object: scrollView.contentView,
  647. queue: .main
  648. ) { [weak self, weak scrollView] _ in
  649. guard let self, let scrollView else { return }
  650. self.refreshMeetingsIfScrolledToEdge(scrollView)
  651. }
  652. }
  653. private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
  654. guard let documentView = scrollView.documentView else { return }
  655. let visibleRect = scrollView.contentView.bounds
  656. let contentHeight = documentView.bounds.height
  657. let viewportHeight = visibleRect.height
  658. if contentHeight <= viewportHeight + 2 {
  659. return
  660. }
  661. let maxOffset = max(contentHeight - viewportHeight, 0)
  662. let y = visibleRect.origin.y
  663. let threshold: CGFloat = 12
  664. let reachedTop = y <= threshold
  665. let reachedBottom = y >= (maxOffset - threshold)
  666. guard reachedTop || reachedBottom else { return }
  667. let now = Date()
  668. if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
  669. return
  670. }
  671. lastScrollEdgeRefreshAt = now
  672. triggerMeetingsRefresh(force: true)
  673. }
  674. @MainActor
  675. private func resetLoginSigningInState() {
  676. isSigningIn = false
  677. nextSignInButton?.title = "Next"
  678. nextSignInButton?.isEnabled = true
  679. zoomSocialButton?.isEnabled = true
  680. googleButton?.isEnabled = true
  681. }
  682. /// Returns false if the user cancelled or left credentials empty.
  683. @MainActor
  684. private func ensureZoomOAuthClientConfigured() -> Bool {
  685. if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
  686. return true
  687. }
  688. return presentZoomOAuthCredentialPrompt()
  689. }
  690. private func showSimpleError(_ title: String, error: Error) {
  691. let alert = NSAlert()
  692. alert.alertStyle = .warning
  693. alert.messageText = title
  694. alert.informativeText = error.localizedDescription
  695. alert.runModal()
  696. }
  697. private func showSimpleAlert(title: String, message: String) {
  698. let alert = NSAlert()
  699. alert.alertStyle = .informational
  700. alert.messageText = title
  701. alert.informativeText = message
  702. alert.runModal()
  703. }
  704. private struct ScheduledMeeting {
  705. let title: String
  706. let start: Date
  707. let end: Date?
  708. let host: String
  709. let source: String
  710. let webURL: URL?
  711. }
  712. @MainActor
  713. private func applyMeetings(_ meetings: [ScheduledMeeting]) {
  714. allScheduledMeetings = meetings
  715. applyFilteredMeetings()
  716. }
  717. @MainActor
  718. private func applyFilteredMeetings() {
  719. guard let stack = meetingsListStack else { return }
  720. stack.arrangedSubviews.forEach { view in
  721. stack.removeArrangedSubview(view)
  722. view.removeFromSuperview()
  723. }
  724. let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  725. let calendar = Calendar.current
  726. let dayStart = selectedMeetingsDayStart
  727. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
  728. let source = allScheduledMeetings.filter { meeting in
  729. meeting.start >= dayStart && meeting.start < dayEnd
  730. }
  731. let filtered: [ScheduledMeeting]
  732. if query.isEmpty {
  733. filtered = source
  734. } else {
  735. filtered = source.filter { meeting in
  736. meeting.title.lowercased().contains(query)
  737. || meeting.host.lowercased().contains(query)
  738. || meeting.source.lowercased().contains(query)
  739. }
  740. }
  741. let ordered = filtered.sorted(by: { $0.start < $1.start })
  742. if ordered.isEmpty {
  743. emptyMeetingLabel?.isHidden = false
  744. if source.isEmpty {
  745. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  746. emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
  747. } else {
  748. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  749. emptyMeetingLabel?.stringValue = "No meetings match your search."
  750. }
  751. return
  752. }
  753. emptyMeetingLabel?.isHidden = true
  754. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  755. for meeting in ordered {
  756. let card = makeMeetingRowCard(meeting)
  757. stack.addArrangedSubview(card)
  758. // Make each meeting card span the full list width (like Zoom).
  759. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  760. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  761. }
  762. }
  763. }
  764. @MainActor
  765. private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
  766. selectedMeetingsDayStart = Calendar.current.startOfDay(for: newDayStart)
  767. updateMeetingsDayUI()
  768. applyFilteredMeetings()
  769. }
  770. @MainActor
  771. private func updateMeetingsDayUI() {
  772. let dayStart = selectedMeetingsDayStart
  773. let formatter = DateFormatter()
  774. formatter.dateFormat = "EEEE, MMM d"
  775. meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
  776. let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
  777. meetingsTodayButton?.isEnabled = isToday == false
  778. meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  779. }
  780. private func meetingsDayDisplayName(for dayStart: Date) -> String {
  781. let calendar = Calendar.current
  782. if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
  783. if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
  784. calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
  785. if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
  786. calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
  787. let formatter = DateFormatter()
  788. formatter.dateFormat = "EEEE, MMM d"
  789. return formatter.string(from: dayStart)
  790. }
  791. @objc private func meetingsPrevDayTapped() {
  792. let calendar = Calendar.current
  793. let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
  794. Task { @MainActor in
  795. self.setSelectedMeetingsDayStart(prev)
  796. }
  797. }
  798. @objc private func meetingsNextDayTapped() {
  799. let calendar = Calendar.current
  800. let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
  801. Task { @MainActor in
  802. self.setSelectedMeetingsDayStart(next)
  803. }
  804. }
  805. @objc private func meetingsTodayTapped() {
  806. Task { @MainActor in
  807. self.setSelectedMeetingsDayStart(Date())
  808. }
  809. }
  810. private func loadScheduledMeetings() async {
  811. if isLoadingMeetings { return }
  812. isLoadingMeetings = true
  813. defer {
  814. isLoadingMeetings = false
  815. Task { @MainActor in
  816. self.setMeetingsLoadingUI(false)
  817. }
  818. }
  819. await MainActor.run {
  820. self.setMeetingsLoadingUI(true)
  821. }
  822. do {
  823. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  824. let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
  825. await MainActor.run {
  826. self.meetingsRateLimitedUntil = nil
  827. self.applyMeetings(zoomMeetings)
  828. }
  829. } catch {
  830. await MainActor.run {
  831. self.applyMeetings([])
  832. if case ZoomOAuthError.missingClientId = error {
  833. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  834. self.promptForZoomOAuthCredentialsIfNeeded()
  835. } else if case ZoomOAuthError.missingClientSecret = error {
  836. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  837. self.promptForZoomOAuthCredentialsIfNeeded()
  838. } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
  839. self.zoomOAuth.clearSavedTokens()
  840. self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  841. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  842. let seconds = max(retryAfterSeconds ?? 300, 30)
  843. self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
  844. let minutes = Int(ceil(Double(seconds) / 60.0))
  845. self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  846. } else {
  847. self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  848. }
  849. }
  850. }
  851. }
  852. @MainActor
  853. private func presentZoomOAuthCredentialPrompt() -> Bool {
  854. let alert = NSAlert()
  855. alert.alertStyle = .informational
  856. alert.messageText = "Configure Zoom OAuth"
  857. 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."
  858. let wrapper = NSStackView()
  859. wrapper.orientation = .vertical
  860. wrapper.spacing = 8
  861. wrapper.translatesAutoresizingMaskIntoConstraints = false
  862. let clientIdField = NSTextField()
  863. clientIdField.placeholderString = "Zoom Client ID"
  864. clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
  865. let clientSecretField = NSSecureTextField()
  866. clientSecretField.placeholderString = "Zoom Client Secret"
  867. clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
  868. [clientIdField, clientSecretField].forEach { field in
  869. field.translatesAutoresizingMaskIntoConstraints = false
  870. field.widthAnchor.constraint(equalToConstant: 420).isActive = true
  871. wrapper.addArrangedSubview(field)
  872. }
  873. alert.accessoryView = wrapper
  874. alert.addButton(withTitle: "Save")
  875. alert.addButton(withTitle: "Cancel")
  876. let result = alert.runModal()
  877. if result == .alertFirstButtonReturn {
  878. var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  879. var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  880. if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
  881. if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
  882. if clientId.isEmpty == false, clientSecret.isEmpty == false {
  883. zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
  884. return true
  885. }
  886. meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
  887. }
  888. return false
  889. }
  890. @MainActor
  891. private func promptForZoomOAuthCredentialsIfNeeded() {
  892. guard isPromptingZoomCredentials == false else { return }
  893. isPromptingZoomCredentials = true
  894. defer { isPromptingZoomCredentials = false }
  895. if presentZoomOAuthCredentialPrompt() {
  896. meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
  897. Task { await self.loadScheduledMeetings() }
  898. }
  899. }
  900. private struct ZoomUserMeResponse: Decodable {
  901. let first_name: String?
  902. let last_name: String?
  903. let display_name: String?
  904. let email: String?
  905. let pic_url: String?
  906. var displayName: String? {
  907. if let display_name, display_name.isEmpty == false { return display_name }
  908. let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
  909. return parts.isEmpty ? nil : parts.joined(separator: " ")
  910. }
  911. var pictureURL: String? {
  912. guard let pic_url, pic_url.isEmpty == false else { return nil }
  913. return pic_url
  914. }
  915. }
  916. private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
  917. let url = URL(string: "https://api.zoom.us/v2/users/me")!
  918. var request = URLRequest(url: url)
  919. request.httpMethod = "GET"
  920. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  921. let (data, response) = try await URLSession.shared.data(for: request)
  922. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  923. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
  924. }
  925. return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
  926. }
  927. private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
  928. struct ZoomMeeting: Decodable {
  929. let id: Int?
  930. let topic: String?
  931. let start_time: String?
  932. let duration: Int?
  933. let host_id: String?
  934. let join_url: String?
  935. }
  936. struct ZoomMeetingsPage: Decodable {
  937. let meetings: [ZoomMeeting]
  938. let next_page_token: String?
  939. }
  940. let iso = ISO8601DateFormatter()
  941. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  942. let fallbackISO = ISO8601DateFormatter()
  943. fallbackISO.formatOptions = [.withInternetDateTime]
  944. func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
  945. raw.compactMap { meeting in
  946. guard let startRaw = meeting.start_time else { return nil }
  947. let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
  948. guard let start else { return nil }
  949. let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
  950. let webURL: URL? = {
  951. func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
  952. var components = URLComponents()
  953. components.scheme = "https"
  954. components.host = "zoom.us"
  955. components.path = "/wc/join/\(meetingId)"
  956. if let pwd, pwd.isEmpty == false {
  957. components.queryItems = [URLQueryItem(name: "pwd", value: pwd)]
  958. }
  959. return components.url
  960. }
  961. if let join = meeting.join_url, let url = URL(string: join), url.scheme != nil {
  962. // Prefer the Zoom Web Client join URL so a click joins in the browser.
  963. // join_url is often `https://zoom.us/j/<id>?pwd=...`
  964. if url.path.contains("/wc/join/") {
  965. return url
  966. }
  967. if let id = meeting.id {
  968. let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
  969. .queryItems?
  970. .first(where: { $0.name == "pwd" })?
  971. .value
  972. return wcJoinURL(meetingId: id, pwd: pwd)
  973. }
  974. return url
  975. }
  976. if let id = meeting.id {
  977. return wcJoinURL(meetingId: id, pwd: nil)
  978. }
  979. return nil
  980. }()
  981. return ScheduledMeeting(
  982. title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
  983. start: start,
  984. end: end,
  985. host: meeting.host_id ?? "Zoom Host",
  986. source: "Zoom",
  987. webURL: webURL
  988. )
  989. }
  990. }
  991. var allMeetings: [ZoomMeeting] = []
  992. var nextPageToken: String?
  993. repeat {
  994. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  995. var items: [URLQueryItem] = [
  996. URLQueryItem(name: "type", value: "scheduled"),
  997. URLQueryItem(name: "page_size", value: "30")
  998. ]
  999. if let nextPageToken, nextPageToken.isEmpty == false {
  1000. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  1001. }
  1002. components.queryItems = items
  1003. var request = URLRequest(url: components.url!)
  1004. request.httpMethod = "GET"
  1005. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  1006. let (data, response) = try await URLSession.shared.data(for: request)
  1007. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  1008. let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
  1009. if (response as? HTTPURLResponse)?.statusCode == 429 {
  1010. let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
  1011. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  1012. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  1013. }
  1014. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  1015. throw ZoomOAuthError.missingRequiredScope(raw)
  1016. }
  1017. throw GoogleOAuthError.tokenExchangeFailed(raw)
  1018. }
  1019. let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
  1020. allMeetings.append(contentsOf: decoded.meetings)
  1021. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  1022. nextPageToken = (token?.isEmpty == false) ? token : nil
  1023. } while nextPageToken != nil
  1024. return mapMeetings(allMeetings)
  1025. }
  1026. // MARK: - Login UI
  1027. private func makeLoginView() -> NSView {
  1028. let root = NSView()
  1029. let sidebar = makeSidebar(items: ["Home", "Chat", "Phone", "Docs", "Whiteboards", "Clips", "More"], selected: "Home", style: .login)
  1030. let content = NSView()
  1031. root.addSubview(sidebar)
  1032. root.addSubview(content)
  1033. sidebar.translatesAutoresizingMaskIntoConstraints = false
  1034. content.translatesAutoresizingMaskIntoConstraints = false
  1035. NSLayoutConstraint.activate([
  1036. sidebar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  1037. sidebar.topAnchor.constraint(equalTo: root.topAnchor),
  1038. sidebar.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  1039. sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth),
  1040. content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
  1041. content.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  1042. content.topAnchor.constraint(equalTo: root.topAnchor),
  1043. content.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  1044. ])
  1045. let back = makeLabel("‹ Back", size: 32, color: accentBlue, weight: .regular, centered: false)
  1046. let logo = makeLabel("zoom\nWorkplace", size: 24, color: primaryText, weight: .bold, centered: true)
  1047. logo.maximumNumberOfLines = 2
  1048. let domain = makeLabel("us05web.zoom.us", size: 16, color: primaryText, weight: .semibold, centered: true)
  1049. let emailField = NSTextField()
  1050. emailField.placeholderString = "Email or phone number"
  1051. emailField.font = .systemFont(ofSize: 20, weight: .regular)
  1052. emailField.textColor = .white
  1053. emailField.wantsLayer = true
  1054. emailField.layer?.cornerRadius = 10
  1055. emailField.layer?.borderWidth = 1.5
  1056. emailField.layer?.borderColor = accentBlue.cgColor
  1057. emailField.layer?.backgroundColor = cardBackground.cgColor
  1058. emailField.focusRingType = .none
  1059. let nextButton = NSButton(title: "Next", target: self, action: #selector(zoomPrimarySignInTapped))
  1060. nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
  1061. nextButton.isBordered = false
  1062. nextButton.wantsLayer = true
  1063. nextButton.layer?.cornerRadius = 10
  1064. nextButton.layer?.backgroundColor = cardBackground.cgColor
  1065. nextButton.contentTintColor = primaryText
  1066. let divider = NSBox()
  1067. divider.boxType = .separator
  1068. let socialText = makeLabel("or sign in with", size: 14, color: secondaryText, weight: .regular, centered: true)
  1069. let sso = makeSocialButton(icon: "🔑", text: "SSO")
  1070. let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
  1071. let apple = makeSocialButton(icon: "", text: "Apple")
  1072. let facebook = makeSocialButton(icon: "f", text: "Facebook")
  1073. let zoomSocial = makeSocialButton(icon: "Z", text: "Zoom", action: #selector(zoomPrimarySignInTapped))
  1074. self.googleButton = google.button
  1075. self.nextSignInButton = nextButton
  1076. self.zoomSocialButton = zoomSocial.button
  1077. let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, zoomSocial.container])
  1078. social.orientation = .horizontal
  1079. social.spacing = 14
  1080. social.distribution = .fillEqually
  1081. let signup = makeLabel("Don't have an account? Sign up", size: 15, color: primaryText, weight: .regular, centered: true)
  1082. let footer = makeLabel("Help Terms Privacy", size: 14, color: accentBlue, weight: .regular, centered: true)
  1083. [back, logo, domain, emailField, nextButton, divider, socialText, social, signup, footer].forEach {
  1084. $0.translatesAutoresizingMaskIntoConstraints = false
  1085. content.addSubview($0)
  1086. }
  1087. NSLayoutConstraint.activate([
  1088. back.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
  1089. back.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 34),
  1090. logo.topAnchor.constraint(equalTo: content.topAnchor, constant: 118),
  1091. logo.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1092. domain.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: 12),
  1093. domain.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1094. emailField.topAnchor.constraint(equalTo: domain.bottomAnchor, constant: 30),
  1095. emailField.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1096. emailField.widthAnchor.constraint(equalToConstant: 520),
  1097. emailField.heightAnchor.constraint(equalToConstant: 52),
  1098. nextButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 20),
  1099. nextButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1100. nextButton.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  1101. nextButton.heightAnchor.constraint(equalToConstant: 52),
  1102. divider.topAnchor.constraint(equalTo: nextButton.bottomAnchor, constant: 28),
  1103. divider.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1104. divider.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  1105. socialText.centerYAnchor.constraint(equalTo: divider.centerYAnchor),
  1106. socialText.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1107. social.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 18),
  1108. social.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1109. social.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  1110. signup.topAnchor.constraint(equalTo: social.bottomAnchor, constant: 14),
  1111. signup.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1112. footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
  1113. footer.centerXAnchor.constraint(equalTo: content.centerXAnchor)
  1114. ])
  1115. return root
  1116. }
  1117. private func makeSettingsView() -> NSView {
  1118. let panel = NSView()
  1119. panel.translatesAutoresizingMaskIntoConstraints = false
  1120. let scroll = NSScrollView()
  1121. scroll.translatesAutoresizingMaskIntoConstraints = false
  1122. scroll.drawsBackground = false
  1123. scroll.hasHorizontalScroller = false
  1124. scroll.hasVerticalScroller = true
  1125. scroll.autohidesScrollers = true
  1126. scroll.borderType = .noBorder
  1127. scroll.scrollerStyle = .overlay
  1128. scroll.automaticallyAdjustsContentInsets = false
  1129. let clip = TopAlignedClipView()
  1130. clip.drawsBackground = false
  1131. scroll.contentView = clip
  1132. panel.addSubview(scroll)
  1133. let content = NSView()
  1134. content.translatesAutoresizingMaskIntoConstraints = false
  1135. scroll.documentView = content
  1136. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  1137. card.translatesAutoresizingMaskIntoConstraints = false
  1138. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  1139. content.addSubview(card)
  1140. let stack = NSStackView()
  1141. stack.translatesAutoresizingMaskIntoConstraints = false
  1142. stack.orientation = .vertical
  1143. stack.spacing = 18
  1144. stack.alignment = .leading
  1145. card.addSubview(stack)
  1146. let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
  1147. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  1148. stack.addArrangedSubview(pageTitle)
  1149. stack.addArrangedSubview(pageSubtitle)
  1150. stack.setCustomSpacing(24, after: pageSubtitle)
  1151. let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1152. stack.addArrangedSubview(appearanceTitle)
  1153. let darkModeRow = makeSettingsDarkModeRow()
  1154. stack.addArrangedSubview(darkModeRow)
  1155. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1156. stack.setCustomSpacing(24, after: darkModeRow)
  1157. let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1158. stack.addArrangedSubview(accountTitle)
  1159. let googleAccountRow = makeSettingsGoogleAccountRow()
  1160. stack.addArrangedSubview(googleAccountRow)
  1161. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1162. stack.setCustomSpacing(24, after: googleAccountRow)
  1163. let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1164. stack.addArrangedSubview(appTitle)
  1165. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  1166. stack.addArrangedSubview(shareButton)
  1167. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1168. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  1169. stack.addArrangedSubview(upgradeButton)
  1170. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1171. settingsUpgradeButton = upgradeButton
  1172. let restoreButton = makeSettingsActionButton(icon: "↺", title: "Restore Purchases", action: .restorePurchases)
  1173. stack.addArrangedSubview(restoreButton)
  1174. restoreButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1175. settingsRestoreButton = restoreButton
  1176. stack.setCustomSpacing(24, after: restoreButton)
  1177. let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1178. stack.addArrangedSubview(legalTitle)
  1179. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  1180. stack.addArrangedSubview(privacyButton)
  1181. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1182. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  1183. stack.addArrangedSubview(supportButton)
  1184. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1185. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  1186. stack.addArrangedSubview(termsButton)
  1187. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1188. NSLayoutConstraint.activate([
  1189. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  1190. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  1191. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  1192. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  1193. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  1194. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  1195. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  1196. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  1197. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  1198. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1199. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 36),
  1200. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  1201. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  1202. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  1203. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  1204. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  1205. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  1206. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  1207. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  1208. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  1209. ])
  1210. updatePremiumButtons()
  1211. return panel
  1212. }
  1213. private enum SettingsAction: Int {
  1214. case shareApp = 1
  1215. case upgrade = 2
  1216. case restorePurchases = 3
  1217. case privacyPolicy = 4
  1218. case support = 5
  1219. case termsOfServices = 6
  1220. }
  1221. private func makeSettingsDarkModeRow() -> NSView {
  1222. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1223. row.translatesAutoresizingMaskIntoConstraints = false
  1224. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  1225. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1226. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  1227. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  1228. let toggle = NSSwitch()
  1229. toggle.translatesAutoresizingMaskIntoConstraints = false
  1230. toggle.state = darkModeEnabled ? .on : .off
  1231. toggle.target = self
  1232. toggle.action = #selector(settingsDarkModeToggled(_:))
  1233. settingsDarkModeSwitch = toggle
  1234. row.addSubview(icon)
  1235. row.addSubview(title)
  1236. row.addSubview(toggle)
  1237. NSLayoutConstraint.activate([
  1238. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  1239. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  1240. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  1241. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  1242. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  1243. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  1244. ])
  1245. return row
  1246. }
  1247. private func makeSettingsGoogleAccountRow() -> NSView {
  1248. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1249. row.translatesAutoresizingMaskIntoConstraints = false
  1250. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1251. let signedIn = isUserLoggedIn()
  1252. let titleText = signedIn ? "Google account connected" : "Google account not connected"
  1253. let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
  1254. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  1255. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  1256. subtitle.maximumNumberOfLines = 2
  1257. subtitle.lineBreakMode = .byTruncatingTail
  1258. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  1259. actionButton.translatesAutoresizingMaskIntoConstraints = false
  1260. actionButton.bezelStyle = .rounded
  1261. actionButton.controlSize = .regular
  1262. settingsGoogleActionButton = actionButton
  1263. row.addSubview(title)
  1264. row.addSubview(subtitle)
  1265. row.addSubview(actionButton)
  1266. NSLayoutConstraint.activate([
  1267. row.heightAnchor.constraint(equalToConstant: 78),
  1268. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  1269. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  1270. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1271. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  1272. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  1273. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  1274. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  1275. ])
  1276. return row
  1277. }
  1278. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  1279. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  1280. button.translatesAutoresizingMaskIntoConstraints = false
  1281. button.isBordered = false
  1282. button.wantsLayer = true
  1283. button.layer?.cornerRadius = 10
  1284. button.normalColor = palette.inputBackground
  1285. button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
  1286. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1287. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  1288. button.tag = action.rawValue
  1289. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
  1290. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  1291. button.addSubview(iconLabel)
  1292. button.addSubview(titleLabel)
  1293. NSLayoutConstraint.activate([
  1294. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  1295. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  1296. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  1297. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  1298. ])
  1299. return button
  1300. }
  1301. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  1302. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  1303. switch action {
  1304. case .shareApp:
  1305. showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
  1306. case .upgrade:
  1307. settingsUpgradePremiumTapped()
  1308. case .restorePurchases:
  1309. settingsRestorePurchasesTapped()
  1310. case .privacyPolicy:
  1311. showSimpleAlert(title: "Privacy Policy", message: "Add your Privacy Policy URL in the app and open it here.")
  1312. case .support:
  1313. showSimpleAlert(title: "Support", message: "Add your Support URL/email in the app and open it here.")
  1314. case .termsOfServices:
  1315. showSimpleAlert(title: "Terms", message: "Add your Terms URL in the app and open it here.")
  1316. }
  1317. }
  1318. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  1319. if isUserLoggedIn() {
  1320. logoutTapped()
  1321. return
  1322. }
  1323. showSimpleAlert(title: "Sign in", message: "Please sign in from the login screen to connect your account.")
  1324. }
  1325. private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  1326. let v = NSView()
  1327. v.wantsLayer = true
  1328. v.layer?.backgroundColor = color.cgColor
  1329. v.layer?.cornerRadius = cornerRadius
  1330. return v
  1331. }
  1332. private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  1333. view.wantsLayer = true
  1334. view.layer?.borderColor = borderColor.cgColor
  1335. view.layer?.borderWidth = borderWidth
  1336. if shadow {
  1337. view.layer?.shadowColor = NSColor.black.cgColor
  1338. view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
  1339. view.layer?.shadowRadius = 18
  1340. view.layer?.shadowOffset = NSSize(width: 0, height: -2)
  1341. } else {
  1342. view.layer?.shadowOpacity = 0
  1343. }
  1344. }
  1345. private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  1346. let label = NSTextField(labelWithString: text)
  1347. label.translatesAutoresizingMaskIntoConstraints = false
  1348. label.font = font
  1349. label.textColor = color
  1350. label.backgroundColor = .clear
  1351. label.isBezeled = false
  1352. label.isEditable = false
  1353. label.isSelectable = false
  1354. return label
  1355. }
  1356. private func startStoreKit() {
  1357. storeKitStartupTask?.cancel()
  1358. storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
  1359. DispatchQueue.main.async {
  1360. self?.updatePremiumButtons()
  1361. }
  1362. }
  1363. storeKitStartupTask = Task { [weak self] in
  1364. await self?.storeKitCoordinator.start()
  1365. await MainActor.run {
  1366. self?.updatePremiumButtons()
  1367. }
  1368. }
  1369. }
  1370. @MainActor
  1371. private func updatePremiumButtons() {
  1372. let isPremium = storeKitCoordinator.hasPremiumAccess
  1373. settingsUpgradeButton?.title = isPremium ? "Premium Active" : "Upgrade"
  1374. settingsUpgradeButton?.isEnabled = isPremium == false
  1375. settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
  1376. settingsRestoreButton?.isEnabled = true
  1377. updateTopBarPremiumButton()
  1378. }
  1379. @MainActor
  1380. private func updateTopBarPremiumButton() {
  1381. guard let button = topBarPremiumButton else { return }
  1382. let isPremium = storeKitCoordinator.hasPremiumAccess
  1383. if isPremium {
  1384. let title = "Manage Subscription"
  1385. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1386. button.attributedTitle = NSAttributedString(string: title, attributes: [
  1387. .foregroundColor: NSColor.white,
  1388. .font: font
  1389. ])
  1390. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  1391. if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
  1392. let img = base.withSymbolConfiguration(symbolConfig) {
  1393. img.isTemplate = true
  1394. button.image = img
  1395. button.imagePosition = .imageLeading
  1396. button.imageHugsTitle = true
  1397. }
  1398. button.contentTintColor = NSColor.white
  1399. button.toolTip = title
  1400. button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
  1401. } else {
  1402. let title = "Upgrade to Pro"
  1403. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1404. button.attributedTitle = NSAttributedString(string: title, attributes: [
  1405. .foregroundColor: NSColor.white,
  1406. .font: font
  1407. ])
  1408. button.image = nil
  1409. button.toolTip = title
  1410. button.layer?.backgroundColor = accentBlue.cgColor
  1411. button.contentTintColor = .white
  1412. }
  1413. }
  1414. @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
  1415. setDarkMode(sender.state == .on)
  1416. }
  1417. private func setDarkMode(_ enabled: Bool) {
  1418. darkModeEnabled = enabled
  1419. palette = Palette(isDarkMode: enabled)
  1420. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  1421. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  1422. if let window = view.window {
  1423. applyWindowBackgroundForCurrentTheme(window)
  1424. }
  1425. if isUserLoggedIn() {
  1426. let keepSelected = selectedHomeSidebarItem
  1427. selectedHomeSidebarItem = keepSelected
  1428. showHomeView(profile: nil)
  1429. } else {
  1430. showLoginView()
  1431. }
  1432. }
  1433. private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
  1434. // Keep the window's backing in sync with the app's current theme, otherwise rounded
  1435. // shell corners can briefly show the previous mode's lighter/darker color.
  1436. window.backgroundColor = appBackground
  1437. window.isOpaque = true
  1438. if let contentView = window.contentView {
  1439. contentView.wantsLayer = true
  1440. contentView.layer?.backgroundColor = appBackground.cgColor
  1441. }
  1442. }
  1443. private func systemPrefersDarkMode() -> Bool {
  1444. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  1445. let style = global?["AppleInterfaceStyle"] as? String
  1446. return style?.lowercased() == "dark"
  1447. }
  1448. @objc private func settingsUpgradePremiumTapped() {
  1449. guard storeKitCoordinator.hasPremiumAccess == false else { return }
  1450. settingsUpgradeButton?.isEnabled = false
  1451. settingsUpgradeButton?.alphaValue = 0.6
  1452. Task { [weak self] in
  1453. guard let self else { return }
  1454. let result = await self.storeKitCoordinator.purchase(plan: .lifetime)
  1455. await MainActor.run {
  1456. self.updatePremiumButtons()
  1457. switch result {
  1458. case .success:
  1459. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  1460. case .pending:
  1461. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  1462. case .cancelled:
  1463. break
  1464. case .failed(let message):
  1465. self.showSimpleAlert(title: "Purchase Failed", message: message)
  1466. }
  1467. }
  1468. }
  1469. }
  1470. @objc private func settingsRestorePurchasesTapped() {
  1471. Task { [weak self] in
  1472. guard let self else { return }
  1473. let message = await self.storeKitCoordinator.restorePurchases()
  1474. await MainActor.run {
  1475. self.updatePremiumButtons()
  1476. self.showSimpleAlert(title: "Restore Purchases", message: message)
  1477. }
  1478. }
  1479. }
  1480. // MARK: - Paywall (ported from meetings_app)
  1481. private func showPaywall() {
  1482. if let existing = paywallWindow {
  1483. refreshPaywallStoreUI()
  1484. existing.makeKeyAndOrderFront(nil)
  1485. NSApp.activate(ignoringOtherApps: true)
  1486. return
  1487. }
  1488. let content = makePaywallContent()
  1489. let controller = NSViewController()
  1490. controller.view = content
  1491. let panel = NSPanel(
  1492. contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
  1493. styleMask: [.titled, .closable, .fullSizeContentView],
  1494. backing: .buffered,
  1495. defer: false
  1496. )
  1497. panel.title = "Get Premium"
  1498. panel.titleVisibility = .hidden
  1499. panel.titlebarAppearsTransparent = true
  1500. panel.hidesOnDeactivate = true
  1501. panel.isReleasedWhenClosed = false
  1502. panel.standardWindowButton(.closeButton)?.isHidden = true
  1503. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  1504. panel.standardWindowButton(.zoomButton)?.isHidden = true
  1505. panel.center()
  1506. panel.contentViewController = controller
  1507. panel.makeKeyAndOrderFront(nil)
  1508. NSApp.activate(ignoringOtherApps: true)
  1509. paywallWindow = panel
  1510. Task { [weak self] in
  1511. guard let self else { return }
  1512. await self.storeKitCoordinator.refreshProducts()
  1513. await MainActor.run {
  1514. self.refreshPaywallStoreUI()
  1515. }
  1516. }
  1517. }
  1518. @objc private func closePaywallClicked(_ sender: Any?) {
  1519. paywallWindow?.performClose(nil)
  1520. paywallWindow = nil
  1521. }
  1522. private func makePaywallContent() -> NSView {
  1523. paywallPlanViews.removeAll()
  1524. premiumPlanByView.removeAll()
  1525. paywallOfferLabel = nil
  1526. paywallContinueLabel = nil
  1527. paywallContinueButton = nil
  1528. paywallContinueEnabled = true
  1529. let panel = NSView()
  1530. panel.translatesAutoresizingMaskIntoConstraints = false
  1531. panel.wantsLayer = true
  1532. panel.layer?.backgroundColor = appBackground.cgColor
  1533. let contentStack = NSStackView()
  1534. contentStack.translatesAutoresizingMaskIntoConstraints = false
  1535. contentStack.orientation = .vertical
  1536. contentStack.spacing = 12
  1537. contentStack.alignment = .leading
  1538. panel.addSubview(contentStack)
  1539. let topRow = NSStackView()
  1540. topRow.translatesAutoresizingMaskIntoConstraints = false
  1541. topRow.orientation = .horizontal
  1542. topRow.alignment = .centerY
  1543. topRow.distribution = .fill
  1544. topRow.spacing = 10
  1545. topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: primaryText))
  1546. let topSpacer = NSView()
  1547. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  1548. topRow.addArrangedSubview(topSpacer)
  1549. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  1550. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1551. closeButton.isBordered = false
  1552. closeButton.bezelStyle = .regularSquare
  1553. closeButton.wantsLayer = true
  1554. closeButton.layer?.cornerRadius = 14
  1555. closeButton.normalColor = palette.inputBackground
  1556. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  1557. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1558. closeButton.layer?.borderWidth = 1
  1559. closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
  1560. closeButton.contentTintColor = secondaryText
  1561. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1562. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1563. topRow.addArrangedSubview(closeButton)
  1564. topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1565. contentStack.addArrangedSubview(topRow)
  1566. contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: secondaryText))
  1567. let benefits = paywallBenefitsSection()
  1568. contentStack.addArrangedSubview(benefits)
  1569. contentStack.setCustomSpacing(18, after: benefits)
  1570. let weeklyCard = paywallPlanCard(
  1571. title: "Weekly",
  1572. price: "PKR 1,100.00",
  1573. badge: "Basic Deal",
  1574. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1575. subtitle: nil,
  1576. plan: .weekly,
  1577. strikePrice: nil
  1578. )
  1579. contentStack.addArrangedSubview(weeklyCard)
  1580. let monthlyCard = paywallPlanCard(
  1581. title: "Monthly",
  1582. price: "PKR 2,500.00",
  1583. badge: "Free Trial",
  1584. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  1585. subtitle: "625.00/week",
  1586. plan: .monthly,
  1587. strikePrice: nil
  1588. )
  1589. contentStack.addArrangedSubview(monthlyCard)
  1590. let yearlyCard = paywallPlanCard(
  1591. title: "Yearly",
  1592. price: "PKR 9,900.00",
  1593. badge: "Best Deal",
  1594. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1595. subtitle: "190.38/week",
  1596. plan: .yearly,
  1597. strikePrice: nil
  1598. )
  1599. contentStack.addArrangedSubview(yearlyCard)
  1600. let lifetimeCard = paywallPlanCard(
  1601. title: "Lifetime",
  1602. price: "PKR 14,900.00",
  1603. badge: "Save 50%",
  1604. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1605. subtitle: nil,
  1606. plan: .lifetime,
  1607. strikePrice: "PKR 29,800.00"
  1608. )
  1609. contentStack.addArrangedSubview(lifetimeCard)
  1610. updatePaywallPlanSelection()
  1611. contentStack.setCustomSpacing(20, after: lifetimeCard)
  1612. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  1613. offer.alignment = .center
  1614. paywallOfferLabel = offer
  1615. let offerWrap = NSView()
  1616. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  1617. offerWrap.addSubview(offer)
  1618. NSLayoutConstraint.activate([
  1619. offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  1620. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  1621. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  1622. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  1623. ])
  1624. contentStack.addArrangedSubview(offerWrap)
  1625. contentStack.setCustomSpacing(18, after: offerWrap)
  1626. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  1627. continueButton.translatesAutoresizingMaskIntoConstraints = false
  1628. continueButton.isBordered = false
  1629. continueButton.bezelStyle = .regularSquare
  1630. continueButton.wantsLayer = true
  1631. continueButton.layer?.cornerRadius = 14
  1632. continueButton.normalColor = accentBlue
  1633. continueButton.hoverColor = accentBlue.withAlphaComponent(0.92)
  1634. continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
  1635. continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1636. styleSurface(continueButton, borderColor: accentBlue.withAlphaComponent(0.85), borderWidth: 1, shadow: true)
  1637. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  1638. continueButton.addSubview(continueLabel)
  1639. NSLayoutConstraint.activate([
  1640. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  1641. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  1642. ])
  1643. paywallContinueButton = continueButton
  1644. paywallContinueLabel = continueLabel
  1645. contentStack.addArrangedSubview(continueButton)
  1646. contentStack.setCustomSpacing(16, after: continueButton)
  1647. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
  1648. secure.alignment = .center
  1649. let secureWrap = NSView()
  1650. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  1651. secureWrap.addSubview(secure)
  1652. NSLayoutConstraint.activate([
  1653. secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  1654. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  1655. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  1656. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  1657. ])
  1658. contentStack.addArrangedSubview(secureWrap)
  1659. NSLayoutConstraint.activate([
  1660. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  1661. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  1662. contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
  1663. contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
  1664. ])
  1665. refreshPaywallStoreUI()
  1666. return panel
  1667. }
  1668. private func paywallBenefitsSection() -> NSView {
  1669. let stack = NSStackView()
  1670. stack.translatesAutoresizingMaskIntoConstraints = false
  1671. stack.orientation = .vertical
  1672. stack.spacing = 8
  1673. stack.alignment = .leading
  1674. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1675. let rowOne = NSStackView()
  1676. rowOne.translatesAutoresizingMaskIntoConstraints = false
  1677. rowOne.orientation = .horizontal
  1678. rowOne.spacing = 10
  1679. rowOne.distribution = .fillEqually
  1680. rowOne.alignment = .centerY
  1681. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  1682. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  1683. let rowTwo = NSStackView()
  1684. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  1685. rowTwo.orientation = .horizontal
  1686. rowTwo.spacing = 10
  1687. rowTwo.distribution = .fillEqually
  1688. rowTwo.alignment = .centerY
  1689. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  1690. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  1691. stack.addArrangedSubview(rowOne)
  1692. stack.addArrangedSubview(rowTwo)
  1693. return stack
  1694. }
  1695. private func paywallBenefitItem(icon: String, text: String) -> NSView {
  1696. let card = NSView()
  1697. card.translatesAutoresizingMaskIntoConstraints = false
  1698. card.wantsLayer = true
  1699. card.layer?.cornerRadius = 10
  1700. card.layer?.backgroundColor = palette.inputBackground.cgColor
  1701. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  1702. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1703. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  1704. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  1705. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  1706. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  1707. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1708. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: accentBlue)
  1709. iconWrap.addSubview(iconLabel)
  1710. NSLayoutConstraint.activate([
  1711. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  1712. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  1713. ])
  1714. let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: primaryText)
  1715. card.addSubview(iconWrap)
  1716. card.addSubview(title)
  1717. NSLayoutConstraint.activate([
  1718. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  1719. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  1720. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  1721. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  1722. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  1723. ])
  1724. return card
  1725. }
  1726. private func paywallPlanCard(
  1727. title: String,
  1728. price: String,
  1729. badge: String,
  1730. badgeColor: NSColor,
  1731. subtitle: String?,
  1732. plan: PremiumPlan,
  1733. strikePrice: String?
  1734. ) -> NSView {
  1735. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  1736. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1737. wrapper.isBordered = false
  1738. wrapper.bezelStyle = .regularSquare
  1739. wrapper.wantsLayer = true
  1740. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  1741. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1742. wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
  1743. wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
  1744. let card = NSView()
  1745. card.translatesAutoresizingMaskIntoConstraints = false
  1746. card.wantsLayer = true
  1747. card.layer?.cornerRadius = 16
  1748. card.layer?.backgroundColor = palette.sectionCard.cgColor
  1749. card.heightAnchor.constraint(equalToConstant: 82).isActive = true
  1750. wrapper.addSubview(card)
  1751. NSLayoutConstraint.activate([
  1752. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1753. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1754. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
  1755. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  1756. ])
  1757. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1758. let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
  1759. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  1760. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  1761. badgeWrap.wantsLayer = true
  1762. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  1763. badgeWrap.layer?.borderWidth = 1
  1764. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  1765. badgeWrap.layer?.shadowOpacity = 0.20
  1766. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1767. badgeWrap.layer?.shadowRadius = 3
  1768. badgeWrap.addSubview(badgeLabel)
  1769. NSLayoutConstraint.activate([
  1770. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  1771. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  1772. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  1773. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  1774. ])
  1775. wrapper.addSubview(badgeWrap)
  1776. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: accentBlue)
  1777. card.addSubview(titleLabel)
  1778. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: primaryText)
  1779. card.addSubview(priceLabel)
  1780. NSLayoutConstraint.activate([
  1781. badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  1782. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  1783. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  1784. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
  1785. priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  1786. priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
  1787. ])
  1788. if let subtitle {
  1789. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: secondaryText)
  1790. card.addSubview(sub)
  1791. NSLayoutConstraint.activate([
  1792. sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  1793. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
  1794. ])
  1795. }
  1796. if let strikePrice {
  1797. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  1798. card.addSubview(strike)
  1799. NSLayoutConstraint.activate([
  1800. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  1801. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  1802. ])
  1803. }
  1804. paywallPlanViews[plan] = card
  1805. premiumPlanByView[ObjectIdentifier(card)] = plan
  1806. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  1807. guard let self, let card else { return }
  1808. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  1809. }
  1810. wrapper.onHoverChanged?(false)
  1811. return wrapper
  1812. }
  1813. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  1814. guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
  1815. selectedPremiumPlan = plan
  1816. updatePaywallPlanSelection()
  1817. }
  1818. private func updatePaywallPlanSelection() {
  1819. for (plan, view) in paywallPlanViews {
  1820. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  1821. }
  1822. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  1823. }
  1824. private func paywallOfferText(for plan: PremiumPlan) -> String {
  1825. if storeKitCoordinator.hasPremiumAccess {
  1826. return "Premium is active on this Apple ID."
  1827. }
  1828. if let product = storeKitCoordinator.productsByID[plan.rawValue] {
  1829. return "\(product.displayPrice) purchase"
  1830. }
  1831. switch plan {
  1832. case .weekly: return "PKR 1,100.00/week"
  1833. case .monthly: return "PKR 2,500.00/month"
  1834. case .yearly: return "PKR 9,900.00/year"
  1835. case .lifetime: return "PKR 14,900.00 one-time purchase"
  1836. }
  1837. }
  1838. private func refreshPaywallStoreUI() {
  1839. updatePaywallPlanSelection()
  1840. updatePaywallContinueState(isLoading: false)
  1841. }
  1842. @objc private func paywallContinueClicked(_ sender: Any?) {
  1843. startSelectedPlanPurchase()
  1844. }
  1845. private func startSelectedPlanPurchase() {
  1846. guard paywallContinueEnabled else { return }
  1847. paywallPurchaseTask?.cancel()
  1848. updatePaywallContinueState(isLoading: true)
  1849. let selectedPlan = selectedPremiumPlan
  1850. paywallPurchaseTask = Task { [weak self] in
  1851. guard let self else { return }
  1852. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  1853. await MainActor.run {
  1854. self.updatePaywallContinueState(isLoading: false)
  1855. self.refreshPaywallStoreUI()
  1856. self.updatePremiumButtons()
  1857. switch result {
  1858. case .success:
  1859. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  1860. self.paywallWindow?.performClose(nil)
  1861. self.paywallWindow = nil
  1862. case .pending:
  1863. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  1864. case .cancelled:
  1865. break
  1866. case .failed(let message):
  1867. self.showSimpleAlert(title: "Purchase Failed", message: message)
  1868. }
  1869. }
  1870. }
  1871. }
  1872. private func updatePaywallContinueState(isLoading: Bool) {
  1873. if isLoading {
  1874. paywallContinueEnabled = false
  1875. paywallContinueLabel?.stringValue = "Processing..."
  1876. paywallContinueButton?.alphaValue = 0.75
  1877. return
  1878. }
  1879. if storeKitCoordinator.hasPremiumAccess {
  1880. paywallContinueEnabled = false
  1881. paywallContinueLabel?.stringValue = "Premium Active"
  1882. paywallContinueButton?.alphaValue = 0.75
  1883. } else {
  1884. paywallContinueEnabled = true
  1885. paywallContinueLabel?.stringValue = "Continue"
  1886. paywallContinueButton?.alphaValue = 1.0
  1887. }
  1888. }
  1889. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  1890. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  1891. let idleBorder = palette.inputBorder
  1892. let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
  1893. let hoverIdleBackground =
  1894. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  1895. let selectedBackground = palette.isDarkMode
  1896. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  1897. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  1898. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  1899. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  1900. card.layer?.borderWidth = isSelected ? 2 : 1
  1901. card.layer?.shadowColor = NSColor.black.cgColor
  1902. card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  1903. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1904. card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
  1905. }
  1906. // MARK: - Home UI
  1907. private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
  1908. let root = NSView()
  1909. let shell = NSView()
  1910. shell.wantsLayer = true
  1911. shell.layer?.backgroundColor = appShellBackground.cgColor
  1912. shell.layer?.cornerRadius = appShellCornerRadius
  1913. shell.layer?.borderWidth = 1
  1914. shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  1915. let chromeColumn = NSView()
  1916. chromeColumn.wantsLayer = true
  1917. chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  1918. chromeColumn.layer?.cornerRadius = appShellCornerRadius
  1919. chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
  1920. chromeColumn.layer?.masksToBounds = true
  1921. let chromeDivider = NSView()
  1922. chromeDivider.wantsLayer = true
  1923. chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
  1924. let chromeHeader = NSView()
  1925. chromeHeader.wantsLayer = true
  1926. chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  1927. homeSidebarRowViews = [:]
  1928. homeSidebarIconViews = [:]
  1929. homeSidebarLabels = [:]
  1930. let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler"], selected: selectedHomeSidebarItem, style: .home)
  1931. let content = NSView()
  1932. content.wantsLayer = true
  1933. content.layer?.backgroundColor = NSColor.clear.cgColor
  1934. root.addSubview(shell)
  1935. shell.addSubview(chromeColumn)
  1936. shell.addSubview(content)
  1937. chromeColumn.addSubview(chromeDivider)
  1938. chromeColumn.addSubview(chromeHeader)
  1939. chromeColumn.addSubview(sidebar)
  1940. shell.translatesAutoresizingMaskIntoConstraints = false
  1941. chromeColumn.translatesAutoresizingMaskIntoConstraints = false
  1942. chromeDivider.translatesAutoresizingMaskIntoConstraints = false
  1943. chromeHeader.translatesAutoresizingMaskIntoConstraints = false
  1944. sidebar.translatesAutoresizingMaskIntoConstraints = false
  1945. content.translatesAutoresizingMaskIntoConstraints = false
  1946. NSLayoutConstraint.activate([
  1947. shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  1948. shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  1949. shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
  1950. shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  1951. chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
  1952. chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
  1953. chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
  1954. chromeColumn.widthAnchor.constraint(equalToConstant: 82),
  1955. chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  1956. chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  1957. chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  1958. chromeDivider.widthAnchor.constraint(equalToConstant: 1),
  1959. chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  1960. chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  1961. chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  1962. chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
  1963. sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  1964. sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  1965. sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
  1966. sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  1967. content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  1968. content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
  1969. content.topAnchor.constraint(equalTo: shell.topAnchor),
  1970. content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
  1971. ])
  1972. let brandStack = NSStackView()
  1973. brandStack.orientation = .vertical
  1974. brandStack.spacing = 0
  1975. brandStack.alignment = .leading
  1976. let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
  1977. let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
  1978. brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
  1979. brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
  1980. [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
  1981. let topBar = NSView()
  1982. topBar.wantsLayer = true
  1983. topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  1984. let topBarDivider = NSView()
  1985. topBarDivider.wantsLayer = true
  1986. topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
  1987. let searchPill = NSView()
  1988. searchPill.wantsLayer = true
  1989. searchPill.layer?.backgroundColor = searchPillBackground.cgColor
  1990. searchPill.layer?.cornerRadius = 10
  1991. searchPill.layer?.borderWidth = 0
  1992. let searchIcon = NSImageView()
  1993. searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
  1994. searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
  1995. searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  1996. searchIcon.imageScaling = .scaleProportionallyUpOrDown
  1997. let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
  1998. searchHintLabel.isHidden = false
  1999. let searchField = SearchPillTextField()
  2000. searchField.isBordered = false
  2001. searchField.drawsBackground = false
  2002. searchField.backgroundColor = .clear
  2003. searchField.focusRingType = .none
  2004. searchField.font = .systemFont(ofSize: 13, weight: .regular)
  2005. searchField.textColor = primaryText
  2006. searchField.alignment = .left
  2007. searchField.placeholderString = nil
  2008. if let cell = searchField.cell as? NSTextFieldCell {
  2009. cell.isBezeled = false
  2010. cell.isBordered = false
  2011. cell.backgroundColor = .clear
  2012. }
  2013. let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
  2014. guard let searchField, let searchHintLabel else { return }
  2015. let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
  2016. searchHintLabel.isHidden = shouldShow == false
  2017. }
  2018. searchField.onFocusChange = { [weak self] focused in
  2019. self?.applySearchPillFocusBorder(focused: focused)
  2020. updateSearchHintVisibility()
  2021. }
  2022. updateSearchHintVisibility()
  2023. let searchRow = NSStackView()
  2024. searchRow.orientation = .horizontal
  2025. searchRow.spacing = 14
  2026. searchRow.alignment = .centerY
  2027. searchRow.addArrangedSubview(searchPill)
  2028. let rightTopBarCluster = NSStackView()
  2029. rightTopBarCluster.orientation = .horizontal
  2030. rightTopBarCluster.spacing = 10
  2031. rightTopBarCluster.alignment = .centerY
  2032. let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
  2033. topBarPremiumButton = upgradeToProButton
  2034. updateTopBarPremiumButton()
  2035. let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
  2036. profileChip.isBordered = false
  2037. profileChip.wantsLayer = true
  2038. profileChip.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
  2039. profileChip.layer?.cornerRadius = 10
  2040. profileChip.contentTintColor = primaryText
  2041. profileChip.font = .systemFont(ofSize: 14, weight: .bold)
  2042. profileChip.toolTip = "Profile (click to logout)"
  2043. [upgradeToProButton, profileChip].forEach { rightTopBarCluster.addArrangedSubview($0) }
  2044. let welcome = makeLabel("Home", size: 15, color: secondaryText, weight: .medium, centered: false)
  2045. let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
  2046. let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
  2047. let actions = NSStackView(views: [
  2048. makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
  2049. makeActionTile(title: "Join", symbol: "plus", color: accentBlue),
  2050. makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
  2051. ])
  2052. actions.orientation = .horizontal
  2053. actions.spacing = 12
  2054. actions.alignment = .centerY
  2055. actions.distribution = .fillEqually
  2056. let panel = NSView()
  2057. panel.wantsLayer = true
  2058. panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  2059. panel.layer?.cornerRadius = 16
  2060. panel.layer?.borderWidth = 1
  2061. panel.layer?.borderColor = palette.inputBorder.cgColor
  2062. let todaysDateFormatter = DateFormatter()
  2063. todaysDateFormatter.dateFormat = "EEEE, MMM d"
  2064. let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
  2065. let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
  2066. let meetingsDayNav = NSStackView()
  2067. meetingsDayNav.orientation = .horizontal
  2068. meetingsDayNav.spacing = 4
  2069. meetingsDayNav.alignment = .centerY
  2070. let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
  2071. let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
  2072. let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
  2073. [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
  2074. let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
  2075. let meetingsScrollView = NSScrollView()
  2076. meetingsScrollView.drawsBackground = false
  2077. meetingsScrollView.hasVerticalScroller = true
  2078. meetingsScrollView.hasHorizontalScroller = false
  2079. meetingsScrollView.autohidesScrollers = true
  2080. let meetingsDocument = FlippedView()
  2081. let meetingsStack = NSStackView()
  2082. meetingsStack.orientation = .vertical
  2083. meetingsStack.spacing = 14
  2084. meetingsStack.alignment = .leading
  2085. // Keep meeting cards pinned to the top of the scroll content.
  2086. meetingsStack.setContentHuggingPriority(.required, for: .vertical)
  2087. meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  2088. let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
  2089. refreshMeetingsButton.isBordered = false
  2090. refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
  2091. refreshMeetingsButton.contentTintColor = primaryText
  2092. let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  2093. if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
  2094. let image = base.withSymbolConfiguration(refreshSymbolConfig) {
  2095. image.isTemplate = true
  2096. refreshMeetingsButton.image = image
  2097. refreshMeetingsButton.imagePosition = .imageLeading
  2098. refreshMeetingsButton.imageHugsTitle = true
  2099. refreshMeetingsButton.imageScaling = .scaleNone
  2100. }
  2101. refreshMeetingsButton.wantsLayer = true
  2102. refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
  2103. ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
  2104. : NSColor.black.withAlphaComponent(0.06)
  2105. ).cgColor
  2106. refreshMeetingsButton.layer?.cornerRadius = 11
  2107. refreshMeetingsButton.layer?.borderWidth = 1
  2108. refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
  2109. self.refreshMeetingsButton = refreshMeetingsButton
  2110. let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
  2111. placeholder.isHidden = true
  2112. let settingsView = makeSettingsView()
  2113. settingsView.isHidden = selectedHomeSidebarItem != "Settings"
  2114. let contentColumn = NSView()
  2115. contentColumn.translatesAutoresizingMaskIntoConstraints = false
  2116. content.addSubview(topBar)
  2117. content.addSubview(topBarDivider)
  2118. content.addSubview(contentColumn)
  2119. [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
  2120. $0.translatesAutoresizingMaskIntoConstraints = false
  2121. }
  2122. [brandStack].forEach {
  2123. shell.addSubview($0)
  2124. }
  2125. [searchRow, rightTopBarCluster].forEach {
  2126. topBar.addSubview($0)
  2127. }
  2128. [searchIcon, searchField, searchHintLabel].forEach {
  2129. searchPill.addSubview($0)
  2130. }
  2131. [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView].forEach {
  2132. $0.translatesAutoresizingMaskIntoConstraints = false
  2133. contentColumn.addSubview($0)
  2134. }
  2135. topBar.translatesAutoresizingMaskIntoConstraints = false
  2136. topBarDivider.translatesAutoresizingMaskIntoConstraints = false
  2137. meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
  2138. meetingsStack.translatesAutoresizingMaskIntoConstraints = false
  2139. meetingsScrollView.documentView = meetingsDocument
  2140. meetingsDocument.addSubview(meetingsStack)
  2141. let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
  2142. searchRowCenterX.priority = .defaultHigh
  2143. NSLayoutConstraint.activate([
  2144. brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset),
  2145. brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
  2146. brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
  2147. topBar.topAnchor.constraint(equalTo: content.topAnchor),
  2148. topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  2149. topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  2150. topBar.heightAnchor.constraint(equalToConstant: 56),
  2151. topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
  2152. topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  2153. topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  2154. topBarDivider.heightAnchor.constraint(equalToConstant: 1),
  2155. contentColumn.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
  2156. contentColumn.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
  2157. contentColumn.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
  2158. contentColumn.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
  2159. searchRowCenterX,
  2160. searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  2161. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
  2162. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
  2163. searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
  2164. rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  2165. rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  2166. searchPill.heightAnchor.constraint(equalToConstant: 32),
  2167. searchPill.widthAnchor.constraint(equalToConstant: 320),
  2168. searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
  2169. searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  2170. searchIcon.widthAnchor.constraint(equalToConstant: 16),
  2171. searchIcon.heightAnchor.constraint(equalToConstant: 16),
  2172. searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
  2173. searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  2174. searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
  2175. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  2176. searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
  2177. searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  2178. profileChip.widthAnchor.constraint(equalToConstant: 34),
  2179. profileChip.heightAnchor.constraint(equalToConstant: 34),
  2180. welcome.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 18),
  2181. welcome.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2182. timeTitle.topAnchor.constraint(equalTo: welcome.bottomAnchor, constant: 12),
  2183. timeTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2184. dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
  2185. dateTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2186. actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
  2187. actions.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2188. actions.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 12),
  2189. actions.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -12),
  2190. actions.heightAnchor.constraint(equalToConstant: 100),
  2191. panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
  2192. panel.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2193. panel.widthAnchor.constraint(equalToConstant: 610),
  2194. panel.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 6),
  2195. panel.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -6),
  2196. panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  2197. panel.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor, constant: -14),
  2198. panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
  2199. panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
  2200. meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  2201. meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  2202. meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
  2203. meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
  2204. noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  2205. noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  2206. noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
  2207. meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
  2208. meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  2209. meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  2210. meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
  2211. meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
  2212. meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
  2213. meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
  2214. meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
  2215. meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
  2216. refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  2217. refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  2218. refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
  2219. refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
  2220. placeholder.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2221. placeholder.centerYAnchor.constraint(equalTo: contentColumn.centerYAnchor),
  2222. settingsView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
  2223. settingsView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
  2224. settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
  2225. settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor)
  2226. ])
  2227. timeLabel = timeTitle
  2228. dateLabel = dateTitle
  2229. homeWelcomeLabel = welcome
  2230. homeTimeLabelView = timeTitle
  2231. homeDateLabelView = dateTitle
  2232. homeActionsRow = actions
  2233. homeMeetingsPanel = panel
  2234. homePlaceholderLabel = placeholder
  2235. homeSettingsView = settingsView
  2236. meetingsDayHeaderLabel = panelHeader
  2237. meetingsListStack = meetingsStack
  2238. meetingsStatusLabel = meetingsStatus
  2239. emptyMeetingLabel = noMeeting
  2240. meetingsPrevDayButton = prevDayButton
  2241. meetingsTodayButton = todayButton
  2242. meetingsNextDayButton = nextDayButton
  2243. observeMeetingsScrollEdges(in: meetingsScrollView)
  2244. updateClock()
  2245. updateMeetingsDayUI()
  2246. applyFilteredMeetings()
  2247. homeSearchField = searchField
  2248. homeSearchPill = searchPill
  2249. searchTextObserver = NotificationCenter.default.addObserver(
  2250. forName: NSControl.textDidChangeNotification,
  2251. object: searchField,
  2252. queue: .main
  2253. ) { [weak self] _ in
  2254. self?.applyFilteredMeetings()
  2255. updateSearchHintVisibility()
  2256. }
  2257. return root
  2258. }
  2259. deinit {
  2260. removeSearchFieldObserver()
  2261. removeSearchShortcutMonitor()
  2262. }
  2263. private func startClock() {
  2264. clockTimer?.invalidate()
  2265. clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  2266. self?.updateClock()
  2267. }
  2268. updateClock()
  2269. }
  2270. private func updateClock() {
  2271. let now = Date()
  2272. let timeFormatter = DateFormatter()
  2273. timeFormatter.dateFormat = "h:mm a"
  2274. let dateFormatter = DateFormatter()
  2275. dateFormatter.dateFormat = "EEEE, d MMM"
  2276. timeLabel?.stringValue = timeFormatter.string(from: now)
  2277. dateLabel?.stringValue = dateFormatter.string(from: now)
  2278. }
  2279. // MARK: - Shared UI
  2280. private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .login) -> NSView {
  2281. let sidebar = NSView()
  2282. sidebar.wantsLayer = true
  2283. sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
  2284. let stack = NSStackView()
  2285. stack.orientation = .vertical
  2286. stack.spacing = style == .home ? 12 : 16
  2287. stack.alignment = .centerX
  2288. stack.distribution = .fill
  2289. // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
  2290. stack.setContentHuggingPriority(.required, for: .vertical)
  2291. stack.setContentCompressionResistancePriority(.required, for: .vertical)
  2292. stack.translatesAutoresizingMaskIntoConstraints = false
  2293. sidebar.addSubview(stack)
  2294. for item in items {
  2295. let row = NSView()
  2296. row.translatesAutoresizingMaskIntoConstraints = false
  2297. row.wantsLayer = true
  2298. let selectedRow = item == selected
  2299. row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  2300. row.layer?.cornerRadius = style == .home ? 12 : 10
  2301. row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
  2302. // Prevent rows from stretching/collapsing when the window resizes.
  2303. row.setContentHuggingPriority(.required, for: .vertical)
  2304. row.setContentCompressionResistancePriority(.required, for: .vertical)
  2305. if style == .home {
  2306. // Must be tall enough for icon (26) + paddings + label without clipping.
  2307. row.heightAnchor.constraint(equalToConstant: 66).isActive = true
  2308. }
  2309. if style == .home {
  2310. let iconContainer = NSView()
  2311. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  2312. row.addSubview(iconContainer)
  2313. let iconView = NSImageView()
  2314. iconView.translatesAutoresizingMaskIntoConstraints = false
  2315. iconView.contentTintColor = selectedRow ? primaryText : secondaryText
  2316. iconView.imageScaling = .scaleProportionallyUpOrDown
  2317. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  2318. iconView.image = NSImage(systemSymbolName: sidebarSymbolName(for: item, filled: selectedRow), accessibilityDescription: item)
  2319. iconContainer.addSubview(iconView)
  2320. let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  2321. label.translatesAutoresizingMaskIntoConstraints = false
  2322. row.addSubview(label)
  2323. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  2324. hit.identifier = NSUserInterfaceItemIdentifier(item)
  2325. hit.isBordered = false
  2326. hit.bezelStyle = .shadowlessSquare
  2327. hit.focusRingType = .none
  2328. hit.translatesAutoresizingMaskIntoConstraints = false
  2329. row.addSubview(hit, positioned: .above, relativeTo: nil)
  2330. NSLayoutConstraint.activate([
  2331. iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
  2332. iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  2333. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  2334. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  2335. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  2336. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  2337. iconView.widthAnchor.constraint(equalToConstant: 22),
  2338. iconView.heightAnchor.constraint(equalToConstant: 22),
  2339. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  2340. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  2341. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
  2342. hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  2343. hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
  2344. hit.topAnchor.constraint(equalTo: row.topAnchor),
  2345. hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  2346. ])
  2347. homeSidebarRowViews[item] = row
  2348. homeSidebarIconViews[item] = iconView
  2349. homeSidebarLabels[item] = label
  2350. if item == "Hub" {
  2351. let badge = makeSidebarBadge(text: "1")
  2352. badge.translatesAutoresizingMaskIntoConstraints = false
  2353. iconContainer.addSubview(badge)
  2354. NSLayoutConstraint.activate([
  2355. badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
  2356. badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
  2357. ])
  2358. } else if item == "More" {
  2359. let dot = NSView()
  2360. dot.translatesAutoresizingMaskIntoConstraints = false
  2361. dot.wantsLayer = true
  2362. dot.layer?.backgroundColor = NSColor.systemRed.cgColor
  2363. dot.layer?.cornerRadius = 4
  2364. row.addSubview(dot)
  2365. NSLayoutConstraint.activate([
  2366. dot.widthAnchor.constraint(equalToConstant: 8),
  2367. dot.heightAnchor.constraint(equalToConstant: 8),
  2368. dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  2369. dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
  2370. ])
  2371. }
  2372. } else {
  2373. let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
  2374. let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  2375. [icon, label].forEach {
  2376. $0.translatesAutoresizingMaskIntoConstraints = false
  2377. row.addSubview($0)
  2378. }
  2379. NSLayoutConstraint.activate([
  2380. icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  2381. icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  2382. label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
  2383. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  2384. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  2385. ])
  2386. }
  2387. stack.addArrangedSubview(row)
  2388. }
  2389. if style == .home {
  2390. let spacer = NSView()
  2391. spacer.translatesAutoresizingMaskIntoConstraints = false
  2392. // Keep Settings in a stable position (no vertical shifting on resize).
  2393. spacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
  2394. spacer.setContentHuggingPriority(.required, for: .vertical)
  2395. spacer.setContentCompressionResistancePriority(.required, for: .vertical)
  2396. stack.addArrangedSubview(spacer)
  2397. let settingsRow = NSView()
  2398. settingsRow.translatesAutoresizingMaskIntoConstraints = false
  2399. settingsRow.wantsLayer = true
  2400. let settingsSelected = selected == "Settings"
  2401. settingsRow.layer?.backgroundColor = settingsSelected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  2402. settingsRow.layer?.cornerRadius = 12
  2403. settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
  2404. settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
  2405. let iconContainer = NSView()
  2406. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  2407. settingsRow.addSubview(iconContainer)
  2408. let iconView = NSImageView()
  2409. iconView.translatesAutoresizingMaskIntoConstraints = false
  2410. iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
  2411. iconView.imageScaling = .scaleProportionallyUpOrDown
  2412. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  2413. let settingsSymbolPreferred = settingsSelected ? "gearshape.fill" : "gearshape"
  2414. iconView.image = NSImage(systemSymbolName: settingsSymbolPreferred, accessibilityDescription: "Settings")
  2415. ?? NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
  2416. iconContainer.addSubview(iconView)
  2417. let label = makeLabel("Settings", size: 10, color: settingsSelected ? primaryText : secondaryText, weight: .regular, centered: true)
  2418. label.translatesAutoresizingMaskIntoConstraints = false
  2419. settingsRow.addSubview(label)
  2420. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  2421. hit.identifier = NSUserInterfaceItemIdentifier("Settings")
  2422. hit.isBordered = false
  2423. hit.bezelStyle = .shadowlessSquare
  2424. hit.focusRingType = .none
  2425. hit.translatesAutoresizingMaskIntoConstraints = false
  2426. settingsRow.addSubview(hit, positioned: .above, relativeTo: nil)
  2427. NSLayoutConstraint.activate([
  2428. iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
  2429. iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
  2430. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  2431. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  2432. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  2433. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  2434. iconView.widthAnchor.constraint(equalToConstant: 22),
  2435. iconView.heightAnchor.constraint(equalToConstant: 22),
  2436. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  2437. label.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
  2438. label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8),
  2439. hit.leadingAnchor.constraint(equalTo: settingsRow.leadingAnchor),
  2440. hit.trailingAnchor.constraint(equalTo: settingsRow.trailingAnchor),
  2441. hit.topAnchor.constraint(equalTo: settingsRow.topAnchor),
  2442. hit.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor)
  2443. ])
  2444. homeSidebarRowViews["Settings"] = settingsRow
  2445. homeSidebarIconViews["Settings"] = iconView
  2446. homeSidebarLabels["Settings"] = label
  2447. stack.addArrangedSubview(settingsRow)
  2448. }
  2449. NSLayoutConstraint.activate([
  2450. stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
  2451. stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
  2452. ])
  2453. if style == .home {
  2454. NSLayoutConstraint.activate([
  2455. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
  2456. ])
  2457. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  2458. } else {
  2459. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 18).isActive = true
  2460. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  2461. }
  2462. return sidebar
  2463. }
  2464. @objc private func homeSidebarItemTapped(_ sender: NSButton) {
  2465. guard let item = sender.identifier?.rawValue else { return }
  2466. selectedHomeSidebarItem = item
  2467. updateHomeSidebarHighlight()
  2468. updateSelectedHomeSectionUI()
  2469. }
  2470. @MainActor
  2471. private func updateHomeSidebarHighlight() {
  2472. for (item, row) in homeSidebarRowViews {
  2473. let selected = item == selectedHomeSidebarItem
  2474. row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  2475. homeSidebarLabels[item]?.textColor = selected ? primaryText : secondaryText
  2476. homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : secondaryText
  2477. let symbolName = sidebarSymbolName(for: item, filled: selected)
  2478. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: item) {
  2479. homeSidebarIconViews[item]?.image = image
  2480. }
  2481. }
  2482. }
  2483. @MainActor
  2484. private func updateSelectedHomeSectionUI() {
  2485. let isHome = selectedHomeSidebarItem == "Home"
  2486. let isSettings = selectedHomeSidebarItem == "Settings"
  2487. let title = selectedHomeSidebarItem
  2488. homeWelcomeLabel?.stringValue = title
  2489. homeWelcomeLabel?.isHidden = isSettings
  2490. let dashboardViews: [NSView?] = [
  2491. homeTimeLabelView,
  2492. homeDateLabelView,
  2493. homeActionsRow,
  2494. homeMeetingsPanel,
  2495. meetingsDayHeaderLabel,
  2496. meetingsStatusLabel,
  2497. meetingsPrevDayButton,
  2498. meetingsTodayButton,
  2499. meetingsNextDayButton,
  2500. emptyMeetingLabel,
  2501. meetingsScrollView,
  2502. refreshMeetingsButton
  2503. ]
  2504. dashboardViews.forEach { $0?.isHidden = isHome == false || isSettings }
  2505. homeSettingsView?.isHidden = isSettings == false
  2506. if isHome {
  2507. homePlaceholderLabel?.isHidden = true
  2508. } else {
  2509. // Keep non-Home pages empty for now.
  2510. homePlaceholderLabel?.isHidden = true
  2511. }
  2512. }
  2513. private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
  2514. switch item {
  2515. case "Home":
  2516. return filled ? "house.fill" : "house"
  2517. case "Meetings":
  2518. return filled ? "video.fill" : "video"
  2519. case "Chat":
  2520. return filled ? "message.fill" : "message"
  2521. case "Scheduler":
  2522. // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
  2523. return "calendar.badge.clock"
  2524. case "Settings":
  2525. // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
  2526. return filled ? "gearshape.fill" : "gearshape"
  2527. case "Hub":
  2528. return "square.grid.3x3"
  2529. case "More":
  2530. return "ellipsis"
  2531. default:
  2532. return "circle"
  2533. }
  2534. }
  2535. private func makeSidebarBadge(text: String) -> NSView {
  2536. let badge = NSView()
  2537. badge.wantsLayer = true
  2538. badge.layer?.backgroundColor = NSColor.systemRed.cgColor
  2539. badge.layer?.cornerRadius = 12 / 2
  2540. badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
  2541. badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
  2542. let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
  2543. label.translatesAutoresizingMaskIntoConstraints = false
  2544. badge.addSubview(label)
  2545. NSLayoutConstraint.activate([
  2546. label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
  2547. label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
  2548. ])
  2549. return badge
  2550. }
  2551. @MainActor
  2552. private func alignNativeTrafficLights() {
  2553. guard let window = view.window else { return }
  2554. guard let closeButton = window.standardWindowButton(.closeButton),
  2555. let miniButton = window.standardWindowButton(.miniaturizeButton),
  2556. let zoomButton = window.standardWindowButton(.zoomButton) else { return }
  2557. guard let buttonContainer = closeButton.superview else { return }
  2558. let buttons = [closeButton, miniButton, zoomButton]
  2559. // Compute from top inset so moving "down" is stable in titlebar coordinates.
  2560. let containerHeight = buttonContainer.bounds.height
  2561. let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
  2562. var nextX = nativeTrafficLightsLeading
  2563. for button in buttons {
  2564. button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
  2565. nextX += button.frame.width + 8
  2566. }
  2567. }
  2568. private func makeUpgradeToProButton(action: Selector?) -> NSButton {
  2569. let title = "Upgrade to Pro"
  2570. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  2571. button.isBordered = false
  2572. button.focusRingType = .none
  2573. button.wantsLayer = true
  2574. button.layer?.backgroundColor = accentBlue.cgColor
  2575. button.layer?.cornerRadius = 14
  2576. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  2577. button.attributedTitle = NSAttributedString(string: title, attributes: [
  2578. .foregroundColor: NSColor.white,
  2579. .font: font
  2580. ])
  2581. button.toolTip = title
  2582. button.translatesAutoresizingMaskIntoConstraints = false
  2583. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  2584. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
  2585. return button
  2586. }
  2587. /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
  2588. private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
  2589. let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
  2590. button.isBordered = false
  2591. button.bezelStyle = .shadowlessSquare
  2592. button.focusRingType = .none
  2593. button.contentTintColor = palette.isDarkMode
  2594. ? NSColor(calibratedWhite: 0.84, alpha: 1)
  2595. : NSColor(calibratedWhite: 0.22, alpha: 1)
  2596. if let toolTip {
  2597. button.toolTip = toolTip
  2598. }
  2599. let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
  2600. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
  2601. let image = base.withSymbolConfiguration(symbolConfig) {
  2602. image.isTemplate = true
  2603. button.image = image
  2604. }
  2605. button.imageScaling = .scaleProportionallyUpOrDown
  2606. button.imagePosition = .imageOnly
  2607. button.translatesAutoresizingMaskIntoConstraints = false
  2608. button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
  2609. button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
  2610. return button
  2611. }
  2612. private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
  2613. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  2614. button.isBordered = false
  2615. button.bezelStyle = .shadowlessSquare
  2616. button.focusRingType = .none
  2617. button.wantsLayer = true
  2618. button.layer?.backgroundColor = (palette.isDarkMode
  2619. ? NSColor.white.withAlphaComponent(0.06)
  2620. : NSColor.black.withAlphaComponent(0.06)
  2621. ).cgColor
  2622. button.layer?.cornerRadius = 10
  2623. button.layer?.borderWidth = 1
  2624. button.layer?.borderColor = (palette.isDarkMode
  2625. ? NSColor.white.withAlphaComponent(0.08)
  2626. : NSColor.black.withAlphaComponent(0.12)
  2627. ).cgColor
  2628. let tint = palette.isDarkMode
  2629. ? NSColor(calibratedWhite: 0.9, alpha: 1)
  2630. : NSColor(calibratedWhite: 0.18, alpha: 1)
  2631. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  2632. button.contentTintColor = tint
  2633. button.font = font
  2634. button.attributedTitle = NSAttributedString(string: title, attributes: [
  2635. .foregroundColor: tint,
  2636. .font: font
  2637. ])
  2638. if let symbol {
  2639. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  2640. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  2641. let image = base.withSymbolConfiguration(symbolConfig) {
  2642. image.isTemplate = true
  2643. button.image = image
  2644. button.imagePosition = .imageLeading
  2645. }
  2646. button.imageHugsTitle = true
  2647. button.imageScaling = .scaleNone
  2648. }
  2649. button.translatesAutoresizingMaskIntoConstraints = false
  2650. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  2651. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
  2652. return button
  2653. }
  2654. private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
  2655. let root = NSView()
  2656. root.translatesAutoresizingMaskIntoConstraints = false
  2657. root.widthAnchor.constraint(equalToConstant: 88).isActive = true
  2658. root.heightAnchor.constraint(equalToConstant: 70).isActive = true
  2659. let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
  2660. iconButton.isBordered = false
  2661. iconButton.wantsLayer = true
  2662. iconButton.layer?.backgroundColor = color.cgColor
  2663. iconButton.layer?.cornerRadius = 15
  2664. iconButton.layer?.shadowOpacity = 0.2
  2665. iconButton.layer?.shadowRadius = 7
  2666. iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
  2667. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
  2668. if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  2669. let configured = baseImage.withSymbolConfiguration(symbolConfig) {
  2670. iconButton.image = configured
  2671. }
  2672. iconButton.contentTintColor = .white
  2673. iconButton.imageScaling = .scaleNone
  2674. let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
  2675. [iconButton, label].forEach {
  2676. $0.translatesAutoresizingMaskIntoConstraints = false
  2677. root.addSubview($0)
  2678. }
  2679. NSLayoutConstraint.activate([
  2680. iconButton.topAnchor.constraint(equalTo: root.topAnchor),
  2681. iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  2682. iconButton.widthAnchor.constraint(equalToConstant: 50),
  2683. iconButton.heightAnchor.constraint(equalToConstant: 50),
  2684. label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
  2685. label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  2686. label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  2687. ])
  2688. return root
  2689. }
  2690. private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
  2691. let card = MeetingCardView(url: meeting.webURL)
  2692. card.wantsLayer = true
  2693. card.layer?.backgroundColor = meetingCardBackground.cgColor
  2694. card.layer?.cornerRadius = 13
  2695. card.layer?.borderWidth = 1
  2696. card.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  2697. card.translatesAutoresizingMaskIntoConstraints = false
  2698. card.heightAnchor.constraint(equalToConstant: 116).isActive = true
  2699. let dateFormatter = DateFormatter()
  2700. dateFormatter.dateFormat = "EEE, MMM d"
  2701. let timeFormatter = DateFormatter()
  2702. timeFormatter.dateFormat = "h:mm a"
  2703. let startText = timeFormatter.string(from: meeting.start)
  2704. let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
  2705. let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
  2706. let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
  2707. let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
  2708. detail.maximumNumberOfLines = 2
  2709. let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
  2710. [title, detail, host].forEach {
  2711. $0.translatesAutoresizingMaskIntoConstraints = false
  2712. card.addSubview($0)
  2713. }
  2714. NSLayoutConstraint.activate([
  2715. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
  2716. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  2717. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  2718. detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
  2719. detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2720. detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  2721. host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
  2722. host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2723. host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  2724. host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
  2725. ])
  2726. return card
  2727. }
  2728. private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
  2729. let label = NSTextField(labelWithString: text)
  2730. label.font = .systemFont(ofSize: size, weight: weight)
  2731. label.textColor = color
  2732. label.alignment = centered ? .center : .left
  2733. return label
  2734. }
  2735. private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
  2736. let wrapper = NSView()
  2737. let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
  2738. button.font = .systemFont(ofSize: 20, weight: .medium)
  2739. button.isBordered = false
  2740. button.wantsLayer = true
  2741. button.layer?.cornerRadius = 12
  2742. button.layer?.backgroundColor = cardBackground.cgColor
  2743. button.contentTintColor = primaryText
  2744. button.translatesAutoresizingMaskIntoConstraints = false
  2745. let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
  2746. label.translatesAutoresizingMaskIntoConstraints = false
  2747. wrapper.addSubview(button)
  2748. wrapper.addSubview(label)
  2749. NSLayoutConstraint.activate([
  2750. button.topAnchor.constraint(equalTo: wrapper.topAnchor),
  2751. button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  2752. button.widthAnchor.constraint(equalToConstant: 52),
  2753. button.heightAnchor.constraint(equalToConstant: 52),
  2754. label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
  2755. label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  2756. label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  2757. ])
  2758. return (wrapper, action == nil ? nil : button)
  2759. }
  2760. }
  2761. private extension Array {
  2762. subscript(safe index: Int) -> Element? {
  2763. guard index >= 0, index < count else { return nil }
  2764. return self[index]
  2765. }
  2766. }
  2767. private final class SearchPillTextField: NSTextField {
  2768. var onFocusChange: ((Bool) -> Void)?
  2769. private(set) var isSearchFocused = false
  2770. func forceClearFocusState() {
  2771. isSearchFocused = false
  2772. onFocusChange?(false)
  2773. }
  2774. override func becomeFirstResponder() -> Bool {
  2775. let ok = super.becomeFirstResponder()
  2776. if ok {
  2777. isSearchFocused = true
  2778. onFocusChange?(true)
  2779. }
  2780. return ok
  2781. }
  2782. override func resignFirstResponder() -> Bool {
  2783. let ok = super.resignFirstResponder()
  2784. if ok {
  2785. isSearchFocused = false
  2786. onFocusChange?(false)
  2787. }
  2788. return ok
  2789. }
  2790. }
  2791. private final class FlippedView: NSView {
  2792. override var isFlipped: Bool { true }
  2793. }
  2794. private final class MeetingCardView: NSView {
  2795. private let url: URL?
  2796. private var tracking: NSTrackingArea?
  2797. private var isHovering = false
  2798. private var normalBackgroundColor: CGColor?
  2799. private var normalBorderColor: CGColor?
  2800. private var normalBorderWidth: CGFloat?
  2801. init(url: URL?) {
  2802. self.url = url
  2803. super.init(frame: .zero)
  2804. }
  2805. required init?(coder: NSCoder) {
  2806. self.url = nil
  2807. super.init(coder: coder)
  2808. }
  2809. override func updateTrackingAreas() {
  2810. super.updateTrackingAreas()
  2811. if let tracking { removeTrackingArea(tracking) }
  2812. let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
  2813. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  2814. addTrackingArea(area)
  2815. tracking = area
  2816. }
  2817. override func resetCursorRects() {
  2818. super.resetCursorRects()
  2819. if url != nil {
  2820. addCursorRect(bounds, cursor: .pointingHand)
  2821. }
  2822. }
  2823. override func mouseEntered(with event: NSEvent) {
  2824. super.mouseEntered(with: event)
  2825. guard url != nil else { return }
  2826. isHovering = true
  2827. applyHoverState()
  2828. }
  2829. override func mouseExited(with event: NSEvent) {
  2830. super.mouseExited(with: event)
  2831. guard url != nil else { return }
  2832. isHovering = false
  2833. applyHoverState()
  2834. }
  2835. override func mouseUp(with event: NSEvent) {
  2836. super.mouseUp(with: event)
  2837. guard let url else { return }
  2838. NSWorkspace.shared.open(url)
  2839. }
  2840. private func applyHoverState() {
  2841. guard let layer else { return }
  2842. if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
  2843. if normalBorderColor == nil { normalBorderColor = layer.borderColor }
  2844. if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
  2845. if isHovering {
  2846. layer.backgroundColor = NSColor.white.withAlphaComponent(0.075).cgColor
  2847. layer.borderColor = NSColor.white.withAlphaComponent(0.85).cgColor
  2848. layer.borderWidth = max(1.5, layer.borderWidth)
  2849. } else {
  2850. layer.backgroundColor = normalBackgroundColor
  2851. layer.borderColor = normalBorderColor
  2852. if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
  2853. }
  2854. }
  2855. }
  2856. struct GoogleOAuthTokens: Codable, Equatable {
  2857. var accessToken: String
  2858. var refreshToken: String?
  2859. var expiresAt: Date
  2860. var scope: String?
  2861. var tokenType: String?
  2862. }
  2863. struct GoogleUserProfile: Codable, Equatable {
  2864. var name: String?
  2865. var email: String?
  2866. var picture: String?
  2867. }
  2868. struct ZoomOAuthTokens: Codable, Equatable {
  2869. var accessToken: String
  2870. var refreshToken: String?
  2871. var expiresAt: Date
  2872. var scope: String?
  2873. var tokenType: String?
  2874. }
  2875. enum ZoomOAuthError: Error {
  2876. case missingClientId
  2877. case missingClientSecret
  2878. case invalidCallbackURL
  2879. case missingAuthorizationCode
  2880. case tokenExchangeFailed(String)
  2881. case missingRequiredScope(String)
  2882. case rateLimited(retryAfterSeconds: Int?)
  2883. case unableToOpenBrowser
  2884. case authenticationTimedOut
  2885. }
  2886. final class ZoomOAuthTokenStore {
  2887. private let defaultsKey: String
  2888. private let defaults: UserDefaults
  2889. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  2890. account: String = "zoomOAuthTokens",
  2891. defaults: UserDefaults = .standard) {
  2892. self.defaultsKey = "\(service).\(account)"
  2893. self.defaults = defaults
  2894. }
  2895. func readTokens() throws -> ZoomOAuthTokens? {
  2896. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  2897. return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
  2898. }
  2899. func writeTokens(_ tokens: ZoomOAuthTokens) throws {
  2900. let data = try JSONEncoder().encode(tokens)
  2901. defaults.set(data, forKey: defaultsKey)
  2902. }
  2903. func clearTokens() {
  2904. defaults.removeObject(forKey: defaultsKey)
  2905. }
  2906. }
  2907. final class ZoomOAuthService: NSObject {
  2908. static let shared = ZoomOAuthService()
  2909. private let tokenStore = ZoomOAuthTokenStore()
  2910. private let clientIdDefaultsKey = "zoom.oauth.clientId"
  2911. private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
  2912. private let infoPlistClientIdKey = "ZoomOAuthClientId"
  2913. private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
  2914. // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
  2915. /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
  2916. private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
  2917. /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
  2918. private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
  2919. func setClientCredentials(clientId: String, clientSecret: String) {
  2920. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  2921. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  2922. }
  2923. func configuredClientId() -> String? {
  2924. if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
  2925. let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
  2926. if trimmed.isEmpty == false { return trimmed }
  2927. }
  2928. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
  2929. .trimmingCharacters(in: .whitespacesAndNewlines)
  2930. if let value, value.isEmpty == false { return value }
  2931. return bundledClientId.isEmpty ? nil : bundledClientId
  2932. }
  2933. func configuredClientSecret() -> String? {
  2934. if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
  2935. let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
  2936. if trimmed.isEmpty == false { return trimmed }
  2937. }
  2938. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
  2939. .trimmingCharacters(in: .whitespacesAndNewlines)
  2940. if let value, value.isEmpty == false { return value }
  2941. return bundledClientSecret.isEmpty ? nil : bundledClientSecret
  2942. }
  2943. func clearSavedTokens() {
  2944. tokenStore.clearTokens()
  2945. }
  2946. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  2947. if let tokens = try tokenStore.readTokens(),
  2948. tokens.expiresAt.timeIntervalSinceNow > 60,
  2949. tokenHasRequiredScope(tokens.scope) {
  2950. return tokens.accessToken
  2951. } else if var tokens = try tokenStore.readTokens(),
  2952. let refreshed = try await refreshTokens(tokens) {
  2953. tokens = refreshed
  2954. try tokenStore.writeTokens(tokens)
  2955. return tokens.accessToken
  2956. }
  2957. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  2958. try tokenStore.writeTokens(tokens)
  2959. return tokens.accessToken
  2960. }
  2961. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
  2962. _ = presentingWindow
  2963. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  2964. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  2965. let loopback = try await OAuthLoopbackServer.start()
  2966. defer { loopback.stop() }
  2967. let redirectURI = loopback.redirectURI
  2968. let state = UUID().uuidString
  2969. var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
  2970. // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
  2971. components.queryItems = [
  2972. URLQueryItem(name: "response_type", value: "code"),
  2973. URLQueryItem(name: "client_id", value: clientId),
  2974. URLQueryItem(name: "redirect_uri", value: redirectURI),
  2975. URLQueryItem(name: "state", value: state)
  2976. ]
  2977. guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
  2978. let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
  2979. guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
  2980. let callbackURL = try await loopback.waitForCallback()
  2981. let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  2982. guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
  2983. guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  2984. throw ZoomOAuthError.missingAuthorizationCode
  2985. }
  2986. return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
  2987. }
  2988. private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
  2989. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  2990. request.httpMethod = "POST"
  2991. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  2992. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  2993. request.httpBody = Self.formURLEncoded([
  2994. "grant_type": "authorization_code",
  2995. "code": code,
  2996. "redirect_uri": redirectURI
  2997. ])
  2998. let (data, response) = try await URLSession.shared.data(for: request)
  2999. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3000. throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  3001. }
  3002. struct TokenResponse: Decodable {
  3003. let access_token: String
  3004. let refresh_token: String?
  3005. let expires_in: Double
  3006. let scope: String?
  3007. let token_type: String?
  3008. }
  3009. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  3010. return ZoomOAuthTokens(
  3011. accessToken: decoded.access_token,
  3012. refreshToken: decoded.refresh_token,
  3013. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  3014. scope: decoded.scope,
  3015. tokenType: decoded.token_type
  3016. )
  3017. }
  3018. private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
  3019. guard let refreshToken = tokens.refreshToken else { return nil }
  3020. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  3021. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  3022. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  3023. request.httpMethod = "POST"
  3024. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  3025. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  3026. request.httpBody = Self.formURLEncoded([
  3027. "grant_type": "refresh_token",
  3028. "refresh_token": refreshToken
  3029. ])
  3030. let (data, response) = try await URLSession.shared.data(for: request)
  3031. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3032. return nil
  3033. }
  3034. struct RefreshResponse: Decodable {
  3035. let access_token: String
  3036. let refresh_token: String?
  3037. let expires_in: Double
  3038. let scope: String?
  3039. let token_type: String?
  3040. }
  3041. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  3042. return ZoomOAuthTokens(
  3043. accessToken: decoded.access_token,
  3044. refreshToken: decoded.refresh_token ?? refreshToken,
  3045. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  3046. scope: decoded.scope ?? tokens.scope,
  3047. tokenType: decoded.token_type ?? tokens.tokenType
  3048. )
  3049. }
  3050. private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
  3051. guard let scopeValue, scopeValue.isEmpty == false else { return false }
  3052. let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
  3053. return parts.contains { part in
  3054. part == "meeting:read"
  3055. || part == "meeting:read:admin"
  3056. || part.contains("meeting:read")
  3057. || part.contains("list_meetings")
  3058. || part.contains("list_user_meetings")
  3059. }
  3060. }
  3061. private static func basicAuth(clientId: String, clientSecret: String) -> String {
  3062. let joined = "\(clientId):\(clientSecret)"
  3063. return Data(joined.utf8).base64EncodedString()
  3064. }
  3065. private static func formURLEncoded(_ params: [String: String]) -> Data {
  3066. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  3067. let pairs = params.map { key, value in
  3068. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  3069. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  3070. return "\(k)=\(v)"
  3071. }.joined(separator: "&")
  3072. return Data(pairs.utf8)
  3073. }
  3074. }
  3075. enum GoogleOAuthError: Error {
  3076. case missingClientId
  3077. case missingClientSecret
  3078. case invalidCallbackURL
  3079. case missingAuthorizationCode
  3080. case tokenExchangeFailed(String)
  3081. case unableToOpenBrowser
  3082. case authenticationTimedOut
  3083. }
  3084. final class GoogleOAuthService: NSObject {
  3085. static let shared = GoogleOAuthService()
  3086. private var inAppOAuthWindowController: InAppOAuthWindowController?
  3087. private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
  3088. private let clientSecret = "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"
  3089. private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
  3090. private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
  3091. private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
  3092. func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
  3093. func clearSavedTokens() {
  3094. tokenStore.clearTokens()
  3095. }
  3096. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  3097. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  3098. request.httpMethod = "GET"
  3099. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3100. let (data, response) = try await URLSession.shared.data(for: request)
  3101. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3102. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  3103. }
  3104. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  3105. }
  3106. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  3107. if let tokens = try tokenStore.readTokens(),
  3108. tokens.expiresAt.timeIntervalSinceNow > 60,
  3109. tokenHasCalendarScope(tokens.scope) {
  3110. return tokens.accessToken
  3111. }
  3112. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  3113. try tokenStore.writeTokens(tokens)
  3114. return tokens.accessToken
  3115. }
  3116. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  3117. _ = presentingWindow
  3118. let codeVerifier = Self.randomURLSafeString(length: 64)
  3119. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  3120. let state = Self.randomURLSafeString(length: 32)
  3121. let loopback = try await OAuthLoopbackServer.start()
  3122. defer { loopback.stop() }
  3123. let redirectURI = loopback.redirectURI
  3124. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  3125. components.queryItems = [
  3126. URLQueryItem(name: "client_id", value: clientId),
  3127. URLQueryItem(name: "redirect_uri", value: redirectURI),
  3128. URLQueryItem(name: "response_type", value: "code"),
  3129. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  3130. URLQueryItem(name: "state", value: state),
  3131. URLQueryItem(name: "code_challenge", value: codeChallenge),
  3132. URLQueryItem(name: "code_challenge_method", value: "S256"),
  3133. URLQueryItem(name: "access_type", value: "offline"),
  3134. URLQueryItem(name: "prompt", value: "consent")
  3135. ]
  3136. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  3137. let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
  3138. guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
  3139. let callbackURL = try await loopback.waitForCallback()
  3140. let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  3141. guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
  3142. guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  3143. throw GoogleOAuthError.missingAuthorizationCode
  3144. }
  3145. return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
  3146. }
  3147. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
  3148. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  3149. request.httpMethod = "POST"
  3150. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  3151. request.httpBody = Self.formURLEncoded([
  3152. "client_id": clientId,
  3153. "client_secret": clientSecret,
  3154. "code": code,
  3155. "code_verifier": codeVerifier,
  3156. "redirect_uri": redirectURI,
  3157. "grant_type": "authorization_code"
  3158. ])
  3159. let (data, response) = try await URLSession.shared.data(for: request)
  3160. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3161. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  3162. }
  3163. struct TokenResponse: Decodable {
  3164. let access_token: String
  3165. let expires_in: Double
  3166. let refresh_token: String?
  3167. let scope: String?
  3168. let token_type: String?
  3169. }
  3170. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  3171. return GoogleOAuthTokens(
  3172. accessToken: decoded.access_token,
  3173. refreshToken: decoded.refresh_token,
  3174. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  3175. scope: decoded.scope,
  3176. tokenType: decoded.token_type
  3177. )
  3178. }
  3179. private static func pkceChallenge(for verifier: String) -> String {
  3180. let digest = SHA256.hash(data: Data(verifier.utf8))
  3181. return Data(digest).base64URLEncodedString()
  3182. }
  3183. private static func randomURLSafeString(length: Int) -> String {
  3184. var bytes = [UInt8](repeating: 0, count: length)
  3185. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  3186. return Data(bytes).base64URLEncodedString()
  3187. }
  3188. private static func formURLEncoded(_ params: [String: String]) -> Data {
  3189. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  3190. let pairs = params.map { key, value in
  3191. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  3192. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  3193. return "\(k)=\(v)"
  3194. }.joined(separator: "&")
  3195. return Data(pairs.utf8)
  3196. }
  3197. private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
  3198. guard let scopeValue else { return false }
  3199. return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
  3200. }
  3201. }
  3202. private extension Data {
  3203. func base64URLEncodedString() -> String {
  3204. base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
  3205. }
  3206. }
  3207. final class KeychainTokenStore {
  3208. private let defaultsKey: String
  3209. private let defaults: UserDefaults
  3210. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  3211. account: String = "googleOAuthTokens",
  3212. defaults: UserDefaults = .standard) {
  3213. self.defaultsKey = "\(service).\(account)"
  3214. self.defaults = defaults
  3215. }
  3216. func readTokens() throws -> GoogleOAuthTokens? {
  3217. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  3218. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  3219. }
  3220. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  3221. let data = try JSONEncoder().encode(tokens)
  3222. defaults.set(data, forKey: defaultsKey)
  3223. }
  3224. func clearTokens() {
  3225. defaults.removeObject(forKey: defaultsKey)
  3226. }
  3227. }
  3228. private final class OAuthLoopbackServer {
  3229. /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
  3230. private static let loopbackOAuthPort: UInt16 = 8742
  3231. private let queue = DispatchQueue(label: "google.oauth.loopback.server")
  3232. private let listener: NWListener
  3233. private var readyContinuation: CheckedContinuation<Void, Error>?
  3234. private var callbackContinuation: CheckedContinuation<URL, Error>?
  3235. private var callbackURL: URL?
  3236. private init(listener: NWListener) {
  3237. self.listener = listener
  3238. }
  3239. static func start() async throws -> OAuthLoopbackServer {
  3240. guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
  3241. throw GoogleOAuthError.invalidCallbackURL
  3242. }
  3243. let listener = try NWListener(using: .tcp, on: port)
  3244. let server = OAuthLoopbackServer(listener: listener)
  3245. try await server.startListening()
  3246. return server
  3247. }
  3248. var redirectURI: String {
  3249. let port = listener.port?.rawValue ?? 0
  3250. return "http://127.0.0.1:\(port)/oauth2redirect"
  3251. }
  3252. func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
  3253. try await withThrowingTaskGroup(of: URL.self) { group in
  3254. group.addTask { [weak self] in
  3255. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  3256. return try await self.awaitCallback()
  3257. }
  3258. group.addTask {
  3259. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  3260. throw GoogleOAuthError.authenticationTimedOut
  3261. }
  3262. let url = try await group.next()!
  3263. group.cancelAll()
  3264. return url
  3265. }
  3266. }
  3267. func stop() {
  3268. listener.cancel()
  3269. }
  3270. private func startListening() async throws {
  3271. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  3272. queue.async {
  3273. self.readyContinuation = continuation
  3274. self.listener.stateUpdateHandler = { [weak self] state in
  3275. guard let self else { return }
  3276. switch state {
  3277. case .ready:
  3278. if let readyContinuation = self.readyContinuation {
  3279. self.readyContinuation = nil
  3280. readyContinuation.resume()
  3281. }
  3282. case .failed(let error):
  3283. if let readyContinuation = self.readyContinuation {
  3284. self.readyContinuation = nil
  3285. readyContinuation.resume(throwing: error)
  3286. }
  3287. case .cancelled:
  3288. if let readyContinuation = self.readyContinuation {
  3289. self.readyContinuation = nil
  3290. readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
  3291. }
  3292. default:
  3293. break
  3294. }
  3295. }
  3296. self.listener.newConnectionHandler = { [weak self] connection in
  3297. self?.handle(connection: connection)
  3298. }
  3299. self.listener.start(queue: self.queue)
  3300. }
  3301. }
  3302. }
  3303. private func awaitCallback() async throws -> URL {
  3304. if let callbackURL { return callbackURL }
  3305. return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  3306. queue.async { self.callbackContinuation = continuation }
  3307. }
  3308. }
  3309. private func handle(connection: NWConnection) {
  3310. connection.start(queue: queue)
  3311. connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
  3312. guard let self else { return }
  3313. let requestLine = data.flatMap { String(data: $0, encoding: .utf8) }?.split(separator: "\r\n").first.map(String.init)
  3314. var parsedURL: URL?
  3315. if let requestLine {
  3316. let parts = requestLine.split(separator: " ")
  3317. if parts.count >= 2 {
  3318. parsedURL = URL(string: "http://127.0.0.1\(parts[1])")
  3319. }
  3320. }
  3321. self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
  3322. if let parsedURL {
  3323. self.callbackURL = parsedURL
  3324. self.callbackContinuation?.resume(returning: parsedURL)
  3325. self.callbackContinuation = nil
  3326. self.listener.cancel()
  3327. }
  3328. connection.cancel()
  3329. }
  3330. }
  3331. private func sendHTTPResponse(connection: NWConnection, success: Bool) {
  3332. 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>"
  3333. 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)"
  3334. connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
  3335. }
  3336. }
  3337. @MainActor
  3338. private final class OAuthWebViewContainerView: NSView {
  3339. private let webView: WKWebView
  3340. init(webView: WKWebView) {
  3341. self.webView = webView
  3342. super.init(frame: .zero)
  3343. addSubview(webView)
  3344. }
  3345. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  3346. override func layout() {
  3347. super.layout()
  3348. webView.frame = bounds
  3349. }
  3350. }
  3351. @MainActor
  3352. private final class InAppOAuthWindowController: NSWindowController {
  3353. private let webView: WKWebView
  3354. init() {
  3355. self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
  3356. let container = OAuthWebViewContainerView(webView: webView)
  3357. let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
  3358. window.title = "Google Sign-In"
  3359. window.contentView = container
  3360. super.init(window: window)
  3361. }
  3362. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  3363. func load(url: URL) { webView.load(URLRequest(url: url)) }
  3364. }
  3365. extension GoogleOAuthError: LocalizedError {
  3366. var errorDescription: String? {
  3367. switch self {
  3368. case .missingClientId: return "Missing Google OAuth Client ID."
  3369. case .missingClientSecret: return "Missing Google OAuth Client Secret."
  3370. case .invalidCallbackURL: return "Invalid OAuth callback URL."
  3371. case .missingAuthorizationCode: return "Google did not return an authorization code."
  3372. case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
  3373. case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
  3374. case .authenticationTimedOut: return "Google sign-in timed out."
  3375. }
  3376. }
  3377. }
  3378. extension ZoomOAuthError: LocalizedError {
  3379. var errorDescription: String? {
  3380. switch self {
  3381. case .missingClientId:
  3382. return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
  3383. case .missingClientSecret:
  3384. return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
  3385. case .invalidCallbackURL:
  3386. 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)."
  3387. case .missingAuthorizationCode:
  3388. return "Zoom did not return an authorization code."
  3389. case .tokenExchangeFailed(let details):
  3390. return details
  3391. case .missingRequiredScope(let details):
  3392. return "The Zoom access token is missing required scopes. \(details)"
  3393. case .rateLimited(let retryAfterSeconds):
  3394. if let retryAfterSeconds {
  3395. return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
  3396. }
  3397. return "Zoom rate limit reached. Try again later."
  3398. case .unableToOpenBrowser:
  3399. return "Could not open the system browser for Zoom sign-in."
  3400. case .authenticationTimedOut:
  3401. return "Zoom sign-in timed out waiting for the browser redirect."
  3402. }
  3403. }
  3404. }