Bez popisu

ViewController.swift 219KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786
  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 var joinMeetingWindow: NSWindow?
  178. private var scheduleMeetingWindow: NSWindow?
  179. private weak var scheduleTopicField: NSTextField?
  180. private weak var scheduleDateField: NSTextField?
  181. private weak var scheduleTimeField: NSTextField?
  182. private weak var scheduleTimeZonePopup: NSPopUpButton?
  183. private weak var scheduleDurationField: NSTextField?
  184. private weak var scheduleSubmitButton: NSButton?
  185. private var scheduleTimeZoneDisplayToIdentifier: [String: String] = [:]
  186. private weak var joinURLField: NSTextField?
  187. private weak var joinMeetingIDField: NSTextField?
  188. private weak var joinPasscodeField: NSTextField?
  189. private weak var joinURLFieldsContainer: NSView?
  190. private weak var joinIDFieldsContainer: NSView?
  191. private weak var joinModeSegment: NSSegmentedControl?
  192. private let paywallContentWidth: CGFloat = 520
  193. private var selectedPremiumPlan: PremiumPlan = .monthly
  194. private var paywallPlanViews: [PremiumPlan: NSView] = [:]
  195. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  196. private weak var paywallOfferLabel: NSTextField?
  197. private weak var paywallContinueLabel: NSTextField?
  198. private weak var paywallContinueButton: NSView?
  199. private var paywallPurchaseTask: Task<Void, Never>?
  200. private var paywallContinueEnabled = true
  201. private var lastKnownPremiumAccess = false
  202. private var allScheduledMeetings: [ScheduledMeeting] = []
  203. private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
  204. private var selectedHomeSidebarItem: String = "Home"
  205. private var homeSidebarRowViews: [String: NSView] = [:]
  206. private var homeSidebarIconViews: [String: NSImageView] = [:]
  207. private var homeSidebarLabels: [String: NSTextField] = [:]
  208. private var searchTextObserver: NSObjectProtocol?
  209. private var searchShortcutMonitor: Any?
  210. private var searchOutsideClickMonitor: Any?
  211. private var clockTimer: Timer?
  212. private var meetingsRefreshTimer: Timer?
  213. private var isSigningIn = false
  214. private var isPromptingZoomCredentials = false
  215. private var isLoadingMeetings = false
  216. private var meetingsScrollObserver: NSObjectProtocol?
  217. private var lastMeetingsRefreshAt = Date.distantPast
  218. private var lastScrollEdgeRefreshAt = Date.distantPast
  219. // Keep this conservative to avoid Zoom API rate limits.
  220. private let meetingsRefreshInterval: TimeInterval = 60
  221. private let scrollRefreshCooldown: TimeInterval = 3
  222. private var meetingsRateLimitedUntil: Date?
  223. private enum SidebarStyle {
  224. case login
  225. case home
  226. }
  227. private enum PremiumPlan: String, CaseIterable {
  228. case weekly = "com.mqldev.zoomapp.premium.weekly"
  229. case monthly = "com.mqldev.zoomapp.premium.monthly"
  230. case yearly = "com.mqldev.zoomapp.premium.yearly"
  231. case lifetime = "com.mqldev.zoomapp.premium.lifetime"
  232. var displayName: String {
  233. switch self {
  234. case .weekly: return "Premium Weekly"
  235. case .monthly: return "Premium Monthly"
  236. case .yearly: return "Premium Yearly"
  237. case .lifetime: return "Premium Lifetime"
  238. }
  239. }
  240. }
  241. private final class StoreKitCoordinator {
  242. enum PurchaseOutcome {
  243. case success
  244. case pending
  245. case cancelled
  246. case failed(String)
  247. }
  248. private(set) var productsByID: [String: Product] = [:]
  249. private(set) var activeProductIDs = Set<String>()
  250. var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
  251. private var transactionUpdatesTask: Task<Void, Never>?
  252. var onEntitlementsChanged: ((Bool) -> Void)?
  253. func start() async {
  254. await refreshProducts()
  255. await refreshEntitlements()
  256. observeTransactionUpdatesIfNeeded()
  257. }
  258. func refreshProducts() async {
  259. do {
  260. let products = try await Product.products(for: PremiumPlan.allCases.map(\.rawValue))
  261. productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
  262. } catch {
  263. productsByID = [:]
  264. }
  265. }
  266. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  267. guard let product = productsByID[plan.rawValue] else {
  268. await refreshProducts()
  269. guard let refreshed = productsByID[plan.rawValue] else {
  270. return .failed("Product not available. Check StoreKit configuration and product IDs.")
  271. }
  272. return await purchase(product: refreshed)
  273. }
  274. return await purchase(product: product)
  275. }
  276. func restorePurchases() async -> String {
  277. do {
  278. try await AppStore.sync()
  279. await refreshEntitlements()
  280. return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
  281. } catch {
  282. return "Restore failed. \(error.localizedDescription)"
  283. }
  284. }
  285. private func purchase(product: Product) async -> PurchaseOutcome {
  286. do {
  287. let result = try await product.purchase()
  288. switch result {
  289. case .success(let verification):
  290. guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
  291. await transaction.finish()
  292. await refreshEntitlements()
  293. return .success
  294. case .pending:
  295. return .pending
  296. case .userCancelled:
  297. return .cancelled
  298. @unknown default:
  299. return .failed("Unknown purchase state.")
  300. }
  301. } catch {
  302. return .failed(error.localizedDescription)
  303. }
  304. }
  305. private func observeTransactionUpdatesIfNeeded() {
  306. guard transactionUpdatesTask == nil else { return }
  307. transactionUpdatesTask = Task { [weak self] in
  308. for await update in Transaction.updates {
  309. guard case .verified(let transaction) = update else { continue }
  310. if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) {
  311. await self?.refreshEntitlements()
  312. }
  313. await transaction.finish()
  314. }
  315. }
  316. }
  317. @MainActor
  318. private func refreshEntitlements() async {
  319. let previousHasPremiumAccess = hasPremiumAccess
  320. let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
  321. var active = Set<String>()
  322. for await entitlement in Transaction.currentEntitlements {
  323. guard case .verified(let transaction) = entitlement else { continue }
  324. guard allIDs.contains(transaction.productID) else { continue }
  325. if Self.isTransactionActive(transaction) {
  326. active.insert(transaction.productID)
  327. }
  328. }
  329. // StoreKit testing can briefly report empty current entitlements even though a latest
  330. // verified transaction exists for a non-consumable. Merge in latest transactions.
  331. for productID in allIDs {
  332. guard let latest = await Transaction.latest(for: productID),
  333. case .verified(let transaction) = latest,
  334. Self.isTransactionActive(transaction) else { continue }
  335. active.insert(productID)
  336. }
  337. activeProductIDs = active
  338. let newHasPremiumAccess = hasPremiumAccess
  339. if newHasPremiumAccess != previousHasPremiumAccess {
  340. onEntitlementsChanged?(newHasPremiumAccess)
  341. }
  342. }
  343. private static func isTransactionActive(_ transaction: Transaction) -> Bool {
  344. if transaction.revocationDate != nil { return false }
  345. if let expirationDate = transaction.expirationDate {
  346. return expirationDate > Date()
  347. }
  348. return true
  349. }
  350. }
  351. private let storeKitCoordinator = StoreKitCoordinator()
  352. private var storeKitStartupTask: Task<Void, Never>?
  353. override func viewDidLoad() {
  354. super.viewDidLoad()
  355. palette = Palette(isDarkMode: darkModeEnabled)
  356. NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  357. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  358. setupUI()
  359. startStoreKit()
  360. }
  361. override func viewDidAppear() {
  362. super.viewDidAppear()
  363. if let window = view.window {
  364. window.setContentSize(NSSize(width: 1020, height: 690))
  365. applyWindowBackgroundForCurrentTheme(window)
  366. // Use full-size content view so custom top chrome sits in the titlebar region.
  367. window.titleVisibility = .hidden
  368. window.titlebarAppearsTransparent = true
  369. window.isMovableByWindowBackground = true
  370. window.styleMask.insert(.fullSizeContentView)
  371. }
  372. alignNativeTrafficLights()
  373. if isUserLoggedIn() {
  374. showHomeView(profile: nil)
  375. } else {
  376. showLoginView()
  377. }
  378. }
  379. override func viewDidLayout() {
  380. super.viewDidLayout()
  381. alignNativeTrafficLights()
  382. }
  383. private func setupUI() {
  384. view.wantsLayer = true
  385. view.layer?.backgroundColor = appBackground.cgColor
  386. rootContainer.translatesAutoresizingMaskIntoConstraints = false
  387. view.addSubview(rootContainer)
  388. NSLayoutConstraint.activate([
  389. rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  390. rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  391. rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
  392. rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  393. ])
  394. }
  395. private func showLoginView() {
  396. clockTimer?.invalidate()
  397. meetingsRefreshTimer?.invalidate()
  398. meetingsRefreshTimer = nil
  399. clearMeetingsScrollObserver()
  400. removeSearchFieldObserver()
  401. removeSearchShortcutMonitor()
  402. homeSearchField = nil
  403. homeSearchPill = nil
  404. allScheduledMeetings = []
  405. homeView?.removeFromSuperview()
  406. homeView = nil
  407. isSigningIn = false
  408. nextSignInButton?.title = "Next"
  409. nextSignInButton?.isEnabled = true
  410. zoomSocialButton?.isEnabled = true
  411. if loginView == nil {
  412. loginView = makeLoginView()
  413. }
  414. guard let loginView else { return }
  415. attachToRoot(loginView)
  416. }
  417. private func showHomeView(profile: GoogleUserProfile?) {
  418. loginView?.removeFromSuperview()
  419. clearMeetingsScrollObserver()
  420. removeSearchFieldObserver()
  421. removeSearchShortcutMonitor()
  422. homeSearchField = nil
  423. homeSearchPill = nil
  424. homeView?.removeFromSuperview()
  425. selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
  426. homeView = makeHomeView(profile: profile)
  427. if let homeView { attachToRoot(homeView) }
  428. installSearchShortcutMonitor()
  429. persistLoggedInState(true)
  430. startClock()
  431. startMeetingsAutoRefresh()
  432. triggerMeetingsRefresh(force: true)
  433. updateHomeSidebarHighlight()
  434. updateSelectedHomeSectionUI()
  435. }
  436. private func isUserLoggedIn() -> Bool {
  437. UserDefaults.standard.bool(forKey: loginStateKey)
  438. }
  439. private func persistLoggedInState(_ loggedIn: Bool) {
  440. UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
  441. }
  442. private func attachToRoot(_ subview: NSView) {
  443. subview.translatesAutoresizingMaskIntoConstraints = false
  444. if subview.superview != rootContainer {
  445. rootContainer.addSubview(subview)
  446. }
  447. NSLayoutConstraint.activate([
  448. subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
  449. subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
  450. subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
  451. subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
  452. ])
  453. }
  454. @objc private func googleLoginTapped() {
  455. guard isSigningIn == false else { return }
  456. isSigningIn = true
  457. googleButton?.title = "..."
  458. googleButton?.isEnabled = false
  459. Task {
  460. do {
  461. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  462. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  463. await MainActor.run {
  464. self.isSigningIn = false
  465. self.googleButton?.title = "G"
  466. self.googleButton?.isEnabled = true
  467. self.showHomeView(profile: profile)
  468. }
  469. } catch {
  470. await MainActor.run {
  471. self.isSigningIn = false
  472. self.googleButton?.title = "G"
  473. self.googleButton?.isEnabled = true
  474. self.showSimpleError("Google login failed", error: error)
  475. }
  476. }
  477. }
  478. }
  479. /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
  480. @objc private func zoomPrimarySignInTapped() {
  481. guard isSigningIn == false else { return }
  482. isSigningIn = true
  483. nextSignInButton?.title = "Signing in…"
  484. nextSignInButton?.isEnabled = false
  485. zoomSocialButton?.isEnabled = false
  486. googleButton?.isEnabled = false
  487. Task {
  488. do {
  489. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  490. guard configured else {
  491. await MainActor.run { self.resetLoginSigningInState() }
  492. return
  493. }
  494. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  495. let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
  496. let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
  497. await MainActor.run {
  498. self.resetLoginSigningInState()
  499. self.showHomeView(profile: profile)
  500. }
  501. } catch {
  502. await MainActor.run {
  503. self.resetLoginSigningInState()
  504. self.showSimpleError("Zoom sign-in failed", error: error)
  505. }
  506. }
  507. }
  508. }
  509. @objc private func scheduleMeetingTapped() {
  510. if let existing = scheduleMeetingWindow {
  511. existing.makeKeyAndOrderFront(nil)
  512. NSApp.activate(ignoringOtherApps: true)
  513. DispatchQueue.main.async { [weak self] in
  514. _ = self?.scheduleTopicField?.becomeFirstResponder()
  515. }
  516. return
  517. }
  518. let content = makeScheduleMeetingPanelContent()
  519. let controller = NSViewController()
  520. controller.view = content
  521. let panel = NSPanel(
  522. contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
  523. styleMask: [.titled, .closable, .fullSizeContentView],
  524. backing: .buffered,
  525. defer: false
  526. )
  527. panel.title = "Schedule a meeting"
  528. panel.titleVisibility = .hidden
  529. panel.titlebarAppearsTransparent = true
  530. panel.hidesOnDeactivate = true
  531. panel.isReleasedWhenClosed = false
  532. panel.standardWindowButton(.closeButton)?.isHidden = true
  533. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  534. panel.standardWindowButton(.zoomButton)?.isHidden = true
  535. panel.isMovableByWindowBackground = true
  536. panel.becomesKeyOnlyIfNeeded = false
  537. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  538. panel.center()
  539. panel.contentViewController = controller
  540. panel.delegate = self
  541. applyWindowBackgroundForCurrentTheme(panel)
  542. panel.makeKeyAndOrderFront(nil)
  543. NSApp.activate(ignoringOtherApps: true)
  544. scheduleMeetingWindow = panel
  545. DispatchQueue.main.async { [weak self] in
  546. _ = self?.scheduleTopicField?.becomeFirstResponder()
  547. }
  548. }
  549. private func makeScheduleMeetingPanelContent() -> NSView {
  550. let root = NSView()
  551. root.translatesAutoresizingMaskIntoConstraints = false
  552. root.wantsLayer = true
  553. root.layer?.backgroundColor = appBackground.cgColor
  554. let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  555. let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
  556. let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
  557. closeButton.translatesAutoresizingMaskIntoConstraints = false
  558. closeButton.isBordered = false
  559. closeButton.bezelStyle = .regularSquare
  560. closeButton.wantsLayer = true
  561. closeButton.layer?.cornerRadius = 14
  562. closeButton.normalColor = palette.inputBackground
  563. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  564. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  565. closeButton.layer?.borderWidth = 1
  566. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  567. closeButton.contentTintColor = secondaryText
  568. closeButton.toolTip = "Close"
  569. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  570. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  571. let titleRow = NSStackView()
  572. titleRow.orientation = .horizontal
  573. titleRow.alignment = .centerY
  574. titleRow.spacing = 10
  575. titleRow.translatesAutoresizingMaskIntoConstraints = false
  576. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  577. titleStack.orientation = .vertical
  578. titleStack.spacing = 2
  579. titleStack.alignment = .leading
  580. let titleSpacer = NSView()
  581. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  582. titleRow.addArrangedSubview(titleStack)
  583. titleRow.addArrangedSubview(titleSpacer)
  584. titleRow.addArrangedSubview(closeButton)
  585. let headerDivider = NSView()
  586. headerDivider.wantsLayer = true
  587. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  588. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  589. let topicBox = makeJoinFormField(placeholder: "Meeting topic")
  590. scheduleTopicField = topicBox.textField
  591. topicBox.textField.stringValue = "Zoom meeting"
  592. let defaultStart = Date().addingTimeInterval(3600)
  593. let dateOnlyFormatter = DateFormatter()
  594. dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
  595. dateOnlyFormatter.timeZone = TimeZone.current
  596. dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
  597. let time12Formatter = DateFormatter()
  598. time12Formatter.locale = Locale(identifier: "en_US_POSIX")
  599. time12Formatter.timeZone = TimeZone.current
  600. time12Formatter.dateFormat = "h:mm a"
  601. let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
  602. scheduleDateField = dateBox.textField
  603. dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
  604. let timeBox = makeJoinFormField(placeholder: "2:30 PM")
  605. scheduleTimeField = timeBox.textField
  606. timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
  607. let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
  608. let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
  609. let tzCombo = NSPopUpButton()
  610. tzCombo.translatesAutoresizingMaskIntoConstraints = false
  611. tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
  612. tzCombo.wantsLayer = true
  613. tzCombo.layer?.cornerRadius = 10
  614. tzCombo.layer?.borderWidth = 1
  615. tzCombo.layer?.borderColor = palette.inputBorder.cgColor
  616. tzCombo.layer?.backgroundColor = palette.inputBackground.cgColor
  617. let tzOptions = makeTimeZoneDisplayOptions(referenceDate: defaultStart)
  618. scheduleTimeZoneDisplayToIdentifier = Dictionary(uniqueKeysWithValues: tzOptions.map { ($0.display, $0.identifier) })
  619. tzCombo.removeAllItems()
  620. tzCombo.addItems(withTitles: tzOptions.map(\.display))
  621. if let selected = tzOptions.first(where: { $0.identifier == TimeZone.current.identifier }) {
  622. tzCombo.selectItem(withTitle: selected.display)
  623. } else {
  624. tzCombo.selectItem(at: 0)
  625. }
  626. tzCombo.toolTip = "Timezone with GMT offset. Meeting start is interpreted in this zone."
  627. scheduleTimeZonePopup = tzCombo
  628. let tzHint = makeLabel("Includes GMT offset, e.g. GMT+05:00 - Asia/Karachi", size: 11, color: mutedText, weight: .regular, centered: false)
  629. let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
  630. tzStack.orientation = .vertical
  631. tzStack.spacing = 8
  632. tzStack.alignment = .leading
  633. tzStack.translatesAutoresizingMaskIntoConstraints = false
  634. let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
  635. durationBox.textField.stringValue = "60"
  636. scheduleDurationField = durationBox.textField
  637. let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
  638. let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
  639. let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
  640. let topicStack = NSStackView(views: [topicLabel, topicBox])
  641. topicStack.orientation = .vertical
  642. topicStack.spacing = 8
  643. topicStack.alignment = .leading
  644. topicStack.translatesAutoresizingMaskIntoConstraints = false
  645. let dateTimeRow = NSStackView(views: [dateBox, timeBox])
  646. dateTimeRow.orientation = .horizontal
  647. dateTimeRow.spacing = 10
  648. dateTimeRow.alignment = .top
  649. dateTimeRow.distribution = .fillEqually
  650. dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
  651. let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
  652. startStack.orientation = .vertical
  653. startStack.spacing = 8
  654. startStack.alignment = .leading
  655. startStack.translatesAutoresizingMaskIntoConstraints = false
  656. let durationStack = NSStackView(views: [durationLabel, durationBox])
  657. durationStack.orientation = .vertical
  658. durationStack.spacing = 8
  659. durationStack.alignment = .leading
  660. durationStack.translatesAutoresizingMaskIntoConstraints = false
  661. let formCard = NSView()
  662. formCard.translatesAutoresizingMaskIntoConstraints = false
  663. formCard.wantsLayer = true
  664. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  665. formCard.layer?.cornerRadius = 14
  666. formCard.layer?.borderWidth = 1
  667. formCard.layer?.borderColor = palette.inputBorder.cgColor
  668. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
  669. cancelButton.isBordered = false
  670. cancelButton.wantsLayer = true
  671. cancelButton.layer?.cornerRadius = 10
  672. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  673. cancelButton.layer?.borderWidth = 1
  674. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  675. cancelButton.contentTintColor = primaryText
  676. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  677. let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
  678. submitButton.isBordered = false
  679. submitButton.wantsLayer = true
  680. submitButton.layer?.cornerRadius = 10
  681. submitButton.normalColor = accentBlue
  682. submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  683. submitButton.contentTintColor = .white
  684. submitButton.font = .systemFont(ofSize: 13, weight: .bold)
  685. submitButton.keyEquivalent = "\r"
  686. scheduleSubmitButton = submitButton
  687. let buttons = NSStackView(views: [cancelButton, submitButton])
  688. buttons.orientation = .horizontal
  689. buttons.spacing = 12
  690. buttons.alignment = .centerY
  691. buttons.distribution = .fillEqually
  692. let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
  693. innerStack.orientation = .vertical
  694. innerStack.spacing = 18
  695. innerStack.alignment = .leading
  696. innerStack.translatesAutoresizingMaskIntoConstraints = false
  697. formCard.addSubview(innerStack)
  698. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  699. NSLayoutConstraint.activate([
  700. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  701. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  702. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  703. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  704. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  705. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  706. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  707. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  708. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  709. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  710. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  711. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  712. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  713. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  714. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  715. topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  716. tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  717. tzCombo.heightAnchor.constraint(equalToConstant: 34),
  718. dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  719. durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  720. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  721. submitButton.heightAnchor.constraint(equalToConstant: 40),
  722. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  723. ])
  724. return root
  725. }
  726. @objc private func scheduleMeetingCancelTapped() {
  727. scheduleMeetingWindow?.performClose(nil)
  728. }
  729. @objc private func scheduleMeetingSubmitTapped() {
  730. let topicRaw = scheduleTopicField?.stringValue ?? ""
  731. let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
  732. guard topic.isEmpty == false else {
  733. showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
  734. return
  735. }
  736. let tzId = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  737. if tzId.isEmpty == false,
  738. scheduleTimeZoneDisplayToIdentifier[tzId] == nil,
  739. TimeZone(identifier: tzId) == nil {
  740. showSimpleAlert(
  741. title: "Timezone",
  742. message: "Choose a timezone from the list (with GMT offset), for example GMT+05:00 - Asia/Karachi."
  743. )
  744. return
  745. }
  746. guard let draft = scheduleMeetingDraftFromFormFields() else {
  747. showSimpleAlert(
  748. title: "Date & time",
  749. message: "Enter the date as YYYY-MM-DD and the time in 12-hour form with AM or PM (in the selected timezone), for example 2026-04-17 and 2:30 PM."
  750. )
  751. return
  752. }
  753. if draft.startDate < Date().addingTimeInterval(-30) {
  754. showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
  755. return
  756. }
  757. let durationRaw = scheduleDurationField?.stringValue ?? ""
  758. let digits = durationRaw.filter(\.isNumber)
  759. guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
  760. showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
  761. return
  762. }
  763. scheduleSubmitButton?.isEnabled = false
  764. let tz = draft.timeZone
  765. Task {
  766. do {
  767. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  768. guard configured else {
  769. await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
  770. return
  771. }
  772. let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  773. let result = try await createZoomMeeting(
  774. accessToken: token,
  775. topic: topic,
  776. startTimeWithOffset: draft.startTimeWithOffset,
  777. durationMinutes: duration,
  778. timeZone: tz
  779. )
  780. await MainActor.run {
  781. self.scheduleSubmitButton?.isEnabled = true
  782. self.scheduleMeetingWindow?.performClose(nil)
  783. self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
  784. self.triggerMeetingsRefresh(force: true)
  785. if let join = result.join_url, join.isEmpty == false {
  786. NSPasteboard.general.clearContents()
  787. NSPasteboard.general.setString(join, forType: .string)
  788. self.showSimpleAlert(
  789. title: "Meeting scheduled",
  790. message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
  791. )
  792. } else {
  793. self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
  794. }
  795. }
  796. } catch {
  797. await MainActor.run {
  798. self.scheduleSubmitButton?.isEnabled = true
  799. if case ZoomOAuthError.missingRequiredScope(_) = error {
  800. self.zoomOAuth.clearSavedTokens()
  801. self.showSimpleAlert(
  802. title: "Zoom permissions",
  803. message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
  804. )
  805. } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
  806. let seconds = max(retryAfter ?? 300, 30)
  807. let minutes = Int(ceil(Double(seconds) / 60.0))
  808. self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
  809. } else {
  810. self.showSimpleError("Could not schedule", error: error)
  811. }
  812. }
  813. }
  814. }
  815. }
  816. private func resetScheduleMeetingPanelReferences() {
  817. scheduleMeetingWindow = nil
  818. scheduleTopicField = nil
  819. scheduleDateField = nil
  820. scheduleTimeField = nil
  821. scheduleTimeZonePopup = nil
  822. scheduleDurationField = nil
  823. scheduleSubmitButton = nil
  824. scheduleTimeZoneDisplayToIdentifier = [:]
  825. }
  826. /// Resolves the schedule panel timezone (IANA id from combo, or system default).
  827. private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
  828. let raw = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  829. if let identifier = scheduleTimeZoneDisplayToIdentifier[raw],
  830. let mapped = TimeZone(identifier: identifier) {
  831. return mapped
  832. }
  833. guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
  834. return tz
  835. }
  836. private struct TimeZoneDisplayOption {
  837. let identifier: String
  838. let display: String
  839. }
  840. private func makeTimeZoneDisplayOptions(referenceDate: Date) -> [TimeZoneDisplayOption] {
  841. TimeZone.knownTimeZoneIdentifiers.compactMap { identifier in
  842. guard let tz = TimeZone(identifier: identifier) else { return nil }
  843. let seconds = tz.secondsFromGMT(for: referenceDate)
  844. let sign = seconds >= 0 ? "+" : "-"
  845. let absSeconds = abs(seconds)
  846. let hours = absSeconds / 3600
  847. let minutes = (absSeconds % 3600) / 60
  848. let offset = String(format: "GMT%@%02d:%02d", sign, hours, minutes)
  849. return TimeZoneDisplayOption(identifier: identifier, display: "\(offset) - \(identifier)")
  850. }
  851. .sorted { lhs, rhs in
  852. if lhs.display == rhs.display { return lhs.identifier < rhs.identifier }
  853. return lhs.display < rhs.display
  854. }
  855. }
  856. private struct ScheduleMeetingDraft {
  857. let startDate: Date
  858. let startTimeWithOffset: String
  859. let timeZone: TimeZone
  860. }
  861. /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
  862. private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
  863. let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  864. let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  865. guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
  866. let tz = resolvedScheduleTimeZoneForMeeting()
  867. let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
  868. let combined = "\(datePart) \(normalizedTime)"
  869. let df = DateFormatter()
  870. df.locale = Locale(identifier: "en_US_POSIX")
  871. df.timeZone = tz
  872. df.isLenient = true
  873. let formats = ["yyyy-MM-dd h:mm a", "yyyy-MM-dd hh:mm a", "yyyy-MM-dd h:mm:ss a"]
  874. for format in formats {
  875. df.dateFormat = format
  876. if let d = df.date(from: combined) {
  877. let localFormatter = DateFormatter()
  878. localFormatter.locale = Locale(identifier: "en_US_POSIX")
  879. localFormatter.timeZone = tz
  880. localFormatter.calendar = Calendar(identifier: .gregorian)
  881. localFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
  882. return ScheduleMeetingDraft(
  883. startDate: d,
  884. startTimeWithOffset: localFormatter.string(from: d),
  885. timeZone: tz
  886. )
  887. }
  888. }
  889. return nil
  890. }
  891. @objc private func joinMeetingTapped() {
  892. if let existing = joinMeetingWindow {
  893. existing.makeKeyAndOrderFront(nil)
  894. NSApp.activate(ignoringOtherApps: true)
  895. DispatchQueue.main.async { [weak self] in
  896. _ = self?.joinURLField?.becomeFirstResponder()
  897. }
  898. return
  899. }
  900. let content = makeJoinMeetingPanelContent()
  901. let controller = NSViewController()
  902. controller.view = content
  903. let panel = NSPanel(
  904. contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
  905. styleMask: [.titled, .closable, .fullSizeContentView],
  906. backing: .buffered,
  907. defer: false
  908. )
  909. panel.title = "Join a meeting"
  910. panel.titleVisibility = .hidden
  911. panel.titlebarAppearsTransparent = true
  912. panel.hidesOnDeactivate = true
  913. panel.isReleasedWhenClosed = false
  914. panel.standardWindowButton(.closeButton)?.isHidden = true
  915. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  916. panel.standardWindowButton(.zoomButton)?.isHidden = true
  917. panel.isMovableByWindowBackground = true
  918. // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
  919. panel.becomesKeyOnlyIfNeeded = false
  920. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  921. panel.center()
  922. panel.contentViewController = controller
  923. panel.delegate = self
  924. applyWindowBackgroundForCurrentTheme(panel)
  925. panel.makeKeyAndOrderFront(nil)
  926. NSApp.activate(ignoringOtherApps: true)
  927. joinMeetingWindow = panel
  928. DispatchQueue.main.async { [weak self] in
  929. guard let self else { return }
  930. _ = self.joinURLField?.becomeFirstResponder()
  931. }
  932. }
  933. private func makeJoinMeetingPanelContent() -> NSView {
  934. let root = NSView()
  935. root.translatesAutoresizingMaskIntoConstraints = false
  936. root.wantsLayer = true
  937. root.layer?.backgroundColor = appBackground.cgColor
  938. let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  939. let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
  940. let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
  941. closeButton.translatesAutoresizingMaskIntoConstraints = false
  942. closeButton.isBordered = false
  943. closeButton.bezelStyle = .regularSquare
  944. closeButton.wantsLayer = true
  945. closeButton.layer?.cornerRadius = 14
  946. closeButton.normalColor = palette.inputBackground
  947. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  948. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  949. closeButton.layer?.borderWidth = 1
  950. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  951. closeButton.contentTintColor = secondaryText
  952. closeButton.toolTip = "Close"
  953. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  954. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  955. let titleRow = NSStackView()
  956. titleRow.orientation = .horizontal
  957. titleRow.alignment = .centerY
  958. titleRow.spacing = 10
  959. titleRow.translatesAutoresizingMaskIntoConstraints = false
  960. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  961. titleStack.orientation = .vertical
  962. titleStack.spacing = 2
  963. titleStack.alignment = .leading
  964. let titleSpacer = NSView()
  965. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  966. titleRow.addArrangedSubview(titleStack)
  967. titleRow.addArrangedSubview(titleSpacer)
  968. titleRow.addArrangedSubview(closeButton)
  969. let headerDivider = NSView()
  970. headerDivider.wantsLayer = true
  971. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  972. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  973. let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
  974. mode.segmentStyle = .rounded
  975. mode.selectedSegment = 0
  976. mode.translatesAutoresizingMaskIntoConstraints = false
  977. mode.font = .systemFont(ofSize: 12, weight: .semibold)
  978. mode.controlSize = .large
  979. if #available(macOS 11.0, *) {
  980. mode.selectedSegmentBezelColor = accentBlue
  981. }
  982. joinModeSegment = mode
  983. let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
  984. joinURLField = urlBox.textField
  985. let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
  986. let urlStack = NSStackView(views: [urlLabel, urlBox])
  987. urlStack.orientation = .vertical
  988. urlStack.spacing = 8
  989. urlStack.alignment = .leading
  990. urlStack.translatesAutoresizingMaskIntoConstraints = false
  991. joinURLFieldsContainer = urlStack
  992. let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
  993. let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
  994. joinMeetingIDField = idBox.textField
  995. joinPasscodeField = passBox.textField
  996. let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
  997. let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
  998. let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
  999. idStack.orientation = .vertical
  1000. idStack.spacing = 8
  1001. idStack.alignment = .leading
  1002. idStack.translatesAutoresizingMaskIntoConstraints = false
  1003. idStack.isHidden = true
  1004. joinIDFieldsContainer = idStack
  1005. let formCard = NSView()
  1006. formCard.translatesAutoresizingMaskIntoConstraints = false
  1007. formCard.wantsLayer = true
  1008. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  1009. formCard.layer?.cornerRadius = 14
  1010. formCard.layer?.borderWidth = 1
  1011. formCard.layer?.borderColor = palette.inputBorder.cgColor
  1012. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
  1013. cancelButton.isBordered = false
  1014. cancelButton.wantsLayer = true
  1015. cancelButton.layer?.cornerRadius = 10
  1016. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1017. cancelButton.layer?.borderWidth = 1
  1018. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  1019. cancelButton.contentTintColor = primaryText
  1020. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1021. let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
  1022. joinButton.isBordered = false
  1023. joinButton.wantsLayer = true
  1024. joinButton.layer?.cornerRadius = 10
  1025. joinButton.normalColor = accentBlue
  1026. joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  1027. joinButton.contentTintColor = .white
  1028. joinButton.font = .systemFont(ofSize: 13, weight: .bold)
  1029. joinButton.keyEquivalent = "\r"
  1030. let buttons = NSStackView(views: [cancelButton, joinButton])
  1031. buttons.orientation = .horizontal
  1032. buttons.spacing = 12
  1033. buttons.alignment = .centerY
  1034. buttons.distribution = .fillEqually
  1035. let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
  1036. innerStack.orientation = .vertical
  1037. innerStack.spacing = 18
  1038. innerStack.alignment = .leading
  1039. innerStack.translatesAutoresizingMaskIntoConstraints = false
  1040. formCard.addSubview(innerStack)
  1041. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  1042. NSLayoutConstraint.activate([
  1043. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1044. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  1045. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  1046. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  1047. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1048. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1049. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  1050. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  1051. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1052. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1053. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  1054. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1055. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1056. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1057. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1058. mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1059. urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1060. idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1061. passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1062. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1063. joinButton.heightAnchor.constraint(equalToConstant: 40),
  1064. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1065. ])
  1066. return root
  1067. }
  1068. private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
  1069. JoinPanelFieldContainer(
  1070. placeholder: placeholder,
  1071. normalBorder: palette.inputBorder,
  1072. focusBorder: accentBlue.withAlphaComponent(0.9),
  1073. fill: palette.inputBackground,
  1074. primaryText: primaryText,
  1075. mutedText: mutedText
  1076. )
  1077. }
  1078. @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
  1079. let urlMode = sender.selectedSegment == 0
  1080. joinURLFieldsContainer?.isHidden = urlMode == false
  1081. joinIDFieldsContainer?.isHidden = urlMode
  1082. }
  1083. @objc private func joinMeetingCancelTapped() {
  1084. joinMeetingWindow?.performClose(nil)
  1085. }
  1086. @objc private func joinMeetingSubmitTapped() {
  1087. guard let segment = joinModeSegment else { return }
  1088. if segment.selectedSegment == 0 {
  1089. let raw = joinURLField?.stringValue ?? ""
  1090. guard let url = parseZoomJoinURLFromUserInput(raw) else {
  1091. showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
  1092. return
  1093. }
  1094. openZoomMeetingInDefaultBrowser(url)
  1095. } else {
  1096. let idRaw = joinMeetingIDField?.stringValue ?? ""
  1097. let digits = idRaw.filter(\.isNumber)
  1098. guard digits.isEmpty == false else {
  1099. showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
  1100. return
  1101. }
  1102. let pass = joinPasscodeField?.stringValue ?? ""
  1103. guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
  1104. showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
  1105. return
  1106. }
  1107. openZoomMeetingInDefaultBrowser(url)
  1108. }
  1109. }
  1110. /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
  1111. private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
  1112. guard meetingIdDigits.isEmpty == false else { return nil }
  1113. var components = URLComponents()
  1114. components.scheme = "https"
  1115. components.host = "zoom.us"
  1116. components.path = "/wc/join/\(meetingIdDigits)"
  1117. let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
  1118. if trimmed.isEmpty == false {
  1119. components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
  1120. }
  1121. return components.url
  1122. }
  1123. private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
  1124. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1125. guard trimmed.isEmpty == false else { return nil }
  1126. let normalized: String
  1127. if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
  1128. normalized = trimmed
  1129. } else {
  1130. normalized = "https://\(trimmed)"
  1131. }
  1132. guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
  1133. let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
  1134. || host == "zoom.com" || host.hasSuffix(".zoom.com")
  1135. guard isZoom else { return nil }
  1136. return url
  1137. }
  1138. private func openZoomMeetingInDefaultBrowser(_ url: URL) {
  1139. let opened = NSWorkspace.shared.open(url)
  1140. if opened {
  1141. joinMeetingWindow?.performClose(nil)
  1142. } else {
  1143. showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
  1144. }
  1145. }
  1146. private func resetJoinMeetingPanelReferences() {
  1147. joinMeetingWindow = nil
  1148. joinURLField = nil
  1149. joinMeetingIDField = nil
  1150. joinPasscodeField = nil
  1151. joinURLFieldsContainer = nil
  1152. joinIDFieldsContainer = nil
  1153. joinModeSegment = nil
  1154. }
  1155. @objc private func logoutTapped() {
  1156. meetingsRefreshTimer?.invalidate()
  1157. meetingsRefreshTimer = nil
  1158. clearMeetingsScrollObserver()
  1159. googleOAuth.clearSavedTokens()
  1160. zoomOAuth.clearSavedTokens()
  1161. persistLoggedInState(false)
  1162. showLoginView()
  1163. }
  1164. @objc private func topBarPlaceholderTapped() {
  1165. // Reserved for future titlebar control actions.
  1166. }
  1167. @objc private func upgradeToProTapped() {
  1168. if storeKitCoordinator.hasPremiumAccess {
  1169. openManageSubscriptions()
  1170. } else {
  1171. showPaywall()
  1172. }
  1173. }
  1174. private func openManageSubscriptions() {
  1175. if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
  1176. NSWorkspace.shared.open(url)
  1177. }
  1178. }
  1179. @objc private func refreshMeetingsTapped() {
  1180. Task { @MainActor in
  1181. self.animateMeetingsRefresh()
  1182. self.setMeetingsLoadingUI(true)
  1183. }
  1184. triggerMeetingsRefresh(force: true)
  1185. }
  1186. @MainActor
  1187. private func setMeetingsLoadingUI(_ loading: Bool) {
  1188. refreshMeetingsButton?.isEnabled = loading == false
  1189. }
  1190. @MainActor
  1191. private func animateMeetingsRefresh() {
  1192. guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
  1193. let views = stack.arrangedSubviews
  1194. let dimmed: CGFloat = 0.86
  1195. NSAnimationContext.runAnimationGroup { context in
  1196. context.duration = 0.07
  1197. context.timingFunction = CAMediaTimingFunction(name: .linear)
  1198. views.forEach { $0.animator().alphaValue = dimmed }
  1199. } completionHandler: {
  1200. NSAnimationContext.runAnimationGroup { context in
  1201. context.duration = 0.08
  1202. context.timingFunction = CAMediaTimingFunction(name: .linear)
  1203. views.forEach { $0.animator().alphaValue = 1.0 }
  1204. }
  1205. }
  1206. }
  1207. private func startMeetingsAutoRefresh() {
  1208. meetingsRefreshTimer?.invalidate()
  1209. // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
  1210. meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
  1211. guard let self else { return }
  1212. self.triggerMeetingsRefresh()
  1213. }
  1214. }
  1215. private func triggerMeetingsRefresh(force: Bool = false) {
  1216. let now = Date()
  1217. if let until = meetingsRateLimitedUntil, now < until {
  1218. return
  1219. }
  1220. if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
  1221. return
  1222. }
  1223. lastMeetingsRefreshAt = now
  1224. Task { await self.loadScheduledMeetings() }
  1225. }
  1226. private func clearMeetingsScrollObserver() {
  1227. if let meetingsScrollObserver {
  1228. NotificationCenter.default.removeObserver(meetingsScrollObserver)
  1229. }
  1230. meetingsScrollObserver = nil
  1231. meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
  1232. meetingsScrollView = nil
  1233. }
  1234. private func removeSearchFieldObserver() {
  1235. if let searchTextObserver {
  1236. NotificationCenter.default.removeObserver(searchTextObserver)
  1237. }
  1238. searchTextObserver = nil
  1239. }
  1240. private func installSearchShortcutMonitor() {
  1241. removeSearchShortcutMonitor()
  1242. searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  1243. guard let self else { return event }
  1244. guard event.modifierFlags.contains(.command),
  1245. event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
  1246. guard self.homeSearchField != nil else { return event }
  1247. DispatchQueue.main.async {
  1248. self.focusHomeSearchField()
  1249. }
  1250. return nil
  1251. }
  1252. searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  1253. guard let self else { return event }
  1254. guard let window = self.view.window, event.window === window else { return event }
  1255. guard let field = self.homeSearchField else { return event }
  1256. guard self.isSearchFieldActive(field, in: window) else { return event }
  1257. let location = event.locationInWindow
  1258. let pill = self.homeSearchPill ?? field
  1259. let rectInWindow = pill.convert(pill.bounds, to: nil)
  1260. if rectInWindow.contains(location) { return event }
  1261. DispatchQueue.main.async {
  1262. window.makeFirstResponder(nil)
  1263. (field as? SearchPillTextField)?.forceClearFocusState()
  1264. self.applySearchPillFocusBorder(focused: false)
  1265. }
  1266. return event
  1267. }
  1268. }
  1269. private func removeSearchShortcutMonitor() {
  1270. if let searchShortcutMonitor {
  1271. NSEvent.removeMonitor(searchShortcutMonitor)
  1272. }
  1273. searchShortcutMonitor = nil
  1274. if let searchOutsideClickMonitor {
  1275. NSEvent.removeMonitor(searchOutsideClickMonitor)
  1276. }
  1277. searchOutsideClickMonitor = nil
  1278. }
  1279. private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
  1280. guard let fr = window.firstResponder else { return false }
  1281. if fr === field { return true }
  1282. if let editor = field.currentEditor(), fr === editor { return true }
  1283. return false
  1284. }
  1285. @MainActor
  1286. private func applySearchPillFocusBorder(focused: Bool) {
  1287. homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
  1288. homeSearchPill?.layer?.borderColor = accentBlue.cgColor
  1289. }
  1290. @MainActor
  1291. private func focusHomeSearchField() {
  1292. guard let field = homeSearchField else { return }
  1293. view.window?.makeFirstResponder(field)
  1294. }
  1295. private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
  1296. clearMeetingsScrollObserver()
  1297. meetingsScrollView = scrollView
  1298. scrollView.contentView.postsBoundsChangedNotifications = true
  1299. meetingsScrollObserver = NotificationCenter.default.addObserver(
  1300. forName: NSView.boundsDidChangeNotification,
  1301. object: scrollView.contentView,
  1302. queue: .main
  1303. ) { [weak self, weak scrollView] _ in
  1304. guard let self, let scrollView else { return }
  1305. self.refreshMeetingsIfScrolledToEdge(scrollView)
  1306. }
  1307. }
  1308. private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
  1309. guard let documentView = scrollView.documentView else { return }
  1310. let visibleRect = scrollView.contentView.bounds
  1311. let contentHeight = documentView.bounds.height
  1312. let viewportHeight = visibleRect.height
  1313. if contentHeight <= viewportHeight + 2 {
  1314. return
  1315. }
  1316. let maxOffset = max(contentHeight - viewportHeight, 0)
  1317. let y = visibleRect.origin.y
  1318. let threshold: CGFloat = 12
  1319. let reachedTop = y <= threshold
  1320. let reachedBottom = y >= (maxOffset - threshold)
  1321. guard reachedTop || reachedBottom else { return }
  1322. let now = Date()
  1323. if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
  1324. return
  1325. }
  1326. lastScrollEdgeRefreshAt = now
  1327. triggerMeetingsRefresh(force: true)
  1328. }
  1329. @MainActor
  1330. private func resetLoginSigningInState() {
  1331. isSigningIn = false
  1332. nextSignInButton?.title = "Next"
  1333. nextSignInButton?.isEnabled = true
  1334. zoomSocialButton?.isEnabled = true
  1335. googleButton?.isEnabled = true
  1336. }
  1337. /// Returns false if the user cancelled or left credentials empty.
  1338. @MainActor
  1339. private func ensureZoomOAuthClientConfigured() -> Bool {
  1340. if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
  1341. return true
  1342. }
  1343. return presentZoomOAuthCredentialPrompt()
  1344. }
  1345. private func showSimpleError(_ title: String, error: Error) {
  1346. let alert = NSAlert()
  1347. alert.alertStyle = .warning
  1348. alert.messageText = title
  1349. alert.informativeText = error.localizedDescription
  1350. alert.runModal()
  1351. }
  1352. private func showSimpleAlert(title: String, message: String) {
  1353. let alert = NSAlert()
  1354. alert.alertStyle = .informational
  1355. alert.messageText = title
  1356. alert.informativeText = message
  1357. alert.runModal()
  1358. }
  1359. private struct ScheduledMeeting {
  1360. let title: String
  1361. let start: Date
  1362. let end: Date?
  1363. let host: String
  1364. let source: String
  1365. let webURL: URL?
  1366. }
  1367. @MainActor
  1368. private func applyMeetings(_ meetings: [ScheduledMeeting]) {
  1369. allScheduledMeetings = meetings
  1370. applyFilteredMeetings()
  1371. }
  1372. @MainActor
  1373. private func applyFilteredMeetings() {
  1374. guard let stack = meetingsListStack else { return }
  1375. stack.arrangedSubviews.forEach { view in
  1376. stack.removeArrangedSubview(view)
  1377. view.removeFromSuperview()
  1378. }
  1379. let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  1380. let calendar = Calendar.current
  1381. let dayStart = selectedMeetingsDayStart
  1382. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
  1383. let source = allScheduledMeetings.filter { meeting in
  1384. meeting.start >= dayStart && meeting.start < dayEnd
  1385. }
  1386. let filtered: [ScheduledMeeting]
  1387. if query.isEmpty {
  1388. filtered = source
  1389. } else {
  1390. filtered = source.filter { meeting in
  1391. meeting.title.lowercased().contains(query)
  1392. || meeting.host.lowercased().contains(query)
  1393. || meeting.source.lowercased().contains(query)
  1394. }
  1395. }
  1396. let ordered = filtered.sorted(by: { $0.start < $1.start })
  1397. if ordered.isEmpty {
  1398. emptyMeetingLabel?.isHidden = false
  1399. if source.isEmpty {
  1400. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  1401. emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
  1402. } else {
  1403. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  1404. emptyMeetingLabel?.stringValue = "No meetings match your search."
  1405. }
  1406. return
  1407. }
  1408. emptyMeetingLabel?.isHidden = true
  1409. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  1410. for meeting in ordered {
  1411. let card = makeMeetingRowCard(meeting)
  1412. stack.addArrangedSubview(card)
  1413. // Make each meeting card span the full list width (like Zoom).
  1414. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  1415. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1416. }
  1417. }
  1418. }
  1419. @MainActor
  1420. private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
  1421. selectedMeetingsDayStart = Calendar.current.startOfDay(for: newDayStart)
  1422. updateMeetingsDayUI()
  1423. applyFilteredMeetings()
  1424. }
  1425. @MainActor
  1426. private func updateMeetingsDayUI() {
  1427. let dayStart = selectedMeetingsDayStart
  1428. let formatter = DateFormatter()
  1429. formatter.dateFormat = "EEEE, MMM d"
  1430. meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
  1431. let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
  1432. meetingsTodayButton?.isEnabled = isToday == false
  1433. meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  1434. }
  1435. private func meetingsDayDisplayName(for dayStart: Date) -> String {
  1436. let calendar = Calendar.current
  1437. if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
  1438. if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
  1439. calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
  1440. if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
  1441. calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
  1442. let formatter = DateFormatter()
  1443. formatter.dateFormat = "EEEE, MMM d"
  1444. return formatter.string(from: dayStart)
  1445. }
  1446. @objc private func meetingsPrevDayTapped() {
  1447. let calendar = Calendar.current
  1448. let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
  1449. Task { @MainActor in
  1450. self.setSelectedMeetingsDayStart(prev)
  1451. }
  1452. }
  1453. @objc private func meetingsNextDayTapped() {
  1454. let calendar = Calendar.current
  1455. let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
  1456. Task { @MainActor in
  1457. self.setSelectedMeetingsDayStart(next)
  1458. }
  1459. }
  1460. @objc private func meetingsTodayTapped() {
  1461. Task { @MainActor in
  1462. self.setSelectedMeetingsDayStart(Date())
  1463. }
  1464. }
  1465. private func loadScheduledMeetings() async {
  1466. if isLoadingMeetings { return }
  1467. isLoadingMeetings = true
  1468. defer {
  1469. isLoadingMeetings = false
  1470. Task { @MainActor in
  1471. self.setMeetingsLoadingUI(false)
  1472. }
  1473. }
  1474. await MainActor.run {
  1475. self.setMeetingsLoadingUI(true)
  1476. }
  1477. do {
  1478. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  1479. let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
  1480. await MainActor.run {
  1481. self.meetingsRateLimitedUntil = nil
  1482. self.applyMeetings(zoomMeetings)
  1483. }
  1484. } catch {
  1485. await MainActor.run {
  1486. self.applyMeetings([])
  1487. if case ZoomOAuthError.missingClientId = error {
  1488. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  1489. self.promptForZoomOAuthCredentialsIfNeeded()
  1490. } else if case ZoomOAuthError.missingClientSecret = error {
  1491. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  1492. self.promptForZoomOAuthCredentialsIfNeeded()
  1493. } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
  1494. self.zoomOAuth.clearSavedTokens()
  1495. self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  1496. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  1497. let seconds = max(retryAfterSeconds ?? 300, 30)
  1498. self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
  1499. let minutes = Int(ceil(Double(seconds) / 60.0))
  1500. self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  1501. } else {
  1502. self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  1503. }
  1504. }
  1505. }
  1506. }
  1507. @MainActor
  1508. private func presentZoomOAuthCredentialPrompt() -> Bool {
  1509. let alert = NSAlert()
  1510. alert.alertStyle = .informational
  1511. alert.messageText = "Configure Zoom OAuth"
  1512. 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. Enable meeting:read and meeting:write scopes on the app so listing and scheduling work."
  1513. let wrapper = NSStackView()
  1514. wrapper.orientation = .vertical
  1515. wrapper.spacing = 8
  1516. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1517. let clientIdField = NSTextField()
  1518. clientIdField.placeholderString = "Zoom Client ID"
  1519. clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
  1520. let clientSecretField = NSSecureTextField()
  1521. clientSecretField.placeholderString = "Zoom Client Secret"
  1522. clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
  1523. [clientIdField, clientSecretField].forEach { field in
  1524. field.translatesAutoresizingMaskIntoConstraints = false
  1525. field.widthAnchor.constraint(equalToConstant: 420).isActive = true
  1526. wrapper.addArrangedSubview(field)
  1527. }
  1528. alert.accessoryView = wrapper
  1529. alert.addButton(withTitle: "Save")
  1530. alert.addButton(withTitle: "Cancel")
  1531. let result = alert.runModal()
  1532. if result == .alertFirstButtonReturn {
  1533. var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  1534. var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  1535. if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
  1536. if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
  1537. if clientId.isEmpty == false, clientSecret.isEmpty == false {
  1538. zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
  1539. return true
  1540. }
  1541. meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
  1542. }
  1543. return false
  1544. }
  1545. @MainActor
  1546. private func promptForZoomOAuthCredentialsIfNeeded() {
  1547. guard isPromptingZoomCredentials == false else { return }
  1548. isPromptingZoomCredentials = true
  1549. defer { isPromptingZoomCredentials = false }
  1550. if presentZoomOAuthCredentialPrompt() {
  1551. meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
  1552. Task { await self.loadScheduledMeetings() }
  1553. }
  1554. }
  1555. private struct ZoomUserMeResponse: Decodable {
  1556. let first_name: String?
  1557. let last_name: String?
  1558. let display_name: String?
  1559. let email: String?
  1560. let pic_url: String?
  1561. var displayName: String? {
  1562. if let display_name, display_name.isEmpty == false { return display_name }
  1563. let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
  1564. return parts.isEmpty ? nil : parts.joined(separator: " ")
  1565. }
  1566. var pictureURL: String? {
  1567. guard let pic_url, pic_url.isEmpty == false else { return nil }
  1568. return pic_url
  1569. }
  1570. }
  1571. private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
  1572. let url = URL(string: "https://api.zoom.us/v2/users/me")!
  1573. var request = URLRequest(url: url)
  1574. request.httpMethod = "GET"
  1575. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  1576. let (data, response) = try await URLSession.shared.data(for: request)
  1577. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  1578. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
  1579. }
  1580. return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
  1581. }
  1582. private struct ZoomCreateMeetingBody: Encodable {
  1583. let topic: String
  1584. let type: Int
  1585. let start_time: String
  1586. let duration: Int
  1587. let timezone: String
  1588. }
  1589. private struct ZoomCreateMeetingAPIResult: Decodable {
  1590. let join_url: String?
  1591. }
  1592. /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
  1593. private func createZoomMeeting(accessToken: String, topic: String, startTimeWithOffset: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
  1594. let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
  1595. var request = URLRequest(url: url)
  1596. request.httpMethod = "POST"
  1597. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  1598. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  1599. let body = ZoomCreateMeetingBody(
  1600. topic: topic,
  1601. type: 2,
  1602. start_time: startTimeWithOffset,
  1603. duration: durationMinutes,
  1604. timezone: timeZone.identifier
  1605. )
  1606. request.httpBody = try JSONEncoder().encode(body)
  1607. let (data, response) = try await URLSession.shared.data(for: request)
  1608. guard let http = response as? HTTPURLResponse else {
  1609. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
  1610. }
  1611. if http.statusCode == 429 {
  1612. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  1613. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  1614. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  1615. }
  1616. guard (200..<300).contains(http.statusCode) else {
  1617. let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
  1618. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  1619. throw ZoomOAuthError.missingRequiredScope(raw)
  1620. }
  1621. throw GoogleOAuthError.tokenExchangeFailed(raw)
  1622. }
  1623. return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
  1624. }
  1625. private func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
  1626. struct ZoomMeeting: Decodable {
  1627. let id: Int?
  1628. let topic: String?
  1629. let start_time: String?
  1630. let duration: Int?
  1631. let host_id: String?
  1632. let join_url: String?
  1633. }
  1634. struct ZoomMeetingsPage: Decodable {
  1635. let meetings: [ZoomMeeting]
  1636. let next_page_token: String?
  1637. }
  1638. let iso = ISO8601DateFormatter()
  1639. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  1640. let fallbackISO = ISO8601DateFormatter()
  1641. fallbackISO.formatOptions = [.withInternetDateTime]
  1642. func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
  1643. raw.compactMap { meeting in
  1644. guard let startRaw = meeting.start_time else { return nil }
  1645. let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
  1646. guard let start else { return nil }
  1647. let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
  1648. let webURL: URL? = {
  1649. func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
  1650. var components = URLComponents()
  1651. components.scheme = "https"
  1652. components.host = "zoom.us"
  1653. components.path = "/wc/join/\(meetingId)"
  1654. if let pwd, pwd.isEmpty == false {
  1655. components.queryItems = [URLQueryItem(name: "pwd", value: pwd)]
  1656. }
  1657. return components.url
  1658. }
  1659. if let join = meeting.join_url, let url = URL(string: join), url.scheme != nil {
  1660. // Prefer the Zoom Web Client join URL so a click joins in the browser.
  1661. // join_url is often `https://zoom.us/j/<id>?pwd=...`
  1662. if url.path.contains("/wc/join/") {
  1663. return url
  1664. }
  1665. if let id = meeting.id {
  1666. let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
  1667. .queryItems?
  1668. .first(where: { $0.name == "pwd" })?
  1669. .value
  1670. return wcJoinURL(meetingId: id, pwd: pwd)
  1671. }
  1672. return url
  1673. }
  1674. if let id = meeting.id {
  1675. return wcJoinURL(meetingId: id, pwd: nil)
  1676. }
  1677. return nil
  1678. }()
  1679. return ScheduledMeeting(
  1680. title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
  1681. start: start,
  1682. end: end,
  1683. host: meeting.host_id ?? "Zoom Host",
  1684. source: "Zoom",
  1685. webURL: webURL
  1686. )
  1687. }
  1688. }
  1689. var allMeetings: [ZoomMeeting] = []
  1690. var nextPageToken: String?
  1691. repeat {
  1692. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  1693. var items: [URLQueryItem] = [
  1694. URLQueryItem(name: "type", value: "scheduled"),
  1695. URLQueryItem(name: "page_size", value: "30")
  1696. ]
  1697. if let nextPageToken, nextPageToken.isEmpty == false {
  1698. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  1699. }
  1700. components.queryItems = items
  1701. var request = URLRequest(url: components.url!)
  1702. request.httpMethod = "GET"
  1703. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  1704. let (data, response) = try await URLSession.shared.data(for: request)
  1705. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  1706. let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
  1707. if (response as? HTTPURLResponse)?.statusCode == 429 {
  1708. let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
  1709. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  1710. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  1711. }
  1712. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  1713. throw ZoomOAuthError.missingRequiredScope(raw)
  1714. }
  1715. throw GoogleOAuthError.tokenExchangeFailed(raw)
  1716. }
  1717. let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
  1718. allMeetings.append(contentsOf: decoded.meetings)
  1719. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  1720. nextPageToken = (token?.isEmpty == false) ? token : nil
  1721. } while nextPageToken != nil
  1722. return mapMeetings(allMeetings)
  1723. }
  1724. // MARK: - Login UI
  1725. private func makeLoginView() -> NSView {
  1726. let root = NSView()
  1727. let sidebar = makeSidebar(items: ["Home", "Chat", "Phone", "Docs", "Whiteboards", "Clips", "More"], selected: "Home", style: .login)
  1728. let content = NSView()
  1729. root.addSubview(sidebar)
  1730. root.addSubview(content)
  1731. sidebar.translatesAutoresizingMaskIntoConstraints = false
  1732. content.translatesAutoresizingMaskIntoConstraints = false
  1733. NSLayoutConstraint.activate([
  1734. sidebar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  1735. sidebar.topAnchor.constraint(equalTo: root.topAnchor),
  1736. sidebar.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  1737. sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth),
  1738. content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
  1739. content.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  1740. content.topAnchor.constraint(equalTo: root.topAnchor),
  1741. content.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  1742. ])
  1743. let back = makeLabel("‹ Back", size: 32, color: accentBlue, weight: .regular, centered: false)
  1744. let logo = makeLabel("zoom\nWorkplace", size: 24, color: primaryText, weight: .bold, centered: true)
  1745. logo.maximumNumberOfLines = 2
  1746. let domain = makeLabel("us05web.zoom.us", size: 16, color: primaryText, weight: .semibold, centered: true)
  1747. let emailField = NSTextField()
  1748. emailField.placeholderString = "Email or phone number"
  1749. emailField.font = .systemFont(ofSize: 20, weight: .regular)
  1750. emailField.textColor = .white
  1751. emailField.wantsLayer = true
  1752. emailField.layer?.cornerRadius = 10
  1753. emailField.layer?.borderWidth = 1.5
  1754. emailField.layer?.borderColor = accentBlue.cgColor
  1755. emailField.layer?.backgroundColor = cardBackground.cgColor
  1756. emailField.focusRingType = .none
  1757. let nextButton = NSButton(title: "Next", target: self, action: #selector(zoomPrimarySignInTapped))
  1758. nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
  1759. nextButton.isBordered = false
  1760. nextButton.wantsLayer = true
  1761. nextButton.layer?.cornerRadius = 10
  1762. nextButton.layer?.backgroundColor = cardBackground.cgColor
  1763. nextButton.contentTintColor = primaryText
  1764. let divider = NSBox()
  1765. divider.boxType = .separator
  1766. let socialText = makeLabel("or sign in with", size: 14, color: secondaryText, weight: .regular, centered: true)
  1767. let sso = makeSocialButton(icon: "🔑", text: "SSO")
  1768. let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
  1769. let apple = makeSocialButton(icon: "", text: "Apple")
  1770. let facebook = makeSocialButton(icon: "f", text: "Facebook")
  1771. let zoomSocial = makeSocialButton(icon: "Z", text: "Zoom", action: #selector(zoomPrimarySignInTapped))
  1772. self.googleButton = google.button
  1773. self.nextSignInButton = nextButton
  1774. self.zoomSocialButton = zoomSocial.button
  1775. let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, zoomSocial.container])
  1776. social.orientation = .horizontal
  1777. social.spacing = 14
  1778. social.distribution = .fillEqually
  1779. let signup = makeLabel("Don't have an account? Sign up", size: 15, color: primaryText, weight: .regular, centered: true)
  1780. let footer = makeLabel("Help Terms Privacy", size: 14, color: accentBlue, weight: .regular, centered: true)
  1781. [back, logo, domain, emailField, nextButton, divider, socialText, social, signup, footer].forEach {
  1782. $0.translatesAutoresizingMaskIntoConstraints = false
  1783. content.addSubview($0)
  1784. }
  1785. NSLayoutConstraint.activate([
  1786. back.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
  1787. back.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 34),
  1788. logo.topAnchor.constraint(equalTo: content.topAnchor, constant: 118),
  1789. logo.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1790. domain.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: 12),
  1791. domain.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1792. emailField.topAnchor.constraint(equalTo: domain.bottomAnchor, constant: 30),
  1793. emailField.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1794. emailField.widthAnchor.constraint(equalToConstant: 520),
  1795. emailField.heightAnchor.constraint(equalToConstant: 52),
  1796. nextButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 20),
  1797. nextButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1798. nextButton.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  1799. nextButton.heightAnchor.constraint(equalToConstant: 52),
  1800. divider.topAnchor.constraint(equalTo: nextButton.bottomAnchor, constant: 28),
  1801. divider.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1802. divider.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  1803. socialText.centerYAnchor.constraint(equalTo: divider.centerYAnchor),
  1804. socialText.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1805. social.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 18),
  1806. social.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1807. social.widthAnchor.constraint(equalTo: emailField.widthAnchor),
  1808. signup.topAnchor.constraint(equalTo: social.bottomAnchor, constant: 14),
  1809. signup.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1810. footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
  1811. footer.centerXAnchor.constraint(equalTo: content.centerXAnchor)
  1812. ])
  1813. return root
  1814. }
  1815. private func makeSettingsView() -> NSView {
  1816. let panel = NSView()
  1817. panel.translatesAutoresizingMaskIntoConstraints = false
  1818. let scroll = NSScrollView()
  1819. scroll.translatesAutoresizingMaskIntoConstraints = false
  1820. scroll.drawsBackground = false
  1821. scroll.hasHorizontalScroller = false
  1822. scroll.hasVerticalScroller = true
  1823. scroll.autohidesScrollers = true
  1824. scroll.borderType = .noBorder
  1825. scroll.scrollerStyle = .overlay
  1826. scroll.automaticallyAdjustsContentInsets = false
  1827. let clip = TopAlignedClipView()
  1828. clip.drawsBackground = false
  1829. scroll.contentView = clip
  1830. panel.addSubview(scroll)
  1831. let content = NSView()
  1832. content.translatesAutoresizingMaskIntoConstraints = false
  1833. scroll.documentView = content
  1834. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  1835. card.translatesAutoresizingMaskIntoConstraints = false
  1836. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  1837. content.addSubview(card)
  1838. let stack = NSStackView()
  1839. stack.translatesAutoresizingMaskIntoConstraints = false
  1840. stack.orientation = .vertical
  1841. stack.spacing = 18
  1842. stack.alignment = .leading
  1843. card.addSubview(stack)
  1844. let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
  1845. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  1846. stack.addArrangedSubview(pageTitle)
  1847. stack.addArrangedSubview(pageSubtitle)
  1848. stack.setCustomSpacing(24, after: pageSubtitle)
  1849. let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1850. stack.addArrangedSubview(appearanceTitle)
  1851. let darkModeRow = makeSettingsDarkModeRow()
  1852. stack.addArrangedSubview(darkModeRow)
  1853. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1854. stack.setCustomSpacing(24, after: darkModeRow)
  1855. let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1856. stack.addArrangedSubview(accountTitle)
  1857. let googleAccountRow = makeSettingsGoogleAccountRow()
  1858. stack.addArrangedSubview(googleAccountRow)
  1859. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1860. stack.setCustomSpacing(24, after: googleAccountRow)
  1861. let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1862. stack.addArrangedSubview(appTitle)
  1863. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  1864. stack.addArrangedSubview(shareButton)
  1865. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1866. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  1867. stack.addArrangedSubview(upgradeButton)
  1868. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1869. settingsUpgradeButton = upgradeButton
  1870. let restoreButton = makeSettingsActionButton(icon: "↺", title: "Restore Purchases", action: .restorePurchases)
  1871. stack.addArrangedSubview(restoreButton)
  1872. restoreButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1873. settingsRestoreButton = restoreButton
  1874. stack.setCustomSpacing(24, after: restoreButton)
  1875. let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  1876. stack.addArrangedSubview(legalTitle)
  1877. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  1878. stack.addArrangedSubview(privacyButton)
  1879. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1880. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  1881. stack.addArrangedSubview(supportButton)
  1882. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1883. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  1884. stack.addArrangedSubview(termsButton)
  1885. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1886. NSLayoutConstraint.activate([
  1887. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  1888. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  1889. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  1890. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  1891. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  1892. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  1893. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  1894. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  1895. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  1896. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  1897. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 36),
  1898. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  1899. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  1900. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  1901. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  1902. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  1903. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  1904. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  1905. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  1906. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  1907. ])
  1908. updatePremiumButtons()
  1909. return panel
  1910. }
  1911. private enum SettingsAction: Int {
  1912. case shareApp = 1
  1913. case upgrade = 2
  1914. case restorePurchases = 3
  1915. case privacyPolicy = 4
  1916. case support = 5
  1917. case termsOfServices = 6
  1918. }
  1919. private func makeSettingsDarkModeRow() -> NSView {
  1920. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1921. row.translatesAutoresizingMaskIntoConstraints = false
  1922. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  1923. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1924. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  1925. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  1926. let toggle = NSSwitch()
  1927. toggle.translatesAutoresizingMaskIntoConstraints = false
  1928. toggle.state = darkModeEnabled ? .on : .off
  1929. toggle.target = self
  1930. toggle.action = #selector(settingsDarkModeToggled(_:))
  1931. settingsDarkModeSwitch = toggle
  1932. row.addSubview(icon)
  1933. row.addSubview(title)
  1934. row.addSubview(toggle)
  1935. NSLayoutConstraint.activate([
  1936. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  1937. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  1938. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  1939. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  1940. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  1941. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  1942. ])
  1943. return row
  1944. }
  1945. private func makeSettingsGoogleAccountRow() -> NSView {
  1946. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1947. row.translatesAutoresizingMaskIntoConstraints = false
  1948. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1949. let signedIn = isUserLoggedIn()
  1950. let titleText = signedIn ? "Google account connected" : "Google account not connected"
  1951. let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
  1952. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  1953. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  1954. subtitle.maximumNumberOfLines = 2
  1955. subtitle.lineBreakMode = .byTruncatingTail
  1956. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  1957. actionButton.translatesAutoresizingMaskIntoConstraints = false
  1958. actionButton.bezelStyle = .rounded
  1959. actionButton.controlSize = .regular
  1960. settingsGoogleActionButton = actionButton
  1961. row.addSubview(title)
  1962. row.addSubview(subtitle)
  1963. row.addSubview(actionButton)
  1964. NSLayoutConstraint.activate([
  1965. row.heightAnchor.constraint(equalToConstant: 78),
  1966. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  1967. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  1968. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1969. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  1970. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  1971. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  1972. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  1973. ])
  1974. return row
  1975. }
  1976. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  1977. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  1978. button.translatesAutoresizingMaskIntoConstraints = false
  1979. button.isBordered = false
  1980. button.wantsLayer = true
  1981. button.layer?.cornerRadius = 10
  1982. button.normalColor = palette.inputBackground
  1983. button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
  1984. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1985. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  1986. button.tag = action.rawValue
  1987. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
  1988. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  1989. button.addSubview(iconLabel)
  1990. button.addSubview(titleLabel)
  1991. NSLayoutConstraint.activate([
  1992. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  1993. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  1994. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  1995. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  1996. ])
  1997. return button
  1998. }
  1999. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  2000. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  2001. switch action {
  2002. case .shareApp:
  2003. showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
  2004. case .upgrade:
  2005. settingsUpgradePremiumTapped()
  2006. case .restorePurchases:
  2007. settingsRestorePurchasesTapped()
  2008. case .privacyPolicy:
  2009. showSimpleAlert(title: "Privacy Policy", message: "Add your Privacy Policy URL in the app and open it here.")
  2010. case .support:
  2011. showSimpleAlert(title: "Support", message: "Add your Support URL/email in the app and open it here.")
  2012. case .termsOfServices:
  2013. showSimpleAlert(title: "Terms", message: "Add your Terms URL in the app and open it here.")
  2014. }
  2015. }
  2016. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  2017. if isUserLoggedIn() {
  2018. logoutTapped()
  2019. return
  2020. }
  2021. showSimpleAlert(title: "Sign in", message: "Please sign in from the login screen to connect your account.")
  2022. }
  2023. private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  2024. let v = NSView()
  2025. v.wantsLayer = true
  2026. v.layer?.backgroundColor = color.cgColor
  2027. v.layer?.cornerRadius = cornerRadius
  2028. return v
  2029. }
  2030. private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  2031. view.wantsLayer = true
  2032. view.layer?.borderColor = borderColor.cgColor
  2033. view.layer?.borderWidth = borderWidth
  2034. if shadow {
  2035. view.layer?.shadowColor = NSColor.black.cgColor
  2036. view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
  2037. view.layer?.shadowRadius = 18
  2038. view.layer?.shadowOffset = NSSize(width: 0, height: -2)
  2039. } else {
  2040. view.layer?.shadowOpacity = 0
  2041. }
  2042. }
  2043. private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  2044. let label = NSTextField(labelWithString: text)
  2045. label.translatesAutoresizingMaskIntoConstraints = false
  2046. label.font = font
  2047. label.textColor = color
  2048. label.backgroundColor = .clear
  2049. label.isBezeled = false
  2050. label.isEditable = false
  2051. label.isSelectable = false
  2052. return label
  2053. }
  2054. private func startStoreKit() {
  2055. storeKitStartupTask?.cancel()
  2056. storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
  2057. DispatchQueue.main.async {
  2058. guard let self else { return }
  2059. let newIsPremium = self.storeKitCoordinator.hasPremiumAccess
  2060. let wasPremium = self.lastKnownPremiumAccess
  2061. self.lastKnownPremiumAccess = newIsPremium
  2062. self.updatePremiumButtons()
  2063. if wasPremium && !newIsPremium {
  2064. self.showPaywall()
  2065. }
  2066. }
  2067. }
  2068. storeKitStartupTask = Task { [weak self] in
  2069. await self?.storeKitCoordinator.start()
  2070. await MainActor.run {
  2071. self?.updatePremiumButtons()
  2072. }
  2073. }
  2074. }
  2075. @MainActor
  2076. private func updatePremiumButtons() {
  2077. let isPremium = storeKitCoordinator.hasPremiumAccess
  2078. settingsUpgradeButton?.title = isPremium ? "Premium Active" : "Upgrade"
  2079. settingsUpgradeButton?.isEnabled = isPremium == false
  2080. settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
  2081. settingsRestoreButton?.isEnabled = true
  2082. updateTopBarPremiumButton()
  2083. }
  2084. @MainActor
  2085. private func updateTopBarPremiumButton() {
  2086. guard let button = topBarPremiumButton else { return }
  2087. let isPremium = storeKitCoordinator.hasPremiumAccess
  2088. if isPremium {
  2089. let title = "Manage Subscription"
  2090. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  2091. button.attributedTitle = NSAttributedString(string: title, attributes: [
  2092. .foregroundColor: NSColor.white,
  2093. .font: font
  2094. ])
  2095. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  2096. if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
  2097. let img = base.withSymbolConfiguration(symbolConfig) {
  2098. img.isTemplate = true
  2099. button.image = img
  2100. button.imagePosition = .imageLeading
  2101. button.imageHugsTitle = true
  2102. }
  2103. button.contentTintColor = NSColor.white
  2104. button.toolTip = title
  2105. button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
  2106. } else {
  2107. let title = "Upgrade to Pro"
  2108. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  2109. button.attributedTitle = NSAttributedString(string: title, attributes: [
  2110. .foregroundColor: NSColor.white,
  2111. .font: font
  2112. ])
  2113. button.image = nil
  2114. button.toolTip = title
  2115. button.layer?.backgroundColor = accentBlue.cgColor
  2116. button.contentTintColor = .white
  2117. }
  2118. }
  2119. @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
  2120. setDarkMode(sender.state == .on)
  2121. }
  2122. private func setDarkMode(_ enabled: Bool) {
  2123. darkModeEnabled = enabled
  2124. palette = Palette(isDarkMode: enabled)
  2125. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  2126. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  2127. if let window = view.window {
  2128. applyWindowBackgroundForCurrentTheme(window)
  2129. }
  2130. if isUserLoggedIn() {
  2131. let keepSelected = selectedHomeSidebarItem
  2132. selectedHomeSidebarItem = keepSelected
  2133. showHomeView(profile: nil)
  2134. } else {
  2135. showLoginView()
  2136. }
  2137. }
  2138. private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
  2139. // Keep the window's backing in sync with the app's current theme, otherwise rounded
  2140. // shell corners can briefly show the previous mode's lighter/darker color.
  2141. window.backgroundColor = appBackground
  2142. window.isOpaque = true
  2143. if let contentView = window.contentView {
  2144. contentView.wantsLayer = true
  2145. contentView.layer?.backgroundColor = appBackground.cgColor
  2146. }
  2147. }
  2148. private func systemPrefersDarkMode() -> Bool {
  2149. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  2150. let style = global?["AppleInterfaceStyle"] as? String
  2151. return style?.lowercased() == "dark"
  2152. }
  2153. @objc private func settingsUpgradePremiumTapped() {
  2154. guard storeKitCoordinator.hasPremiumAccess == false else { return }
  2155. settingsUpgradeButton?.isEnabled = false
  2156. settingsUpgradeButton?.alphaValue = 0.6
  2157. Task { [weak self] in
  2158. guard let self else { return }
  2159. let result = await self.storeKitCoordinator.purchase(plan: .lifetime)
  2160. await MainActor.run {
  2161. self.updatePremiumButtons()
  2162. switch result {
  2163. case .success:
  2164. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  2165. case .pending:
  2166. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  2167. case .cancelled:
  2168. break
  2169. case .failed(let message):
  2170. self.showSimpleAlert(title: "Purchase Failed", message: message)
  2171. }
  2172. }
  2173. }
  2174. }
  2175. @objc private func settingsRestorePurchasesTapped() {
  2176. Task { [weak self] in
  2177. guard let self else { return }
  2178. let message = await self.storeKitCoordinator.restorePurchases()
  2179. await MainActor.run {
  2180. self.updatePremiumButtons()
  2181. self.showSimpleAlert(title: "Restore Purchases", message: message)
  2182. }
  2183. }
  2184. }
  2185. // MARK: - Paywall (ported from meetings_app)
  2186. private func showPaywall() {
  2187. if let existing = paywallWindow {
  2188. refreshPaywallStoreUI()
  2189. existing.makeKeyAndOrderFront(nil)
  2190. NSApp.activate(ignoringOtherApps: true)
  2191. return
  2192. }
  2193. let content = makePaywallContent()
  2194. let controller = NSViewController()
  2195. controller.view = content
  2196. let panel = NSPanel(
  2197. contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
  2198. styleMask: [.titled, .closable, .fullSizeContentView],
  2199. backing: .buffered,
  2200. defer: false
  2201. )
  2202. panel.title = "Get Premium"
  2203. panel.titleVisibility = .hidden
  2204. panel.titlebarAppearsTransparent = true
  2205. panel.hidesOnDeactivate = true
  2206. panel.isReleasedWhenClosed = false
  2207. panel.standardWindowButton(.closeButton)?.isHidden = true
  2208. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  2209. panel.standardWindowButton(.zoomButton)?.isHidden = true
  2210. panel.center()
  2211. panel.contentViewController = controller
  2212. panel.makeKeyAndOrderFront(nil)
  2213. NSApp.activate(ignoringOtherApps: true)
  2214. paywallWindow = panel
  2215. Task { [weak self] in
  2216. guard let self else { return }
  2217. await self.storeKitCoordinator.refreshProducts()
  2218. await MainActor.run {
  2219. self.refreshPaywallStoreUI()
  2220. }
  2221. }
  2222. }
  2223. @objc private func closePaywallClicked(_ sender: Any?) {
  2224. paywallWindow?.performClose(nil)
  2225. paywallWindow = nil
  2226. }
  2227. private func makePaywallContent() -> NSView {
  2228. paywallPlanViews.removeAll()
  2229. premiumPlanByView.removeAll()
  2230. paywallOfferLabel = nil
  2231. paywallContinueLabel = nil
  2232. paywallContinueButton = nil
  2233. paywallContinueEnabled = true
  2234. let panel = NSView()
  2235. panel.translatesAutoresizingMaskIntoConstraints = false
  2236. panel.wantsLayer = true
  2237. panel.layer?.backgroundColor = appBackground.cgColor
  2238. let contentStack = NSStackView()
  2239. contentStack.translatesAutoresizingMaskIntoConstraints = false
  2240. contentStack.orientation = .vertical
  2241. contentStack.spacing = 12
  2242. contentStack.alignment = .leading
  2243. panel.addSubview(contentStack)
  2244. let topRow = NSStackView()
  2245. topRow.translatesAutoresizingMaskIntoConstraints = false
  2246. topRow.orientation = .horizontal
  2247. topRow.alignment = .centerY
  2248. topRow.distribution = .fill
  2249. topRow.spacing = 10
  2250. topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: primaryText))
  2251. let topSpacer = NSView()
  2252. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  2253. topRow.addArrangedSubview(topSpacer)
  2254. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  2255. closeButton.translatesAutoresizingMaskIntoConstraints = false
  2256. closeButton.isBordered = false
  2257. closeButton.bezelStyle = .regularSquare
  2258. closeButton.wantsLayer = true
  2259. closeButton.layer?.cornerRadius = 14
  2260. closeButton.normalColor = palette.inputBackground
  2261. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  2262. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  2263. closeButton.layer?.borderWidth = 1
  2264. closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
  2265. closeButton.contentTintColor = secondaryText
  2266. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  2267. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  2268. topRow.addArrangedSubview(closeButton)
  2269. topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  2270. contentStack.addArrangedSubview(topRow)
  2271. contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: secondaryText))
  2272. let benefits = paywallBenefitsSection()
  2273. contentStack.addArrangedSubview(benefits)
  2274. contentStack.setCustomSpacing(18, after: benefits)
  2275. let weeklyCard = paywallPlanCard(
  2276. title: "Weekly",
  2277. price: "PKR 1,100.00",
  2278. badge: "Basic Deal",
  2279. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  2280. subtitle: nil,
  2281. plan: .weekly,
  2282. strikePrice: nil
  2283. )
  2284. contentStack.addArrangedSubview(weeklyCard)
  2285. let monthlyCard = paywallPlanCard(
  2286. title: "Monthly",
  2287. price: "PKR 2,500.00",
  2288. badge: "Free Trial",
  2289. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  2290. subtitle: "625.00/week",
  2291. plan: .monthly,
  2292. strikePrice: nil
  2293. )
  2294. contentStack.addArrangedSubview(monthlyCard)
  2295. let yearlyCard = paywallPlanCard(
  2296. title: "Yearly",
  2297. price: "PKR 9,900.00",
  2298. badge: "Best Deal",
  2299. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  2300. subtitle: "190.38/week",
  2301. plan: .yearly,
  2302. strikePrice: nil
  2303. )
  2304. contentStack.addArrangedSubview(yearlyCard)
  2305. let lifetimeCard = paywallPlanCard(
  2306. title: "Lifetime",
  2307. price: "PKR 14,900.00",
  2308. badge: "Save 50%",
  2309. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  2310. subtitle: nil,
  2311. plan: .lifetime,
  2312. strikePrice: "PKR 29,800.00"
  2313. )
  2314. contentStack.addArrangedSubview(lifetimeCard)
  2315. updatePaywallPlanSelection()
  2316. contentStack.setCustomSpacing(20, after: lifetimeCard)
  2317. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  2318. offer.alignment = .center
  2319. paywallOfferLabel = offer
  2320. let offerWrap = NSView()
  2321. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  2322. offerWrap.addSubview(offer)
  2323. NSLayoutConstraint.activate([
  2324. offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  2325. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  2326. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  2327. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  2328. ])
  2329. contentStack.addArrangedSubview(offerWrap)
  2330. contentStack.setCustomSpacing(18, after: offerWrap)
  2331. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  2332. continueButton.translatesAutoresizingMaskIntoConstraints = false
  2333. continueButton.isBordered = false
  2334. continueButton.bezelStyle = .regularSquare
  2335. continueButton.wantsLayer = true
  2336. continueButton.layer?.cornerRadius = 14
  2337. continueButton.normalColor = accentBlue
  2338. continueButton.hoverColor = accentBlue.withAlphaComponent(0.92)
  2339. continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
  2340. continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  2341. styleSurface(continueButton, borderColor: accentBlue.withAlphaComponent(0.85), borderWidth: 1, shadow: true)
  2342. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  2343. continueButton.addSubview(continueLabel)
  2344. NSLayoutConstraint.activate([
  2345. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  2346. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  2347. ])
  2348. paywallContinueButton = continueButton
  2349. paywallContinueLabel = continueLabel
  2350. contentStack.addArrangedSubview(continueButton)
  2351. contentStack.setCustomSpacing(16, after: continueButton)
  2352. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
  2353. secure.alignment = .center
  2354. let secureWrap = NSView()
  2355. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  2356. secureWrap.addSubview(secure)
  2357. NSLayoutConstraint.activate([
  2358. secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  2359. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  2360. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  2361. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  2362. ])
  2363. contentStack.addArrangedSubview(secureWrap)
  2364. NSLayoutConstraint.activate([
  2365. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  2366. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  2367. contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
  2368. contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
  2369. ])
  2370. refreshPaywallStoreUI()
  2371. return panel
  2372. }
  2373. private func paywallBenefitsSection() -> NSView {
  2374. let stack = NSStackView()
  2375. stack.translatesAutoresizingMaskIntoConstraints = false
  2376. stack.orientation = .vertical
  2377. stack.spacing = 8
  2378. stack.alignment = .leading
  2379. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  2380. let rowOne = NSStackView()
  2381. rowOne.translatesAutoresizingMaskIntoConstraints = false
  2382. rowOne.orientation = .horizontal
  2383. rowOne.spacing = 10
  2384. rowOne.distribution = .fillEqually
  2385. rowOne.alignment = .centerY
  2386. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  2387. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  2388. let rowTwo = NSStackView()
  2389. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  2390. rowTwo.orientation = .horizontal
  2391. rowTwo.spacing = 10
  2392. rowTwo.distribution = .fillEqually
  2393. rowTwo.alignment = .centerY
  2394. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  2395. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  2396. stack.addArrangedSubview(rowOne)
  2397. stack.addArrangedSubview(rowTwo)
  2398. return stack
  2399. }
  2400. private func paywallBenefitItem(icon: String, text: String) -> NSView {
  2401. let card = NSView()
  2402. card.translatesAutoresizingMaskIntoConstraints = false
  2403. card.wantsLayer = true
  2404. card.layer?.cornerRadius = 10
  2405. card.layer?.backgroundColor = palette.inputBackground.cgColor
  2406. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  2407. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2408. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  2409. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  2410. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  2411. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  2412. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2413. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: accentBlue)
  2414. iconWrap.addSubview(iconLabel)
  2415. NSLayoutConstraint.activate([
  2416. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  2417. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  2418. ])
  2419. let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: primaryText)
  2420. card.addSubview(iconWrap)
  2421. card.addSubview(title)
  2422. NSLayoutConstraint.activate([
  2423. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  2424. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  2425. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  2426. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  2427. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  2428. ])
  2429. return card
  2430. }
  2431. private func paywallPlanCard(
  2432. title: String,
  2433. price: String,
  2434. badge: String,
  2435. badgeColor: NSColor,
  2436. subtitle: String?,
  2437. plan: PremiumPlan,
  2438. strikePrice: String?
  2439. ) -> NSView {
  2440. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  2441. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2442. wrapper.isBordered = false
  2443. wrapper.bezelStyle = .regularSquare
  2444. wrapper.wantsLayer = true
  2445. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  2446. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  2447. wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
  2448. wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
  2449. let card = NSView()
  2450. card.translatesAutoresizingMaskIntoConstraints = false
  2451. card.wantsLayer = true
  2452. card.layer?.cornerRadius = 16
  2453. card.layer?.backgroundColor = palette.sectionCard.cgColor
  2454. card.heightAnchor.constraint(equalToConstant: 82).isActive = true
  2455. wrapper.addSubview(card)
  2456. NSLayoutConstraint.activate([
  2457. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  2458. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  2459. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
  2460. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  2461. ])
  2462. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2463. let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
  2464. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  2465. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  2466. badgeWrap.wantsLayer = true
  2467. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  2468. badgeWrap.layer?.borderWidth = 1
  2469. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  2470. badgeWrap.layer?.shadowOpacity = 0.20
  2471. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  2472. badgeWrap.layer?.shadowRadius = 3
  2473. badgeWrap.addSubview(badgeLabel)
  2474. NSLayoutConstraint.activate([
  2475. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  2476. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  2477. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  2478. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  2479. ])
  2480. wrapper.addSubview(badgeWrap)
  2481. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: accentBlue)
  2482. card.addSubview(titleLabel)
  2483. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: primaryText)
  2484. card.addSubview(priceLabel)
  2485. NSLayoutConstraint.activate([
  2486. badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  2487. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  2488. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  2489. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
  2490. priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  2491. priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
  2492. ])
  2493. if let subtitle {
  2494. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: secondaryText)
  2495. card.addSubview(sub)
  2496. NSLayoutConstraint.activate([
  2497. sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  2498. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
  2499. ])
  2500. }
  2501. if let strikePrice {
  2502. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  2503. card.addSubview(strike)
  2504. NSLayoutConstraint.activate([
  2505. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  2506. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  2507. ])
  2508. }
  2509. paywallPlanViews[plan] = card
  2510. premiumPlanByView[ObjectIdentifier(card)] = plan
  2511. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  2512. guard let self, let card else { return }
  2513. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  2514. }
  2515. wrapper.onHoverChanged?(false)
  2516. return wrapper
  2517. }
  2518. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  2519. guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
  2520. selectedPremiumPlan = plan
  2521. updatePaywallPlanSelection()
  2522. }
  2523. private func updatePaywallPlanSelection() {
  2524. for (plan, view) in paywallPlanViews {
  2525. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  2526. }
  2527. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  2528. }
  2529. private func paywallOfferText(for plan: PremiumPlan) -> String {
  2530. if storeKitCoordinator.hasPremiumAccess {
  2531. return "Premium is active on this Apple ID."
  2532. }
  2533. if let product = storeKitCoordinator.productsByID[plan.rawValue] {
  2534. return "\(product.displayPrice) purchase"
  2535. }
  2536. switch plan {
  2537. case .weekly: return "PKR 1,100.00/week"
  2538. case .monthly: return "PKR 2,500.00/month"
  2539. case .yearly: return "PKR 9,900.00/year"
  2540. case .lifetime: return "PKR 14,900.00 one-time purchase"
  2541. }
  2542. }
  2543. private func refreshPaywallStoreUI() {
  2544. updatePaywallPlanSelection()
  2545. updatePaywallContinueState(isLoading: false)
  2546. }
  2547. @objc private func paywallContinueClicked(_ sender: Any?) {
  2548. startSelectedPlanPurchase()
  2549. }
  2550. private func startSelectedPlanPurchase() {
  2551. guard paywallContinueEnabled else { return }
  2552. paywallPurchaseTask?.cancel()
  2553. updatePaywallContinueState(isLoading: true)
  2554. let selectedPlan = selectedPremiumPlan
  2555. paywallPurchaseTask = Task { [weak self] in
  2556. guard let self else { return }
  2557. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  2558. await MainActor.run {
  2559. self.updatePaywallContinueState(isLoading: false)
  2560. self.refreshPaywallStoreUI()
  2561. self.updatePremiumButtons()
  2562. switch result {
  2563. case .success:
  2564. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  2565. self.paywallWindow?.performClose(nil)
  2566. self.paywallWindow = nil
  2567. case .pending:
  2568. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  2569. case .cancelled:
  2570. break
  2571. case .failed(let message):
  2572. self.showSimpleAlert(title: "Purchase Failed", message: message)
  2573. }
  2574. }
  2575. }
  2576. }
  2577. private func updatePaywallContinueState(isLoading: Bool) {
  2578. if isLoading {
  2579. paywallContinueEnabled = false
  2580. paywallContinueLabel?.stringValue = "Processing..."
  2581. paywallContinueButton?.alphaValue = 0.75
  2582. return
  2583. }
  2584. if storeKitCoordinator.hasPremiumAccess {
  2585. paywallContinueEnabled = false
  2586. paywallContinueLabel?.stringValue = "Premium Active"
  2587. paywallContinueButton?.alphaValue = 0.75
  2588. } else {
  2589. paywallContinueEnabled = true
  2590. paywallContinueLabel?.stringValue = "Continue"
  2591. paywallContinueButton?.alphaValue = 1.0
  2592. }
  2593. }
  2594. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  2595. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  2596. let idleBorder = palette.inputBorder
  2597. let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
  2598. let hoverIdleBackground =
  2599. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  2600. let selectedBackground = palette.isDarkMode
  2601. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  2602. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  2603. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  2604. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  2605. card.layer?.borderWidth = isSelected ? 2 : 1
  2606. card.layer?.shadowColor = NSColor.black.cgColor
  2607. card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  2608. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  2609. card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
  2610. }
  2611. // MARK: - Home UI
  2612. private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
  2613. let root = NSView()
  2614. let shell = NSView()
  2615. shell.wantsLayer = true
  2616. shell.layer?.backgroundColor = appShellBackground.cgColor
  2617. shell.layer?.cornerRadius = appShellCornerRadius
  2618. shell.layer?.borderWidth = 1
  2619. shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  2620. let chromeColumn = NSView()
  2621. chromeColumn.wantsLayer = true
  2622. chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  2623. chromeColumn.layer?.cornerRadius = appShellCornerRadius
  2624. chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
  2625. chromeColumn.layer?.masksToBounds = true
  2626. let chromeDivider = NSView()
  2627. chromeDivider.wantsLayer = true
  2628. chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
  2629. let chromeHeader = NSView()
  2630. chromeHeader.wantsLayer = true
  2631. chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  2632. homeSidebarRowViews = [:]
  2633. homeSidebarIconViews = [:]
  2634. homeSidebarLabels = [:]
  2635. let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler"], selected: selectedHomeSidebarItem, style: .home)
  2636. let content = NSView()
  2637. content.wantsLayer = true
  2638. content.layer?.backgroundColor = NSColor.clear.cgColor
  2639. root.addSubview(shell)
  2640. shell.addSubview(chromeColumn)
  2641. shell.addSubview(content)
  2642. chromeColumn.addSubview(chromeDivider)
  2643. chromeColumn.addSubview(chromeHeader)
  2644. chromeColumn.addSubview(sidebar)
  2645. shell.translatesAutoresizingMaskIntoConstraints = false
  2646. chromeColumn.translatesAutoresizingMaskIntoConstraints = false
  2647. chromeDivider.translatesAutoresizingMaskIntoConstraints = false
  2648. chromeHeader.translatesAutoresizingMaskIntoConstraints = false
  2649. sidebar.translatesAutoresizingMaskIntoConstraints = false
  2650. content.translatesAutoresizingMaskIntoConstraints = false
  2651. NSLayoutConstraint.activate([
  2652. shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  2653. shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  2654. shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
  2655. shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  2656. chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
  2657. chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
  2658. chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
  2659. chromeColumn.widthAnchor.constraint(equalToConstant: 82),
  2660. chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  2661. chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  2662. chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  2663. chromeDivider.widthAnchor.constraint(equalToConstant: 1),
  2664. chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  2665. chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  2666. chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  2667. chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
  2668. sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  2669. sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  2670. sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
  2671. sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  2672. content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  2673. content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
  2674. content.topAnchor.constraint(equalTo: shell.topAnchor),
  2675. content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
  2676. ])
  2677. let brandStack = NSStackView()
  2678. brandStack.orientation = .vertical
  2679. brandStack.spacing = 0
  2680. brandStack.alignment = .leading
  2681. let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
  2682. let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
  2683. brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
  2684. brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
  2685. [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
  2686. let topBar = NSView()
  2687. topBar.wantsLayer = true
  2688. topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  2689. let topBarDivider = NSView()
  2690. topBarDivider.wantsLayer = true
  2691. topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
  2692. let searchPill = NSView()
  2693. searchPill.wantsLayer = true
  2694. searchPill.layer?.backgroundColor = searchPillBackground.cgColor
  2695. searchPill.layer?.cornerRadius = 10
  2696. searchPill.layer?.borderWidth = 0
  2697. let searchIcon = NSImageView()
  2698. searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
  2699. searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
  2700. searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  2701. searchIcon.imageScaling = .scaleProportionallyUpOrDown
  2702. let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
  2703. searchHintLabel.isHidden = false
  2704. let searchField = SearchPillTextField()
  2705. searchField.isBordered = false
  2706. searchField.drawsBackground = false
  2707. searchField.backgroundColor = .clear
  2708. searchField.focusRingType = .none
  2709. searchField.font = .systemFont(ofSize: 13, weight: .regular)
  2710. searchField.textColor = primaryText
  2711. searchField.alignment = .left
  2712. searchField.placeholderString = nil
  2713. if let cell = searchField.cell as? NSTextFieldCell {
  2714. cell.isBezeled = false
  2715. cell.isBordered = false
  2716. cell.backgroundColor = .clear
  2717. }
  2718. let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
  2719. guard let searchField, let searchHintLabel else { return }
  2720. let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
  2721. searchHintLabel.isHidden = shouldShow == false
  2722. }
  2723. searchField.onFocusChange = { [weak self] focused in
  2724. self?.applySearchPillFocusBorder(focused: focused)
  2725. updateSearchHintVisibility()
  2726. }
  2727. updateSearchHintVisibility()
  2728. let searchRow = NSStackView()
  2729. searchRow.orientation = .horizontal
  2730. searchRow.spacing = 14
  2731. searchRow.alignment = .centerY
  2732. searchRow.addArrangedSubview(searchPill)
  2733. let rightTopBarCluster = NSStackView()
  2734. rightTopBarCluster.orientation = .horizontal
  2735. rightTopBarCluster.spacing = 10
  2736. rightTopBarCluster.alignment = .centerY
  2737. let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
  2738. topBarPremiumButton = upgradeToProButton
  2739. updateTopBarPremiumButton()
  2740. let profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
  2741. profileChip.isBordered = false
  2742. profileChip.wantsLayer = true
  2743. profileChip.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
  2744. profileChip.layer?.cornerRadius = 10
  2745. profileChip.contentTintColor = primaryText
  2746. profileChip.font = .systemFont(ofSize: 14, weight: .bold)
  2747. profileChip.toolTip = "Profile (click to logout)"
  2748. [upgradeToProButton, profileChip].forEach { rightTopBarCluster.addArrangedSubview($0) }
  2749. let welcome = makeLabel("Home", size: 15, color: secondaryText, weight: .medium, centered: false)
  2750. let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
  2751. let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
  2752. let actions = NSStackView(views: [
  2753. makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange),
  2754. makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
  2755. makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
  2756. ])
  2757. actions.orientation = .horizontal
  2758. actions.spacing = 12
  2759. actions.alignment = .centerY
  2760. actions.distribution = .fillEqually
  2761. let panel = NSView()
  2762. panel.wantsLayer = true
  2763. panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  2764. panel.layer?.cornerRadius = 16
  2765. panel.layer?.borderWidth = 1
  2766. panel.layer?.borderColor = palette.inputBorder.cgColor
  2767. let todaysDateFormatter = DateFormatter()
  2768. todaysDateFormatter.dateFormat = "EEEE, MMM d"
  2769. let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
  2770. let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
  2771. let meetingsDayNav = NSStackView()
  2772. meetingsDayNav.orientation = .horizontal
  2773. meetingsDayNav.spacing = 4
  2774. meetingsDayNav.alignment = .centerY
  2775. let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
  2776. let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
  2777. let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
  2778. [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
  2779. let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
  2780. let meetingsScrollView = NSScrollView()
  2781. meetingsScrollView.drawsBackground = false
  2782. meetingsScrollView.hasVerticalScroller = true
  2783. meetingsScrollView.hasHorizontalScroller = false
  2784. meetingsScrollView.autohidesScrollers = true
  2785. let meetingsDocument = FlippedView()
  2786. let meetingsStack = NSStackView()
  2787. meetingsStack.orientation = .vertical
  2788. meetingsStack.spacing = 14
  2789. meetingsStack.alignment = .leading
  2790. // Keep meeting cards pinned to the top of the scroll content.
  2791. meetingsStack.setContentHuggingPriority(.required, for: .vertical)
  2792. meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  2793. let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
  2794. refreshMeetingsButton.isBordered = false
  2795. refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
  2796. refreshMeetingsButton.contentTintColor = primaryText
  2797. let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  2798. if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
  2799. let image = base.withSymbolConfiguration(refreshSymbolConfig) {
  2800. image.isTemplate = true
  2801. refreshMeetingsButton.image = image
  2802. refreshMeetingsButton.imagePosition = .imageLeading
  2803. refreshMeetingsButton.imageHugsTitle = true
  2804. refreshMeetingsButton.imageScaling = .scaleNone
  2805. }
  2806. refreshMeetingsButton.wantsLayer = true
  2807. refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
  2808. ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
  2809. : NSColor.black.withAlphaComponent(0.06)
  2810. ).cgColor
  2811. refreshMeetingsButton.layer?.cornerRadius = 11
  2812. refreshMeetingsButton.layer?.borderWidth = 1
  2813. refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
  2814. self.refreshMeetingsButton = refreshMeetingsButton
  2815. let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
  2816. placeholder.isHidden = true
  2817. let settingsView = makeSettingsView()
  2818. settingsView.isHidden = selectedHomeSidebarItem != "Settings"
  2819. let contentColumn = NSView()
  2820. contentColumn.translatesAutoresizingMaskIntoConstraints = false
  2821. content.addSubview(topBar)
  2822. content.addSubview(topBarDivider)
  2823. content.addSubview(contentColumn)
  2824. [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
  2825. $0.translatesAutoresizingMaskIntoConstraints = false
  2826. }
  2827. [brandStack].forEach {
  2828. shell.addSubview($0)
  2829. }
  2830. [searchRow, rightTopBarCluster].forEach {
  2831. topBar.addSubview($0)
  2832. }
  2833. [searchIcon, searchField, searchHintLabel].forEach {
  2834. searchPill.addSubview($0)
  2835. }
  2836. [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView].forEach {
  2837. $0.translatesAutoresizingMaskIntoConstraints = false
  2838. contentColumn.addSubview($0)
  2839. }
  2840. topBar.translatesAutoresizingMaskIntoConstraints = false
  2841. topBarDivider.translatesAutoresizingMaskIntoConstraints = false
  2842. meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
  2843. meetingsStack.translatesAutoresizingMaskIntoConstraints = false
  2844. meetingsScrollView.documentView = meetingsDocument
  2845. meetingsDocument.addSubview(meetingsStack)
  2846. let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
  2847. searchRowCenterX.priority = .defaultHigh
  2848. NSLayoutConstraint.activate([
  2849. brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset),
  2850. brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
  2851. brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
  2852. topBar.topAnchor.constraint(equalTo: content.topAnchor),
  2853. topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  2854. topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  2855. topBar.heightAnchor.constraint(equalToConstant: 56),
  2856. topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
  2857. topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  2858. topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  2859. topBarDivider.heightAnchor.constraint(equalToConstant: 1),
  2860. contentColumn.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
  2861. contentColumn.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
  2862. contentColumn.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
  2863. contentColumn.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
  2864. searchRowCenterX,
  2865. searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  2866. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
  2867. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
  2868. searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
  2869. rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  2870. rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  2871. searchPill.heightAnchor.constraint(equalToConstant: 32),
  2872. searchPill.widthAnchor.constraint(equalToConstant: 320),
  2873. searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
  2874. searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  2875. searchIcon.widthAnchor.constraint(equalToConstant: 16),
  2876. searchIcon.heightAnchor.constraint(equalToConstant: 16),
  2877. searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
  2878. searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  2879. searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
  2880. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  2881. searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
  2882. searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  2883. profileChip.widthAnchor.constraint(equalToConstant: 34),
  2884. profileChip.heightAnchor.constraint(equalToConstant: 34),
  2885. welcome.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 18),
  2886. welcome.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2887. timeTitle.topAnchor.constraint(equalTo: welcome.bottomAnchor, constant: 12),
  2888. timeTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2889. dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
  2890. dateTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2891. actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
  2892. actions.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2893. actions.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 12),
  2894. actions.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -12),
  2895. actions.heightAnchor.constraint(equalToConstant: 100),
  2896. panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
  2897. panel.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2898. panel.widthAnchor.constraint(equalToConstant: 610),
  2899. panel.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 6),
  2900. panel.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -6),
  2901. panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  2902. panel.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor, constant: -14),
  2903. panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
  2904. panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
  2905. meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  2906. meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  2907. meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
  2908. meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
  2909. noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  2910. noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  2911. noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
  2912. meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
  2913. meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  2914. meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  2915. meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
  2916. meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
  2917. meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
  2918. meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
  2919. meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
  2920. meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
  2921. refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  2922. refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  2923. refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
  2924. refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
  2925. placeholder.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
  2926. placeholder.centerYAnchor.constraint(equalTo: contentColumn.centerYAnchor),
  2927. settingsView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
  2928. settingsView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
  2929. settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
  2930. settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor)
  2931. ])
  2932. timeLabel = timeTitle
  2933. dateLabel = dateTitle
  2934. homeWelcomeLabel = welcome
  2935. homeTimeLabelView = timeTitle
  2936. homeDateLabelView = dateTitle
  2937. homeActionsRow = actions
  2938. homeMeetingsPanel = panel
  2939. homePlaceholderLabel = placeholder
  2940. homeSettingsView = settingsView
  2941. meetingsDayHeaderLabel = panelHeader
  2942. meetingsListStack = meetingsStack
  2943. meetingsStatusLabel = meetingsStatus
  2944. emptyMeetingLabel = noMeeting
  2945. meetingsPrevDayButton = prevDayButton
  2946. meetingsTodayButton = todayButton
  2947. meetingsNextDayButton = nextDayButton
  2948. observeMeetingsScrollEdges(in: meetingsScrollView)
  2949. updateClock()
  2950. updateMeetingsDayUI()
  2951. applyFilteredMeetings()
  2952. homeSearchField = searchField
  2953. homeSearchPill = searchPill
  2954. searchTextObserver = NotificationCenter.default.addObserver(
  2955. forName: NSControl.textDidChangeNotification,
  2956. object: searchField,
  2957. queue: .main
  2958. ) { [weak self] _ in
  2959. self?.applyFilteredMeetings()
  2960. updateSearchHintVisibility()
  2961. }
  2962. return root
  2963. }
  2964. deinit {
  2965. removeSearchFieldObserver()
  2966. removeSearchShortcutMonitor()
  2967. }
  2968. private func startClock() {
  2969. clockTimer?.invalidate()
  2970. clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  2971. self?.updateClock()
  2972. }
  2973. updateClock()
  2974. }
  2975. private func updateClock() {
  2976. let now = Date()
  2977. let timeFormatter = DateFormatter()
  2978. timeFormatter.dateFormat = "h:mm a"
  2979. let dateFormatter = DateFormatter()
  2980. dateFormatter.dateFormat = "EEEE, d MMM"
  2981. timeLabel?.stringValue = timeFormatter.string(from: now)
  2982. dateLabel?.stringValue = dateFormatter.string(from: now)
  2983. }
  2984. // MARK: - Shared UI
  2985. private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .login) -> NSView {
  2986. let sidebar = NSView()
  2987. sidebar.wantsLayer = true
  2988. sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
  2989. let stack = NSStackView()
  2990. stack.orientation = .vertical
  2991. stack.spacing = style == .home ? 12 : 16
  2992. stack.alignment = .centerX
  2993. stack.distribution = .fill
  2994. // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
  2995. stack.setContentHuggingPriority(.required, for: .vertical)
  2996. stack.setContentCompressionResistancePriority(.required, for: .vertical)
  2997. stack.translatesAutoresizingMaskIntoConstraints = false
  2998. sidebar.addSubview(stack)
  2999. for item in items {
  3000. let row = NSView()
  3001. row.translatesAutoresizingMaskIntoConstraints = false
  3002. row.wantsLayer = true
  3003. let selectedRow = item == selected
  3004. row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  3005. row.layer?.cornerRadius = style == .home ? 12 : 10
  3006. row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
  3007. // Prevent rows from stretching/collapsing when the window resizes.
  3008. row.setContentHuggingPriority(.required, for: .vertical)
  3009. row.setContentCompressionResistancePriority(.required, for: .vertical)
  3010. if style == .home {
  3011. // Must be tall enough for icon (26) + paddings + label without clipping.
  3012. row.heightAnchor.constraint(equalToConstant: 66).isActive = true
  3013. }
  3014. if style == .home {
  3015. let iconContainer = NSView()
  3016. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  3017. row.addSubview(iconContainer)
  3018. let iconView = NSImageView()
  3019. iconView.translatesAutoresizingMaskIntoConstraints = false
  3020. iconView.contentTintColor = selectedRow ? primaryText : secondaryText
  3021. iconView.imageScaling = .scaleProportionallyUpOrDown
  3022. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  3023. iconView.image = NSImage(systemSymbolName: sidebarSymbolName(for: item, filled: selectedRow), accessibilityDescription: item)
  3024. iconContainer.addSubview(iconView)
  3025. let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  3026. label.translatesAutoresizingMaskIntoConstraints = false
  3027. row.addSubview(label)
  3028. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  3029. hit.identifier = NSUserInterfaceItemIdentifier(item)
  3030. hit.isBordered = false
  3031. hit.bezelStyle = .shadowlessSquare
  3032. hit.focusRingType = .none
  3033. hit.translatesAutoresizingMaskIntoConstraints = false
  3034. row.addSubview(hit, positioned: .above, relativeTo: nil)
  3035. NSLayoutConstraint.activate([
  3036. iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
  3037. iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  3038. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  3039. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  3040. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  3041. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  3042. iconView.widthAnchor.constraint(equalToConstant: 22),
  3043. iconView.heightAnchor.constraint(equalToConstant: 22),
  3044. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  3045. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  3046. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
  3047. hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  3048. hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
  3049. hit.topAnchor.constraint(equalTo: row.topAnchor),
  3050. hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  3051. ])
  3052. homeSidebarRowViews[item] = row
  3053. homeSidebarIconViews[item] = iconView
  3054. homeSidebarLabels[item] = label
  3055. if item == "Hub" {
  3056. let badge = makeSidebarBadge(text: "1")
  3057. badge.translatesAutoresizingMaskIntoConstraints = false
  3058. iconContainer.addSubview(badge)
  3059. NSLayoutConstraint.activate([
  3060. badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
  3061. badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
  3062. ])
  3063. } else if item == "More" {
  3064. let dot = NSView()
  3065. dot.translatesAutoresizingMaskIntoConstraints = false
  3066. dot.wantsLayer = true
  3067. dot.layer?.backgroundColor = NSColor.systemRed.cgColor
  3068. dot.layer?.cornerRadius = 4
  3069. row.addSubview(dot)
  3070. NSLayoutConstraint.activate([
  3071. dot.widthAnchor.constraint(equalToConstant: 8),
  3072. dot.heightAnchor.constraint(equalToConstant: 8),
  3073. dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  3074. dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
  3075. ])
  3076. }
  3077. } else {
  3078. let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
  3079. let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
  3080. [icon, label].forEach {
  3081. $0.translatesAutoresizingMaskIntoConstraints = false
  3082. row.addSubview($0)
  3083. }
  3084. NSLayoutConstraint.activate([
  3085. icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  3086. icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  3087. label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
  3088. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  3089. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  3090. ])
  3091. }
  3092. stack.addArrangedSubview(row)
  3093. }
  3094. if style == .home {
  3095. let spacer = NSView()
  3096. spacer.translatesAutoresizingMaskIntoConstraints = false
  3097. // Keep Settings in a stable position (no vertical shifting on resize).
  3098. spacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
  3099. spacer.setContentHuggingPriority(.required, for: .vertical)
  3100. spacer.setContentCompressionResistancePriority(.required, for: .vertical)
  3101. stack.addArrangedSubview(spacer)
  3102. let settingsRow = NSView()
  3103. settingsRow.translatesAutoresizingMaskIntoConstraints = false
  3104. settingsRow.wantsLayer = true
  3105. let settingsSelected = selected == "Settings"
  3106. settingsRow.layer?.backgroundColor = settingsSelected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  3107. settingsRow.layer?.cornerRadius = 12
  3108. settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
  3109. settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
  3110. let iconContainer = NSView()
  3111. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  3112. settingsRow.addSubview(iconContainer)
  3113. let iconView = NSImageView()
  3114. iconView.translatesAutoresizingMaskIntoConstraints = false
  3115. iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
  3116. iconView.imageScaling = .scaleProportionallyUpOrDown
  3117. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  3118. let settingsSymbolPreferred = settingsSelected ? "gearshape.fill" : "gearshape"
  3119. iconView.image = NSImage(systemSymbolName: settingsSymbolPreferred, accessibilityDescription: "Settings")
  3120. ?? NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
  3121. iconContainer.addSubview(iconView)
  3122. let label = makeLabel("Settings", size: 10, color: settingsSelected ? primaryText : secondaryText, weight: .regular, centered: true)
  3123. label.translatesAutoresizingMaskIntoConstraints = false
  3124. settingsRow.addSubview(label)
  3125. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  3126. hit.identifier = NSUserInterfaceItemIdentifier("Settings")
  3127. hit.isBordered = false
  3128. hit.bezelStyle = .shadowlessSquare
  3129. hit.focusRingType = .none
  3130. hit.translatesAutoresizingMaskIntoConstraints = false
  3131. settingsRow.addSubview(hit, positioned: .above, relativeTo: nil)
  3132. NSLayoutConstraint.activate([
  3133. iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
  3134. iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
  3135. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  3136. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  3137. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  3138. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  3139. iconView.widthAnchor.constraint(equalToConstant: 22),
  3140. iconView.heightAnchor.constraint(equalToConstant: 22),
  3141. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  3142. label.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
  3143. label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8),
  3144. hit.leadingAnchor.constraint(equalTo: settingsRow.leadingAnchor),
  3145. hit.trailingAnchor.constraint(equalTo: settingsRow.trailingAnchor),
  3146. hit.topAnchor.constraint(equalTo: settingsRow.topAnchor),
  3147. hit.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor)
  3148. ])
  3149. homeSidebarRowViews["Settings"] = settingsRow
  3150. homeSidebarIconViews["Settings"] = iconView
  3151. homeSidebarLabels["Settings"] = label
  3152. stack.addArrangedSubview(settingsRow)
  3153. }
  3154. NSLayoutConstraint.activate([
  3155. stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
  3156. stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
  3157. ])
  3158. if style == .home {
  3159. NSLayoutConstraint.activate([
  3160. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
  3161. ])
  3162. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  3163. } else {
  3164. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 18).isActive = true
  3165. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  3166. }
  3167. return sidebar
  3168. }
  3169. @objc private func homeSidebarItemTapped(_ sender: NSButton) {
  3170. guard let item = sender.identifier?.rawValue else { return }
  3171. selectedHomeSidebarItem = item
  3172. updateHomeSidebarHighlight()
  3173. updateSelectedHomeSectionUI()
  3174. }
  3175. @MainActor
  3176. private func updateHomeSidebarHighlight() {
  3177. for (item, row) in homeSidebarRowViews {
  3178. let selected = item == selectedHomeSidebarItem
  3179. row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  3180. homeSidebarLabels[item]?.textColor = selected ? primaryText : secondaryText
  3181. homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : secondaryText
  3182. let symbolName = sidebarSymbolName(for: item, filled: selected)
  3183. if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: item) {
  3184. homeSidebarIconViews[item]?.image = image
  3185. }
  3186. }
  3187. }
  3188. @MainActor
  3189. private func updateSelectedHomeSectionUI() {
  3190. let isHome = selectedHomeSidebarItem == "Home"
  3191. let isScheduler = selectedHomeSidebarItem == "Scheduler"
  3192. let isSettings = selectedHomeSidebarItem == "Settings"
  3193. let title = selectedHomeSidebarItem
  3194. homeWelcomeLabel?.stringValue = title
  3195. homeWelcomeLabel?.isHidden = isSettings
  3196. let dashboardViews: [NSView?] = [
  3197. homeTimeLabelView,
  3198. homeDateLabelView,
  3199. homeActionsRow,
  3200. homeMeetingsPanel,
  3201. meetingsDayHeaderLabel,
  3202. meetingsStatusLabel,
  3203. meetingsPrevDayButton,
  3204. meetingsTodayButton,
  3205. meetingsNextDayButton,
  3206. meetingsScrollView,
  3207. refreshMeetingsButton
  3208. ]
  3209. // Keep the main dashboard (including Schedule) visible on Home and Scheduler; other sidebar items are placeholders.
  3210. let hideDashboard = (isHome == false && isScheduler == false) || isSettings
  3211. dashboardViews.forEach { $0?.isHidden = hideDashboard }
  3212. // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
  3213. // and showed "No meetings…" on top of meeting cards when returning to Home.
  3214. if hideDashboard {
  3215. emptyMeetingLabel?.isHidden = true
  3216. } else {
  3217. let hasMeetingCards = (meetingsListStack?.arrangedSubviews.isEmpty == false)
  3218. emptyMeetingLabel?.isHidden = hasMeetingCards
  3219. }
  3220. homeSettingsView?.isHidden = isSettings == false
  3221. if isHome || isScheduler {
  3222. homePlaceholderLabel?.isHidden = true
  3223. } else {
  3224. // Keep non-Home pages empty for now.
  3225. homePlaceholderLabel?.isHidden = true
  3226. }
  3227. }
  3228. private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
  3229. switch item {
  3230. case "Home":
  3231. return filled ? "house.fill" : "house"
  3232. case "Meetings":
  3233. return filled ? "video.fill" : "video"
  3234. case "Chat":
  3235. return filled ? "message.fill" : "message"
  3236. case "Scheduler":
  3237. // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
  3238. return "calendar.badge.clock"
  3239. case "Settings":
  3240. // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
  3241. return filled ? "gearshape.fill" : "gearshape"
  3242. case "Hub":
  3243. return "square.grid.3x3"
  3244. case "More":
  3245. return "ellipsis"
  3246. default:
  3247. return "circle"
  3248. }
  3249. }
  3250. private func makeSidebarBadge(text: String) -> NSView {
  3251. let badge = NSView()
  3252. badge.wantsLayer = true
  3253. badge.layer?.backgroundColor = NSColor.systemRed.cgColor
  3254. badge.layer?.cornerRadius = 12 / 2
  3255. badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
  3256. badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
  3257. let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
  3258. label.translatesAutoresizingMaskIntoConstraints = false
  3259. badge.addSubview(label)
  3260. NSLayoutConstraint.activate([
  3261. label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
  3262. label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
  3263. ])
  3264. return badge
  3265. }
  3266. @MainActor
  3267. private func alignNativeTrafficLights() {
  3268. guard let window = view.window else { return }
  3269. guard let closeButton = window.standardWindowButton(.closeButton),
  3270. let miniButton = window.standardWindowButton(.miniaturizeButton),
  3271. let zoomButton = window.standardWindowButton(.zoomButton) else { return }
  3272. guard let buttonContainer = closeButton.superview else { return }
  3273. let buttons = [closeButton, miniButton, zoomButton]
  3274. // Compute from top inset so moving "down" is stable in titlebar coordinates.
  3275. let containerHeight = buttonContainer.bounds.height
  3276. let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
  3277. var nextX = nativeTrafficLightsLeading
  3278. for button in buttons {
  3279. button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
  3280. nextX += button.frame.width + 8
  3281. }
  3282. }
  3283. private func makeUpgradeToProButton(action: Selector?) -> NSButton {
  3284. let title = "Upgrade to Pro"
  3285. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  3286. button.isBordered = false
  3287. button.focusRingType = .none
  3288. button.wantsLayer = true
  3289. button.layer?.backgroundColor = accentBlue.cgColor
  3290. button.layer?.cornerRadius = 14
  3291. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  3292. button.attributedTitle = NSAttributedString(string: title, attributes: [
  3293. .foregroundColor: NSColor.white,
  3294. .font: font
  3295. ])
  3296. button.toolTip = title
  3297. button.translatesAutoresizingMaskIntoConstraints = false
  3298. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  3299. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
  3300. return button
  3301. }
  3302. /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
  3303. private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
  3304. let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
  3305. button.isBordered = false
  3306. button.bezelStyle = .shadowlessSquare
  3307. button.focusRingType = .none
  3308. button.contentTintColor = palette.isDarkMode
  3309. ? NSColor(calibratedWhite: 0.84, alpha: 1)
  3310. : NSColor(calibratedWhite: 0.22, alpha: 1)
  3311. if let toolTip {
  3312. button.toolTip = toolTip
  3313. }
  3314. let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
  3315. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
  3316. let image = base.withSymbolConfiguration(symbolConfig) {
  3317. image.isTemplate = true
  3318. button.image = image
  3319. }
  3320. button.imageScaling = .scaleProportionallyUpOrDown
  3321. button.imagePosition = .imageOnly
  3322. button.translatesAutoresizingMaskIntoConstraints = false
  3323. button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
  3324. button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
  3325. return button
  3326. }
  3327. private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
  3328. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  3329. button.isBordered = false
  3330. button.bezelStyle = .shadowlessSquare
  3331. button.focusRingType = .none
  3332. button.wantsLayer = true
  3333. button.layer?.backgroundColor = (palette.isDarkMode
  3334. ? NSColor.white.withAlphaComponent(0.06)
  3335. : NSColor.black.withAlphaComponent(0.06)
  3336. ).cgColor
  3337. button.layer?.cornerRadius = 10
  3338. button.layer?.borderWidth = 1
  3339. button.layer?.borderColor = (palette.isDarkMode
  3340. ? NSColor.white.withAlphaComponent(0.08)
  3341. : NSColor.black.withAlphaComponent(0.12)
  3342. ).cgColor
  3343. let tint = palette.isDarkMode
  3344. ? NSColor(calibratedWhite: 0.9, alpha: 1)
  3345. : NSColor(calibratedWhite: 0.18, alpha: 1)
  3346. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  3347. button.contentTintColor = tint
  3348. button.font = font
  3349. button.attributedTitle = NSAttributedString(string: title, attributes: [
  3350. .foregroundColor: tint,
  3351. .font: font
  3352. ])
  3353. if let symbol {
  3354. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  3355. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  3356. let image = base.withSymbolConfiguration(symbolConfig) {
  3357. image.isTemplate = true
  3358. button.image = image
  3359. button.imagePosition = .imageLeading
  3360. }
  3361. button.imageHugsTitle = true
  3362. button.imageScaling = .scaleNone
  3363. }
  3364. button.translatesAutoresizingMaskIntoConstraints = false
  3365. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  3366. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
  3367. return button
  3368. }
  3369. private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
  3370. let root = NSView()
  3371. root.translatesAutoresizingMaskIntoConstraints = false
  3372. root.widthAnchor.constraint(equalToConstant: 88).isActive = true
  3373. root.heightAnchor.constraint(equalToConstant: 70).isActive = true
  3374. let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
  3375. iconButton.isBordered = false
  3376. iconButton.wantsLayer = true
  3377. iconButton.layer?.backgroundColor = color.cgColor
  3378. iconButton.layer?.cornerRadius = 15
  3379. iconButton.layer?.shadowOpacity = 0.2
  3380. iconButton.layer?.shadowRadius = 7
  3381. iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
  3382. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
  3383. if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  3384. let configured = baseImage.withSymbolConfiguration(symbolConfig) {
  3385. iconButton.image = configured
  3386. }
  3387. iconButton.contentTintColor = .white
  3388. iconButton.imageScaling = .scaleNone
  3389. let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
  3390. [iconButton, label].forEach {
  3391. $0.translatesAutoresizingMaskIntoConstraints = false
  3392. root.addSubview($0)
  3393. }
  3394. NSLayoutConstraint.activate([
  3395. iconButton.topAnchor.constraint(equalTo: root.topAnchor),
  3396. iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  3397. iconButton.widthAnchor.constraint(equalToConstant: 50),
  3398. iconButton.heightAnchor.constraint(equalToConstant: 50),
  3399. label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
  3400. label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  3401. label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  3402. ])
  3403. return root
  3404. }
  3405. private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
  3406. let hoverBackground = palette.isDarkMode
  3407. ? NSColor.white.withAlphaComponent(0.075)
  3408. : NSColor.black.withAlphaComponent(0.10)
  3409. let hoverBorder = palette.isDarkMode
  3410. ? NSColor.white.withAlphaComponent(0.85)
  3411. : NSColor.black.withAlphaComponent(0.22)
  3412. let card = MeetingCardView(url: meeting.webURL, hoverBackground: hoverBackground, hoverBorder: hoverBorder)
  3413. card.wantsLayer = true
  3414. card.layer?.backgroundColor = meetingCardBackground.cgColor
  3415. card.layer?.cornerRadius = 13
  3416. card.layer?.borderWidth = 1
  3417. card.layer?.borderColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.10)).cgColor
  3418. card.translatesAutoresizingMaskIntoConstraints = false
  3419. card.heightAnchor.constraint(equalToConstant: 116).isActive = true
  3420. let dateFormatter = DateFormatter()
  3421. dateFormatter.dateFormat = "EEE, MMM d"
  3422. let timeFormatter = DateFormatter()
  3423. timeFormatter.dateFormat = "h:mm a"
  3424. let startText = timeFormatter.string(from: meeting.start)
  3425. let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
  3426. let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
  3427. let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
  3428. let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
  3429. detail.maximumNumberOfLines = 2
  3430. let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
  3431. [title, detail, host].forEach {
  3432. $0.translatesAutoresizingMaskIntoConstraints = false
  3433. card.addSubview($0)
  3434. }
  3435. NSLayoutConstraint.activate([
  3436. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
  3437. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  3438. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  3439. detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
  3440. detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  3441. detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  3442. host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
  3443. host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  3444. host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  3445. host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
  3446. ])
  3447. return card
  3448. }
  3449. private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
  3450. let label = NSTextField(labelWithString: text)
  3451. label.font = .systemFont(ofSize: size, weight: weight)
  3452. label.textColor = color
  3453. label.alignment = centered ? .center : .left
  3454. return label
  3455. }
  3456. private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
  3457. let wrapper = NSView()
  3458. let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
  3459. button.font = .systemFont(ofSize: 20, weight: .medium)
  3460. button.isBordered = false
  3461. button.wantsLayer = true
  3462. button.layer?.cornerRadius = 12
  3463. button.layer?.backgroundColor = cardBackground.cgColor
  3464. button.contentTintColor = primaryText
  3465. button.translatesAutoresizingMaskIntoConstraints = false
  3466. let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
  3467. label.translatesAutoresizingMaskIntoConstraints = false
  3468. wrapper.addSubview(button)
  3469. wrapper.addSubview(label)
  3470. NSLayoutConstraint.activate([
  3471. button.topAnchor.constraint(equalTo: wrapper.topAnchor),
  3472. button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  3473. button.widthAnchor.constraint(equalToConstant: 52),
  3474. button.heightAnchor.constraint(equalToConstant: 52),
  3475. label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
  3476. label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  3477. label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  3478. ])
  3479. return (wrapper, action == nil ? nil : button)
  3480. }
  3481. }
  3482. extension ViewController: NSWindowDelegate {
  3483. func windowWillClose(_ notification: Notification) {
  3484. guard let window = notification.object as? NSWindow else { return }
  3485. if window === joinMeetingWindow {
  3486. resetJoinMeetingPanelReferences()
  3487. }
  3488. if window === scheduleMeetingWindow {
  3489. resetScheduleMeetingPanelReferences()
  3490. }
  3491. }
  3492. }
  3493. private extension Array {
  3494. subscript(safe index: Int) -> Element? {
  3495. guard index >= 0, index < count else { return nil }
  3496. return self[index]
  3497. }
  3498. }
  3499. /// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
  3500. private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
  3501. private func lineHeight() -> CGFloat {
  3502. guard let font = font else { return 0 }
  3503. return ceil(font.ascender - font.descender + font.leading)
  3504. }
  3505. private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
  3506. let lh = lineHeight()
  3507. guard lh > 0, h > lh else { return 0 }
  3508. return max(0, floor((h - lh) / 2))
  3509. }
  3510. private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
  3511. let lh = lineHeight()
  3512. guard lh > 0 else { return cellFrame }
  3513. let m = verticalMargin(forBoundsHeight: cellFrame.height)
  3514. var r = cellFrame
  3515. r.origin.y += m
  3516. r.size.height = lh
  3517. return r
  3518. }
  3519. override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
  3520. super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
  3521. }
  3522. override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
  3523. let text = super.setUpFieldEditorAttributes(textObj)
  3524. guard let tv = text as? NSTextView else { return text }
  3525. // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
  3526. let h = max(controlView?.bounds.height ?? 0, 42)
  3527. let margin = verticalMargin(forBoundsHeight: h)
  3528. if margin > 0 {
  3529. // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
  3530. tv.textContainerInset = NSSize(width: 0, height: margin)
  3531. }
  3532. return text
  3533. }
  3534. }
  3535. /// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
  3536. private final class JoinPanelFieldContainer: NSView {
  3537. let textField: NSTextField
  3538. private let normalBorder: NSColor
  3539. private let focusBorder: NSColor
  3540. private let fill: NSColor
  3541. private var beginObserver: NSObjectProtocol?
  3542. private var endObserver: NSObjectProtocol?
  3543. init(
  3544. placeholder: String,
  3545. normalBorder: NSColor,
  3546. focusBorder: NSColor,
  3547. fill: NSColor,
  3548. primaryText: NSColor,
  3549. mutedText: NSColor
  3550. ) {
  3551. self.normalBorder = normalBorder
  3552. self.focusBorder = focusBorder
  3553. self.fill = fill
  3554. textField = NSTextField()
  3555. textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
  3556. super.init(frame: .zero)
  3557. translatesAutoresizingMaskIntoConstraints = false
  3558. wantsLayer = true
  3559. // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
  3560. layer?.masksToBounds = false
  3561. layer?.cornerRadius = 23
  3562. layer?.backgroundColor = fill.cgColor
  3563. applyBorder(focused: false)
  3564. textField.font = .systemFont(ofSize: 14, weight: .regular)
  3565. textField.textColor = primaryText
  3566. textField.alignment = .center
  3567. textField.isEditable = true
  3568. textField.isSelectable = true
  3569. textField.refusesFirstResponder = false
  3570. textField.focusRingType = .none
  3571. textField.isBordered = false
  3572. textField.drawsBackground = false
  3573. textField.translatesAutoresizingMaskIntoConstraints = false
  3574. if let cell = textField.cell as? NSTextFieldCell {
  3575. cell.isBezeled = false
  3576. cell.drawsBackground = false
  3577. cell.alignment = .center
  3578. cell.usesSingleLineMode = true
  3579. cell.lineBreakMode = .byTruncatingTail
  3580. let placeholderParagraph = NSMutableParagraphStyle()
  3581. placeholderParagraph.alignment = .center
  3582. cell.placeholderAttributedString = NSAttributedString(
  3583. string: placeholder,
  3584. attributes: [
  3585. .foregroundColor: mutedText.withAlphaComponent(0.88),
  3586. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  3587. .paragraphStyle: placeholderParagraph
  3588. ]
  3589. )
  3590. }
  3591. addSubview(textField)
  3592. NSLayoutConstraint.activate([
  3593. textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  3594. textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  3595. textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
  3596. textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
  3597. heightAnchor.constraint(equalToConstant: 46)
  3598. ])
  3599. beginObserver = NotificationCenter.default.addObserver(
  3600. forName: NSControl.textDidBeginEditingNotification,
  3601. object: textField,
  3602. queue: .main
  3603. ) { [weak self] _ in
  3604. self?.applyBorder(focused: true)
  3605. }
  3606. endObserver = NotificationCenter.default.addObserver(
  3607. forName: NSControl.textDidEndEditingNotification,
  3608. object: textField,
  3609. queue: .main
  3610. ) { [weak self] _ in
  3611. self?.applyBorder(focused: false)
  3612. }
  3613. }
  3614. required init?(coder: NSCoder) {
  3615. fatalError("init(coder:) has not been implemented")
  3616. }
  3617. deinit {
  3618. if let beginObserver {
  3619. NotificationCenter.default.removeObserver(beginObserver)
  3620. }
  3621. if let endObserver {
  3622. NotificationCenter.default.removeObserver(endObserver)
  3623. }
  3624. }
  3625. private func applyBorder(focused: Bool) {
  3626. layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
  3627. layer?.borderWidth = focused ? 1.5 : 1
  3628. }
  3629. override func viewDidMoveToWindow() {
  3630. super.viewDidMoveToWindow()
  3631. if window == nil {
  3632. applyBorder(focused: false)
  3633. }
  3634. }
  3635. }
  3636. private final class SearchPillTextField: NSTextField {
  3637. var onFocusChange: ((Bool) -> Void)?
  3638. private(set) var isSearchFocused = false
  3639. func forceClearFocusState() {
  3640. isSearchFocused = false
  3641. onFocusChange?(false)
  3642. }
  3643. override func becomeFirstResponder() -> Bool {
  3644. let ok = super.becomeFirstResponder()
  3645. if ok {
  3646. isSearchFocused = true
  3647. onFocusChange?(true)
  3648. }
  3649. return ok
  3650. }
  3651. override func resignFirstResponder() -> Bool {
  3652. let ok = super.resignFirstResponder()
  3653. if ok {
  3654. isSearchFocused = false
  3655. onFocusChange?(false)
  3656. }
  3657. return ok
  3658. }
  3659. }
  3660. private final class FlippedView: NSView {
  3661. override var isFlipped: Bool { true }
  3662. }
  3663. private final class MeetingCardView: NSView {
  3664. private let url: URL?
  3665. private let hoverBackground: NSColor
  3666. private let hoverBorder: NSColor
  3667. private var tracking: NSTrackingArea?
  3668. private var isHovering = false
  3669. private var normalBackgroundColor: CGColor?
  3670. private var normalBorderColor: CGColor?
  3671. private var normalBorderWidth: CGFloat?
  3672. init(url: URL?, hoverBackground: NSColor, hoverBorder: NSColor) {
  3673. self.url = url
  3674. self.hoverBackground = hoverBackground
  3675. self.hoverBorder = hoverBorder
  3676. super.init(frame: .zero)
  3677. }
  3678. required init?(coder: NSCoder) {
  3679. self.url = nil
  3680. self.hoverBackground = NSColor.white.withAlphaComponent(0.075)
  3681. self.hoverBorder = NSColor.white.withAlphaComponent(0.85)
  3682. super.init(coder: coder)
  3683. }
  3684. override func updateTrackingAreas() {
  3685. super.updateTrackingAreas()
  3686. if let tracking { removeTrackingArea(tracking) }
  3687. let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
  3688. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  3689. addTrackingArea(area)
  3690. tracking = area
  3691. }
  3692. override func resetCursorRects() {
  3693. super.resetCursorRects()
  3694. if url != nil {
  3695. addCursorRect(bounds, cursor: .pointingHand)
  3696. }
  3697. }
  3698. override func mouseEntered(with event: NSEvent) {
  3699. super.mouseEntered(with: event)
  3700. guard url != nil else { return }
  3701. isHovering = true
  3702. applyHoverState()
  3703. }
  3704. override func mouseExited(with event: NSEvent) {
  3705. super.mouseExited(with: event)
  3706. guard url != nil else { return }
  3707. isHovering = false
  3708. applyHoverState()
  3709. }
  3710. override func mouseUp(with event: NSEvent) {
  3711. super.mouseUp(with: event)
  3712. guard let url else { return }
  3713. NSWorkspace.shared.open(url)
  3714. }
  3715. private func applyHoverState() {
  3716. guard let layer else { return }
  3717. if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
  3718. if normalBorderColor == nil { normalBorderColor = layer.borderColor }
  3719. if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
  3720. if isHovering {
  3721. layer.backgroundColor = hoverBackground.cgColor
  3722. layer.borderColor = hoverBorder.cgColor
  3723. layer.borderWidth = max(1.5, layer.borderWidth)
  3724. } else {
  3725. layer.backgroundColor = normalBackgroundColor
  3726. layer.borderColor = normalBorderColor
  3727. if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
  3728. }
  3729. }
  3730. }
  3731. struct GoogleOAuthTokens: Codable, Equatable {
  3732. var accessToken: String
  3733. var refreshToken: String?
  3734. var expiresAt: Date
  3735. var scope: String?
  3736. var tokenType: String?
  3737. }
  3738. struct GoogleUserProfile: Codable, Equatable {
  3739. var name: String?
  3740. var email: String?
  3741. var picture: String?
  3742. }
  3743. struct ZoomOAuthTokens: Codable, Equatable {
  3744. var accessToken: String
  3745. var refreshToken: String?
  3746. var expiresAt: Date
  3747. var scope: String?
  3748. var tokenType: String?
  3749. }
  3750. enum ZoomOAuthError: Error {
  3751. case missingClientId
  3752. case missingClientSecret
  3753. case invalidCallbackURL
  3754. case missingAuthorizationCode
  3755. case tokenExchangeFailed(String)
  3756. case missingRequiredScope(String)
  3757. case rateLimited(retryAfterSeconds: Int?)
  3758. case unableToOpenBrowser
  3759. case authenticationTimedOut
  3760. }
  3761. final class ZoomOAuthTokenStore {
  3762. private let defaultsKey: String
  3763. private let defaults: UserDefaults
  3764. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  3765. account: String = "zoomOAuthTokens",
  3766. defaults: UserDefaults = .standard) {
  3767. self.defaultsKey = "\(service).\(account)"
  3768. self.defaults = defaults
  3769. }
  3770. func readTokens() throws -> ZoomOAuthTokens? {
  3771. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  3772. return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
  3773. }
  3774. func writeTokens(_ tokens: ZoomOAuthTokens) throws {
  3775. let data = try JSONEncoder().encode(tokens)
  3776. defaults.set(data, forKey: defaultsKey)
  3777. }
  3778. func clearTokens() {
  3779. defaults.removeObject(forKey: defaultsKey)
  3780. }
  3781. }
  3782. final class ZoomOAuthService: NSObject {
  3783. static let shared = ZoomOAuthService()
  3784. private let tokenStore = ZoomOAuthTokenStore()
  3785. private let clientIdDefaultsKey = "zoom.oauth.clientId"
  3786. private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
  3787. private let infoPlistClientIdKey = "ZoomOAuthClientId"
  3788. private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
  3789. // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
  3790. /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
  3791. private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
  3792. /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
  3793. private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
  3794. func setClientCredentials(clientId: String, clientSecret: String) {
  3795. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  3796. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  3797. }
  3798. func configuredClientId() -> String? {
  3799. if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
  3800. let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
  3801. if trimmed.isEmpty == false { return trimmed }
  3802. }
  3803. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
  3804. .trimmingCharacters(in: .whitespacesAndNewlines)
  3805. if let value, value.isEmpty == false { return value }
  3806. return bundledClientId.isEmpty ? nil : bundledClientId
  3807. }
  3808. func configuredClientSecret() -> String? {
  3809. if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
  3810. let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
  3811. if trimmed.isEmpty == false { return trimmed }
  3812. }
  3813. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
  3814. .trimmingCharacters(in: .whitespacesAndNewlines)
  3815. if let value, value.isEmpty == false { return value }
  3816. return bundledClientSecret.isEmpty ? nil : bundledClientSecret
  3817. }
  3818. func clearSavedTokens() {
  3819. tokenStore.clearTokens()
  3820. }
  3821. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  3822. if let tokens = try tokenStore.readTokens(),
  3823. tokens.expiresAt.timeIntervalSinceNow > 60,
  3824. tokenHasRequiredScope(tokens.scope) {
  3825. return tokens.accessToken
  3826. } else if var tokens = try tokenStore.readTokens(),
  3827. let refreshed = try await refreshTokens(tokens) {
  3828. tokens = refreshed
  3829. try tokenStore.writeTokens(tokens)
  3830. return tokens.accessToken
  3831. }
  3832. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  3833. try tokenStore.writeTokens(tokens)
  3834. return tokens.accessToken
  3835. }
  3836. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
  3837. _ = presentingWindow
  3838. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  3839. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  3840. let loopback = try await OAuthLoopbackServer.start()
  3841. defer { loopback.stop() }
  3842. let redirectURI = loopback.redirectURI
  3843. let state = UUID().uuidString
  3844. var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
  3845. // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
  3846. components.queryItems = [
  3847. URLQueryItem(name: "response_type", value: "code"),
  3848. URLQueryItem(name: "client_id", value: clientId),
  3849. URLQueryItem(name: "redirect_uri", value: redirectURI),
  3850. URLQueryItem(name: "state", value: state)
  3851. ]
  3852. guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
  3853. let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
  3854. guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
  3855. let callbackURL = try await loopback.waitForCallback()
  3856. let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  3857. guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
  3858. guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  3859. throw ZoomOAuthError.missingAuthorizationCode
  3860. }
  3861. return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
  3862. }
  3863. private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
  3864. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  3865. request.httpMethod = "POST"
  3866. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  3867. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  3868. request.httpBody = Self.formURLEncoded([
  3869. "grant_type": "authorization_code",
  3870. "code": code,
  3871. "redirect_uri": redirectURI
  3872. ])
  3873. let (data, response) = try await URLSession.shared.data(for: request)
  3874. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3875. throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  3876. }
  3877. struct TokenResponse: Decodable {
  3878. let access_token: String
  3879. let refresh_token: String?
  3880. let expires_in: Double
  3881. let scope: String?
  3882. let token_type: String?
  3883. }
  3884. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  3885. return ZoomOAuthTokens(
  3886. accessToken: decoded.access_token,
  3887. refreshToken: decoded.refresh_token,
  3888. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  3889. scope: decoded.scope,
  3890. tokenType: decoded.token_type
  3891. )
  3892. }
  3893. private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
  3894. guard let refreshToken = tokens.refreshToken else { return nil }
  3895. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  3896. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  3897. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  3898. request.httpMethod = "POST"
  3899. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  3900. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  3901. request.httpBody = Self.formURLEncoded([
  3902. "grant_type": "refresh_token",
  3903. "refresh_token": refreshToken
  3904. ])
  3905. let (data, response) = try await URLSession.shared.data(for: request)
  3906. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3907. return nil
  3908. }
  3909. struct RefreshResponse: Decodable {
  3910. let access_token: String
  3911. let refresh_token: String?
  3912. let expires_in: Double
  3913. let scope: String?
  3914. let token_type: String?
  3915. }
  3916. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  3917. return ZoomOAuthTokens(
  3918. accessToken: decoded.access_token,
  3919. refreshToken: decoded.refresh_token ?? refreshToken,
  3920. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  3921. scope: decoded.scope ?? tokens.scope,
  3922. tokenType: decoded.token_type ?? tokens.tokenType
  3923. )
  3924. }
  3925. private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
  3926. guard let scopeValue, scopeValue.isEmpty == false else { return false }
  3927. let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
  3928. return parts.contains { part in
  3929. part == "meeting:read"
  3930. || part == "meeting:read:admin"
  3931. || part == "meeting:write"
  3932. || part == "meeting:write:admin"
  3933. || part.contains("meeting:read")
  3934. || part.contains("meeting:write")
  3935. || part.contains("list_meetings")
  3936. || part.contains("list_user_meetings")
  3937. }
  3938. }
  3939. private static func basicAuth(clientId: String, clientSecret: String) -> String {
  3940. let joined = "\(clientId):\(clientSecret)"
  3941. return Data(joined.utf8).base64EncodedString()
  3942. }
  3943. private static func formURLEncoded(_ params: [String: String]) -> Data {
  3944. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  3945. let pairs = params.map { key, value in
  3946. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  3947. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  3948. return "\(k)=\(v)"
  3949. }.joined(separator: "&")
  3950. return Data(pairs.utf8)
  3951. }
  3952. }
  3953. enum GoogleOAuthError: Error {
  3954. case missingClientId
  3955. case missingClientSecret
  3956. case invalidCallbackURL
  3957. case missingAuthorizationCode
  3958. case tokenExchangeFailed(String)
  3959. case unableToOpenBrowser
  3960. case authenticationTimedOut
  3961. }
  3962. final class GoogleOAuthService: NSObject {
  3963. static let shared = GoogleOAuthService()
  3964. private var inAppOAuthWindowController: InAppOAuthWindowController?
  3965. private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
  3966. private let clientSecret = "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"
  3967. private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
  3968. private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
  3969. private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
  3970. func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
  3971. func clearSavedTokens() {
  3972. tokenStore.clearTokens()
  3973. }
  3974. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  3975. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  3976. request.httpMethod = "GET"
  3977. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3978. let (data, response) = try await URLSession.shared.data(for: request)
  3979. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3980. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  3981. }
  3982. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  3983. }
  3984. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  3985. if let tokens = try tokenStore.readTokens(),
  3986. tokens.expiresAt.timeIntervalSinceNow > 60,
  3987. tokenHasCalendarScope(tokens.scope) {
  3988. return tokens.accessToken
  3989. }
  3990. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  3991. try tokenStore.writeTokens(tokens)
  3992. return tokens.accessToken
  3993. }
  3994. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  3995. _ = presentingWindow
  3996. let codeVerifier = Self.randomURLSafeString(length: 64)
  3997. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  3998. let state = Self.randomURLSafeString(length: 32)
  3999. let loopback = try await OAuthLoopbackServer.start()
  4000. defer { loopback.stop() }
  4001. let redirectURI = loopback.redirectURI
  4002. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  4003. components.queryItems = [
  4004. URLQueryItem(name: "client_id", value: clientId),
  4005. URLQueryItem(name: "redirect_uri", value: redirectURI),
  4006. URLQueryItem(name: "response_type", value: "code"),
  4007. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  4008. URLQueryItem(name: "state", value: state),
  4009. URLQueryItem(name: "code_challenge", value: codeChallenge),
  4010. URLQueryItem(name: "code_challenge_method", value: "S256"),
  4011. URLQueryItem(name: "access_type", value: "offline"),
  4012. URLQueryItem(name: "prompt", value: "consent")
  4013. ]
  4014. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  4015. let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
  4016. guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
  4017. let callbackURL = try await loopback.waitForCallback()
  4018. let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  4019. guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
  4020. guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  4021. throw GoogleOAuthError.missingAuthorizationCode
  4022. }
  4023. return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
  4024. }
  4025. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
  4026. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  4027. request.httpMethod = "POST"
  4028. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  4029. request.httpBody = Self.formURLEncoded([
  4030. "client_id": clientId,
  4031. "client_secret": clientSecret,
  4032. "code": code,
  4033. "code_verifier": codeVerifier,
  4034. "redirect_uri": redirectURI,
  4035. "grant_type": "authorization_code"
  4036. ])
  4037. let (data, response) = try await URLSession.shared.data(for: request)
  4038. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  4039. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  4040. }
  4041. struct TokenResponse: Decodable {
  4042. let access_token: String
  4043. let expires_in: Double
  4044. let refresh_token: String?
  4045. let scope: String?
  4046. let token_type: String?
  4047. }
  4048. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  4049. return GoogleOAuthTokens(
  4050. accessToken: decoded.access_token,
  4051. refreshToken: decoded.refresh_token,
  4052. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  4053. scope: decoded.scope,
  4054. tokenType: decoded.token_type
  4055. )
  4056. }
  4057. private static func pkceChallenge(for verifier: String) -> String {
  4058. let digest = SHA256.hash(data: Data(verifier.utf8))
  4059. return Data(digest).base64URLEncodedString()
  4060. }
  4061. private static func randomURLSafeString(length: Int) -> String {
  4062. var bytes = [UInt8](repeating: 0, count: length)
  4063. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  4064. return Data(bytes).base64URLEncodedString()
  4065. }
  4066. private static func formURLEncoded(_ params: [String: String]) -> Data {
  4067. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  4068. let pairs = params.map { key, value in
  4069. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  4070. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  4071. return "\(k)=\(v)"
  4072. }.joined(separator: "&")
  4073. return Data(pairs.utf8)
  4074. }
  4075. private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
  4076. guard let scopeValue else { return false }
  4077. return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
  4078. }
  4079. }
  4080. private extension Data {
  4081. func base64URLEncodedString() -> String {
  4082. base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
  4083. }
  4084. }
  4085. final class KeychainTokenStore {
  4086. private let defaultsKey: String
  4087. private let defaults: UserDefaults
  4088. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  4089. account: String = "googleOAuthTokens",
  4090. defaults: UserDefaults = .standard) {
  4091. self.defaultsKey = "\(service).\(account)"
  4092. self.defaults = defaults
  4093. }
  4094. func readTokens() throws -> GoogleOAuthTokens? {
  4095. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  4096. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  4097. }
  4098. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  4099. let data = try JSONEncoder().encode(tokens)
  4100. defaults.set(data, forKey: defaultsKey)
  4101. }
  4102. func clearTokens() {
  4103. defaults.removeObject(forKey: defaultsKey)
  4104. }
  4105. }
  4106. private final class OAuthLoopbackServer {
  4107. /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
  4108. private static let loopbackOAuthPort: UInt16 = 8742
  4109. private let queue = DispatchQueue(label: "google.oauth.loopback.server")
  4110. private let listener: NWListener
  4111. private var readyContinuation: CheckedContinuation<Void, Error>?
  4112. private var callbackContinuation: CheckedContinuation<URL, Error>?
  4113. private var callbackURL: URL?
  4114. private init(listener: NWListener) {
  4115. self.listener = listener
  4116. }
  4117. static func start() async throws -> OAuthLoopbackServer {
  4118. guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
  4119. throw GoogleOAuthError.invalidCallbackURL
  4120. }
  4121. let listener = try NWListener(using: .tcp, on: port)
  4122. let server = OAuthLoopbackServer(listener: listener)
  4123. try await server.startListening()
  4124. return server
  4125. }
  4126. var redirectURI: String {
  4127. let port = listener.port?.rawValue ?? 0
  4128. return "http://127.0.0.1:\(port)/oauth2redirect"
  4129. }
  4130. func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
  4131. try await withThrowingTaskGroup(of: URL.self) { group in
  4132. group.addTask { [weak self] in
  4133. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  4134. return try await self.awaitCallback()
  4135. }
  4136. group.addTask {
  4137. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  4138. throw GoogleOAuthError.authenticationTimedOut
  4139. }
  4140. let url = try await group.next()!
  4141. group.cancelAll()
  4142. return url
  4143. }
  4144. }
  4145. func stop() {
  4146. listener.cancel()
  4147. }
  4148. private func startListening() async throws {
  4149. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
  4150. queue.async {
  4151. self.readyContinuation = continuation
  4152. self.listener.stateUpdateHandler = { [weak self] state in
  4153. guard let self else { return }
  4154. switch state {
  4155. case .ready:
  4156. if let readyContinuation = self.readyContinuation {
  4157. self.readyContinuation = nil
  4158. readyContinuation.resume()
  4159. }
  4160. case .failed(let error):
  4161. if let readyContinuation = self.readyContinuation {
  4162. self.readyContinuation = nil
  4163. readyContinuation.resume(throwing: error)
  4164. }
  4165. case .cancelled:
  4166. if let readyContinuation = self.readyContinuation {
  4167. self.readyContinuation = nil
  4168. readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
  4169. }
  4170. default:
  4171. break
  4172. }
  4173. }
  4174. self.listener.newConnectionHandler = { [weak self] connection in
  4175. self?.handle(connection: connection)
  4176. }
  4177. self.listener.start(queue: self.queue)
  4178. }
  4179. }
  4180. }
  4181. private func awaitCallback() async throws -> URL {
  4182. if let callbackURL { return callbackURL }
  4183. return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  4184. queue.async { self.callbackContinuation = continuation }
  4185. }
  4186. }
  4187. private func handle(connection: NWConnection) {
  4188. connection.start(queue: queue)
  4189. connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
  4190. guard let self else { return }
  4191. let requestLine = data.flatMap { String(data: $0, encoding: .utf8) }?.split(separator: "\r\n").first.map(String.init)
  4192. var parsedURL: URL?
  4193. if let requestLine {
  4194. let parts = requestLine.split(separator: " ")
  4195. if parts.count >= 2 {
  4196. parsedURL = URL(string: "http://127.0.0.1\(parts[1])")
  4197. }
  4198. }
  4199. self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
  4200. if let parsedURL {
  4201. self.callbackURL = parsedURL
  4202. self.callbackContinuation?.resume(returning: parsedURL)
  4203. self.callbackContinuation = nil
  4204. self.listener.cancel()
  4205. }
  4206. connection.cancel()
  4207. }
  4208. }
  4209. private func sendHTTPResponse(connection: NWConnection, success: Bool) {
  4210. 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>"
  4211. 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)"
  4212. connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
  4213. }
  4214. }
  4215. @MainActor
  4216. private final class OAuthWebViewContainerView: NSView {
  4217. private let webView: WKWebView
  4218. init(webView: WKWebView) {
  4219. self.webView = webView
  4220. super.init(frame: .zero)
  4221. addSubview(webView)
  4222. }
  4223. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  4224. override func layout() {
  4225. super.layout()
  4226. webView.frame = bounds
  4227. }
  4228. }
  4229. @MainActor
  4230. private final class InAppOAuthWindowController: NSWindowController {
  4231. private let webView: WKWebView
  4232. init() {
  4233. self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
  4234. let container = OAuthWebViewContainerView(webView: webView)
  4235. let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
  4236. window.title = "Google Sign-In"
  4237. window.contentView = container
  4238. super.init(window: window)
  4239. }
  4240. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  4241. func load(url: URL) { webView.load(URLRequest(url: url)) }
  4242. }
  4243. extension GoogleOAuthError: LocalizedError {
  4244. var errorDescription: String? {
  4245. switch self {
  4246. case .missingClientId: return "Missing Google OAuth Client ID."
  4247. case .missingClientSecret: return "Missing Google OAuth Client Secret."
  4248. case .invalidCallbackURL: return "Invalid OAuth callback URL."
  4249. case .missingAuthorizationCode: return "Google did not return an authorization code."
  4250. case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
  4251. case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
  4252. case .authenticationTimedOut: return "Google sign-in timed out."
  4253. }
  4254. }
  4255. }
  4256. extension ZoomOAuthError: LocalizedError {
  4257. var errorDescription: String? {
  4258. switch self {
  4259. case .missingClientId:
  4260. return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
  4261. case .missingClientSecret:
  4262. return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
  4263. case .invalidCallbackURL:
  4264. 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)."
  4265. case .missingAuthorizationCode:
  4266. return "Zoom did not return an authorization code."
  4267. case .tokenExchangeFailed(let details):
  4268. return details
  4269. case .missingRequiredScope(let details):
  4270. return "The Zoom access token is missing required scopes. \(details)"
  4271. case .rateLimited(let retryAfterSeconds):
  4272. if let retryAfterSeconds {
  4273. return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
  4274. }
  4275. return "Zoom rate limit reached. Try again later."
  4276. case .unableToOpenBrowser:
  4277. return "Could not open the system browser for Zoom sign-in."
  4278. case .authenticationTimedOut:
  4279. return "Zoom sign-in timed out waiting for the browser redirect."
  4280. }
  4281. }
  4282. }