| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205 |
- //
- // ViewController.swift
- // zoom_app
- //
- // Created by Dev Mac 1 on 14/04/2026.
- //
- import Cocoa
- import CryptoKit
- import StoreKit
- import WebKit
- class ViewController: NSViewController {
- enum HomeSection: String {
- case home = "Home"
- case meetings = "Meetings"
- case scheduler = "Scheduler"
- case settings = "Settings"
- }
- private let googleOAuth = GoogleOAuthService.shared
- private let zoomOAuth = ZoomOAuthService.shared
- private let loginStateKey = "zoom_app.isLoggedIn"
- private let darkModeDefaultsKey = "settings.darkModeEnabled"
- private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
- private let ratingCompletedKey = "zoom_app.ratingCompleted"
- private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
- private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
- private struct Palette {
- let isDarkMode: Bool
- var appBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.96, alpha: 1)
- }
- var sidebarBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.94, alpha: 1)
- }
- var sidebarActiveBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.86, alpha: 1)
- }
- var cardBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
- : NSColor.white
- }
- var secondaryCardBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
- // Slightly darker than pure white so cards are visible on Light background.
- : NSColor(calibratedWhite: 0.93, alpha: 1)
- }
- var appShellBackground: NSColor { appBackground }
- var contentShellBackground: NSColor { appBackground }
- var topStripBackground: NSColor { chromeUnifiedBackground }
- var chromeUnifiedBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.92, alpha: 1)
- }
- var searchPillBackground: NSColor {
- isDarkMode
- ? NSColor.white.withAlphaComponent(0.06)
- : NSColor.black.withAlphaComponent(0.06)
- }
- var meetingCardBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.98, alpha: 1)
- }
- var sectionCard: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
- : NSColor.white
- }
- var inputBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.97, alpha: 1)
- }
- var inputBorder: NSColor {
- isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor.black.withAlphaComponent(0.10)
- }
- var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
- var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
- var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
- }
- private final class TopAlignedClipView: NSClipView {
- override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
- var rect = super.constrainBoundsRect(proposedBounds)
- rect.origin.x = 0
- return rect
- }
- }
- private final class HoverButton: NSButton {
- var normalColor: NSColor = .clear { didSet { applyBackground() } }
- var hoverColor: NSColor = .clear
- var onHoverChanged: ((Bool) -> Void)?
- private var tracking: NSTrackingArea?
- private var hovering = false { didSet { applyBackground() } }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let tracking { removeTrackingArea(tracking) }
- let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
- addTrackingArea(area)
- tracking = area
- }
- override func mouseEntered(with event: NSEvent) {
- hovering = true
- onHoverChanged?(true)
- }
- override func mouseExited(with event: NSEvent) {
- hovering = false
- onHoverChanged?(false)
- }
- private func applyBackground() {
- wantsLayer = true
- layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
- }
- }
- private final class HoverTileView: NSView {
- var onHoverChanged: ((Bool) -> Void)?
- private var tracking: NSTrackingArea?
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let tracking { removeTrackingArea(tracking) }
- let area = NSTrackingArea(
- rect: bounds,
- options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- addTrackingArea(area)
- tracking = area
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- onHoverChanged?(true)
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- onHoverChanged?(false)
- }
- }
- private var palette = Palette(isDarkMode: true)
- private var darkModeEnabled: Bool {
- get {
- let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
- return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
- }
- set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
- }
- private let sidebarWidth: CGFloat = 78
- private var appBackground: NSColor { palette.appBackground }
- private var sidebarBackground: NSColor { palette.sidebarBackground }
- private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
- private var cardBackground: NSColor { palette.cardBackground }
- private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
- private var appShellBackground: NSColor { palette.appShellBackground }
- private var contentShellBackground: NSColor { palette.contentShellBackground }
- private var topStripBackground: NSColor { palette.topStripBackground }
- private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
- private var searchPillBackground: NSColor { palette.searchPillBackground }
- private var meetingCardBackground: NSColor { palette.meetingCardBackground }
- private let appShellCornerRadius: CGFloat = 20
- private let homeChromeHeaderHeight: CGFloat = 56
- private let nativeTrafficLightsLeading: CGFloat = 14
- private let nativeTrafficLightsTopInset: CGFloat = 20
- private let brandLeadingInset: CGFloat = 84
- private let brandFullScreenLeadingInset: CGFloat = 14
- private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
- private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
- private var primaryText: NSColor { palette.primaryText }
- private var secondaryText: NSColor { palette.secondaryText }
- private var mutedText: NSColor { palette.mutedText }
- private let rootContainer = NSView()
- private var homeView: NSView?
- private weak var googleButton: NSButton?
- private weak var timeLabel: NSTextField?
- private weak var dateLabel: NSTextField?
- private weak var meetingsDayHeaderLabel: NSTextField?
- private weak var emptyMeetingLabel: NSTextField?
- private weak var meetingsListStack: NSStackView?
- private weak var meetingsStatusLabel: NSTextField?
- private weak var meetingsScrollView: NSScrollView?
- private weak var meetingsPrevDayButton: NSButton?
- private weak var meetingsNextDayButton: NSButton?
- private weak var meetingsTodayButton: NSButton?
- private weak var refreshMeetingsButton: NSButton?
- private weak var homeWelcomeLabel: NSTextField?
- private weak var homeTimeLabelView: NSTextField?
- private weak var homeDateLabelView: NSTextField?
- private weak var homeActionsRow: NSView?
- private weak var homeMeetingsPanel: NSView?
- private weak var homePlaceholderLabel: NSTextField?
- private weak var homeSearchField: NSTextField?
- private weak var homeSearchPill: NSView?
- private weak var meetingSearchOverlayView: NSView?
- private weak var meetingSearchPanelView: NSView?
- private weak var meetingSearchField: NSTextField?
- private weak var meetingSearchResultsStack: NSStackView?
- private weak var meetingSearchEmptyLabel: NSTextField?
- private weak var homeSettingsView: NSView?
- private weak var schedulerRootView: NSView?
- private weak var schedulerMonthLabel: NSTextField?
- private weak var schedulerCalendarGridStack: NSStackView?
- private weak var schedulerSelectedDateLabel: NSTextField?
- private weak var schedulerMeetingsListStack: NSStackView?
- private weak var schedulerMeetingsEmptyLabel: NSTextField?
- private weak var schedulerTodayButton: NSButton?
- private weak var meetingsPageRootView: NSView?
- private weak var meetingsPageCategoryFilter: NSPopUpButton?
- private weak var meetingsPageFromDatePicker: NSDatePicker?
- private weak var meetingsPageToDatePicker: NSDatePicker?
- private weak var meetingsPageRefreshButton: NSButton?
- private weak var meetingsPageCountLabel: NSTextField?
- private weak var meetingsPageStatusLabel: NSTextField?
- private weak var meetingsPageListStack: NSStackView?
- private weak var meetingsPageEmptyLabel: NSTextField?
- private weak var meetingsPageLoadMoreButton: NSButton?
- private weak var meetingsPageScrollView: NSScrollView?
- private weak var homePremiumLockOverlay: NSButton?
- private weak var meetingsPremiumLockOverlay: NSButton?
- private weak var schedulerPremiumLockOverlay: NSButton?
- private var schedulerDayButtons: [Date: NSButton] = [:]
- private var schedulerDateByButtonTag: [Int: Date] = [:]
- private var nextSchedulerButtonTag: Int = 1
- private weak var settingsDarkModeSwitch: NSSwitch?
- private weak var settingsUpgradeButton: NSButton?
- private weak var settingsUpgradeTitleLabel: NSTextField?
- private weak var settingsGoogleActionButton: NSButton?
- private weak var topBarPremiumButton: NSButton?
- private weak var topBarAuthButton: NSButton?
- private var topBarAuthButtonWidthConstraint: NSLayoutConstraint?
- private var profileMenuPopover: NSPopover?
- private weak var paywallOverlayView: NSView?
- private weak var paywallPanelView: NSView?
- private weak var paywallBodyStack: NSStackView?
- private weak var paywallPlansRowWide: NSStackView?
- private weak var paywallPlansGridNarrow: NSStackView?
- private var paywallIsDesktopLayout = false
- private var paywallPanelWidthConstraint: NSLayoutConstraint?
- private var paywallPanelHeightConstraint: NSLayoutConstraint?
- private var joinMeetingWindow: NSWindow?
- private var scheduleMeetingWindow: NSWindow?
- private weak var scheduleTopicField: NSTextField?
- private weak var scheduleDateField: NSTextField?
- private weak var scheduleTimeField: NSTextField?
- private weak var scheduleTimeZonePopup: NSPopUpButton?
- private weak var scheduleDurationField: NSTextField?
- private weak var scheduleSubmitButton: NSButton?
- private var scheduleTimeZoneDisplayToIdentifier: [String: String] = [:]
- private weak var joinURLField: NSTextField?
- private weak var joinMeetingIDField: NSTextField?
- private weak var joinPasscodeField: NSTextField?
- private weak var joinURLFieldsContainer: NSView?
- private weak var joinIDFieldsContainer: NSView?
- private weak var joinModeSegment: NSSegmentedControl?
- private let paywallContentWidth: CGFloat = 520
- private var selectedPremiumPlan: PremiumPlan = .yearly
- private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
- private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
- private var paywallPlanPriceLabels: [PremiumPlan: [NSTextField]] = [:]
- private var paywallPlanSubtitleLabels: [PremiumPlan: [NSTextField]] = [:]
- private var paywallPlanStrikeLabels: [PremiumPlan: [NSTextField]] = [:]
- private weak var paywallOfferLabel: NSTextField?
- private weak var paywallContinueLabel: NSTextField?
- private weak var paywallContinueButton: NSView?
- private var paywallPurchaseTask: Task<Void, Never>?
- private var paywallContinueEnabled = true
- private var lastKnownPremiumAccess = false
- private var allScheduledMeetings: [ScheduledMeeting] = []
- private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
- private var schedulerVisibleMonthStart: Date = {
- let calendar = Calendar.current
- let today = Date()
- let components = calendar.dateComponents([.year, .month], from: today)
- return calendar.date(from: components) ?? calendar.startOfDay(for: today)
- }()
- private var selectedHomeSidebarItem: String = "Home"
- private var homeSidebarRowViews: [String: NSView] = [:]
- private var homeSidebarIconViews: [String: NSImageView] = [:]
- private var homeSidebarLabels: [String: NSTextField] = [:]
- private var searchTextObserver: NSObjectProtocol?
- private var searchShortcutMonitor: Any?
- private var searchOutsideClickMonitor: Any?
- private var meetingSearchTextObserver: NSObjectProtocol?
- private var globalSearchResults: [ScheduledMeeting] = []
- private var topBarProfileInitial = "H"
- private var clockTimer: Timer?
- private var meetingsRefreshTimer: Timer?
- private var brandLeadingConstraint: NSLayoutConstraint?
- private var windowFullScreenObservers: [NSObjectProtocol] = []
- private var isSigningIn = false
- private var isPromptingZoomCredentials = false
- private var isLoadingMeetings = false
- private var meetingsScrollObserver: NSObjectProtocol?
- private var meetingsPageScrollObserver: NSObjectProtocol?
- private var lastMeetingsRefreshAt = Date.distantPast
- private var lastScrollEdgeRefreshAt = Date.distantPast
- private var lastMeetingsPageAutoLoadAt = Date.distantPast
- // Keep this conservative to avoid Zoom API rate limits.
- private let meetingsRefreshInterval: TimeInterval = 60
- private let scrollRefreshCooldown: TimeInterval = 3
- private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
- private var meetingsRateLimitedUntil: Date?
- private var zoomAccountTimeZone: TimeZone?
- private var meetingTimeZoneOverrides: [String: String] = [:]
- private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
- private var meetingsPageSelectedRangePreset: String = "All"
- private var meetingsPageFromDateFilter: Date?
- private var meetingsPageToDateFilter: Date?
- private var meetingsPageVisibleCount: Int = 0
- private let meetingsPageSize: Int = 8
- private var premiumRatingPromptWorkItem: DispatchWorkItem?
- private var usageRatingPromptWorkItem: DispatchWorkItem?
- private let usageRatingPromptDelay: TimeInterval = 30 * 60
- private let premiumRatingPromptDelay: TimeInterval = 10
-
- private enum SidebarStyle {
- case home
- }
- private enum PremiumPlan: String, CaseIterable {
- case weekly = "com.mqldev.zoomapp.premium.weekly"
- case monthly = "com.mqldev.zoomapp.premium.monthly"
- case yearly = "com.mqldev.zoomapp.premium.yearly"
- case lifetime = "com.mqldev.zoomapp.premium.lifetime"
- var displayName: String {
- switch self {
- case .weekly: return "Premium Weekly"
- case .monthly: return "Premium Monthly"
- case .yearly: return "Premium Yearly"
- case .lifetime: return "Premium Lifetime"
- }
- }
- }
- private final class StoreKitCoordinator {
- enum PurchaseOutcome {
- case success
- case pending
- case cancelled
- case failed(String)
- }
- private(set) var productsByID: [String: Product] = [:]
- private(set) var activeProductIDs = Set<String>()
- var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
- var hasLifetimePremiumAccess: Bool { activeProductIDs.contains(PremiumPlan.lifetime.rawValue) }
- private var transactionUpdatesTask: Task<Void, Never>?
- var onEntitlementsChanged: ((Bool) -> Void)?
- deinit {
- transactionUpdatesTask?.cancel()
- }
- func start() async {
- await refreshProducts()
- await refreshEntitlements()
- observeTransactionUpdatesIfNeeded()
- }
- func refreshProducts() async {
- let productIDs = PremiumPlan.allCases.map(\.rawValue)
- var loadedProducts: [Product] = []
- var attempts = 0
- let maxAttempts = 3
- while attempts < maxAttempts {
- attempts += 1
- do {
- loadedProducts = try await Product.products(for: productIDs)
- } catch {
- loadedProducts = []
- }
- if loadedProducts.isEmpty == false {
- break
- }
- if attempts < maxAttempts {
- // StoreKit can transiently return empty right after launch/network changes.
- try? await Task.sleep(nanoseconds: UInt64(attempts) * 500_000_000)
- }
- }
- if loadedProducts.isEmpty == false {
- productsByID = Dictionary(uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) })
- }
- }
- func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
- guard let product = productsByID[plan.rawValue] else {
- await refreshProducts()
- guard let refreshed = productsByID[plan.rawValue] else {
- return .failed("Product not available. Check StoreKit configuration and product IDs.")
- }
- return await purchase(product: refreshed)
- }
- return await purchase(product: product)
- }
- func restorePurchases() async -> String {
- do {
- try await AppStore.sync()
- await refreshEntitlements()
- return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
- } catch {
- return "Restore failed. \(error.localizedDescription)"
- }
- }
- private func purchase(product: Product) async -> PurchaseOutcome {
- do {
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
- await transaction.finish()
- await refreshEntitlements()
- return .success
- case .pending:
- return .pending
- case .userCancelled:
- return .cancelled
- @unknown default:
- return .failed("Unknown purchase state.")
- }
- } catch {
- return .failed(error.localizedDescription)
- }
- }
- private func observeTransactionUpdatesIfNeeded() {
- guard transactionUpdatesTask == nil else { return }
- transactionUpdatesTask = Task { [weak self] in
- for await update in Transaction.updates {
- guard case .verified(let transaction) = update else { continue }
- guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
- await self?.refreshEntitlements()
- await transaction.finish()
- }
- }
- }
- @MainActor
- private func refreshEntitlements() async {
- let previousHasPremiumAccess = hasPremiumAccess
- let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
- var active = Set<String>()
- for await entitlement in Transaction.currentEntitlements {
- guard case .verified(let transaction) = entitlement else { continue }
- guard allIDs.contains(transaction.productID) else { continue }
- if Self.isTransactionActive(transaction) {
- active.insert(transaction.productID)
- }
- }
- // StoreKit testing can briefly report empty current entitlements even though a latest
- // verified transaction exists for a non-consumable. Merge in latest transactions.
- for productID in allIDs {
- guard let latest = await Transaction.latest(for: productID),
- case .verified(let transaction) = latest else { continue }
- if Self.isTransactionActive(transaction) {
- active.insert(productID)
- }
- }
- activeProductIDs = active
- let newHasPremiumAccess = hasPremiumAccess
- if newHasPremiumAccess != previousHasPremiumAccess {
- onEntitlementsChanged?(newHasPremiumAccess)
- }
- }
- private static func isTransactionActive(_ transaction: Transaction) -> Bool {
- if transaction.revocationDate != nil { return false }
- if let expirationDate = transaction.expirationDate {
- return expirationDate > Date()
- }
- return true
- }
- }
- private let storeKitCoordinator = StoreKitCoordinator()
- private var storeKitStartupTask: Task<Void, Never>?
- private var launchPaywallWorkItem: DispatchWorkItem?
- private let launchPaywallDelay: TimeInterval = 2
- private var hasPresentedLaunchPaywall = false
- override func viewDidLoad() {
- super.viewDidLoad()
- darkModeEnabled = systemPrefersDarkMode()
- palette = Palette(isDarkMode: darkModeEnabled)
- NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
- view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
- meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
- ensureUsageSessionStartDate()
- scheduleUsageBasedRatingPromptIfNeeded()
- setupUI()
- startStoreKit()
- }
- override func viewDidAppear() {
- super.viewDidAppear()
- if let window = view.window {
- window.setContentSize(NSSize(width: 1020, height: 690))
- applyWindowBackgroundForCurrentTheme(window)
- // Use full-size content view so custom top chrome sits in the titlebar region.
- window.titleVisibility = .hidden
- window.titlebarAppearsTransparent = true
- window.isMovableByWindowBackground = true
- window.styleMask.insert(.fullSizeContentView)
- installWindowFullScreenObservers(for: window)
- }
- alignNativeTrafficLights()
- showHomeView(profile: nil)
- syncBrandPositionForCurrentWindowState(animated: false)
- scheduleLaunchPaywallIfNeeded()
- }
- override func viewDidLayout() {
- super.viewDidLayout()
- alignNativeTrafficLights()
- if paywallOverlayView != nil {
- updatePaywallPanelSize()
- updatePaywallResponsiveLayout()
- }
- }
- private func setupUI() {
- view.wantsLayer = true
- view.layer?.backgroundColor = appBackground.cgColor
- rootContainer.translatesAutoresizingMaskIntoConstraints = false
- view.addSubview(rootContainer)
- NSLayoutConstraint.activate([
- rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
- rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ])
- }
- private func installWindowFullScreenObservers(for window: NSWindow) {
- guard windowFullScreenObservers.isEmpty else { return }
- let center = NotificationCenter.default
- let enterObserver = center.addObserver(
- forName: NSWindow.didEnterFullScreenNotification,
- object: window,
- queue: .main
- ) { [weak self] _ in
- self?.syncBrandPositionForCurrentWindowState(animated: true)
- }
- let exitObserver = center.addObserver(
- forName: NSWindow.didExitFullScreenNotification,
- object: window,
- queue: .main
- ) { [weak self] _ in
- self?.syncBrandPositionForCurrentWindowState(animated: true)
- }
- windowFullScreenObservers = [enterObserver, exitObserver]
- }
- private func syncBrandPositionForCurrentWindowState(animated: Bool) {
- guard let constraint = brandLeadingConstraint else { return }
- let isFullScreen = view.window?.styleMask.contains(.fullScreen) == true
- let targetInset = isFullScreen ? brandFullScreenLeadingInset : brandLeadingInset
- guard abs(constraint.constant - targetInset) > 0.5 else { return }
- if animated {
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.2
- constraint.animator().constant = targetInset
- view.layoutSubtreeIfNeeded()
- }
- } else {
- constraint.constant = targetInset
- }
- }
- private func showHomeView(profile: GoogleUserProfile?) {
- clearMeetingsScrollObserver()
- clearMeetingsPageScrollObserver()
- removeSearchFieldObserver()
- removeMeetingSearchObserver()
- removeSearchShortcutMonitor()
- homeSearchField = nil
- homeSearchPill = nil
- meetingSearchOverlayView = nil
- meetingSearchPanelView = nil
- meetingSearchField = nil
- meetingSearchResultsStack = nil
- meetingSearchEmptyLabel = nil
- meetingsPageRootView = nil
- homeView?.removeFromSuperview()
- if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
- topBarProfileInitial = String(profileName.prefix(1)).uppercased()
- }
- selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
- homeView = makeHomeView(profile: profile)
- if let homeView { attachToRoot(homeView) }
- installSearchShortcutMonitor()
- startClock()
- applyAuthenticationUIState(triggerInitialRefresh: true)
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- private func isUserLoggedIn() -> Bool {
- UserDefaults.standard.bool(forKey: loginStateKey)
- }
- private func persistLoggedInState(_ loggedIn: Bool) {
- UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
- }
- private func attachToRoot(_ subview: NSView) {
- subview.translatesAutoresizingMaskIntoConstraints = false
- if subview.superview != rootContainer {
- rootContainer.addSubview(subview)
- }
- NSLayoutConstraint.activate([
- subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
- subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
- subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
- subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
- ])
- }
- @objc private func googleLoginTapped() {
- guard isSigningIn == false else { return }
- isSigningIn = true
- googleButton?.title = "..."
- googleButton?.isEnabled = false
- Task {
- do {
- let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
- let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
- await MainActor.run {
- self.googleButton?.title = "G"
- self.resetLoginSigningInState()
- self.completeLoginAndRestart(profile: profile)
- }
- } catch {
- await MainActor.run {
- self.isSigningIn = false
- self.googleButton?.title = "G"
- self.googleButton?.isEnabled = true
- self.showSimpleError("Google login failed", error: error)
- }
- }
- }
- }
- /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
- @objc private func zoomPrimarySignInTapped() {
- guard isSigningIn == false else { return }
- isSigningIn = true
- topBarAuthButton?.title = "Signing in..."
- topBarAuthButton?.isEnabled = false
- googleButton?.isEnabled = false
- Task {
- do {
- let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
- guard configured else {
- await MainActor.run { self.resetLoginSigningInState() }
- return
- }
- let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
- let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
- let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
- let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
- await MainActor.run {
- self.resetLoginSigningInState()
- self.zoomAccountTimeZone = profileTimeZone
- self.completeLoginAndRestart(profile: profile)
- }
- } catch {
- await MainActor.run {
- self.resetLoginSigningInState()
- self.showSimpleError("Zoom sign-in failed", error: error)
- }
- }
- }
- }
- @objc private func scheduleMeetingTapped() {
- guard isUserLoggedIn() else {
- showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to schedule a meeting.")
- return
- }
- guard ensurePremiumAccess() else { return }
- if let existing = scheduleMeetingWindow {
- existing.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- DispatchQueue.main.async { [weak self] in
- _ = self?.scheduleTopicField?.becomeFirstResponder()
- }
- return
- }
- let content = makeScheduleMeetingPanelContent()
- let controller = NSViewController()
- controller.view = content
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
- styleMask: [.titled, .closable, .fullSizeContentView],
- backing: .buffered,
- defer: false
- )
- panel.title = "Schedule a meeting"
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
- panel.hidesOnDeactivate = true
- panel.isReleasedWhenClosed = false
- panel.standardWindowButton(.closeButton)?.isHidden = true
- panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
- panel.standardWindowButton(.zoomButton)?.isHidden = true
- panel.isMovableByWindowBackground = true
- panel.becomesKeyOnlyIfNeeded = false
- panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
- panel.center()
- panel.contentViewController = controller
- panel.delegate = self
- applyWindowBackgroundForCurrentTheme(panel)
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- scheduleMeetingWindow = panel
- DispatchQueue.main.async { [weak self] in
- _ = self?.scheduleTopicField?.becomeFirstResponder()
- }
- }
- private func makeScheduleMeetingPanelContent() -> NSView {
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = appBackground.cgColor
- let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
- let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
- let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.isBordered = false
- closeButton.bezelStyle = .regularSquare
- closeButton.wantsLayer = true
- closeButton.layer?.cornerRadius = 14
- closeButton.normalColor = palette.inputBackground
- closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
- closeButton.layer?.borderColor = palette.inputBorder.cgColor
- closeButton.layer?.borderWidth = 1
- closeButton.font = .systemFont(ofSize: 13, weight: .bold)
- closeButton.contentTintColor = secondaryText
- closeButton.toolTip = "Close"
- closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
- closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- let titleRow = NSStackView()
- titleRow.orientation = .horizontal
- titleRow.alignment = .centerY
- titleRow.spacing = 10
- titleRow.translatesAutoresizingMaskIntoConstraints = false
- let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
- titleStack.orientation = .vertical
- titleStack.spacing = 2
- titleStack.alignment = .leading
- let titleSpacer = NSView()
- titleSpacer.translatesAutoresizingMaskIntoConstraints = false
- titleRow.addArrangedSubview(titleStack)
- titleRow.addArrangedSubview(titleSpacer)
- titleRow.addArrangedSubview(closeButton)
- let headerDivider = NSView()
- headerDivider.wantsLayer = true
- headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
- headerDivider.translatesAutoresizingMaskIntoConstraints = false
- let topicBox = makeJoinFormField(placeholder: "Meeting topic")
- scheduleTopicField = topicBox.textField
- topicBox.textField.stringValue = "Zoom meeting"
- let defaultStart = Date().addingTimeInterval(3600)
- let dateOnlyFormatter = DateFormatter()
- dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
- dateOnlyFormatter.timeZone = TimeZone.current
- dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
- let time12Formatter = DateFormatter()
- time12Formatter.locale = Locale(identifier: "en_US_POSIX")
- time12Formatter.timeZone = TimeZone.current
- time12Formatter.dateFormat = "h:mm a"
- let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
- scheduleDateField = dateBox.textField
- dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
- let timeBox = makeJoinFormField(placeholder: "2:30 PM")
- scheduleTimeField = timeBox.textField
- timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
- let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
- let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
- let tzCombo = NSPopUpButton()
- tzCombo.translatesAutoresizingMaskIntoConstraints = false
- tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
- tzCombo.wantsLayer = true
- tzCombo.layer?.cornerRadius = 10
- tzCombo.layer?.borderWidth = 1
- tzCombo.layer?.borderColor = palette.inputBorder.cgColor
- tzCombo.layer?.backgroundColor = palette.inputBackground.cgColor
- let tzOptions = makeTimeZoneDisplayOptions(referenceDate: defaultStart)
- scheduleTimeZoneDisplayToIdentifier = Dictionary(uniqueKeysWithValues: tzOptions.map { ($0.display, $0.identifier) })
- tzCombo.removeAllItems()
- tzCombo.addItems(withTitles: tzOptions.map(\.display))
- if let selected = tzOptions.first(where: { $0.identifier == TimeZone.current.identifier }) {
- tzCombo.selectItem(withTitle: selected.display)
- } else {
- tzCombo.selectItem(at: 0)
- }
- tzCombo.toolTip = "Timezone with GMT offset. Meeting start is interpreted in this zone."
- scheduleTimeZonePopup = tzCombo
- let tzHint = makeLabel("Includes GMT offset, e.g. GMT+05:00 - Asia/Karachi", size: 11, color: mutedText, weight: .regular, centered: false)
- let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
- tzStack.orientation = .vertical
- tzStack.spacing = 8
- tzStack.alignment = .leading
- tzStack.translatesAutoresizingMaskIntoConstraints = false
- let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
- durationBox.textField.stringValue = "60"
- scheduleDurationField = durationBox.textField
- let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
- let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
- let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
- let topicStack = NSStackView(views: [topicLabel, topicBox])
- topicStack.orientation = .vertical
- topicStack.spacing = 8
- topicStack.alignment = .leading
- topicStack.translatesAutoresizingMaskIntoConstraints = false
- let dateTimeRow = NSStackView(views: [dateBox, timeBox])
- dateTimeRow.orientation = .horizontal
- dateTimeRow.spacing = 10
- dateTimeRow.alignment = .top
- dateTimeRow.distribution = .fillEqually
- dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
- let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
- startStack.orientation = .vertical
- startStack.spacing = 8
- startStack.alignment = .leading
- startStack.translatesAutoresizingMaskIntoConstraints = false
- let durationStack = NSStackView(views: [durationLabel, durationBox])
- durationStack.orientation = .vertical
- durationStack.spacing = 8
- durationStack.alignment = .leading
- durationStack.translatesAutoresizingMaskIntoConstraints = false
- let formCard = NSView()
- formCard.translatesAutoresizingMaskIntoConstraints = false
- formCard.wantsLayer = true
- formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
- formCard.layer?.cornerRadius = 14
- formCard.layer?.borderWidth = 1
- formCard.layer?.borderColor = palette.inputBorder.cgColor
- let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
- cancelButton.isBordered = false
- cancelButton.wantsLayer = true
- cancelButton.layer?.cornerRadius = 10
- cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
- cancelButton.layer?.borderWidth = 1
- cancelButton.layer?.borderColor = palette.inputBorder.cgColor
- cancelButton.contentTintColor = primaryText
- cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
- submitButton.isBordered = false
- submitButton.wantsLayer = true
- submitButton.layer?.cornerRadius = 10
- submitButton.normalColor = accentBlue
- submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
- submitButton.contentTintColor = .white
- submitButton.font = .systemFont(ofSize: 13, weight: .bold)
- submitButton.keyEquivalent = "\r"
- scheduleSubmitButton = submitButton
- let buttons = NSStackView(views: [cancelButton, submitButton])
- buttons.orientation = .horizontal
- buttons.spacing = 12
- buttons.alignment = .centerY
- buttons.distribution = .fillEqually
- let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
- innerStack.orientation = .vertical
- innerStack.spacing = 18
- innerStack.alignment = .leading
- innerStack.translatesAutoresizingMaskIntoConstraints = false
- formCard.addSubview(innerStack)
- [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
- NSLayoutConstraint.activate([
- titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
- headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
- headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- headerDivider.heightAnchor.constraint(equalToConstant: 1),
- formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
- formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
- innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
- innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
- innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
- innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
- topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- tzCombo.heightAnchor.constraint(equalToConstant: 34),
- dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- submitButton.heightAnchor.constraint(equalToConstant: 40),
- cancelButton.heightAnchor.constraint(equalToConstant: 40)
- ])
- return root
- }
- @objc private func scheduleMeetingCancelTapped() {
- scheduleMeetingWindow?.performClose(nil)
- }
- @objc private func scheduleMeetingSubmitTapped() {
- let topicRaw = scheduleTopicField?.stringValue ?? ""
- let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard topic.isEmpty == false else {
- showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
- return
- }
- let tzId = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if tzId.isEmpty == false,
- scheduleTimeZoneDisplayToIdentifier[tzId] == nil,
- TimeZone(identifier: tzId) == nil {
- showSimpleAlert(
- title: "Timezone",
- message: "Choose a timezone from the list (with GMT offset), for example GMT+05:00 - Asia/Karachi."
- )
- return
- }
- guard let draft = scheduleMeetingDraftFromFormFields() else {
- showSimpleAlert(
- title: "Date & time",
- 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."
- )
- return
- }
- if draft.startDate < Date().addingTimeInterval(-30) {
- showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
- return
- }
- let durationRaw = scheduleDurationField?.stringValue ?? ""
- let digits = durationRaw.filter(\.isNumber)
- guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
- showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
- return
- }
- scheduleSubmitButton?.isEnabled = false
- let tz = draft.timeZone
- Task {
- do {
- let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
- guard configured else {
- await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
- return
- }
- let token = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
- let result = try await createZoomMeeting(
- accessToken: token,
- topic: topic,
- startTimeUTC: draft.startTimeUTC,
- durationMinutes: duration,
- timeZone: tz
- )
- await MainActor.run {
- self.saveMeetingTimeZoneOverride(meetingID: result.id.map(String.init), timeZone: tz)
- }
- await MainActor.run {
- self.scheduleSubmitButton?.isEnabled = true
- self.scheduleMeetingWindow?.performClose(nil)
- self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
- self.triggerMeetingsRefresh(force: true)
- if let join = result.join_url, join.isEmpty == false {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(join, forType: .string)
- self.showSimpleAlert(
- title: "Meeting scheduled",
- message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
- )
- } else {
- self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
- }
- }
- } catch {
- await MainActor.run {
- self.scheduleSubmitButton?.isEnabled = true
- if case ZoomOAuthError.missingRequiredScope(_) = error {
- self.zoomOAuth.clearSavedTokens()
- self.showSimpleAlert(
- title: "Zoom permissions",
- message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
- )
- } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
- let seconds = max(retryAfter ?? 300, 30)
- let minutes = Int(ceil(Double(seconds) / 60.0))
- self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
- } else {
- self.showSimpleError("Could not schedule", error: error)
- }
- }
- }
- }
- }
- private func resetScheduleMeetingPanelReferences() {
- scheduleMeetingWindow = nil
- scheduleTopicField = nil
- scheduleDateField = nil
- scheduleTimeField = nil
- scheduleTimeZonePopup = nil
- scheduleDurationField = nil
- scheduleSubmitButton = nil
- scheduleTimeZoneDisplayToIdentifier = [:]
- }
- /// Resolves the schedule panel timezone (IANA id from combo, or system default).
- private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
- let raw = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if let identifier = scheduleTimeZoneDisplayToIdentifier[raw],
- let mapped = TimeZone(identifier: identifier) {
- return mapped
- }
- guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
- return tz
- }
- private struct TimeZoneDisplayOption {
- let identifier: String
- let display: String
- }
- private func makeTimeZoneDisplayOptions(referenceDate: Date) -> [TimeZoneDisplayOption] {
- TimeZone.knownTimeZoneIdentifiers.compactMap { identifier in
- guard let tz = TimeZone(identifier: identifier) else { return nil }
- let seconds = tz.secondsFromGMT(for: referenceDate)
- let sign = seconds >= 0 ? "+" : "-"
- let absSeconds = abs(seconds)
- let hours = absSeconds / 3600
- let minutes = (absSeconds % 3600) / 60
- let offset = String(format: "GMT%@%02d:%02d", sign, hours, minutes)
- return TimeZoneDisplayOption(identifier: identifier, display: "\(offset) - \(identifier)")
- }
- .sorted { lhs, rhs in
- if lhs.display == rhs.display { return lhs.identifier < rhs.identifier }
- return lhs.display < rhs.display
- }
- }
- /// Parses zoom timezone values like `Asia/Karachi`, `GMT+05:00`, `UTC+5`, `+0500`, or `Z`.
- private func parseZoomTimeZone(_ raw: String?) -> TimeZone? {
- guard let raw else { return nil }
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { return nil }
- if let named = TimeZone(identifier: trimmed) { return named }
- if trimmed.caseInsensitiveCompare("Z") == .orderedSame { return TimeZone(secondsFromGMT: 0) }
- let normalized = trimmed.uppercased()
- let pattern = #"^(?:GMT|UTC)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$"#
- guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
- let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
- guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
- match.numberOfRanges == 4,
- let signRange = Range(match.range(at: 1), in: normalized),
- let hourRange = Range(match.range(at: 2), in: normalized) else {
- return nil
- }
- let sign = normalized[signRange] == "-" ? -1 : 1
- let hours = Int(normalized[hourRange]) ?? -1
- let minutes: Int = {
- if let minuteRange = Range(match.range(at: 3), in: normalized) {
- return Int(normalized[minuteRange]) ?? -1
- }
- return 0
- }()
- guard (0...23).contains(hours), (0...59).contains(minutes) else { return nil }
- return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60)))
- }
- private func saveMeetingTimeZoneOverride(meetingID: String?, timeZone: TimeZone) {
- guard let meetingID, meetingID.isEmpty == false else { return }
- meetingTimeZoneOverrides[meetingID] = timeZone.identifier
- UserDefaults.standard.set(meetingTimeZoneOverrides, forKey: meetingTimeZoneOverrideKey)
- }
- private struct ScheduleMeetingDraft {
- let startDate: Date
- let startTimeUTC: String
- let timeZone: TimeZone
- }
- /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
- private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
- let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
- let tz = resolvedScheduleTimeZoneForMeeting()
- let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
- // Parse date strictly as yyyy-MM-dd.
- let dateComponentsRaw = datePart.split(separator: "-")
- guard dateComponentsRaw.count == 3,
- let year = Int(dateComponentsRaw[0]),
- let month = Int(dateComponentsRaw[1]),
- let day = Int(dateComponentsRaw[2]) else {
- return nil
- }
- // Parse time strictly as h:mm AM/PM (optional :ss).
- // This avoids any locale/date formatter ambiguity that can flip AM/PM.
- let pattern = #"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])\s*$"#
- guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
- let nsRange = NSRange(normalizedTime.startIndex..<normalizedTime.endIndex, in: normalizedTime)
- guard let match = regex.firstMatch(in: normalizedTime, options: [], range: nsRange),
- match.numberOfRanges == 5,
- let hourRange = Range(match.range(at: 1), in: normalizedTime),
- let minuteRange = Range(match.range(at: 2), in: normalizedTime),
- let meridiemRange = Range(match.range(at: 4), in: normalizedTime) else {
- return nil
- }
- let rawHour = Int(normalizedTime[hourRange]) ?? -1
- let minute = Int(normalizedTime[minuteRange]) ?? -1
- let second: Int = {
- if let secondRange = Range(match.range(at: 3), in: normalizedTime) {
- return Int(normalizedTime[secondRange]) ?? -1
- }
- return 0
- }()
- let meridiem = normalizedTime[meridiemRange].uppercased()
- guard (1...12).contains(rawHour),
- (0...59).contains(minute),
- (0...59).contains(second) else {
- return nil
- }
- var hour24 = rawHour % 12
- if meridiem == "PM" { hour24 += 12 }
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = tz
- var components = DateComponents()
- components.year = year
- components.month = month
- components.day = day
- components.hour = hour24
- components.minute = minute
- components.second = second
- components.timeZone = tz
- guard let startDate = calendar.date(from: components) else { return nil }
- let utcFormatter = DateFormatter()
- utcFormatter.locale = Locale(identifier: "en_US_POSIX")
- utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
- utcFormatter.calendar = Calendar(identifier: .gregorian)
- // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
- utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
- return ScheduleMeetingDraft(
- startDate: startDate,
- startTimeUTC: utcFormatter.string(from: startDate),
- timeZone: tz
- )
- }
- @objc private func joinMeetingTapped() {
- if let existing = joinMeetingWindow {
- existing.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- DispatchQueue.main.async { [weak self] in
- _ = self?.joinURLField?.becomeFirstResponder()
- }
- return
- }
- let content = makeJoinMeetingPanelContent()
- let controller = NSViewController()
- controller.view = content
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
- styleMask: [.titled, .closable, .fullSizeContentView],
- backing: .buffered,
- defer: false
- )
- panel.title = "Join a meeting"
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
- panel.hidesOnDeactivate = true
- panel.isReleasedWhenClosed = false
- panel.standardWindowButton(.closeButton)?.isHidden = true
- panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
- panel.standardWindowButton(.zoomButton)?.isHidden = true
- panel.isMovableByWindowBackground = true
- // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
- panel.becomesKeyOnlyIfNeeded = false
- panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
- panel.center()
- panel.contentViewController = controller
- panel.delegate = self
- applyWindowBackgroundForCurrentTheme(panel)
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- joinMeetingWindow = panel
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- _ = self.joinURLField?.becomeFirstResponder()
- }
- }
- private func makeJoinMeetingPanelContent() -> NSView {
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = appBackground.cgColor
- let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
- let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
- let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.isBordered = false
- closeButton.bezelStyle = .regularSquare
- closeButton.wantsLayer = true
- closeButton.layer?.cornerRadius = 14
- closeButton.normalColor = palette.inputBackground
- closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
- closeButton.layer?.borderColor = palette.inputBorder.cgColor
- closeButton.layer?.borderWidth = 1
- closeButton.font = .systemFont(ofSize: 13, weight: .bold)
- closeButton.contentTintColor = secondaryText
- closeButton.toolTip = "Close"
- closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
- closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- let titleRow = NSStackView()
- titleRow.orientation = .horizontal
- titleRow.alignment = .centerY
- titleRow.spacing = 10
- titleRow.translatesAutoresizingMaskIntoConstraints = false
- let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
- titleStack.orientation = .vertical
- titleStack.spacing = 2
- titleStack.alignment = .leading
- let titleSpacer = NSView()
- titleSpacer.translatesAutoresizingMaskIntoConstraints = false
- titleRow.addArrangedSubview(titleStack)
- titleRow.addArrangedSubview(titleSpacer)
- titleRow.addArrangedSubview(closeButton)
- let headerDivider = NSView()
- headerDivider.wantsLayer = true
- headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
- headerDivider.translatesAutoresizingMaskIntoConstraints = false
- let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
- mode.segmentStyle = .rounded
- mode.selectedSegment = 0
- mode.translatesAutoresizingMaskIntoConstraints = false
- mode.font = .systemFont(ofSize: 12, weight: .semibold)
- mode.controlSize = .large
- if #available(macOS 11.0, *) {
- mode.selectedSegmentBezelColor = accentBlue
- }
- joinModeSegment = mode
- let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
- joinURLField = urlBox.textField
- let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
- let urlStack = NSStackView(views: [urlLabel, urlBox])
- urlStack.orientation = .vertical
- urlStack.spacing = 8
- urlStack.alignment = .leading
- urlStack.translatesAutoresizingMaskIntoConstraints = false
- joinURLFieldsContainer = urlStack
- let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
- let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
- joinMeetingIDField = idBox.textField
- joinPasscodeField = passBox.textField
- let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
- let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
- let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
- idStack.orientation = .vertical
- idStack.spacing = 8
- idStack.alignment = .leading
- idStack.translatesAutoresizingMaskIntoConstraints = false
- idStack.isHidden = true
- joinIDFieldsContainer = idStack
- let formCard = NSView()
- formCard.translatesAutoresizingMaskIntoConstraints = false
- formCard.wantsLayer = true
- formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
- formCard.layer?.cornerRadius = 14
- formCard.layer?.borderWidth = 1
- formCard.layer?.borderColor = palette.inputBorder.cgColor
- let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
- cancelButton.isBordered = false
- cancelButton.wantsLayer = true
- cancelButton.layer?.cornerRadius = 10
- cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
- cancelButton.layer?.borderWidth = 1
- cancelButton.layer?.borderColor = palette.inputBorder.cgColor
- cancelButton.contentTintColor = primaryText
- cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
- joinButton.isBordered = false
- joinButton.wantsLayer = true
- joinButton.layer?.cornerRadius = 10
- joinButton.normalColor = accentBlue
- joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
- joinButton.contentTintColor = .white
- joinButton.font = .systemFont(ofSize: 13, weight: .bold)
- joinButton.keyEquivalent = "\r"
- let buttons = NSStackView(views: [cancelButton, joinButton])
- buttons.orientation = .horizontal
- buttons.spacing = 12
- buttons.alignment = .centerY
- buttons.distribution = .fillEqually
- let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
- innerStack.orientation = .vertical
- innerStack.spacing = 18
- innerStack.alignment = .leading
- innerStack.translatesAutoresizingMaskIntoConstraints = false
- formCard.addSubview(innerStack)
- [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
- NSLayoutConstraint.activate([
- titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
- headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
- headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- headerDivider.heightAnchor.constraint(equalToConstant: 1),
- formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
- formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
- innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
- innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
- innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
- innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
- mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- joinButton.heightAnchor.constraint(equalToConstant: 40),
- cancelButton.heightAnchor.constraint(equalToConstant: 40)
- ])
- return root
- }
- private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
- JoinPanelFieldContainer(
- placeholder: placeholder,
- normalBorder: palette.inputBorder,
- focusBorder: accentBlue.withAlphaComponent(0.9),
- fill: palette.inputBackground,
- primaryText: primaryText,
- mutedText: mutedText
- )
- }
- @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
- let urlMode = sender.selectedSegment == 0
- joinURLFieldsContainer?.isHidden = urlMode == false
- joinIDFieldsContainer?.isHidden = urlMode
- }
- @objc private func joinMeetingCancelTapped() {
- joinMeetingWindow?.performClose(nil)
- }
- @objc private func joinMeetingSubmitTapped() {
- guard let segment = joinModeSegment else { return }
- if segment.selectedSegment == 0 {
- let raw = joinURLField?.stringValue ?? ""
- guard let url = parseZoomJoinURLFromUserInput(raw) else {
- showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
- return
- }
- openZoomMeetingInDefaultBrowser(url)
- } else {
- let idRaw = joinMeetingIDField?.stringValue ?? ""
- let digits = idRaw.filter(\.isNumber)
- guard digits.isEmpty == false else {
- showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
- return
- }
- let pass = joinPasscodeField?.stringValue ?? ""
- guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
- showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
- return
- }
- openZoomMeetingInDefaultBrowser(url)
- }
- }
- /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
- private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
- guard meetingIdDigits.isEmpty == false else { return nil }
- var components = URLComponents()
- components.scheme = "https"
- components.host = "zoom.us"
- components.path = "/wc/join/\(meetingIdDigits)"
- let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty == false {
- components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
- }
- return components.url
- }
- private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { return nil }
- let normalized: String
- if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
- normalized = trimmed
- } else {
- normalized = "https://\(trimmed)"
- }
- guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
- let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
- || host == "zoom.com" || host.hasSuffix(".zoom.com")
- guard isZoom else { return nil }
- return url
- }
- private func openZoomMeetingInDefaultBrowser(_ url: URL) {
- let opened = NSWorkspace.shared.open(url)
- if opened {
- joinMeetingWindow?.performClose(nil)
- } else {
- showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
- }
- }
- private func resetJoinMeetingPanelReferences() {
- joinMeetingWindow = nil
- joinURLField = nil
- joinMeetingIDField = nil
- joinPasscodeField = nil
- joinURLFieldsContainer = nil
- joinIDFieldsContainer = nil
- joinModeSegment = nil
- }
- @objc private func logoutTapped() {
- meetingsRefreshTimer?.invalidate()
- meetingsRefreshTimer = nil
- clearMeetingsScrollObserver()
- googleOAuth.clearSavedTokens()
- zoomOAuth.clearSavedTokens()
- persistLoggedInState(false)
- allScheduledMeetings = []
- applyAuthenticationUIState(triggerInitialRefresh: false)
- Task { @MainActor in
- self.applyMeetings([])
- }
- }
- @objc private func topBarAuthButtonTapped() {
- if isUserLoggedIn() {
- showProfileMenu()
- } else {
- zoomPrimarySignInTapped()
- }
- }
- @objc private func profileMenuLogoutTapped() {
- profileMenuPopover?.performClose(nil)
- logoutTapped()
- }
- private func showProfileMenu() {
- guard let button = topBarAuthButton else { return }
- // Recreate so the popover always matches current light/dark mode.
- profileMenuPopover = makeProfileMenuPopover()
- guard let popover = profileMenuPopover else { return }
- if popover.isShown {
- popover.performClose(nil)
- return
- }
- popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
- }
- private func makeProfileMenuPopover() -> NSPopover {
- let popover = NSPopover()
- popover.behavior = .transient
- popover.animates = true
- let contentController = NSViewController()
- let root = NSView(frame: NSRect(x: 0, y: 0, width: 198, height: 86))
- root.wantsLayer = true
- root.layer?.cornerRadius = 12
- root.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor(calibratedRed: 30 / 255, green: 33 / 255, blue: 40 / 255, alpha: 1)
- : NSColor(calibratedRed: 246 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
- ).cgColor
- root.layer?.borderWidth = 1
- root.layer?.borderColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor.black.withAlphaComponent(0.08)
- ).cgColor
- let logoutButton = NSButton(title: "Logout", target: self, action: #selector(profileMenuLogoutTapped))
- logoutButton.translatesAutoresizingMaskIntoConstraints = false
- logoutButton.isBordered = false
- logoutButton.wantsLayer = true
- logoutButton.layer?.cornerRadius = 9
- logoutButton.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor(calibratedWhite: 0.0, alpha: 0.06)
- ).cgColor
- logoutButton.contentTintColor = (palette.isDarkMode
- ? .white
- : NSColor(calibratedRed: 22 / 255, green: 24 / 255, blue: 29 / 255, alpha: 1)
- )
- logoutButton.font = .systemFont(ofSize: 13, weight: .semibold)
- logoutButton.alignment = .left
- logoutButton.imagePosition = .imageLeading
- logoutButton.imageHugsTitle = true
- logoutButton.bezelStyle = .shadowlessSquare
- logoutButton.title = " Logout"
- if let symbol = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: "Logout") {
- logoutButton.image = symbol.withSymbolConfiguration(.init(pointSize: 13, weight: .semibold))
- }
- let subtitle = makeLabel(
- "Account",
- size: 11,
- color: palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.72)
- : NSColor(calibratedRed: 88 / 255, green: 92 / 255, blue: 102 / 255, alpha: 1),
- weight: .medium,
- centered: false
- )
- subtitle.translatesAutoresizingMaskIntoConstraints = false
- root.addSubview(subtitle)
- root.addSubview(logoutButton)
- NSLayoutConstraint.activate([
- subtitle.topAnchor.constraint(equalTo: root.topAnchor, constant: 10),
- subtitle.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12),
- subtitle.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
- logoutButton.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 8),
- logoutButton.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 10),
- logoutButton.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -10),
- logoutButton.heightAnchor.constraint(equalToConstant: 34)
- ])
- contentController.view = root
- popover.contentViewController = contentController
- popover.contentSize = root.frame.size
- return popover
- }
-
- @objc private func topBarPlaceholderTapped() {
- // Reserved for future titlebar control actions.
- }
- @objc private func upgradeToProTapped() {
- if storeKitCoordinator.hasLifetimePremiumAccess {
- openManageSubscriptions()
- } else {
- showPaywall()
- }
- }
- private func openManageSubscriptions() {
- if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
- NSWorkspace.shared.open(url)
- }
- }
- @objc private func refreshMeetingsTapped() {
- guard isUserLoggedIn() else {
- applyGuestLoginMessages()
- return
- }
- guard ensurePremiumAccess() else {
- applyPremiumLockMessages()
- return
- }
- Task { @MainActor in
- self.animateMeetingsRefresh()
- self.setMeetingsLoadingUI(true)
- }
- triggerMeetingsRefresh(force: true)
- }
- @MainActor
- private func setMeetingsLoadingUI(_ loading: Bool) {
- refreshMeetingsButton?.isEnabled = loading == false
- meetingsPageRefreshButton?.isEnabled = loading == false
- if loading {
- meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
- }
- }
- @MainActor
- private func animateMeetingsRefresh() {
- guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
- let views = stack.arrangedSubviews
- let dimmed: CGFloat = 0.86
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.07
- context.timingFunction = CAMediaTimingFunction(name: .linear)
- views.forEach { $0.animator().alphaValue = dimmed }
- } completionHandler: {
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.08
- context.timingFunction = CAMediaTimingFunction(name: .linear)
- views.forEach { $0.animator().alphaValue = 1.0 }
- }
- }
- }
- private func startMeetingsAutoRefresh() {
- guard isUserLoggedIn() else { return }
- meetingsRefreshTimer?.invalidate()
- // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
- meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
- guard let self else { return }
- self.triggerMeetingsRefresh()
- }
- }
- private func triggerMeetingsRefresh(force: Bool = false) {
- guard isUserLoggedIn() else { return }
- guard storeKitCoordinator.hasPremiumAccess else { return }
- let now = Date()
- if let until = meetingsRateLimitedUntil, now < until {
- return
- }
- if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
- return
- }
- lastMeetingsRefreshAt = now
- Task { await self.loadScheduledMeetings() }
- }
- private func clearMeetingsScrollObserver() {
- if let meetingsScrollObserver {
- NotificationCenter.default.removeObserver(meetingsScrollObserver)
- }
- meetingsScrollObserver = nil
- meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
- meetingsScrollView = nil
- }
- private func clearMeetingsPageScrollObserver() {
- if let meetingsPageScrollObserver {
- NotificationCenter.default.removeObserver(meetingsPageScrollObserver)
- }
- meetingsPageScrollObserver = nil
- meetingsPageScrollView?.contentView.postsBoundsChangedNotifications = false
- meetingsPageScrollView = nil
- }
- private func removeSearchFieldObserver() {
- if let searchTextObserver {
- NotificationCenter.default.removeObserver(searchTextObserver)
- }
- searchTextObserver = nil
- }
- private func removeMeetingSearchObserver() {
- if let meetingSearchTextObserver {
- NotificationCenter.default.removeObserver(meetingSearchTextObserver)
- }
- meetingSearchTextObserver = nil
- }
- private func installSearchShortcutMonitor() {
- removeSearchShortcutMonitor()
- searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
- guard let self else { return event }
- if event.keyCode == 53, self.meetingSearchOverlayView?.isHidden == false {
- DispatchQueue.main.async {
- self.hideMeetingSearchPopup()
- }
- return nil
- }
- guard event.modifierFlags.contains(.command),
- event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
- guard self.homeSearchPill != nil else { return event }
- DispatchQueue.main.async {
- self.showMeetingSearchPopup(seedQuery: self.meetingSearchField?.stringValue ?? "")
- }
- return nil
- }
- searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
- guard let self else { return event }
- guard let window = self.view.window, event.window === window else { return event }
- guard self.meetingSearchOverlayView?.isHidden == false else { return event }
- guard let panel = self.meetingSearchPanelView else { return event }
- let rectInWindow = panel.convert(panel.bounds, to: nil)
- if rectInWindow.contains(event.locationInWindow) == false {
- DispatchQueue.main.async {
- self.hideMeetingSearchPopup()
- }
- }
- return event
- }
- }
- private func removeSearchShortcutMonitor() {
- if let searchShortcutMonitor {
- NSEvent.removeMonitor(searchShortcutMonitor)
- }
- searchShortcutMonitor = nil
- if let searchOutsideClickMonitor {
- NSEvent.removeMonitor(searchOutsideClickMonitor)
- }
- searchOutsideClickMonitor = nil
- }
- private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
- guard let fr = window.firstResponder else { return false }
- if fr === field { return true }
- if let editor = field.currentEditor(), fr === editor { return true }
- return false
- }
- @MainActor
- private func applySearchPillFocusBorder(focused: Bool) {
- homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
- homeSearchPill?.layer?.borderColor = accentBlue.cgColor
- }
- @MainActor
- private func focusHomeSearchField() {
- guard let field = homeSearchField else { return }
- view.window?.makeFirstResponder(field)
- }
- @MainActor
- private func showMeetingSearchPopup(seedQuery: String) {
- guard let overlay = meetingSearchOverlayView else { return }
- overlay.isHidden = false
- overlay.alphaValue = 0
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.16
- overlay.animator().alphaValue = 1
- }
- if let field = meetingSearchField {
- field.stringValue = seedQuery
- view.window?.makeFirstResponder(field)
- }
- applyGlobalMeetingSearch(query: seedQuery)
- }
- @MainActor
- private func hideMeetingSearchPopup() {
- guard let overlay = meetingSearchOverlayView else { return }
- overlay.isHidden = true
- overlay.alphaValue = 1
- view.window?.makeFirstResponder(nil)
- }
- private func applyGlobalMeetingSearch(query: String) {
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- let now = Date()
- let dateFormatter = DateFormatter()
- dateFormatter.dateStyle = .medium
- dateFormatter.timeStyle = .short
- let filtered: [ScheduledMeeting]
- if trimmed.isEmpty {
- filtered = allScheduledMeetings
- } else {
- filtered = allScheduledMeetings.filter { meeting in
- let dateText = dateFormatter.string(from: meeting.start).lowercased()
- return meeting.title.lowercased().contains(trimmed)
- || meeting.host.lowercased().contains(trimmed)
- || meeting.source.lowercased().contains(trimmed)
- || (meeting.meetingID?.lowercased().contains(trimmed) ?? false)
- || dateText.contains(trimmed)
- }
- }
- globalSearchResults = filtered.sorted { lhs, rhs in
- let lhsIsUpcoming = lhs.start >= now
- let rhsIsUpcoming = rhs.start >= now
- if lhsIsUpcoming != rhsIsUpcoming {
- return lhsIsUpcoming && !rhsIsUpcoming
- }
- if lhsIsUpcoming {
- return lhs.start < rhs.start
- }
- return lhs.start > rhs.start
- }
- renderMeetingSearchResults()
- }
- private func renderMeetingSearchResults() {
- guard let stack = meetingSearchResultsStack else { return }
- stack.arrangedSubviews.forEach { row in
- stack.removeArrangedSubview(row)
- row.removeFromSuperview()
- }
- meetingSearchEmptyLabel?.isHidden = globalSearchResults.isEmpty == false
- for (index, meeting) in globalSearchResults.enumerated() {
- let row = makeMeetingSearchResultRow(meeting)
- row.tag = index
- stack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- private func makeMeetingSearchResultRow(_ meeting: ScheduledMeeting) -> NSButton {
- let row = NSButton(title: "", target: self, action: #selector(meetingSearchResultTapped(_:)))
- row.isBordered = false
- row.bezelStyle = .regularSquare
- row.setButtonType(.momentaryChange)
- row.wantsLayer = true
- row.layer?.backgroundColor = meetingCardBackground.cgColor
- row.layer?.cornerRadius = 10
- row.layer?.borderWidth = 1
- row.layer?.borderColor = palette.inputBorder.cgColor
- row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 84).isActive = true
- let title = makeLabel(meeting.title, size: 14, color: primaryText, weight: .semibold, centered: false)
- title.lineBreakMode = .byTruncatingTail
- let subtitle = makeLabel(compactMeetingSummary(meeting), size: 12, color: secondaryText, weight: .regular, centered: false)
- subtitle.lineBreakMode = .byTruncatingTail
- let meta = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: mutedText, weight: .regular, centered: false)
- meta.lineBreakMode = .byTruncatingTail
- [title, subtitle, meta].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview($0)
- }
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
- title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12),
- title.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12),
- subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
- subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor),
- meta.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
- meta.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- meta.trailingAnchor.constraint(equalTo: title.trailingAnchor)
- ])
- return row
- }
- private func compactMeetingSummary(_ meeting: ScheduledMeeting) -> String {
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "EEE, MMM d"
- let timeFormatter = DateFormatter()
- timeFormatter.dateFormat = "h:mm a"
- let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
- dateFormatter.timeZone = displayTimeZone
- timeFormatter.timeZone = displayTimeZone
- let start = timeFormatter.string(from: meeting.start)
- let end = meeting.end.map { timeFormatter.string(from: $0) }
- let range = end.map { "\(start) - \($0)" } ?? start
- if let id = meeting.meetingID, id.isEmpty == false {
- return "\(dateFormatter.string(from: meeting.start)) • \(range) • ID \(id)"
- }
- return "\(dateFormatter.string(from: meeting.start)) • \(range)"
- }
- @objc private func topSearchPillTapped() {
- showMeetingSearchPopup(seedQuery: meetingSearchField?.stringValue ?? "")
- }
- @objc private func meetingSearchResultTapped(_ sender: NSButton) {
- guard let meeting = globalSearchResults[safe: sender.tag] else { return }
- if let url = meeting.webURL {
- NSWorkspace.shared.open(url)
- } else {
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- if let id = meeting.meetingID, id.isEmpty == false {
- pasteboard.setString(id, forType: .string)
- } else {
- pasteboard.setString(meeting.title, forType: .string)
- }
- }
- hideMeetingSearchPopup()
- }
- private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
- clearMeetingsScrollObserver()
- meetingsScrollView = scrollView
- scrollView.contentView.postsBoundsChangedNotifications = true
- meetingsScrollObserver = NotificationCenter.default.addObserver(
- forName: NSView.boundsDidChangeNotification,
- object: scrollView.contentView,
- queue: .main
- ) { [weak self, weak scrollView] _ in
- guard let self, let scrollView else { return }
- self.refreshMeetingsIfScrolledToEdge(scrollView)
- }
- }
- private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
- guard let documentView = scrollView.documentView else { return }
- let visibleRect = scrollView.contentView.bounds
- let contentHeight = documentView.bounds.height
- let viewportHeight = visibleRect.height
- if contentHeight <= viewportHeight + 2 {
- return
- }
- let maxOffset = max(contentHeight - viewportHeight, 0)
- let y = visibleRect.origin.y
- let threshold: CGFloat = 12
- let reachedTop = y <= threshold
- let reachedBottom = y >= (maxOffset - threshold)
- guard reachedTop || reachedBottom else { return }
- let now = Date()
- if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
- return
- }
- lastScrollEdgeRefreshAt = now
- triggerMeetingsRefresh(force: true)
- }
- private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
- clearMeetingsPageScrollObserver()
- meetingsPageScrollView = scrollView
- scrollView.contentView.postsBoundsChangedNotifications = true
- meetingsPageScrollObserver = NotificationCenter.default.addObserver(
- forName: NSView.boundsDidChangeNotification,
- object: scrollView.contentView,
- queue: .main
- ) { [weak self, weak scrollView] _ in
- guard let self, let scrollView else { return }
- self.loadMoreMeetingsIfNeededForScroll(scrollView)
- }
- }
- private func loadMoreMeetingsIfNeededForScroll(_ scrollView: NSScrollView) {
- guard meetingsPageVisibleCount < meetingsPageFilteredMeetings.count else { return }
- guard let documentView = scrollView.documentView else { return }
- let visibleRect = scrollView.contentView.bounds
- let contentHeight = documentView.bounds.height
- let viewportHeight = visibleRect.height
- guard contentHeight > 0, viewportHeight > 0 else { return }
- let maxOffset = max(contentHeight - viewportHeight, 0)
- let y = visibleRect.origin.y
- let threshold: CGFloat = 72
- guard y >= (maxOffset - threshold) else { return }
- let now = Date()
- guard now.timeIntervalSince(lastMeetingsPageAutoLoadAt) >= meetingsPageAutoLoadCooldown else { return }
- lastMeetingsPageAutoLoadAt = now
- let nextCount = meetingsPageVisibleCount + meetingsPageSize
- meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
- renderMeetingsPageList()
- }
- @MainActor
- private func resetLoginSigningInState() {
- isSigningIn = false
- updateTopBarAuthButton()
- googleButton?.isEnabled = true
- }
- @MainActor
- private func completeLoginAndRestart(profile: GoogleUserProfile?) {
- persistLoggedInState(true)
- showHomeView(profile: profile)
- if let window = view.window {
- window.makeKeyAndOrderFront(nil)
- }
- NSApp.activate(ignoringOtherApps: true)
- }
- @MainActor
- private func updateTopBarAuthButton() {
- guard let button = topBarAuthButton else { return }
- let loggedIn = isUserLoggedIn()
- if loggedIn {
- button.title = topBarProfileInitial
- button.toolTip = "Profile"
- button.font = .systemFont(ofSize: 14, weight: .bold)
- button.contentTintColor = primaryText
- button.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
- button.layer?.cornerRadius = 10
- topBarAuthButtonWidthConstraint?.constant = 34
- } else {
- button.title = "Sign in with Zoom"
- button.toolTip = "Sign in with Zoom"
- button.font = .systemFont(ofSize: 12, weight: .semibold)
- button.contentTintColor = .white
- button.layer?.backgroundColor = accentBlue.cgColor
- button.layer?.cornerRadius = 11
- topBarAuthButtonWidthConstraint?.constant = 120
- }
- button.isEnabled = isSigningIn == false
- }
- @MainActor
- private func applyGuestLoginMessages() {
- let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
- let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
- let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
- meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
- emptyMeetingLabel?.isHidden = isHome == false
- emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
- meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
- meetingsPageEmptyLabel?.isHidden = isMeetings == false
- meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
- meetingsPageCountLabel?.stringValue = "0 scheduled"
- schedulerMeetingsEmptyLabel?.isHidden = isScheduler == false
- schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
- }
- @MainActor
- private func applyPremiumLockMessages() {
- // Keep underlying section labels hidden while locked to avoid duplicate text beneath overlays.
- meetingsStatusLabel?.stringValue = ""
- emptyMeetingLabel?.isHidden = true
- meetingsPageStatusLabel?.stringValue = ""
- meetingsPageEmptyLabel?.isHidden = true
- meetingsPageCountLabel?.stringValue = ""
- schedulerMeetingsEmptyLabel?.isHidden = true
- }
- private func ensurePremiumAccess() -> Bool {
- guard storeKitCoordinator.hasPremiumAccess else {
- showPaywall()
- return false
- }
- return true
- }
- @MainActor
- private func applyAuthenticationUIState(triggerInitialRefresh: Bool) {
- let loggedIn = isUserLoggedIn()
- updateTopBarAuthButton()
- settingsGoogleActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
- settingsGoogleActionButton?.isEnabled = true
- updatePremiumLockedSections()
- meetingsRefreshTimer?.invalidate()
- meetingsRefreshTimer = nil
- meetingsRateLimitedUntil = nil
- if loggedIn {
- if storeKitCoordinator.hasPremiumAccess {
- startMeetingsAutoRefresh()
- if triggerInitialRefresh {
- triggerMeetingsRefresh(force: true)
- }
- } else {
- applyMeetings([])
- applyPremiumLockMessages()
- }
- } else {
- applyGuestLoginMessages()
- }
- }
- /// Returns false if the user cancelled or left credentials empty.
- @MainActor
- private func ensureZoomOAuthClientConfigured() -> Bool {
- if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
- return true
- }
- return presentZoomOAuthCredentialPrompt()
- }
- private func showSimpleError(_ title: String, error: Error) {
- let alert = NSAlert()
- alert.alertStyle = .warning
- alert.messageText = title
- alert.informativeText = error.localizedDescription
- alert.runModal()
- }
- private func showSimpleAlert(title: String, message: String) {
- let alert = NSAlert()
- alert.alertStyle = .informational
- alert.messageText = title
- alert.informativeText = message
- alert.runModal()
- }
- private struct ScheduledMeeting {
- let title: String
- let start: Date
- let end: Date?
- let meetingTimeZone: TimeZone?
- let host: String
- let source: String
- let meetingID: String?
- let typeLabel: String
- let statusLabel: String
- let webURL: URL?
- }
- @MainActor
- private func applyMeetings(_ meetings: [ScheduledMeeting]) {
- allScheduledMeetings = meetings
- if meetings.isEmpty == false {
- let calendar = Calendar.current
- let selectedStart = calendar.startOfDay(for: selectedMeetingsDayStart)
- let selectedEnd = calendar.date(byAdding: .day, value: 1, to: selectedStart) ?? selectedStart.addingTimeInterval(60 * 60 * 24)
- let selectedDayHasMeetings = meetings.contains { meeting in
- meeting.start >= selectedStart && meeting.start < selectedEnd
- }
- if selectedDayHasMeetings == false,
- let firstUpcoming = meetings
- .filter({ $0.start >= Date() })
- .sorted(by: { $0.start < $1.start })
- .first {
- selectedMeetingsDayStart = calendar.startOfDay(for: firstUpcoming.start)
- }
- }
- applyFilteredMeetings()
- applyMeetingsPageFilters(resetVisible: true)
- }
- @MainActor
- private func applyFilteredMeetings() {
- guard let stack = meetingsListStack else { return }
- let showingHomeSection = selectedHomeSidebarItem == HomeSection.home.rawValue
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- guard isUserLoggedIn() else {
- meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
- emptyMeetingLabel?.isHidden = showingHomeSection == false
- emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- meetingsStatusLabel?.stringValue = ""
- emptyMeetingLabel?.isHidden = true
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- return
- }
- let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- let calendar = Calendar.current
- let dayStart = selectedMeetingsDayStart
- let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
- let source = allScheduledMeetings.filter { meeting in
- meeting.start >= dayStart && meeting.start < dayEnd
- }
- let filtered: [ScheduledMeeting]
- if query.isEmpty {
- filtered = source
- } else {
- filtered = source.filter { meeting in
- meeting.title.lowercased().contains(query)
- || meeting.host.lowercased().contains(query)
- || meeting.source.lowercased().contains(query)
- }
- }
- let ordered = filtered.sorted(by: { $0.start < $1.start })
- if ordered.isEmpty {
- emptyMeetingLabel?.isHidden = showingHomeSection == false
- if source.isEmpty {
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
- emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
- } else {
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
- emptyMeetingLabel?.stringValue = "No meetings match your search."
- }
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- return
- }
- emptyMeetingLabel?.isHidden = true
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
- for meeting in ordered {
- let card = makeMeetingRowCard(meeting)
- stack.addArrangedSubview(card)
- // Make each meeting card span the full list width (like Zoom).
- if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
- card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- }
- @MainActor
- private func applyMeetingsPageFilters(resetVisible: Bool) {
- guard isUserLoggedIn() else {
- meetingsPageFilteredMeetings = []
- meetingsPageVisibleCount = 0
- renderMeetingsPageList()
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- meetingsPageFilteredMeetings = []
- meetingsPageVisibleCount = 0
- renderMeetingsPageList()
- return
- }
- let calendar = Calendar.current
- let now = Date()
- let activePreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? meetingsPageSelectedRangePreset
- meetingsPageSelectedRangePreset = activePreset
- let activeFromDate: Date?
- let activeToDate: Date?
- if activePreset == "Custom range",
- let fromPicker = meetingsPageFromDatePicker,
- let toPicker = meetingsPageToDatePicker {
- let fromDay = calendar.startOfDay(for: fromPicker.dateValue)
- let toDay = calendar.startOfDay(for: toPicker.dateValue)
- if fromDay <= toDay {
- activeFromDate = fromDay
- activeToDate = toDay
- } else {
- activeFromDate = toDay
- activeToDate = fromDay
- }
- meetingsPageFromDateFilter = activeFromDate
- meetingsPageToDateFilter = activeToDate
- } else {
- activeFromDate = meetingsPageFromDateFilter
- activeToDate = meetingsPageToDateFilter
- }
- var filtered = allScheduledMeetings.filter { meeting in
- guard meeting.start >= now else { return false }
- switch activePreset {
- case "Today":
- let dayStart = calendar.startOfDay(for: now)
- let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now
- return meeting.start >= now && meeting.start < dayEnd
- case "This week":
- let weekInterval = calendar.dateInterval(of: .weekOfYear, for: now)
- let weekEnd = weekInterval?.end ?? now
- return meeting.start >= now && meeting.start < weekEnd
- case "This month":
- let monthInterval = calendar.dateInterval(of: .month, for: now)
- let monthEnd = monthInterval?.end ?? now
- return meeting.start >= now && meeting.start < monthEnd
- case "Custom range":
- if let fromDate = activeFromDate {
- let startOfFrom = calendar.startOfDay(for: fromDate)
- if meeting.start < startOfFrom { return false }
- }
- if let toDate = activeToDate {
- let startOfTo = calendar.startOfDay(for: toDate)
- let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
- if meeting.start >= endOfTo { return false }
- }
- return true
- default:
- return true
- }
- }
- filtered.sort { $0.start < $1.start }
- meetingsPageFilteredMeetings = filtered
- if resetVisible {
- meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
- } else if filtered.count < meetingsPageVisibleCount {
- meetingsPageVisibleCount = filtered.count
- }
- renderMeetingsPageList()
- }
- @MainActor
- private func renderMeetingsPageList() {
- guard let stack = meetingsPageListStack else { return }
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- guard isUserLoggedIn() else {
- meetingsPageCountLabel?.stringValue = "0 scheduled"
- meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
- meetingsPageEmptyLabel?.isHidden = false
- meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
- meetingsPageLoadMoreButton?.isHidden = true
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- meetingsPageCountLabel?.stringValue = ""
- meetingsPageStatusLabel?.stringValue = ""
- meetingsPageEmptyLabel?.isHidden = true
- meetingsPageLoadMoreButton?.isHidden = true
- return
- }
- let totalCount = meetingsPageFilteredMeetings.count
- let visibleCount = min(meetingsPageVisibleCount, totalCount)
- meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
- if totalCount == 0 {
- meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
- meetingsPageEmptyLabel?.isHidden = false
- meetingsPageLoadMoreButton?.isHidden = true
- return
- }
- meetingsPageEmptyLabel?.isHidden = true
- meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
- for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
- let card = makeMeetingRowCard(meeting)
- stack.addArrangedSubview(card)
- if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
- card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- meetingsPageLoadMoreButton?.isHidden = true
- }
- @objc private func meetingsPageApplyFiltersTapped() {
- let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
- meetingsPageSelectedRangePreset = selectedPreset
- updateMeetingsDatePickersEnabledState()
- if selectedPreset == "Custom range",
- let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
- let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
- let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
- if fromDay <= toDay {
- meetingsPageFromDateFilter = fromDay
- meetingsPageToDateFilter = toDay
- } else {
- meetingsPageFromDateFilter = toDay
- meetingsPageToDateFilter = fromDay
- }
- } else {
- meetingsPageFromDateFilter = nil
- meetingsPageToDateFilter = nil
- }
- Task { @MainActor in
- self.applyMeetingsPageFilters(resetVisible: true)
- }
- }
- @objc private func meetingsPageResetFiltersTapped() {
- meetingsPageSelectedRangePreset = "All"
- meetingsPageFromDateFilter = nil
- meetingsPageToDateFilter = nil
- meetingsPageCategoryFilter?.selectItem(withTitle: "All")
- meetingsPageFromDatePicker?.dateValue = Date()
- meetingsPageToDatePicker?.dateValue = Date()
- updateMeetingsDatePickersEnabledState()
- Task { @MainActor in
- self.applyMeetingsPageFilters(resetVisible: true)
- }
- }
- @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
- meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
- updateMeetingsDatePickersEnabledState()
- Task { @MainActor in
- self.applyMeetingsPageFilters(resetVisible: true)
- }
- }
- private func updateMeetingsDatePickersEnabledState() {
- let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
- meetingsPageFromDatePicker?.isEnabled = isCustomRange
- meetingsPageToDatePicker?.isEnabled = isCustomRange
- meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
- meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
- }
- private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
- let calendar = Calendar.current
- let safeDayStart = calendar.startOfDay(for: dayStart)
- let dayEnd = calendar.date(byAdding: .day, value: 1, to: safeDayStart) ?? safeDayStart.addingTimeInterval(60 * 60 * 24)
- return allScheduledMeetings
- .filter { meeting in
- meeting.start >= safeDayStart && meeting.start < dayEnd
- }
- .sorted(by: { $0.start < $1.start })
- }
- @MainActor
- private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
- let calendar = Calendar.current
- selectedMeetingsDayStart = calendar.startOfDay(for: newDayStart)
- let monthComponents = calendar.dateComponents([.year, .month], from: selectedMeetingsDayStart)
- schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? selectedMeetingsDayStart
- updateMeetingsDayUI()
- applyFilteredMeetings()
- }
- @MainActor
- private func updateMeetingsDayUI() {
- let dayStart = selectedMeetingsDayStart
- let formatter = DateFormatter()
- formatter.dateFormat = "EEEE, MMM d"
- meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
- let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
- meetingsTodayButton?.isEnabled = isToday == false
- meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
- schedulerTodayButton?.isEnabled = isToday == false
- schedulerTodayButton?.alphaValue = isToday ? 0.55 : 1.0
- updateSchedulerDateHeader()
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- }
- private func meetingsDayDisplayName(for dayStart: Date) -> String {
- let calendar = Calendar.current
- if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
- if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
- calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
- if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
- calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
- let formatter = DateFormatter()
- formatter.dateFormat = "EEEE, MMM d"
- return formatter.string(from: dayStart)
- }
- @objc private func meetingsPrevDayTapped() {
- let calendar = Calendar.current
- let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(prev)
- }
- }
- @objc private func meetingsNextDayTapped() {
- let calendar = Calendar.current
- let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(next)
- }
- }
- @objc private func meetingsTodayTapped() {
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(Date())
- }
- }
- private func updateSchedulerDateHeader() {
- let formatter = DateFormatter()
- formatter.dateFormat = "MMMM yyyy"
- schedulerMonthLabel?.stringValue = formatter.string(from: schedulerVisibleMonthStart)
- let selectedFormatter = DateFormatter()
- selectedFormatter.dateFormat = "EEEE, MMM d"
- schedulerSelectedDateLabel?.stringValue = selectedFormatter.string(from: selectedMeetingsDayStart)
- }
- @MainActor
- private func applySchedulerMeetingsForSelectedDate() {
- guard let stack = schedulerMeetingsListStack else { return }
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- guard isUserLoggedIn() else {
- schedulerMeetingsEmptyLabel?.isHidden = false
- schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- schedulerMeetingsEmptyLabel?.isHidden = true
- return
- }
- let meetings = meetingsForDay(selectedMeetingsDayStart)
- if meetings.isEmpty {
- schedulerMeetingsEmptyLabel?.isHidden = false
- schedulerMeetingsEmptyLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: selectedMeetingsDayStart))."
- return
- }
- schedulerMeetingsEmptyLabel?.isHidden = true
- for meeting in meetings {
- let card = makeMeetingRowCard(meeting)
- stack.addArrangedSubview(card)
- if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
- card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- }
- @MainActor
- private func renderSchedulerCalendarGrid() {
- guard let gridStack = schedulerCalendarGridStack else { return }
- schedulerDayButtons = [:]
- schedulerDateByButtonTag = [:]
- nextSchedulerButtonTag = 1
- gridStack.arrangedSubviews.forEach { row in
- gridStack.removeArrangedSubview(row)
- row.removeFromSuperview()
- }
- let calendar = Calendar.current
- let monthStart = schedulerVisibleMonthStart
- let monthRange = calendar.range(of: .day, in: .month, for: monthStart) ?? 1..<32
- let monthWeekday = calendar.component(.weekday, from: monthStart)
- let leadingSlots = monthWeekday - 1
- let totalSlots = 42
- for week in 0..<6 {
- let row = NSStackView()
- row.orientation = .horizontal
- row.distribution = .fillEqually
- row.spacing = 6
- row.translatesAutoresizingMaskIntoConstraints = false
- gridStack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
- for weekday in 0..<7 {
- let cellIndex = (week * 7) + weekday
- let dayOffset = cellIndex - leadingSlots
- let dayDate = calendar.date(byAdding: .day, value: dayOffset, to: monthStart) ?? monthStart
- let dayNumber = calendar.component(.day, from: dayDate)
- let inCurrentMonth = monthRange.contains(dayNumber) && calendar.isDate(dayDate, equalTo: monthStart, toGranularity: .month)
- let hasMeetings = isUserLoggedIn() && meetingsForDay(dayDate).isEmpty == false
- let button = makeSchedulerDayCell(
- day: dayNumber,
- date: dayDate,
- inCurrentMonth: inCurrentMonth,
- selected: calendar.isDate(dayDate, inSameDayAs: selectedMeetingsDayStart),
- isToday: calendar.isDateInToday(dayDate),
- hasMeetings: hasMeetings
- )
- row.addArrangedSubview(button)
- schedulerDayButtons[calendar.startOfDay(for: dayDate)] = button
- }
- }
- // Keep a consistent 6x7 grid footprint.
- if totalSlots == 42 {
- gridStack.needsLayout = true
- }
- }
- private func makeSchedulerDayCell(
- day: Int,
- date: Date,
- inCurrentMonth: Bool,
- selected: Bool,
- isToday: Bool,
- hasMeetings: Bool
- ) -> NSButton {
- let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.focusRingType = .none
- button.font = .systemFont(ofSize: 16, weight: selected ? .semibold : .regular)
- button.alignment = .center
- button.wantsLayer = true
- button.layer?.cornerRadius = 16
- button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
- let normalBackground = selected
- ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
- : NSColor.clear
- let hoverBackground = selected
- ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
- : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
- button.normalColor = normalBackground
- button.hoverColor = hoverBackground
- button.onHoverChanged = { [weak button] hovering in
- guard let button, selected == false else { return }
- if hovering {
- button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
- button.layer?.borderColor = (hasMeetings
- ? self.accentBlue.withAlphaComponent(0.65)
- : self.accentBlue.withAlphaComponent(0.45)
- ).cgColor
- } else if isToday {
- button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
- button.layer?.borderWidth = 1
- } else if hasMeetings {
- button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
- button.layer?.borderWidth = 1
- } else {
- button.layer?.borderColor = NSColor.clear.cgColor
- button.layer?.borderWidth = 0
- }
- }
- if selected {
- button.layer?.borderColor = accentBlue.cgColor
- } else if isToday {
- button.layer?.borderColor = accentOrange.withAlphaComponent(0.8).cgColor
- button.layer?.borderWidth = 1
- } else if hasMeetings {
- button.layer?.borderColor = accentBlue.withAlphaComponent(0.45).cgColor
- } else {
- button.layer?.borderColor = NSColor.clear.cgColor
- }
- button.contentTintColor = selected
- ? primaryText
- : (inCurrentMonth ? primaryText : mutedText.withAlphaComponent(0.75))
- let tag = nextSchedulerButtonTag
- nextSchedulerButtonTag += 1
- button.tag = tag
- schedulerDateByButtonTag[tag] = Calendar.current.startOfDay(for: date)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.heightAnchor.constraint(equalToConstant: 34).isActive = true
- return button
- }
- @objc private func schedulerDayTapped(_ sender: NSButton) {
- guard let selectedDate = schedulerDateByButtonTag[sender.tag] else { return }
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(selectedDate)
- }
- }
- @objc private func schedulerPrevMonthTapped() {
- let calendar = Calendar.current
- let prev = calendar.date(byAdding: .month, value: -1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
- let monthComponents = calendar.dateComponents([.year, .month], from: prev)
- schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? prev
- updateSchedulerDateHeader()
- renderSchedulerCalendarGrid()
- }
- @objc private func schedulerNextMonthTapped() {
- let calendar = Calendar.current
- let next = calendar.date(byAdding: .month, value: 1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
- let monthComponents = calendar.dateComponents([.year, .month], from: next)
- schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? next
- updateSchedulerDateHeader()
- renderSchedulerCalendarGrid()
- }
- @objc private func schedulerTodayTapped() {
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(Date())
- }
- }
- private func loadScheduledMeetings() async {
- guard isUserLoggedIn() else {
- await MainActor.run {
- self.applyMeetings([])
- self.applyGuestLoginMessages()
- }
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- await MainActor.run {
- self.applyMeetings([])
- self.applyPremiumLockMessages()
- }
- return
- }
- if isLoadingMeetings { return }
- isLoadingMeetings = true
- defer {
- isLoadingMeetings = false
- Task { @MainActor in
- self.setMeetingsLoadingUI(false)
- }
- }
- await MainActor.run {
- self.setMeetingsLoadingUI(true)
- }
- do {
- let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
- if zoomAccountTimeZone == nil {
- if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
- let profileTZ = parseZoomTimeZone(profile.timezone)
- await MainActor.run { self.zoomAccountTimeZone = profileTZ }
- }
- }
- let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: zoomAccountTimeZone)
- await MainActor.run {
- self.meetingsRateLimitedUntil = nil
- self.applyMeetings(zoomMeetings)
- }
- } catch {
- await MainActor.run {
- self.applyMeetings([])
- if case ZoomOAuthError.missingClientId = error {
- self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.promptForZoomOAuthCredentialsIfNeeded()
- } else if case ZoomOAuthError.missingClientSecret = error {
- self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.promptForZoomOAuthCredentialsIfNeeded()
- } else if case ZoomOAuthError.missingRequiredScope = error {
- self.zoomOAuth.clearSavedTokens()
- self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
- self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
- } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
- let seconds = max(retryAfterSeconds ?? 300, 30)
- self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
- let minutes = Int(ceil(Double(seconds) / 60.0))
- self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
- self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
- } else {
- self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
- self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
- }
- }
- }
- }
- @MainActor
- private func presentZoomOAuthCredentialPrompt() -> Bool {
- let alert = NSAlert()
- alert.alertStyle = .informational
- alert.messageText = "Configure Zoom OAuth"
- 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."
- let wrapper = NSStackView()
- wrapper.orientation = .vertical
- wrapper.spacing = 8
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- let clientIdField = NSTextField()
- clientIdField.placeholderString = "Zoom Client ID"
- clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
- let clientSecretField = NSSecureTextField()
- clientSecretField.placeholderString = "Zoom Client Secret"
- clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
- [clientIdField, clientSecretField].forEach { field in
- field.translatesAutoresizingMaskIntoConstraints = false
- field.widthAnchor.constraint(equalToConstant: 420).isActive = true
- wrapper.addArrangedSubview(field)
- }
- alert.accessoryView = wrapper
- alert.addButton(withTitle: "Save")
- alert.addButton(withTitle: "Cancel")
- let result = alert.runModal()
- if result == .alertFirstButtonReturn {
- var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
- if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
- if clientId.isEmpty == false, clientSecret.isEmpty == false {
- zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
- return true
- }
- meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
- }
- return false
- }
- @MainActor
- private func promptForZoomOAuthCredentialsIfNeeded() {
- guard isPromptingZoomCredentials == false else { return }
- isPromptingZoomCredentials = true
- defer { isPromptingZoomCredentials = false }
- if presentZoomOAuthCredentialPrompt() {
- meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
- Task { await self.loadScheduledMeetings() }
- }
- }
- private struct ZoomUserMeResponse: Decodable {
- let first_name: String?
- let last_name: String?
- let display_name: String?
- let email: String?
- let pic_url: String?
- let timezone: String?
- var displayName: String? {
- if let display_name, display_name.isEmpty == false { return display_name }
- let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
- return parts.isEmpty ? nil : parts.joined(separator: " ")
- }
- var pictureURL: String? {
- guard let pic_url, pic_url.isEmpty == false else { return nil }
- return pic_url
- }
- }
- private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
- let url = URL(string: "https://api.zoom.us/v2/users/me")!
- var request = URLRequest(url: url)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
- }
- return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
- }
- private struct ZoomCreateMeetingBody: Encodable {
- let topic: String
- let type: Int
- let start_time: String
- let duration: Int
- let timezone: String
- }
- private struct ZoomCreateMeetingAPIResult: Decodable {
- let id: Int?
- let join_url: String?
- }
- /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
- private func createZoomMeeting(accessToken: String, topic: String, startTimeUTC: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
- let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- let body = ZoomCreateMeetingBody(
- topic: topic,
- type: 2,
- start_time: startTimeUTC,
- duration: durationMinutes,
- timezone: timeZone.identifier
- )
- request.httpBody = try JSONEncoder().encode(body)
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
- }
- if http.statusCode == 429 {
- let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
- let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
- throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
- }
- guard (200..<300).contains(http.statusCode) else {
- let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
- }
- private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
- struct ZoomMeeting: Decodable {
- let id: Int?
- let topic: String?
- let start_time: String?
- let timezone: String?
- let duration: Int?
- let host_id: String?
- let type: Int?
- let status: String?
- let join_url: String?
- }
- struct ZoomMeetingsPage: Decodable {
- let meetings: [ZoomMeeting]
- let next_page_token: String?
- }
- let iso = ISO8601DateFormatter()
- iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let fallbackISO = ISO8601DateFormatter()
- fallbackISO.formatOptions = [.withInternetDateTime]
- func inferredTimeZone(from startRaw: String, explicitZoomTimezone: String?) -> TimeZone? {
- if let parsedExplicit = parseZoomTimeZone(explicitZoomTimezone) {
- return parsedExplicit
- }
- if let fallbackTimeZone { return fallbackTimeZone }
- if startRaw.hasSuffix("Z") || startRaw.hasSuffix("z") {
- return TimeZone(secondsFromGMT: 0)
- }
- let startPattern = #"([+-]\d{2}:?\d{2}|[+-]\d{2})$"#
- guard let regex = try? NSRegularExpression(pattern: startPattern) else { return nil }
- let nsRange = NSRange(startRaw.startIndex..<startRaw.endIndex, in: startRaw)
- guard let match = regex.firstMatch(in: startRaw, options: [], range: nsRange),
- let offsetRange = Range(match.range(at: 1), in: startRaw) else {
- return nil
- }
- return parseZoomTimeZone(String(startRaw[offsetRange]))
- }
- func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
- raw.compactMap { meeting in
- guard let startRaw = meeting.start_time else { return nil }
- let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
- guard let start else { return nil }
- let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
- let meetingIDString = meeting.id.map(String.init)
- let overrideZone = meetingIDString
- .flatMap { self.meetingTimeZoneOverrides[$0] }
- .flatMap { TimeZone(identifier: $0) }
- let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
- let webURL: URL? = {
- func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
- var components = URLComponents()
- components.scheme = "https"
- components.host = "zoom.us"
- components.path = "/wc/join/\(meetingId)"
- if let pwd, pwd.isEmpty == false {
- components.queryItems = [URLQueryItem(name: "pwd", value: pwd)]
- }
- return components.url
- }
- if let join = meeting.join_url, let url = URL(string: join), url.scheme != nil {
- // Prefer the Zoom Web Client join URL so a click joins in the browser.
- // join_url is often `https://zoom.us/j/<id>?pwd=...`
- if url.path.contains("/wc/join/") {
- return url
- }
- if let id = meeting.id {
- let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "pwd" })?
- .value
- return wcJoinURL(meetingId: id, pwd: pwd)
- }
- return url
- }
- if let id = meeting.id {
- return wcJoinURL(meetingId: id, pwd: nil)
- }
- return nil
- }()
- return ScheduledMeeting(
- title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
- start: start,
- end: end,
- meetingTimeZone: displayZone,
- host: meeting.host_id ?? "Zoom Host",
- source: "Zoom",
- meetingID: meetingIDString,
- typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
- statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
- webURL: webURL
- )
- }
- }
- var allMeetings: [ZoomMeeting] = []
- var nextPageToken: String?
- repeat {
- var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
- var items: [URLQueryItem] = [
- URLQueryItem(name: "type", value: "scheduled"),
- URLQueryItem(name: "page_size", value: "30")
- ]
- if let nextPageToken, nextPageToken.isEmpty == false {
- items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
- }
- components.queryItems = items
- var request = URLRequest(url: components.url!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
- if (response as? HTTPURLResponse)?.statusCode == 429 {
- let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
- let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
- throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
- }
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
- allMeetings.append(contentsOf: decoded.meetings)
- let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
- nextPageToken = (token?.isEmpty == false) ? token : nil
- } while nextPageToken != nil
- return mapMeetings(allMeetings)
- }
- private func makeSettingsView() -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let scroll = NSScrollView()
- scroll.translatesAutoresizingMaskIntoConstraints = false
- scroll.drawsBackground = false
- scroll.hasHorizontalScroller = false
- scroll.hasVerticalScroller = true
- scroll.autohidesScrollers = true
- scroll.borderType = .noBorder
- scroll.scrollerStyle = .overlay
- scroll.automaticallyAdjustsContentInsets = false
- let clip = TopAlignedClipView()
- clip.drawsBackground = false
- scroll.contentView = clip
- panel.addSubview(scroll)
- let content = NSView()
- content.translatesAutoresizingMaskIntoConstraints = false
- scroll.documentView = content
- let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
- card.translatesAutoresizingMaskIntoConstraints = false
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
- content.addSubview(card)
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.spacing = 18
- stack.alignment = .leading
- card.addSubview(stack)
- let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
- let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
- stack.addArrangedSubview(pageTitle)
- stack.addArrangedSubview(pageSubtitle)
- stack.setCustomSpacing(24, after: pageSubtitle)
- let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(appearanceTitle)
- let darkModeRow = makeSettingsDarkModeRow()
- stack.addArrangedSubview(darkModeRow)
- darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- stack.setCustomSpacing(24, after: darkModeRow)
- let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(accountTitle)
- let googleAccountRow = makeSettingsGoogleAccountRow()
- stack.addArrangedSubview(googleAccountRow)
- googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- stack.setCustomSpacing(24, after: googleAccountRow)
- let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(appTitle)
- let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
- stack.addArrangedSubview(shareButton)
- shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
- stack.addArrangedSubview(upgradeButton)
- upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- settingsUpgradeButton = upgradeButton
- stack.setCustomSpacing(24, after: upgradeButton)
- let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(legalTitle)
- let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
- stack.addArrangedSubview(privacyButton)
- privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
- stack.addArrangedSubview(supportButton)
- supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
- stack.addArrangedSubview(termsButton)
- termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- NSLayoutConstraint.activate([
- scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
- scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
- scroll.topAnchor.constraint(equalTo: panel.topAnchor),
- scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
- content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
- content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
- content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
- content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
- content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
- card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- card.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
- content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
- card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
- card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
- card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
- card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
- stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
- stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
- stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
- stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
- ])
- updatePremiumButtons()
- return panel
- }
- private enum SettingsAction: Int {
- case shareApp = 1
- case upgrade = 2
- case privacyPolicy = 4
- case support = 5
- case termsOfServices = 6
- }
- private func makeSettingsDarkModeRow() -> NSView {
- let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
- row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 52).isActive = true
- styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
- let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
- let toggle = NSSwitch()
- toggle.translatesAutoresizingMaskIntoConstraints = false
- toggle.state = darkModeEnabled ? .on : .off
- toggle.target = self
- toggle.action = #selector(settingsDarkModeToggled(_:))
- settingsDarkModeSwitch = toggle
- row.addSubview(icon)
- row.addSubview(title)
- row.addSubview(toggle)
- NSLayoutConstraint.activate([
- icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
- icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
- title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
- toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
- ])
- return row
- }
- private func makeSettingsGoogleAccountRow() -> NSView {
- let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
- row.translatesAutoresizingMaskIntoConstraints = false
- styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let signedIn = isUserLoggedIn()
- let titleText = signedIn ? "Zoom account connected" : "Zoom account not connected"
- let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
- let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
- let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
- subtitle.maximumNumberOfLines = 2
- subtitle.lineBreakMode = .byTruncatingTail
- let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
- actionButton.translatesAutoresizingMaskIntoConstraints = false
- actionButton.bezelStyle = .rounded
- actionButton.controlSize = .regular
- settingsGoogleActionButton = actionButton
- row.addSubview(title)
- row.addSubview(subtitle)
- row.addSubview(actionButton)
- NSLayoutConstraint.activate([
- row.heightAnchor.constraint(equalToConstant: 78),
- title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
- title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
- subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
- subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
- actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
- actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
- ])
- return row
- }
- private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
- let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.wantsLayer = true
- button.layer?.cornerRadius = 10
- button.normalColor = palette.inputBackground
- button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
- styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- button.heightAnchor.constraint(equalToConstant: 46).isActive = true
- button.tag = action.rawValue
- let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
- if action == .upgrade {
- settingsUpgradeTitleLabel = titleLabel
- }
- button.addSubview(iconLabel)
- button.addSubview(titleLabel)
- NSLayoutConstraint.activate([
- iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
- iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
- titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
- ])
- return button
- }
- @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
- guard let action = SettingsAction(rawValue: sender.tag) else { return }
- switch action {
- case .shareApp:
- showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
- case .upgrade:
- settingsUpgradePremiumTapped()
- case .privacyPolicy:
- showSimpleAlert(title: "Privacy Policy", message: "Add your Privacy Policy URL in the app and open it here.")
- case .support:
- showSimpleAlert(title: "Support", message: "Add your Support URL/email in the app and open it here.")
- case .termsOfServices:
- showSimpleAlert(title: "Terms", message: "Add your Terms URL in the app and open it here.")
- }
- }
- @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
- if isUserLoggedIn() {
- logoutTapped()
- return
- }
- zoomPrimarySignInTapped()
- }
- private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
- let v = NSView()
- v.wantsLayer = true
- v.layer?.backgroundColor = color.cgColor
- v.layer?.cornerRadius = cornerRadius
- return v
- }
- private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
- view.wantsLayer = true
- view.layer?.borderColor = borderColor.cgColor
- view.layer?.borderWidth = borderWidth
- if shadow {
- view.layer?.shadowColor = NSColor.black.cgColor
- view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
- view.layer?.shadowRadius = 18
- view.layer?.shadowOffset = NSSize(width: 0, height: -2)
- } else {
- view.layer?.shadowOpacity = 0
- }
- }
- private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.translatesAutoresizingMaskIntoConstraints = false
- label.font = font
- label.textColor = color
- label.backgroundColor = .clear
- label.isBezeled = false
- label.isEditable = false
- label.isSelectable = false
- return label
- }
- private func styledLabel(
- _ text: String,
- font: NSFont,
- color: NSColor,
- kern: CGFloat? = nil,
- lineSpacing: CGFloat? = nil
- ) -> NSTextField {
- let label = textLabel(text, font: font, color: color)
- if kern == nil, lineSpacing == nil {
- return label
- }
- let paragraph = NSMutableParagraphStyle()
- paragraph.alignment = label.alignment
- paragraph.lineBreakMode = .byWordWrapping
- if let lineSpacing { paragraph.lineSpacing = lineSpacing }
- var attrs: [NSAttributedString.Key: Any] = [
- .font: font,
- .foregroundColor: color,
- .paragraphStyle: paragraph
- ]
- if let kern { attrs[.kern] = kern }
- label.attributedStringValue = NSAttributedString(string: text, attributes: attrs)
- return label
- }
- private func startStoreKit() {
- storeKitStartupTask?.cancel()
- storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
- DispatchQueue.main.async {
- guard let self else { return }
- let newIsPremium = self.storeKitCoordinator.hasPremiumAccess
- let wasPremium = self.lastKnownPremiumAccess
- self.lastKnownPremiumAccess = newIsPremium
- self.updatePremiumButtons()
- if (wasPremium == false) && newIsPremium {
- self.startMeetingsAutoRefresh()
- self.triggerMeetingsRefresh(force: true)
- self.schedulePremiumRatingPromptIfNeeded()
- }
- if wasPremium && !newIsPremium {
- self.premiumRatingPromptWorkItem?.cancel()
- self.premiumRatingPromptWorkItem = nil
- self.showPaywall()
- }
- if newIsPremium {
- self.launchPaywallWorkItem?.cancel()
- self.launchPaywallWorkItem = nil
- self.hasPresentedLaunchPaywall = true
- } else {
- self.scheduleLaunchPaywallIfNeeded()
- }
- }
- }
- storeKitStartupTask = Task { [weak self] in
- await self?.storeKitCoordinator.start()
- await MainActor.run {
- self?.updatePremiumButtons()
- self?.scheduleLaunchPaywallIfNeeded()
- }
- }
- }
- private func scheduleLaunchPaywallIfNeeded() {
- guard hasPresentedLaunchPaywall == false else { return }
- guard storeKitCoordinator.hasPremiumAccess == false else {
- hasPresentedLaunchPaywall = true
- return
- }
- guard launchPaywallWorkItem == nil else { return }
- let workItem = DispatchWorkItem { [weak self] in
- guard let self else { return }
- self.launchPaywallWorkItem = nil
- guard self.hasPresentedLaunchPaywall == false else { return }
- guard self.storeKitCoordinator.hasPremiumAccess == false else {
- self.hasPresentedLaunchPaywall = true
- return
- }
- self.hasPresentedLaunchPaywall = true
- self.showPaywall()
- }
- launchPaywallWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
- }
- @MainActor
- private func updatePremiumButtons() {
- let isPremium = storeKitCoordinator.hasPremiumAccess
- let hasLifetimePremium = storeKitCoordinator.hasLifetimePremiumAccess
- settingsUpgradeButton?.title = ""
- settingsUpgradeTitleLabel?.stringValue = hasLifetimePremium ? "Premium Lifetime" : "Upgrade"
- settingsUpgradeButton?.isHidden = hasLifetimePremium
- settingsUpgradeButton?.isEnabled = hasLifetimePremium == false
- settingsUpgradeButton?.alphaValue = hasLifetimePremium ? 0.6 : 1.0
- updatePremiumLockedSections()
- if isPremium == false, isUserLoggedIn() {
- applyMeetings([])
- applyPremiumLockMessages()
- }
- if isPremium {
- schedulePremiumRatingPromptIfNeeded()
- }
- updateTopBarPremiumButton()
- }
- @MainActor
- private func updateTopBarPremiumButton() {
- guard let button = topBarPremiumButton else { return }
- let isPremium = storeKitCoordinator.hasPremiumAccess
- if isPremium {
- let title = "Manage Subscription"
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: NSColor.white,
- .font: font
- ])
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
- let img = base.withSymbolConfiguration(symbolConfig) {
- img.isTemplate = true
- button.image = img
- button.imagePosition = .imageLeading
- button.imageHugsTitle = true
- }
- button.contentTintColor = NSColor.white
- button.toolTip = title
- button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
- } else {
- let title = "Upgrade to Pro"
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: NSColor.white,
- .font: font
- ])
- button.image = nil
- button.toolTip = title
- button.layer?.backgroundColor = accentBlue.cgColor
- button.contentTintColor = .white
- }
- }
- @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
- setDarkMode(sender.state == .on)
- }
- private func setDarkMode(_ enabled: Bool) {
- darkModeEnabled = enabled
- palette = Palette(isDarkMode: enabled)
- NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
- view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
- if let window = view.window {
- applyWindowBackgroundForCurrentTheme(window)
- }
- let keepSelected = selectedHomeSidebarItem
- selectedHomeSidebarItem = keepSelected
- showHomeView(profile: nil)
- updatePremiumLockedSections()
- }
- private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
- // Keep the window's backing in sync with the app's current theme, otherwise rounded
- // shell corners can briefly show the previous mode's lighter/darker color.
- window.backgroundColor = appBackground
- window.isOpaque = true
- if let contentView = window.contentView {
- contentView.wantsLayer = true
- contentView.layer?.backgroundColor = appBackground.cgColor
- }
- }
- private func systemPrefersDarkMode() -> Bool {
- let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
- let style = global?["AppleInterfaceStyle"] as? String
- return style?.lowercased() == "dark"
- }
- @objc private func settingsUpgradePremiumTapped() {
- guard storeKitCoordinator.hasLifetimePremiumAccess == false else { return }
- showPaywall()
- }
- // MARK: - Paywall (ported from meetings_app)
- private func showPaywall() {
- if storeKitCoordinator.hasPremiumAccess && storeKitCoordinator.hasLifetimePremiumAccess == false {
- selectedPremiumPlan = .lifetime
- }
- if let existing = paywallOverlayView {
- refreshPaywallStoreUI()
- if let superview = existing.superview {
- superview.addSubview(existing)
- }
- updatePaywallResponsiveLayout()
- NSApp.activate(ignoringOtherApps: true)
- return
- }
- let overlay = NSView()
- overlay.translatesAutoresizingMaskIntoConstraints = false
- overlay.wantsLayer = true
- overlay.alphaValue = 0
- overlay.layer?.backgroundColor = NSColor.clear.cgColor
- let dim = NSView()
- dim.translatesAutoresizingMaskIntoConstraints = false
- dim.wantsLayer = true
- dim.layer?.backgroundColor = palette.isDarkMode
- ? NSColor.black.withAlphaComponent(0.62).cgColor
- : NSColor.black.withAlphaComponent(0.28).cgColor
- overlay.addSubview(dim)
- // Clicking outside the paywall dismisses it.
- let outsideButton = NSButton(title: "", target: self, action: #selector(closePaywallClicked(_:)))
- outsideButton.translatesAutoresizingMaskIntoConstraints = false
- outsideButton.isBordered = false
- outsideButton.bezelStyle = .regularSquare
- outsideButton.contentTintColor = .clear
- outsideButton.wantsLayer = true
- outsideButton.layer?.backgroundColor = NSColor.clear.cgColor
- overlay.addSubview(outsideButton)
- let content = makePaywallContent()
- overlay.addSubview(content)
- view.addSubview(overlay)
- NSLayoutConstraint.activate([
- overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- overlay.topAnchor.constraint(equalTo: view.topAnchor),
- overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- dim.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
- dim.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
- dim.topAnchor.constraint(equalTo: overlay.topAnchor),
- dim.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
- outsideButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
- outsideButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
- outsideButton.topAnchor.constraint(equalTo: overlay.topAnchor),
- outsideButton.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
- content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
- content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
- content.topAnchor.constraint(equalTo: overlay.topAnchor),
- content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
- ])
- paywallPanelWidthConstraint = nil
- paywallPanelHeightConstraint = nil
- paywallOverlayView = overlay
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.18
- overlay.animator().alphaValue = 1
- }
- Task { [weak self] in
- guard let self else { return }
- await self.storeKitCoordinator.refreshProducts()
- await MainActor.run {
- self.refreshPaywallStoreUI()
- self.updatePaywallResponsiveLayout()
- }
- }
- }
- @objc private func closePaywallClicked(_ sender: Any?) {
- hidePaywall()
- }
- private func hidePaywall() {
- guard let overlay = paywallOverlayView else { return }
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.16
- overlay.animator().alphaValue = 0
- } completionHandler: { [weak self] in
- overlay.removeFromSuperview()
- self?.paywallPanelWidthConstraint = nil
- self?.paywallPanelHeightConstraint = nil
- self?.paywallPanelView = nil
- self?.paywallBodyStack = nil
- self?.paywallIsDesktopLayout = false
- }
- }
- private func updatePaywallPanelSize() {
- guard paywallPanelWidthConstraint != nil, paywallPanelHeightConstraint != nil else { return }
- let safeInsetX: CGFloat = 80
- let safeInsetY: CGFloat = 110
- let maxWidth = max(640, view.bounds.width - safeInsetX)
- let maxHeight = max(560, view.bounds.height - safeInsetY)
- let panelWidth = min(1040, maxWidth)
- let panelHeight = min(860, maxHeight)
- if abs(paywallPanelWidthConstraint?.constant ?? 0 - panelWidth) > 1 {
- paywallPanelWidthConstraint?.constant = panelWidth
- }
- if abs(paywallPanelHeightConstraint?.constant ?? 0 - panelHeight) > 1 {
- paywallPanelHeightConstraint?.constant = panelHeight
- }
- }
- private func updatePaywallResponsiveLayout() {
- guard let bodyStack = paywallBodyStack, let panel = paywallPanelView else { return }
- let width = panel.bounds.width
- // Two-column desktop layout needs room for the plan column (520+) + a slimmer benefits column.
- let shouldUseDesktop = width >= 900
- guard shouldUseDesktop != paywallIsDesktopLayout else { return }
- paywallIsDesktopLayout = shouldUseDesktop
- if shouldUseDesktop {
- bodyStack.orientation = .horizontal
- bodyStack.alignment = .top
- bodyStack.spacing = 26
- } else {
- bodyStack.orientation = .vertical
- bodyStack.alignment = .leading
- bodyStack.spacing = 18
- }
- let useWidePlans = width >= 880
- paywallPlansRowWide?.isHidden = useWidePlans == false
- paywallPlansGridNarrow?.isHidden = useWidePlans
- bodyStack.layoutSubtreeIfNeeded()
- }
- private func makePaywallContent() -> NSView {
- paywallPlanViews.removeAll()
- paywallPlanPriceLabels.removeAll()
- paywallPlanSubtitleLabels.removeAll()
- paywallPlanStrikeLabels.removeAll()
- premiumPlanByView.removeAll()
- paywallOfferLabel = nil
- paywallContinueLabel = nil
- paywallContinueButton = nil
- paywallContinueEnabled = true
- paywallPlansRowWide = nil
- paywallPlansGridNarrow = nil
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = NSColor.clear.cgColor
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- panel.wantsLayer = true
- panel.layer?.backgroundColor = NSColor.black.cgColor
- panel.layer?.cornerRadius = 0
- styleSurface(panel, borderColor: NSColor.clear, borderWidth: 0, shadow: false)
- root.addSubview(panel)
- // Full-screen paywall: fill the entire app content area.
- NSLayoutConstraint.activate([
- panel.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- panel.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- panel.topAnchor.constraint(equalTo: root.topAnchor),
- panel.bottomAnchor.constraint(equalTo: root.bottomAnchor)
- ])
- paywallPanelWidthConstraint = nil
- paywallPanelHeightConstraint = nil
- // Responsive logic should measure the panel size (not the full-screen root).
- self.paywallPanelView = panel
- let contentStack = NSStackView()
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- contentStack.orientation = .vertical
- contentStack.spacing = 14
- contentStack.alignment = .leading
- panel.addSubview(contentStack)
- let topRow = NSStackView()
- topRow.translatesAutoresizingMaskIntoConstraints = false
- topRow.orientation = .horizontal
- topRow.alignment = .centerY
- topRow.distribution = .fill
- topRow.spacing = 10
- topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 26, weight: .semibold), color: primaryText))
- let topSpacer = NSView()
- topSpacer.translatesAutoresizingMaskIntoConstraints = false
- topRow.addArrangedSubview(topSpacer)
- let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.isBordered = false
- closeButton.bezelStyle = .regularSquare
- closeButton.wantsLayer = true
- closeButton.layer?.cornerRadius = 14
- closeButton.normalColor = palette.inputBackground
- closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
- closeButton.layer?.borderColor = palette.inputBorder.cgColor
- closeButton.layer?.borderWidth = 1
- closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
- closeButton.contentTintColor = secondaryText
- closeButton.toolTip = "Continue with free plan"
- panel.addSubview(closeButton)
- NSLayoutConstraint.activate([
- closeButton.widthAnchor.constraint(equalToConstant: 28),
- closeButton.heightAnchor.constraint(equalToConstant: 28),
- closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
- closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48)
- ])
- contentStack.addArrangedSubview(topRow)
- topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let hero = paywallHeroCard(
- eyebrow: "PREMIUM EXPERIENCE",
- title: "Elevate your meetings workflow",
- subtitle: "Unlock automation, premium meeting tools, and priority support for every session."
- )
- contentStack.addArrangedSubview(hero)
- hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let features = paywallFeaturesRow()
- contentStack.addArrangedSubview(features)
- features.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let bodyStack = NSStackView()
- bodyStack.translatesAutoresizingMaskIntoConstraints = false
- bodyStack.orientation = .vertical
- bodyStack.spacing = 16
- bodyStack.alignment = .leading
- bodyStack.distribution = .fill
- contentStack.addArrangedSubview(bodyStack)
- paywallBodyStack = bodyStack
- bodyStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let planColumn = NSStackView()
- planColumn.translatesAutoresizingMaskIntoConstraints = false
- planColumn.orientation = .vertical
- planColumn.spacing = 12
- planColumn.alignment = .leading
- planColumn.distribution = .fill
- bodyStack.addArrangedSubview(planColumn)
- planColumn.widthAnchor.constraint(equalTo: bodyStack.widthAnchor).isActive = true
- let weeklyCard = paywallPlanCard(
- title: "Weekly",
- price: paywallDisplayPrice(for: .weekly),
- badge: "Basic",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .weekly),
- plan: .weekly,
- strikePrice: nil
- )
- let monthlyCard = paywallPlanCard(
- title: "Monthly",
- price: paywallDisplayPrice(for: .monthly),
- badge: "Popular",
- badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .monthly),
- plan: .monthly,
- strikePrice: nil
- )
- let yearlyCard = paywallPlanCard(
- title: "Yearly",
- price: paywallDisplayPrice(for: .yearly),
- badge: "Save 67%",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .yearly),
- plan: .yearly,
- strikePrice: nil
- )
- let lifetimeCard = paywallPlanCard(
- title: "Lifetime",
- price: paywallDisplayPrice(for: .lifetime),
- badge: "Best Value",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .lifetime),
- plan: .lifetime,
- strikePrice: nil
- )
- let plansRowWide = NSStackView()
- plansRowWide.translatesAutoresizingMaskIntoConstraints = false
- plansRowWide.orientation = .horizontal
- plansRowWide.spacing = 12
- plansRowWide.alignment = .centerY
- plansRowWide.distribution = .fillEqually
- plansRowWide.addArrangedSubview(weeklyCard)
- plansRowWide.addArrangedSubview(monthlyCard)
- plansRowWide.addArrangedSubview(yearlyCard)
- plansRowWide.addArrangedSubview(lifetimeCard)
- paywallPlansRowWide = plansRowWide
- planColumn.addArrangedSubview(plansRowWide)
- plansRowWide.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- let plansGridNarrow = NSStackView()
- plansGridNarrow.translatesAutoresizingMaskIntoConstraints = false
- plansGridNarrow.orientation = .vertical
- plansGridNarrow.spacing = 12
- plansGridNarrow.alignment = .leading
- plansGridNarrow.distribution = .fillEqually
- let plansRow1 = NSStackView()
- plansRow1.translatesAutoresizingMaskIntoConstraints = false
- plansRow1.orientation = .horizontal
- plansRow1.spacing = 12
- plansRow1.alignment = .centerY
- plansRow1.distribution = .fillEqually
- plansRow1.addArrangedSubview(paywallPlanCard(
- title: "Weekly",
- price: paywallDisplayPrice(for: .weekly),
- badge: "Basic Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .weekly),
- plan: .weekly,
- strikePrice: nil
- ))
- plansRow1.addArrangedSubview(paywallPlanCard(
- title: "Monthly",
- price: paywallDisplayPrice(for: .monthly),
- badge: "Popular",
- badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .monthly),
- plan: .monthly,
- strikePrice: nil
- ))
- let plansRow2 = NSStackView()
- plansRow2.translatesAutoresizingMaskIntoConstraints = false
- plansRow2.orientation = .horizontal
- plansRow2.spacing = 12
- plansRow2.alignment = .centerY
- plansRow2.distribution = .fillEqually
- plansRow2.addArrangedSubview(paywallPlanCard(
- title: "Yearly",
- price: paywallDisplayPrice(for: .yearly),
- badge: "Best Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .yearly),
- plan: .yearly,
- strikePrice: nil
- ))
- plansRow2.addArrangedSubview(paywallPlanCard(
- title: "Lifetime",
- price: paywallDisplayPrice(for: .lifetime),
- badge: "Save 50%",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .lifetime),
- plan: .lifetime,
- strikePrice: nil
- ))
- plansGridNarrow.addArrangedSubview(plansRow1)
- plansGridNarrow.addArrangedSubview(plansRow2)
- plansGridNarrow.isHidden = true
- paywallPlansGridNarrow = plansGridNarrow
- planColumn.addArrangedSubview(plansGridNarrow)
- plansGridNarrow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- plansRow1.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
- plansRow2.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
- updatePaywallPlanSelection()
- planColumn.setCustomSpacing(16, after: plansGridNarrow)
- let trustRow = paywallTrustRow()
- planColumn.addArrangedSubview(trustRow)
- trustRow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- planColumn.setCustomSpacing(14, after: trustRow)
- let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
- offer.alignment = .center
- paywallOfferLabel = offer
- let offerWrap = NSView()
- offerWrap.translatesAutoresizingMaskIntoConstraints = false
- offerWrap.addSubview(offer)
- NSLayoutConstraint.activate([
- offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
- offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
- offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
- ])
- planColumn.addArrangedSubview(offerWrap)
- offerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- planColumn.setCustomSpacing(14, after: offerWrap)
- let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
- continueButton.translatesAutoresizingMaskIntoConstraints = false
- continueButton.isBordered = false
- continueButton.bezelStyle = .regularSquare
- continueButton.wantsLayer = true
- continueButton.layer?.cornerRadius = 14
- continueButton.normalColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 1)
- continueButton.hoverColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 0.92)
- continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
- styleSurface(continueButton, borderColor: NSColor.black.withAlphaComponent(0.12), borderWidth: 1, shadow: true)
- let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
- continueButton.addSubview(continueLabel)
- NSLayoutConstraint.activate([
- continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
- continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
- ])
- paywallContinueButton = continueButton
- paywallContinueLabel = continueLabel
- let continueButtonWrap = NSView()
- continueButtonWrap.translatesAutoresizingMaskIntoConstraints = false
- continueButtonWrap.addSubview(continueButton)
- planColumn.addArrangedSubview(continueButtonWrap)
- NSLayoutConstraint.activate([
- continueButtonWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor),
- continueButton.topAnchor.constraint(equalTo: continueButtonWrap.topAnchor),
- continueButton.bottomAnchor.constraint(equalTo: continueButtonWrap.bottomAnchor),
- continueButton.centerXAnchor.constraint(equalTo: continueButtonWrap.centerXAnchor),
- continueButton.widthAnchor.constraint(equalToConstant: 400),
- continueButton.widthAnchor.constraint(lessThanOrEqualTo: continueButtonWrap.widthAnchor, constant: -40)
- ])
- planColumn.setCustomSpacing(16, after: continueButtonWrap)
- let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
- secure.alignment = .center
- let secureWrap = NSView()
- secureWrap.translatesAutoresizingMaskIntoConstraints = false
- secureWrap.addSubview(secure)
- NSLayoutConstraint.activate([
- secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
- secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
- secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
- ])
- planColumn.addArrangedSubview(secureWrap)
- secureWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- planColumn.setCustomSpacing(8, after: secureWrap)
- let primaryFooterButton = paywallFooterButton(
- title: storeKitCoordinator.hasPremiumAccess ? "Manage Subscription" : "Continue with free plan",
- action: storeKitCoordinator.hasPremiumAccess
- ? #selector(paywallManageSubscriptionClicked(_:))
- : #selector(closePaywallClicked(_:))
- )
- let restoreButton = paywallFooterButton(
- title: "Restore Purchase",
- action: #selector(paywallRestorePurchaseClicked(_:))
- )
- let privacyButton = paywallFooterButton(
- title: "Privacy Policy",
- action: #selector(paywallPrivacyPolicyClicked(_:))
- )
- let supportButton = paywallFooterButton(
- title: "Support",
- action: #selector(paywallSupportClicked(_:))
- )
- let termsButton = paywallFooterButton(
- title: "Terms of Services",
- action: #selector(paywallTermsOfServicesClicked(_:))
- )
- // Footer: evenly spaced links across the paywall width.
- let footerContent = NSStackView()
- footerContent.translatesAutoresizingMaskIntoConstraints = false
- footerContent.orientation = .horizontal
- footerContent.alignment = .centerY
- footerContent.distribution = .equalSpacing
- footerContent.spacing = 36
- footerContent.addArrangedSubview(primaryFooterButton)
- footerContent.addArrangedSubview(restoreButton)
- footerContent.addArrangedSubview(privacyButton)
- footerContent.addArrangedSubview(supportButton)
- footerContent.addArrangedSubview(termsButton)
- let footerWrap = NSView()
- footerWrap.translatesAutoresizingMaskIntoConstraints = false
- footerWrap.addSubview(footerContent)
- NSLayoutConstraint.activate([
- footerContent.leadingAnchor.constraint(equalTo: footerWrap.leadingAnchor, constant: 44),
- footerContent.trailingAnchor.constraint(equalTo: footerWrap.trailingAnchor, constant: -44),
- footerContent.topAnchor.constraint(equalTo: footerWrap.topAnchor),
- footerContent.bottomAnchor.constraint(equalTo: footerWrap.bottomAnchor)
- ])
- // Push footer to the bottom of the paywall.
- let footerPushSpacer = NSView()
- footerPushSpacer.translatesAutoresizingMaskIntoConstraints = false
- footerPushSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
- footerPushSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- planColumn.addArrangedSubview(footerPushSpacer)
- planColumn.addArrangedSubview(footerWrap)
- footerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- // Keep a comfortable top margin, but center the paywall content vertically when there's room.
- let contentTopMin = contentStack.topAnchor.constraint(greaterThanOrEqualTo: panel.topAnchor, constant: 84)
- let contentBottomMax = contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -18)
- let contentCenterY = contentStack.centerYAnchor.constraint(equalTo: panel.centerYAnchor)
- contentCenterY.priority = .defaultLow
- let contentBottomEq = contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -18)
- contentBottomEq.priority = .defaultLow
- NSLayoutConstraint.activate([
- contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 70),
- contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
- contentTopMin,
- contentBottomMax,
- contentCenterY,
- contentBottomEq
- ])
- refreshPaywallStoreUI()
- updatePaywallResponsiveLayout()
- return root
- }
- private func paywallBenefitsSection() -> NSView {
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
- let rowOne = NSStackView()
- rowOne.translatesAutoresizingMaskIntoConstraints = false
- rowOne.orientation = .horizontal
- rowOne.spacing = 10
- rowOne.distribution = .fillEqually
- rowOne.alignment = .centerY
- rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
- rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
- let rowTwo = NSStackView()
- rowTwo.translatesAutoresizingMaskIntoConstraints = false
- rowTwo.orientation = .horizontal
- rowTwo.spacing = 10
- rowTwo.distribution = .fillEqually
- rowTwo.alignment = .centerY
- rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
- rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
- stack.addArrangedSubview(rowOne)
- stack.addArrangedSubview(rowTwo)
- return stack
- }
- private func paywallHeroCard(eyebrow: String, title: String, subtitle: String) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 14
- card.layer?.backgroundColor = palette.secondaryCardBackground.cgColor
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let eyebrowLabel = styledLabel(
- eyebrow,
- font: NSFont.systemFont(ofSize: 11, weight: .semibold),
- color: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 0.92),
- kern: 0.8
- )
- eyebrowLabel.alignment = .left
- let titleLabel = styledLabel(
- title,
- font: NSFont.systemFont(ofSize: 18, weight: .semibold),
- color: primaryText,
- lineSpacing: 1
- )
- titleLabel.alignment = .left
- let subtitleLabel = styledLabel(
- subtitle,
- font: NSFont.systemFont(ofSize: 13, weight: .regular),
- color: secondaryText,
- lineSpacing: 2
- )
- subtitleLabel.maximumNumberOfLines = 2
- subtitleLabel.lineBreakMode = .byTruncatingTail
- subtitleLabel.alignment = .left
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.alignment = .leading
- stack.spacing = 6
- stack.addArrangedSubview(eyebrowLabel)
- stack.addArrangedSubview(titleLabel)
- stack.addArrangedSubview(subtitleLabel)
- card.addSubview(stack)
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
- stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
- ])
- return card
- }
- private func paywallFeaturesRow() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.spacing = 10
- row.alignment = .centerY
- row.distribution = .fillEqually
- row.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
- row.addArrangedSubview(paywallBenefitItem(icon: "💬", text: "Smart scheduling"))
- row.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
- row.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
- return row
- }
- private func paywallTrustRow() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.spacing = 10
- row.alignment = .centerY
- row.distribution = .fillEqually
- row.addArrangedSubview(paywallTrustItem(title: "Cancel anytime", subtitle: "No lock-in"))
- row.addArrangedSubview(paywallTrustItem(title: "Instant access", subtitle: "Unlock all tools"))
- row.addArrangedSubview(paywallTrustItem(title: "Secure billing", subtitle: "Handled by Apple"))
- return row
- }
- private func paywallTrustItem(title: String, subtitle: String) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 12
- card.layer?.backgroundColor = palette.inputBackground.cgColor
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: primaryText)
- let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.alignment = .centerX
- stack.spacing = 2
- stack.addArrangedSubview(titleLabel)
- stack.addArrangedSubview(subtitleLabel)
- card.addSubview(stack)
- NSLayoutConstraint.activate([
- card.heightAnchor.constraint(equalToConstant: 44),
- stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
- stack.centerYAnchor.constraint(equalTo: card.centerYAnchor)
- ])
- return card
- }
- private func paywallBenefitItem(icon: String, text: String) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 10
- card.layer?.backgroundColor = palette.inputBackground.cgColor
- card.heightAnchor.constraint(equalToConstant: 36).isActive = true
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
- iconWrap.translatesAutoresizingMaskIntoConstraints = false
- iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
- iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
- styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 13, weight: .medium), color: accentBlue)
- iconWrap.addSubview(iconLabel)
- NSLayoutConstraint.activate([
- iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
- iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
- ])
- let title = textLabel(text, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: primaryText)
- card.addSubview(iconWrap)
- card.addSubview(title)
- NSLayoutConstraint.activate([
- iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
- iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
- title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
- title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
- title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
- ])
- return card
- }
- private func paywallPlanCard(
- title: String,
- price: String,
- badge: String,
- badgeColor: NSColor,
- subtitle: String?,
- plan: PremiumPlan,
- strikePrice: String?
- ) -> NSView {
- let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- wrapper.isBordered = false
- wrapper.bezelStyle = .regularSquare
- wrapper.wantsLayer = true
- wrapper.layer?.backgroundColor = NSColor.clear.cgColor
- wrapper.heightAnchor.constraint(equalToConstant: 138).isActive = true
- wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 16
- card.layer?.backgroundColor = palette.sectionCard.cgColor
- wrapper.addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 14),
- card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let badgeLabel = styledLabel(
- badge.uppercased(),
- font: NSFont.systemFont(ofSize: 10.5, weight: .semibold),
- color: .white,
- kern: 0.6
- )
- let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
- badgeWrap.translatesAutoresizingMaskIntoConstraints = false
- badgeWrap.wantsLayer = true
- badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
- badgeWrap.layer?.borderWidth = 1
- badgeWrap.layer?.shadowColor = NSColor.black.cgColor
- badgeWrap.layer?.shadowOpacity = 0.20
- badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
- badgeWrap.layer?.shadowRadius = 3
- badgeWrap.addSubview(badgeLabel)
- NSLayoutConstraint.activate([
- badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
- badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
- badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
- badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
- ])
- wrapper.addSubview(badgeWrap)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: accentBlue)
- card.addSubview(titleLabel)
- let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: primaryText)
- card.addSubview(priceLabel)
- paywallPlanPriceLabels[plan, default: []].append(priceLabel)
- NSLayoutConstraint.activate([
- badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
- badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
- priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
- ])
- if let subtitle {
- let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
- card.addSubview(sub)
- paywallPlanSubtitleLabels[plan, default: []].append(sub)
- NSLayoutConstraint.activate([
- sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
- sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
- sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
- ])
- }
- if let strikePrice {
- let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
- card.addSubview(strike)
- paywallPlanStrikeLabels[plan, default: []].append(strike)
- NSLayoutConstraint.activate([
- strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
- strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
- ])
- }
- paywallPlanViews[plan, default: []].append(card)
- premiumPlanByView[ObjectIdentifier(card)] = plan
- wrapper.onHoverChanged = { [weak self, weak card] hovering in
- guard let self, let card else { return }
- self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
- }
- wrapper.onHoverChanged?(false)
- return wrapper
- }
- @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
- guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
- selectedPremiumPlan = plan
- updatePaywallPlanSelection()
- }
- private func updatePaywallPlanSelection() {
- for (plan, views) in paywallPlanViews {
- for view in views {
- applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
- }
- }
- paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
- updatePaywallContinueState(isLoading: false)
- }
- private func paywallOfferText(for plan: PremiumPlan) -> String {
- if storeKitCoordinator.hasLifetimePremiumAccess {
- return "Lifetime premium is active on this Apple ID."
- }
- guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
- let price = paywallDisplayPrice(for: plan)
- switch plan {
- case .weekly: return "\(price)/week"
- case .monthly: return "\(price)/month"
- case .yearly: return "\(price)/year"
- case .lifetime: return "\(price) one-time purchase"
- }
- }
- if let offer = product.subscription?.introductoryOffer,
- offer.paymentMode == .freeTrial,
- let trial = paywallSubscriptionPeriodDescription(offer.period) {
- let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
- if let renewal {
- return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
- }
- return "Free for \(trial), then \(product.displayPrice)"
- }
- if plan == .lifetime {
- return "\(product.displayPrice) one-time purchase"
- }
- if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
- return "\(product.displayPrice)/\(renewal)"
- }
- let fallbackPrice = paywallDisplayPrice(for: plan)
- switch plan {
- case .weekly: return "\(fallbackPrice)/week"
- case .monthly: return "\(fallbackPrice)/month"
- case .yearly: return "\(fallbackPrice)/year"
- case .lifetime: return "\(fallbackPrice) one-time purchase"
- }
- }
- private func paywallFreeTrialText(for plan: PremiumPlan) -> String? {
- guard let product = storeKitCoordinator.productsByID[plan.rawValue],
- let offer = product.subscription?.introductoryOffer,
- offer.paymentMode == .freeTrial,
- let trial = paywallSubscriptionPeriodDescription(offer.period) else {
- return nil
- }
- return trial
- }
- private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
- if plan == .lifetime { return "One-time purchase" }
- guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
- switch plan {
- case .weekly: return "Billed weekly"
- case .monthly: return "Billed monthly"
- case .yearly: return "Billed yearly"
- case .lifetime: return "One-time purchase"
- }
- }
- if let period = product.subscription?.subscriptionPeriod,
- let unit = paywallSubscriptionPeriodDescription(period),
- let trial = paywallFreeTrialText(for: plan) {
- return "\(product.displayPrice)/\(unit) • \(trial) free trial"
- }
- if let period = product.subscription?.subscriptionPeriod,
- let unit = paywallSubscriptionPeriodDescription(period) {
- return "Billed \(unit)"
- }
- switch plan {
- case .weekly: return "Billed weekly"
- case .monthly: return "Billed monthly"
- case .yearly: return "Billed yearly"
- case .lifetime: return "One-time purchase"
- }
- }
- private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
- guard let period else { return nil }
- let value = period.value
- switch period.unit {
- case .day:
- return value == 1 ? "1 day" : "\(value) days"
- case .week:
- return value == 1 ? "1 week" : "\(value) weeks"
- case .month:
- return value == 1 ? "1 month" : "\(value) months"
- case .year:
- return value == 1 ? "1 year" : "\(value) years"
- @unknown default:
- return nil
- }
- }
- private func refreshPaywallStoreUI() {
- refreshPaywallPlanPrices()
- updatePaywallPlanSelection()
- updatePaywallContinueState(isLoading: false)
- }
- private func refreshPaywallPlanPrices() {
- for plan in PremiumPlan.allCases {
- let price = paywallDisplayPrice(for: plan)
- paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
- let subtitle = paywallPlanSubtitle(for: plan)
- paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
- }
- }
- private func paywallDisplayPrice(for plan: PremiumPlan) -> String {
- if let product = storeKitCoordinator.productsByID[plan.rawValue] {
- return product.displayPrice
- }
- switch plan {
- case .weekly: return "3.99"
- case .monthly: return "7.99"
- case .yearly: return "24.99"
- case .lifetime: return "44.99"
- }
- }
- @objc private func paywallContinueClicked(_ sender: Any?) {
- startSelectedPlanPurchase()
- }
- private func paywallFooterButton(title: String, action: Selector) -> NSButton {
- let button = NSButton(title: title, target: self, action: action)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.contentTintColor = secondaryText
- button.alignment = .center
- button.heightAnchor.constraint(equalToConstant: 24).isActive = true
- return button
- }
- @objc private func paywallPrivacyPolicyClicked(_ sender: Any?) {
- showSimpleAlert(
- title: "Privacy Policy",
- message: "Placeholder: Privacy Policy content/link will be added before publishing."
- )
- }
- @objc private func paywallSupportClicked(_ sender: Any?) {
- showSimpleAlert(
- title: "Support",
- message: "Placeholder: Support contact details/link will be added before publishing."
- )
- }
- @objc private func paywallTermsOfServicesClicked(_ sender: Any?) {
- showSimpleAlert(
- title: "Terms of Services",
- message: "Placeholder: Terms of Services content/link will be added before publishing."
- )
- }
- @objc private func paywallManageSubscriptionClicked(_ sender: Any?) {
- openManageSubscriptions()
- }
- @objc private func paywallRestorePurchaseClicked(_ sender: Any?) {
- Task { [weak self] in
- guard let self else { return }
- let message = await self.storeKitCoordinator.restorePurchases()
- await MainActor.run {
- self.refreshPaywallStoreUI()
- self.updatePremiumButtons()
- self.showSimpleAlert(title: "Restore Purchase", message: message)
- }
- }
- }
- private func startSelectedPlanPurchase() {
- guard paywallContinueEnabled else { return }
- paywallPurchaseTask?.cancel()
- updatePaywallContinueState(isLoading: true)
- let selectedPlan = selectedPremiumPlan
- let hadPremiumBeforePurchase = storeKitCoordinator.hasPremiumAccess
- let hadLifetimeBeforePurchase = storeKitCoordinator.hasLifetimePremiumAccess
- paywallPurchaseTask = Task { [weak self] in
- guard let self else { return }
- if self.storeKitCoordinator.productsByID[selectedPlan.rawValue] == nil {
- await self.storeKitCoordinator.refreshProducts()
- }
- let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
- await MainActor.run {
- self.updatePaywallContinueState(isLoading: false)
- self.refreshPaywallStoreUI()
- self.updatePremiumButtons()
- switch result {
- case .success:
- if self.storeKitCoordinator.hasPremiumAccess {
- // Ensure QA can repeatedly verify rating flow right after upgrading.
- UserDefaults.standard.set(false, forKey: self.ratingCompletedKey)
- UserDefaults.standard.set(false, forKey: self.ratingPromptShownKey)
- self.schedulePremiumRatingPromptIfNeeded()
- }
- if selectedPlan == .lifetime, hadLifetimeBeforePurchase == false, hadPremiumBeforePurchase {
- self.showSimpleAlert(
- title: "Lifetime Activated",
- message: "Lifetime premium is now active. If you previously subscribed (weekly/monthly/yearly), please cancel that subscription in your Apple ID subscriptions to avoid future renewals."
- )
- } else {
- self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
- }
- self.hidePaywall()
- if self.storeKitCoordinator.hasPremiumAccess {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
- self?.presentRatingPrompt(source: "premium")
- }
- }
- case .pending:
- self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
- case .cancelled:
- break
- case .failed(let message):
- let fallbackMessage = "StoreKit did not return products for this plan yet. Wait a few seconds and try again, or reopen the app."
- if message.contains("Product not available") {
- self.showSimpleAlert(title: "Purchase Failed", message: fallbackMessage)
- } else {
- self.showSimpleAlert(title: "Purchase Failed", message: message)
- }
- }
- }
- }
- }
- private func updatePaywallContinueState(isLoading: Bool) {
- if isLoading {
- paywallContinueEnabled = false
- paywallContinueLabel?.stringValue = "Processing..."
- paywallContinueButton?.alphaValue = 0.75
- return
- }
- if storeKitCoordinator.hasLifetimePremiumAccess {
- paywallContinueEnabled = false
- paywallContinueLabel?.stringValue = "Lifetime Premium Active"
- paywallContinueButton?.alphaValue = 0.75
- } else if storeKitCoordinator.productsByID[selectedPremiumPlan.rawValue] == nil {
- paywallContinueEnabled = false
- paywallContinueLabel?.stringValue = "Store Unavailable"
- paywallContinueButton?.alphaValue = 0.75
- } else {
- paywallContinueEnabled = true
- if let trial = paywallFreeTrialText(for: selectedPremiumPlan) {
- paywallContinueLabel?.stringValue = "Start with \(trial) trial"
- } else {
- paywallContinueLabel?.stringValue = "Continue"
- }
- paywallContinueButton?.alphaValue = 1.0
- }
- }
- private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
- let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
- let idleBorder = palette.inputBorder
- let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
- let hoverIdleBackground =
- palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
- let selectedBackground = palette.isDarkMode
- ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
- : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
- card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
- card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
- card.layer?.borderWidth = isSelected ? 2 : 1
- card.layer?.shadowColor = NSColor.black.cgColor
- card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
- card.layer?.shadowOffset = CGSize(width: 0, height: -1)
- card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
- }
- // MARK: - Home UI
- private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
- let root = NSView()
- let shell = NSView()
- shell.wantsLayer = true
- shell.layer?.backgroundColor = appShellBackground.cgColor
- shell.layer?.cornerRadius = appShellCornerRadius
- shell.layer?.borderWidth = 1
- shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
- let chromeColumn = NSView()
- chromeColumn.wantsLayer = true
- chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
- chromeColumn.layer?.cornerRadius = appShellCornerRadius
- chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
- chromeColumn.layer?.masksToBounds = true
- let chromeDivider = NSView()
- chromeDivider.wantsLayer = true
- chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
- let chromeHeader = NSView()
- chromeHeader.wantsLayer = true
- chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
- homeSidebarRowViews = [:]
- homeSidebarIconViews = [:]
- homeSidebarLabels = [:]
- let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler"], selected: selectedHomeSidebarItem, style: .home)
- let content = NSView()
- content.wantsLayer = true
- content.layer?.backgroundColor = NSColor.clear.cgColor
- root.addSubview(shell)
- shell.addSubview(chromeColumn)
- shell.addSubview(content)
- chromeColumn.addSubview(chromeDivider)
- chromeColumn.addSubview(chromeHeader)
- chromeColumn.addSubview(sidebar)
- shell.translatesAutoresizingMaskIntoConstraints = false
- chromeColumn.translatesAutoresizingMaskIntoConstraints = false
- chromeDivider.translatesAutoresizingMaskIntoConstraints = false
- chromeHeader.translatesAutoresizingMaskIntoConstraints = false
- sidebar.translatesAutoresizingMaskIntoConstraints = false
- content.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
- shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
- chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
- chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
- chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
- chromeColumn.widthAnchor.constraint(equalToConstant: 82),
- chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
- chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
- chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- chromeDivider.widthAnchor.constraint(equalToConstant: 1),
- chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
- chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
- chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
- sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
- sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
- sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
- content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
- content.topAnchor.constraint(equalTo: shell.topAnchor),
- content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
- ])
- let brandStack = NSStackView()
- brandStack.orientation = .vertical
- brandStack.spacing = 0
- brandStack.alignment = .leading
- let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
- let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
- brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
- brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
- [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
- let topBar = NSView()
- topBar.wantsLayer = true
- topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
-
- let topBarDivider = NSView()
- topBarDivider.wantsLayer = true
- topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
- let searchPill = NSView()
- searchPill.wantsLayer = true
- searchPill.layer?.backgroundColor = searchPillBackground.cgColor
- searchPill.layer?.cornerRadius = 10
- searchPill.layer?.borderWidth = 0
- let searchIcon = NSImageView()
- searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
- searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
- searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
- searchIcon.imageScaling = .scaleProportionallyUpOrDown
- let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
- searchHintLabel.isHidden = false
- let searchField = SearchPillTextField()
- searchField.isBordered = false
- searchField.drawsBackground = false
- searchField.backgroundColor = .clear
- searchField.focusRingType = .none
- searchField.isEditable = false
- searchField.isSelectable = false
- searchField.font = .systemFont(ofSize: 13, weight: .regular)
- searchField.textColor = primaryText
- searchField.alignment = .left
- searchField.placeholderString = nil
- if let cell = searchField.cell as? NSTextFieldCell {
- cell.isBezeled = false
- cell.isBordered = false
- cell.backgroundColor = .clear
- }
- let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
- guard let searchField, let searchHintLabel else { return }
- let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
- searchHintLabel.isHidden = shouldShow == false
- }
- searchField.onFocusChange = { [weak self] focused in
- self?.applySearchPillFocusBorder(focused: focused)
- updateSearchHintVisibility()
- }
- updateSearchHintVisibility()
- let searchTap = NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped))
- searchPill.addGestureRecognizer(searchTap)
- searchField.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
- searchIcon.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
- searchHintLabel.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
- let searchRow = NSStackView()
- searchRow.orientation = .horizontal
- searchRow.spacing = 14
- searchRow.alignment = .centerY
- searchRow.addArrangedSubview(searchPill)
- let rightTopBarCluster = NSStackView()
- rightTopBarCluster.orientation = .horizontal
- rightTopBarCluster.spacing = 10
- rightTopBarCluster.alignment = .centerY
- let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
- topBarPremiumButton = upgradeToProButton
- updateTopBarPremiumButton()
- let authButton = NSButton(title: "", target: self, action: #selector(topBarAuthButtonTapped))
- authButton.isBordered = false
- authButton.wantsLayer = true
- authButton.layer?.cornerRadius = 11
- authButton.contentTintColor = .white
- authButton.font = .systemFont(ofSize: 12, weight: .semibold)
- authButton.bezelStyle = .shadowlessSquare
- self.topBarAuthButton = authButton
- [upgradeToProButton, authButton].forEach { rightTopBarCluster.addArrangedSubview($0) }
- let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
- let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
- let actions = NSStackView(views: [
- makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange, action: #selector(newMeetingTapped)),
- makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
- makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
- ])
- actions.orientation = .horizontal
- actions.spacing = 12
- actions.alignment = .centerY
- actions.distribution = .fillEqually
- let panel = NSView()
- panel.wantsLayer = true
- panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- panel.layer?.cornerRadius = 16
- panel.layer?.borderWidth = 1
- panel.layer?.borderColor = palette.inputBorder.cgColor
-
- let todaysDateFormatter = DateFormatter()
- todaysDateFormatter.dateFormat = "EEEE, MMM d"
- let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
- let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
-
- let meetingsDayNav = NSStackView()
- meetingsDayNav.orientation = .horizontal
- meetingsDayNav.spacing = 4
- meetingsDayNav.alignment = .centerY
- let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
- let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
- let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
- [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
-
- let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
- let meetingsScrollView = NSScrollView()
- meetingsScrollView.drawsBackground = false
- meetingsScrollView.hasVerticalScroller = true
- meetingsScrollView.hasHorizontalScroller = false
- meetingsScrollView.autohidesScrollers = true
- let meetingsDocument = FlippedView()
- let meetingsStack = NSStackView()
- meetingsStack.orientation = .vertical
- meetingsStack.spacing = 14
- meetingsStack.alignment = .leading
- // Keep meeting cards pinned to the top of the scroll content.
- meetingsStack.setContentHuggingPriority(.required, for: .vertical)
- meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
- let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
- refreshMeetingsButton.isBordered = false
- refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
- refreshMeetingsButton.contentTintColor = primaryText
- let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
- if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
- let image = base.withSymbolConfiguration(refreshSymbolConfig) {
- image.isTemplate = true
- refreshMeetingsButton.image = image
- refreshMeetingsButton.imagePosition = .imageLeading
- refreshMeetingsButton.imageHugsTitle = true
- refreshMeetingsButton.imageScaling = .scaleNone
- }
- refreshMeetingsButton.wantsLayer = true
- refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
- : NSColor.black.withAlphaComponent(0.06)
- ).cgColor
- refreshMeetingsButton.layer?.cornerRadius = 11
- refreshMeetingsButton.layer?.borderWidth = 1
- refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
- self.refreshMeetingsButton = refreshMeetingsButton
- let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
- placeholder.isHidden = true
- let settingsView = makeSettingsView()
- settingsView.isHidden = selectedHomeSidebarItem != "Settings"
- let meetingsPageView = makeMeetingsPageView()
- meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
- let searchOverlay = NSView()
- searchOverlay.wantsLayer = true
- searchOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.45).cgColor
- searchOverlay.isHidden = true
- searchOverlay.translatesAutoresizingMaskIntoConstraints = false
- let searchPanel = NSView()
- searchPanel.wantsLayer = true
- searchPanel.layer?.backgroundColor = secondaryCardBackground.cgColor
- searchPanel.layer?.cornerRadius = 14
- searchPanel.layer?.borderWidth = 1
- searchPanel.layer?.borderColor = palette.inputBorder.cgColor
- searchPanel.translatesAutoresizingMaskIntoConstraints = false
- let popupTitle = makeLabel("Search Meetings", size: 18, color: primaryText, weight: .semibold, centered: false)
- popupTitle.translatesAutoresizingMaskIntoConstraints = false
- let popupSubtitle = makeLabel("Find any scheduled meeting instantly", size: 12, color: secondaryText, weight: .regular, centered: false)
- popupSubtitle.translatesAutoresizingMaskIntoConstraints = false
- let popupSearchChrome = NSView()
- popupSearchChrome.translatesAutoresizingMaskIntoConstraints = false
- popupSearchChrome.wantsLayer = true
- popupSearchChrome.layer?.backgroundColor = searchPillBackground.cgColor
- popupSearchChrome.layer?.cornerRadius = 10
- popupSearchChrome.layer?.borderWidth = 1
- popupSearchChrome.layer?.borderColor = palette.inputBorder.cgColor
- let popupSearchIcon = NSImageView()
- popupSearchIcon.translatesAutoresizingMaskIntoConstraints = false
- popupSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search meetings")
- popupSearchIcon.contentTintColor = secondaryText
- popupSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
- let popupSearchField = NSTextField()
- popupSearchField.translatesAutoresizingMaskIntoConstraints = false
- popupSearchField.isBordered = false
- popupSearchField.drawsBackground = false
- popupSearchField.backgroundColor = .clear
- popupSearchField.focusRingType = .none
- popupSearchField.font = .systemFont(ofSize: 15, weight: .regular)
- popupSearchField.textColor = primaryText
- popupSearchField.placeholderString = "Search all meetings by title, host, source, ID..."
- let popupResultsScroll = NSScrollView()
- popupResultsScroll.translatesAutoresizingMaskIntoConstraints = false
- popupResultsScroll.drawsBackground = false
- popupResultsScroll.hasVerticalScroller = true
- popupResultsScroll.hasHorizontalScroller = false
- popupResultsScroll.autohidesScrollers = true
- let popupResultsDocument = FlippedView()
- popupResultsDocument.translatesAutoresizingMaskIntoConstraints = false
- let popupResultsStack = NSStackView()
- popupResultsStack.translatesAutoresizingMaskIntoConstraints = false
- popupResultsStack.orientation = .vertical
- popupResultsStack.spacing = 10
- popupResultsStack.alignment = .leading
- let popupEmpty = makeLabel("No meetings match your search.", size: 13, color: secondaryText, weight: .regular, centered: true)
- popupEmpty.translatesAutoresizingMaskIntoConstraints = false
- popupEmpty.isHidden = true
- root.addSubview(searchOverlay)
- searchOverlay.addSubview(searchPanel)
- searchPanel.addSubview(popupTitle)
- searchPanel.addSubview(popupSubtitle)
- searchPanel.addSubview(popupSearchChrome)
- searchPanel.addSubview(popupResultsScroll)
- searchPanel.addSubview(popupEmpty)
- popupSearchChrome.addSubview(popupSearchIcon)
- popupSearchChrome.addSubview(popupSearchField)
- popupResultsScroll.documentView = popupResultsDocument
- popupResultsDocument.addSubview(popupResultsStack)
- let schedulerRoot = NSView()
- schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
- let schedulerSplit = NSStackView()
- schedulerSplit.orientation = .horizontal
- schedulerSplit.spacing = 14
- schedulerSplit.distribution = .fillEqually
- schedulerSplit.alignment = .top
- let schedulerCalendarPanel = NSView()
- schedulerCalendarPanel.wantsLayer = true
- schedulerCalendarPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- schedulerCalendarPanel.layer?.cornerRadius = 16
- schedulerCalendarPanel.layer?.borderWidth = 1
- schedulerCalendarPanel.layer?.borderColor = palette.inputBorder.cgColor
- let schedulerMeetingsPanel = NSView()
- schedulerMeetingsPanel.wantsLayer = true
- schedulerMeetingsPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- schedulerMeetingsPanel.layer?.cornerRadius = 16
- schedulerMeetingsPanel.layer?.borderWidth = 1
- schedulerMeetingsPanel.layer?.borderColor = palette.inputBorder.cgColor
- let schedulerCalendarTopRow = NSStackView()
- schedulerCalendarTopRow.orientation = .horizontal
- schedulerCalendarTopRow.alignment = .centerY
- schedulerCalendarTopRow.distribution = .fill
- schedulerCalendarTopRow.spacing = 10
- let schedulerConnectLabel = makeLabel("Zoom calendar", size: 18, color: accentBlue, weight: .semibold, centered: false)
- let schedulerPlusButton = NSButton(title: "", target: self, action: #selector(scheduleMeetingTapped))
- schedulerPlusButton.isBordered = false
- schedulerPlusButton.bezelStyle = .shadowlessSquare
- schedulerPlusButton.focusRingType = .none
- schedulerPlusButton.wantsLayer = true
- schedulerPlusButton.layer?.backgroundColor = accentBlue.cgColor
- schedulerPlusButton.layer?.cornerRadius = 14
- schedulerPlusButton.contentTintColor = .white
- schedulerPlusButton.toolTip = "Connect calendar"
- if let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Connect calendar") {
- schedulerPlusButton.image = image
- schedulerPlusButton.imagePosition = .imageOnly
- }
- schedulerPlusButton.translatesAutoresizingMaskIntoConstraints = false
- schedulerPlusButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
- schedulerPlusButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- let schedulerMonthRow = NSStackView()
- schedulerMonthRow.orientation = .horizontal
- schedulerMonthRow.alignment = .centerY
- schedulerMonthRow.spacing = 8
- let schedulerPrevMonthButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(schedulerPrevMonthTapped), dimension: 18, pointSize: 10, toolTip: "Previous month")
- let schedulerMonthLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
- let schedulerNextMonthButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(schedulerNextMonthTapped), dimension: 18, pointSize: 10, toolTip: "Next month")
- [schedulerPrevMonthButton, schedulerMonthLabel, schedulerNextMonthButton].forEach { schedulerMonthRow.addArrangedSubview($0) }
- let schedulerWeekdaysRow = NSStackView()
- schedulerWeekdaysRow.orientation = .horizontal
- schedulerWeekdaysRow.distribution = .fillEqually
- // Match date-row spacing so each date column aligns under weekday labels.
- schedulerWeekdaysRow.spacing = 6
- let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
- for symbol in weekdaySymbols {
- let weekdayLabel = makeLabel(symbol, size: 12, color: mutedText, weight: .medium, centered: true)
- schedulerWeekdaysRow.addArrangedSubview(weekdayLabel)
- }
- let schedulerCalendarGrid = NSStackView()
- schedulerCalendarGrid.orientation = .vertical
- schedulerCalendarGrid.distribution = .fillEqually
- schedulerCalendarGrid.alignment = .leading
- schedulerCalendarGrid.spacing = 6
- let schedulerRightTopRow = NSStackView()
- schedulerRightTopRow.orientation = .horizontal
- schedulerRightTopRow.alignment = .centerY
- schedulerRightTopRow.spacing = 10
- let schedulerSelectedDateLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
- let schedulerTodayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(schedulerTodayTapped))
- schedulerTodayButton.toolTip = "Jump to today"
- [schedulerTodayButton].forEach { schedulerRightTopRow.addArrangedSubview($0) }
- let schedulerMeetingsHeader = makeLabel("Scheduled meetings", size: 13, color: secondaryText, weight: .medium, centered: false)
- let schedulerMeetingsScroll = NSScrollView()
- schedulerMeetingsScroll.drawsBackground = false
- schedulerMeetingsScroll.hasVerticalScroller = true
- schedulerMeetingsScroll.hasHorizontalScroller = false
- schedulerMeetingsScroll.autohidesScrollers = true
- let schedulerMeetingsDocument = FlippedView()
- let schedulerMeetingsStack = NSStackView()
- schedulerMeetingsStack.orientation = .vertical
- schedulerMeetingsStack.spacing = 12
- schedulerMeetingsStack.alignment = .leading
- schedulerMeetingsStack.setContentHuggingPriority(.required, for: .vertical)
- schedulerMeetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
- schedulerMeetingsScroll.documentView = schedulerMeetingsDocument
- schedulerMeetingsDocument.addSubview(schedulerMeetingsStack)
- let schedulerEmptyLabel = makeLabel("No meetings scheduled for this date.", size: 15, color: secondaryText, weight: .regular, centered: true)
- schedulerEmptyLabel.isHidden = true
- let contentColumn = NSView()
- contentColumn.translatesAutoresizingMaskIntoConstraints = false
- content.addSubview(topBar)
- content.addSubview(topBarDivider)
- content.addSubview(contentColumn)
- [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- }
- [brandStack].forEach {
- shell.addSubview($0)
- }
- [searchRow, rightTopBarCluster].forEach {
- topBar.addSubview($0)
- }
- [searchIcon, searchField, searchHintLabel].forEach {
- searchPill.addSubview($0)
- }
- [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, schedulerRoot].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- contentColumn.addSubview($0)
- }
- [schedulerSplit, schedulerCalendarPanel, schedulerMeetingsPanel, schedulerCalendarTopRow, schedulerConnectLabel, schedulerPlusButton, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid, schedulerRightTopRow, schedulerSelectedDateLabel, schedulerTodayButton, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel, schedulerMeetingsDocument, schedulerMeetingsStack].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- }
- schedulerRoot.addSubview(schedulerSplit)
- [schedulerCalendarPanel, schedulerMeetingsPanel].forEach { schedulerSplit.addArrangedSubview($0) }
- [schedulerCalendarTopRow, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid].forEach { schedulerCalendarPanel.addSubview($0) }
- schedulerCalendarTopRow.addArrangedSubview(schedulerConnectLabel)
- schedulerCalendarTopRow.addArrangedSubview(NSView())
- schedulerCalendarTopRow.addArrangedSubview(schedulerPlusButton)
- [schedulerSelectedDateLabel, schedulerRightTopRow, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel].forEach { schedulerMeetingsPanel.addSubview($0) }
- topBar.translatesAutoresizingMaskIntoConstraints = false
- topBarDivider.translatesAutoresizingMaskIntoConstraints = false
- meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
- meetingsStack.translatesAutoresizingMaskIntoConstraints = false
- meetingsScrollView.documentView = meetingsDocument
- meetingsDocument.addSubview(meetingsStack)
- let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
- searchRowCenterX.priority = .defaultHigh
- let brandLeading = brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset)
- brandLeadingConstraint = brandLeading
- NSLayoutConstraint.activate([
- brandLeading,
- brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
- brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
- topBar.topAnchor.constraint(equalTo: content.topAnchor),
- topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
- topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
- topBar.heightAnchor.constraint(equalToConstant: 56),
- topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
- topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
- topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
- topBarDivider.heightAnchor.constraint(equalToConstant: 1),
- contentColumn.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
- contentColumn.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
- contentColumn.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
- contentColumn.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
- searchRowCenterX,
- searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
- searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
- searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
- searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
- rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
- rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
- searchPill.heightAnchor.constraint(equalToConstant: 32),
- searchPill.widthAnchor.constraint(equalToConstant: 320),
- searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
- searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
- searchIcon.widthAnchor.constraint(equalToConstant: 16),
- searchIcon.heightAnchor.constraint(equalToConstant: 16),
- searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
- searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
- searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
- searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
- searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
- searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
- authButton.heightAnchor.constraint(equalToConstant: 30),
- timeTitle.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 20),
- timeTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
- dateTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
- actions.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- actions.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 12),
- actions.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -12),
- actions.heightAnchor.constraint(equalToConstant: 100),
- panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
- panel.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- panel.widthAnchor.constraint(equalToConstant: 610),
- panel.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 6),
- panel.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -6),
- panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
- panel.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor, constant: -14),
- panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
- panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
- meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
- meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
- meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
- meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
- noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
- noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
- noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
- meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
- meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
- meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
- meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
- meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
- meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
- meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
- meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
- meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
- refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
- refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
- refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
- refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
- placeholder.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- placeholder.centerYAnchor.constraint(equalTo: contentColumn.centerYAnchor),
- settingsView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
- settingsView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
- settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
- settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
- meetingsPageView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
- meetingsPageView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
- meetingsPageView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
- meetingsPageView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor),
- schedulerRoot.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 8),
- schedulerRoot.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor, constant: 8),
- schedulerRoot.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor, constant: -8),
- schedulerRoot.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor, constant: -8),
- schedulerSplit.topAnchor.constraint(equalTo: schedulerRoot.topAnchor),
- schedulerSplit.leadingAnchor.constraint(equalTo: schedulerRoot.leadingAnchor),
- schedulerSplit.trailingAnchor.constraint(equalTo: schedulerRoot.trailingAnchor),
- schedulerSplit.bottomAnchor.constraint(equalTo: schedulerRoot.bottomAnchor),
- schedulerCalendarTopRow.topAnchor.constraint(equalTo: schedulerCalendarPanel.topAnchor, constant: 18),
- schedulerCalendarTopRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerCalendarTopRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerMonthRow.topAnchor.constraint(equalTo: schedulerCalendarTopRow.bottomAnchor, constant: 18),
- schedulerMonthRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerMonthRow.trailingAnchor.constraint(lessThanOrEqualTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerWeekdaysRow.topAnchor.constraint(equalTo: schedulerMonthRow.bottomAnchor, constant: 14),
- schedulerWeekdaysRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerWeekdaysRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerCalendarGrid.topAnchor.constraint(equalTo: schedulerWeekdaysRow.bottomAnchor, constant: 10),
- schedulerCalendarGrid.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerCalendarGrid.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerCalendarGrid.bottomAnchor.constraint(equalTo: schedulerCalendarPanel.bottomAnchor, constant: -16),
- schedulerCalendarGrid.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
- schedulerSelectedDateLabel.topAnchor.constraint(equalTo: schedulerMeetingsPanel.topAnchor, constant: 18),
- schedulerSelectedDateLabel.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
- schedulerSelectedDateLabel.trailingAnchor.constraint(lessThanOrEqualTo: schedulerRightTopRow.leadingAnchor, constant: -10),
- schedulerRightTopRow.centerYAnchor.constraint(equalTo: schedulerSelectedDateLabel.centerYAnchor),
- schedulerRightTopRow.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
- schedulerMeetingsHeader.topAnchor.constraint(equalTo: schedulerSelectedDateLabel.bottomAnchor, constant: 12),
- schedulerMeetingsHeader.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
- schedulerMeetingsHeader.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
- schedulerMeetingsScroll.topAnchor.constraint(equalTo: schedulerMeetingsHeader.bottomAnchor, constant: 10),
- schedulerMeetingsScroll.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 14),
- schedulerMeetingsScroll.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -14),
- schedulerMeetingsScroll.bottomAnchor.constraint(equalTo: schedulerMeetingsPanel.bottomAnchor, constant: -14),
- schedulerMeetingsDocument.widthAnchor.constraint(equalTo: schedulerMeetingsScroll.contentView.widthAnchor),
- schedulerMeetingsStack.topAnchor.constraint(equalTo: schedulerMeetingsDocument.topAnchor),
- schedulerMeetingsStack.leadingAnchor.constraint(equalTo: schedulerMeetingsDocument.leadingAnchor),
- schedulerMeetingsStack.trailingAnchor.constraint(equalTo: schedulerMeetingsDocument.trailingAnchor),
- schedulerMeetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: schedulerMeetingsDocument.bottomAnchor),
- schedulerEmptyLabel.centerXAnchor.constraint(equalTo: schedulerMeetingsPanel.centerXAnchor),
- schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor),
- searchOverlay.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- searchOverlay.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- searchOverlay.topAnchor.constraint(equalTo: root.topAnchor),
- searchOverlay.bottomAnchor.constraint(equalTo: root.bottomAnchor),
- searchPanel.centerXAnchor.constraint(equalTo: searchOverlay.centerXAnchor),
- searchPanel.centerYAnchor.constraint(equalTo: searchOverlay.centerYAnchor),
- searchPanel.widthAnchor.constraint(equalToConstant: 640),
- searchPanel.heightAnchor.constraint(equalToConstant: 460),
- popupTitle.topAnchor.constraint(equalTo: searchPanel.topAnchor, constant: 18),
- popupTitle.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
- popupTitle.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
- popupSubtitle.topAnchor.constraint(equalTo: popupTitle.bottomAnchor, constant: 4),
- popupSubtitle.leadingAnchor.constraint(equalTo: popupTitle.leadingAnchor),
- popupSubtitle.trailingAnchor.constraint(equalTo: popupTitle.trailingAnchor),
- popupSearchChrome.topAnchor.constraint(equalTo: popupSubtitle.bottomAnchor, constant: 12),
- popupSearchChrome.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
- popupSearchChrome.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
- popupSearchChrome.heightAnchor.constraint(equalToConstant: 40),
- popupSearchIcon.leadingAnchor.constraint(equalTo: popupSearchChrome.leadingAnchor, constant: 12),
- popupSearchIcon.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
- popupSearchIcon.widthAnchor.constraint(equalToConstant: 16),
- popupSearchIcon.heightAnchor.constraint(equalToConstant: 16),
- popupSearchField.leadingAnchor.constraint(equalTo: popupSearchIcon.trailingAnchor, constant: 8),
- popupSearchField.trailingAnchor.constraint(equalTo: popupSearchChrome.trailingAnchor, constant: -12),
- popupSearchField.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
- popupResultsScroll.topAnchor.constraint(equalTo: popupSearchChrome.bottomAnchor, constant: 12),
- popupResultsScroll.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
- popupResultsScroll.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
- popupResultsScroll.bottomAnchor.constraint(equalTo: searchPanel.bottomAnchor, constant: -18),
- popupResultsStack.topAnchor.constraint(equalTo: popupResultsDocument.topAnchor),
- popupResultsStack.leadingAnchor.constraint(equalTo: popupResultsDocument.leadingAnchor),
- popupResultsStack.trailingAnchor.constraint(equalTo: popupResultsDocument.trailingAnchor),
- popupResultsStack.bottomAnchor.constraint(lessThanOrEqualTo: popupResultsDocument.bottomAnchor),
- popupResultsStack.widthAnchor.constraint(equalTo: popupResultsScroll.contentView.widthAnchor),
- popupEmpty.centerXAnchor.constraint(equalTo: popupResultsScroll.centerXAnchor),
- popupEmpty.centerYAnchor.constraint(equalTo: popupResultsScroll.centerYAnchor)
- ])
- timeLabel = timeTitle
- dateLabel = dateTitle
- homeWelcomeLabel = nil
- homeTimeLabelView = timeTitle
- homeDateLabelView = dateTitle
- homeActionsRow = actions
- homeMeetingsPanel = panel
- homePlaceholderLabel = placeholder
- homeSettingsView = settingsView
- meetingsPageRootView = meetingsPageView
- schedulerRootView = schedulerRoot
- self.schedulerMonthLabel = schedulerMonthLabel
- schedulerCalendarGridStack = schedulerCalendarGrid
- self.schedulerSelectedDateLabel = schedulerSelectedDateLabel
- schedulerMeetingsListStack = schedulerMeetingsStack
- schedulerMeetingsEmptyLabel = schedulerEmptyLabel
- self.schedulerTodayButton = schedulerTodayButton
- meetingsDayHeaderLabel = panelHeader
- meetingsListStack = meetingsStack
- meetingsStatusLabel = meetingsStatus
- emptyMeetingLabel = noMeeting
- meetingsPrevDayButton = prevDayButton
- meetingsTodayButton = todayButton
- meetingsNextDayButton = nextDayButton
- observeMeetingsScrollEdges(in: meetingsScrollView)
- updateClock()
- updateMeetingsDayUI()
- applyFilteredMeetings()
- applyMeetingsPageFilters(resetVisible: true)
- renderSchedulerCalendarGrid()
- updateSchedulerDateHeader()
- applySchedulerMeetingsForSelectedDate()
- homeSearchField = searchField
- homeSearchPill = searchPill
- meetingSearchOverlayView = searchOverlay
- meetingSearchPanelView = searchPanel
- meetingSearchField = popupSearchField
- meetingSearchResultsStack = popupResultsStack
- meetingSearchEmptyLabel = popupEmpty
- removeMeetingSearchObserver()
- meetingSearchTextObserver = NotificationCenter.default.addObserver(
- forName: NSControl.textDidChangeNotification,
- object: popupSearchField,
- queue: .main
- ) { [weak self] _ in
- guard let self else { return }
- self.applyGlobalMeetingSearch(query: popupSearchField.stringValue)
- }
- applyGlobalMeetingSearch(query: "")
- topBarAuthButtonWidthConstraint = authButton.widthAnchor.constraint(equalToConstant: 120)
- topBarAuthButtonWidthConstraint?.isActive = true
- updateTopBarAuthButton()
- applyAuthenticationUIState(triggerInitialRefresh: false)
- updatePremiumLockedSections()
- return root
- }
- @objc private func premiumLockedAreaTapped(_ sender: NSButton) {
- showPaywall()
- }
- @MainActor
- private func updatePremiumLockedSections() {
- let shouldLock = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
- homePremiumLockOverlay = updatePremiumLockOverlay(
- existing: homePremiumLockOverlay,
- in: homeMeetingsPanel,
- locked: shouldLock,
- message: "Upgrade to Premium to access meetings"
- )
- meetingsPremiumLockOverlay = updatePremiumLockOverlay(
- existing: meetingsPremiumLockOverlay,
- in: meetingsPageRootView,
- locked: shouldLock,
- message: "Upgrade to Premium to unlock Meetings"
- )
- schedulerPremiumLockOverlay = updatePremiumLockOverlay(
- existing: schedulerPremiumLockOverlay,
- in: schedulerRootView,
- locked: shouldLock,
- message: "Upgrade to Premium to unlock Scheduler"
- )
- }
- @MainActor
- private func updatePremiumLockOverlay(
- existing: NSButton?,
- in target: NSView?,
- locked: Bool,
- message: String
- ) -> NSButton? {
- guard let target else { return existing }
- let button: NSButton
- if let existing, existing.superview === target {
- button = existing
- } else {
- let created = NSButton(title: message, target: self, action: #selector(premiumLockedAreaTapped(_:)))
- created.translatesAutoresizingMaskIntoConstraints = false
- created.isBordered = false
- created.bezelStyle = .shadowlessSquare
- created.alignment = .center
- created.wantsLayer = true
- created.layer?.cornerRadius = 12
- created.layer?.masksToBounds = true
- created.layer?.backgroundColor = NSColor.clear.cgColor
- created.font = .systemFont(ofSize: 15, weight: .semibold)
- created.contentTintColor = .white
- let blur = NSVisualEffectView()
- blur.translatesAutoresizingMaskIntoConstraints = false
- blur.material = .hudWindow
- blur.blendingMode = .withinWindow
- blur.state = .active
- created.addSubview(blur, positioned: .below, relativeTo: nil)
- let dim = NSView()
- dim.translatesAutoresizingMaskIntoConstraints = false
- dim.wantsLayer = true
- dim.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
- created.addSubview(dim, positioned: .above, relativeTo: blur)
- target.addSubview(created)
- NSLayoutConstraint.activate([
- created.leadingAnchor.constraint(equalTo: target.leadingAnchor),
- created.trailingAnchor.constraint(equalTo: target.trailingAnchor),
- created.topAnchor.constraint(equalTo: target.topAnchor),
- created.bottomAnchor.constraint(equalTo: target.bottomAnchor),
- blur.leadingAnchor.constraint(equalTo: created.leadingAnchor),
- blur.trailingAnchor.constraint(equalTo: created.trailingAnchor),
- blur.topAnchor.constraint(equalTo: created.topAnchor),
- blur.bottomAnchor.constraint(equalTo: created.bottomAnchor),
- dim.leadingAnchor.constraint(equalTo: created.leadingAnchor),
- dim.trailingAnchor.constraint(equalTo: created.trailingAnchor),
- dim.topAnchor.constraint(equalTo: created.topAnchor),
- dim.bottomAnchor.constraint(equalTo: created.bottomAnchor)
- ])
- button = created
- }
- let blurView = button.subviews.compactMap { $0 as? NSVisualEffectView }.first
- let dimView = button.subviews.first { $0 is NSVisualEffectView == false }
- if palette.isDarkMode {
- blurView?.material = .hudWindow
- button.contentTintColor = .white
- dimView?.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
- } else {
- // Lighter, cleaner lock treatment in light mode.
- blurView?.material = .popover
- button.contentTintColor = NSColor(calibratedWhite: 0.09, alpha: 1.0)
- dimView?.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.38).cgColor
- }
- button.title = message
- button.isHidden = locked == false
- return button
- }
- private func makeMeetingsPageView() -> NSView {
- let root = NSView()
- root.wantsLayer = true
- root.layer?.backgroundColor = NSColor.clear.cgColor
- let container = NSView()
- container.translatesAutoresizingMaskIntoConstraints = false
- container.wantsLayer = true
- container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- container.layer?.cornerRadius = 16
- container.layer?.borderWidth = 1
- container.layer?.borderColor = palette.inputBorder.cgColor
- root.addSubview(container)
- let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
- let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
- let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
- let titleStack = NSStackView(views: [title, subtitle, count])
- titleStack.orientation = .vertical
- titleStack.spacing = 4
- titleStack.alignment = .leading
- titleStack.translatesAutoresizingMaskIntoConstraints = false
- let categoryFilter = NSPopUpButton()
- categoryFilter.translatesAutoresizingMaskIntoConstraints = false
- categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
- categoryFilter.selectItem(withTitle: "All")
- categoryFilter.target = self
- categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
- let fromDatePicker = NSDatePicker()
- fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
- fromDatePicker.datePickerStyle = .textFieldAndStepper
- fromDatePicker.datePickerMode = .single
- fromDatePicker.datePickerElements = [.yearMonthDay]
- fromDatePicker.dateValue = Date()
- fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
- let toDatePicker = NSDatePicker()
- toDatePicker.translatesAutoresizingMaskIntoConstraints = false
- toDatePicker.datePickerStyle = .textFieldAndStepper
- toDatePicker.datePickerMode = .single
- toDatePicker.datePickerElements = [.yearMonthDay]
- toDatePicker.dateValue = Date()
- toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
- let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
- applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
- applyFiltersButton.isBordered = false
- applyFiltersButton.wantsLayer = true
- applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
- applyFiltersButton.layer?.cornerRadius = 10
- applyFiltersButton.contentTintColor = .white
- applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
- resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
- resetFiltersButton.isBordered = false
- resetFiltersButton.wantsLayer = true
- resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
- resetFiltersButton.layer?.cornerRadius = 10
- resetFiltersButton.layer?.borderWidth = 1
- resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
- resetFiltersButton.contentTintColor = primaryText
- resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
- refreshButton.translatesAutoresizingMaskIntoConstraints = false
- refreshButton.isBordered = false
- refreshButton.wantsLayer = true
- refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
- refreshButton.layer?.cornerRadius = 10
- refreshButton.layer?.borderWidth = 1
- refreshButton.layer?.borderColor = palette.inputBorder.cgColor
- refreshButton.contentTintColor = primaryText
- refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
- if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
- refreshButton.image = image
- refreshButton.imagePosition = .imageOnly
- }
- let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
- filterRow.orientation = .horizontal
- filterRow.spacing = 10
- filterRow.alignment = .centerY
- filterRow.translatesAutoresizingMaskIntoConstraints = false
- let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
- statusLabel.translatesAutoresizingMaskIntoConstraints = false
- let listScroll = NSScrollView()
- listScroll.translatesAutoresizingMaskIntoConstraints = false
- listScroll.drawsBackground = false
- listScroll.hasVerticalScroller = true
- listScroll.hasHorizontalScroller = false
- listScroll.autohidesScrollers = true
- let listDocument = FlippedView()
- listDocument.translatesAutoresizingMaskIntoConstraints = false
- let listStack = NSStackView()
- listStack.translatesAutoresizingMaskIntoConstraints = false
- listStack.orientation = .vertical
- listStack.spacing = 12
- listStack.alignment = .leading
- listStack.setContentHuggingPriority(.required, for: .vertical)
- listStack.setContentCompressionResistancePriority(.required, for: .vertical)
- listScroll.documentView = listDocument
- listDocument.addSubview(listStack)
- let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
- empty.translatesAutoresizingMaskIntoConstraints = false
- empty.isHidden = true
- let loadMoreButton = NSButton(title: "", target: nil, action: nil)
- loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
- loadMoreButton.isBordered = false
- loadMoreButton.wantsLayer = true
- loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
- loadMoreButton.layer?.cornerRadius = 10
- loadMoreButton.layer?.borderWidth = 1
- loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
- loadMoreButton.contentTintColor = primaryText
- loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
- loadMoreButton.isHidden = true
- [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
- NSLayoutConstraint.activate([
- container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
- container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
- container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
- container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
- titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
- titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
- titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
- filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
- filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
- filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
- categoryFilter.widthAnchor.constraint(equalToConstant: 170),
- fromDatePicker.widthAnchor.constraint(equalToConstant: 170),
- toDatePicker.widthAnchor.constraint(equalToConstant: 170),
- applyFiltersButton.widthAnchor.constraint(equalToConstant: 96),
- resetFiltersButton.widthAnchor.constraint(equalToConstant: 96),
- refreshButton.widthAnchor.constraint(equalToConstant: 44),
- applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
- resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
- refreshButton.heightAnchor.constraint(equalToConstant: 34),
- statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
- statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
- statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
- listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
- listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
- listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
- listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
- listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
- listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
- // Pin bottom edge so document height grows with arranged subviews.
- listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
- empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
- empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
- loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
- loadMoreButton.heightAnchor.constraint(equalToConstant: 1)
- ])
- meetingsPageCategoryFilter = categoryFilter
- meetingsPageFromDatePicker = fromDatePicker
- meetingsPageToDatePicker = toDatePicker
- meetingsPageRefreshButton = refreshButton
- meetingsPageCountLabel = count
- meetingsPageStatusLabel = statusLabel
- meetingsPageListStack = listStack
- meetingsPageEmptyLabel = empty
- meetingsPageScrollView = listScroll
- meetingsPageLoadMoreButton = loadMoreButton
- observeMeetingsPageInfiniteScroll(in: listScroll)
- updateMeetingsDatePickersEnabledState()
- return root
- }
- deinit {
- premiumRatingPromptWorkItem?.cancel()
- usageRatingPromptWorkItem?.cancel()
- removeSearchFieldObserver()
- removeMeetingSearchObserver()
- clearMeetingsPageScrollObserver()
- removeSearchShortcutMonitor()
- for observer in windowFullScreenObservers {
- NotificationCenter.default.removeObserver(observer)
- }
- windowFullScreenObservers.removeAll()
- }
- private func ensureUsageSessionStartDate() {
- if UserDefaults.standard.object(forKey: usageSessionStartedAtKey) == nil {
- UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: usageSessionStartedAtKey)
- }
- }
- private func usageSessionStartedAt() -> Date {
- let storedValue = UserDefaults.standard.double(forKey: usageSessionStartedAtKey)
- if storedValue > 0 {
- return Date(timeIntervalSince1970: storedValue)
- }
- let now = Date()
- UserDefaults.standard.set(now.timeIntervalSince1970, forKey: usageSessionStartedAtKey)
- return now
- }
- private func hasCompletedOrSeenRatingFlow() -> Bool {
- UserDefaults.standard.bool(forKey: ratingCompletedKey)
- }
- private func scheduleUsageBasedRatingPromptIfNeeded() {
- usageRatingPromptWorkItem?.cancel()
- guard hasCompletedOrSeenRatingFlow() == false else { return }
- let elapsed = Date().timeIntervalSince(usageSessionStartedAt())
- let delay = max(0, usageRatingPromptDelay - elapsed)
- let workItem = DispatchWorkItem { [weak self] in
- self?.presentRatingPrompt(source: "usage")
- }
- usageRatingPromptWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
- }
- private func schedulePremiumRatingPromptIfNeeded() {
- guard hasCompletedOrSeenRatingFlow() == false else { return }
- guard premiumRatingPromptWorkItem == nil else { return }
- let workItem = DispatchWorkItem { [weak self] in
- self?.premiumRatingPromptWorkItem = nil
- self?.presentRatingPrompt(source: "premium")
- }
- premiumRatingPromptWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + premiumRatingPromptDelay, execute: workItem)
- }
- private func presentRatingPrompt(source: String) {
- guard hasCompletedOrSeenRatingFlow() == false else { return }
- UserDefaults.standard.set(true, forKey: ratingPromptShownKey)
- NSApp.activate(ignoringOtherApps: true)
- let alert = NSAlert()
- alert.alertStyle = .informational
- alert.messageText = "Enjoying Zoom App?"
- if source == "premium" {
- alert.informativeText = "You unlocked Premium. If you're enjoying the app, please rate us."
- } else {
- alert.informativeText = "You've been using the app for a while. If it's helping you, please rate us."
- }
- alert.addButton(withTitle: "Rate Now")
- alert.addButton(withTitle: "Later")
- let response = alert.runModal()
- if response == .alertFirstButtonReturn {
- UserDefaults.standard.set(true, forKey: ratingCompletedKey)
- SKStoreReviewController.requestReview()
- }
- }
- private func startClock() {
- clockTimer?.invalidate()
- clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
- self?.updateClock()
- }
- updateClock()
- }
- private func updateClock() {
- let now = Date()
- let timeFormatter = DateFormatter()
- timeFormatter.dateFormat = "h:mm a"
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "EEEE, d MMM"
- timeLabel?.stringValue = timeFormatter.string(from: now)
- dateLabel?.stringValue = dateFormatter.string(from: now)
- }
- // MARK: - Shared UI
- private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .home) -> NSView {
- let sidebar = NSView()
- sidebar.wantsLayer = true
- sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.spacing = style == .home ? 12 : 16
- stack.alignment = .centerX
- stack.distribution = .fill
- // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
- stack.setContentHuggingPriority(.required, for: .vertical)
- stack.setContentCompressionResistancePriority(.required, for: .vertical)
- stack.translatesAutoresizingMaskIntoConstraints = false
- sidebar.addSubview(stack)
- for item in items {
- let row = NSView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.wantsLayer = true
- let selectedRow = item == selected
- row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
- row.layer?.cornerRadius = style == .home ? 12 : 10
- row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
- // Prevent rows from stretching/collapsing when the window resizes.
- row.setContentHuggingPriority(.required, for: .vertical)
- row.setContentCompressionResistancePriority(.required, for: .vertical)
- if style == .home {
- // Must be tall enough for icon (26) + paddings + label without clipping.
- row.heightAnchor.constraint(equalToConstant: 66).isActive = true
- }
- if style == .home {
- let iconContainer = NSView()
- iconContainer.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview(iconContainer)
- let iconView = NSImageView()
- iconView.translatesAutoresizingMaskIntoConstraints = false
- iconView.contentTintColor = selectedRow ? primaryText : secondaryText
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
- iconView.image = NSImage(systemSymbolName: sidebarSymbolName(for: item, filled: selectedRow), accessibilityDescription: item)
- iconContainer.addSubview(iconView)
- let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview(label)
- let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
- hit.identifier = NSUserInterfaceItemIdentifier(item)
- hit.isBordered = false
- hit.bezelStyle = .shadowlessSquare
- hit.focusRingType = .none
- hit.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview(hit, positioned: .above, relativeTo: nil)
- NSLayoutConstraint.activate([
- iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
- iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- iconContainer.widthAnchor.constraint(equalToConstant: 26),
- iconContainer.heightAnchor.constraint(equalToConstant: 26),
- iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
- iconView.widthAnchor.constraint(equalToConstant: 22),
- iconView.heightAnchor.constraint(equalToConstant: 22),
- label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
- label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
- hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
- hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
- hit.topAnchor.constraint(equalTo: row.topAnchor),
- hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
- ])
- homeSidebarRowViews[item] = row
- homeSidebarIconViews[item] = iconView
- homeSidebarLabels[item] = label
- if item == "Hub" {
- let badge = makeSidebarBadge(text: "1")
- badge.translatesAutoresizingMaskIntoConstraints = false
- iconContainer.addSubview(badge)
- NSLayoutConstraint.activate([
- badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
- badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
- ])
- } else if item == "More" {
- let dot = NSView()
- dot.translatesAutoresizingMaskIntoConstraints = false
- dot.wantsLayer = true
- dot.layer?.backgroundColor = NSColor.systemRed.cgColor
- dot.layer?.cornerRadius = 4
- row.addSubview(dot)
- NSLayoutConstraint.activate([
- dot.widthAnchor.constraint(equalToConstant: 8),
- dot.heightAnchor.constraint(equalToConstant: 8),
- dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
- dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
- ])
- }
- } else {
- let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
- let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : secondaryText, weight: .regular, centered: true)
- [icon, label].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview($0)
- }
- NSLayoutConstraint.activate([
- icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
- icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
- label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
- ])
- }
- stack.addArrangedSubview(row)
- }
-
- if style == .home {
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- // Keep Settings in a stable position (no vertical shifting on resize).
- spacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
- spacer.setContentHuggingPriority(.required, for: .vertical)
- spacer.setContentCompressionResistancePriority(.required, for: .vertical)
- stack.addArrangedSubview(spacer)
-
- let settingsRow = NSView()
- settingsRow.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.wantsLayer = true
- let settingsSelected = selected == "Settings"
- settingsRow.layer?.backgroundColor = settingsSelected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
- settingsRow.layer?.cornerRadius = 12
- settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
- settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
- let iconContainer = NSView()
- iconContainer.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.addSubview(iconContainer)
- let iconView = NSImageView()
- iconView.translatesAutoresizingMaskIntoConstraints = false
- iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
- let settingsSymbolPreferred = settingsSelected ? "gearshape.fill" : "gearshape"
- iconView.image = NSImage(systemSymbolName: settingsSymbolPreferred, accessibilityDescription: "Settings")
- ?? NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
- iconContainer.addSubview(iconView)
- let label = makeLabel("Settings", size: 10, color: settingsSelected ? primaryText : secondaryText, weight: .regular, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.addSubview(label)
- let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
- hit.identifier = NSUserInterfaceItemIdentifier("Settings")
- hit.isBordered = false
- hit.bezelStyle = .shadowlessSquare
- hit.focusRingType = .none
- hit.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.addSubview(hit, positioned: .above, relativeTo: nil)
- NSLayoutConstraint.activate([
- iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
- iconContainer.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
- iconContainer.widthAnchor.constraint(equalToConstant: 26),
- iconContainer.heightAnchor.constraint(equalToConstant: 26),
- iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
- iconView.widthAnchor.constraint(equalToConstant: 22),
- iconView.heightAnchor.constraint(equalToConstant: 22),
- label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
- label.centerXAnchor.constraint(equalTo: settingsRow.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8),
- hit.leadingAnchor.constraint(equalTo: settingsRow.leadingAnchor),
- hit.trailingAnchor.constraint(equalTo: settingsRow.trailingAnchor),
- hit.topAnchor.constraint(equalTo: settingsRow.topAnchor),
- hit.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor)
- ])
- homeSidebarRowViews["Settings"] = settingsRow
- homeSidebarIconViews["Settings"] = iconView
- homeSidebarLabels["Settings"] = label
- stack.addArrangedSubview(settingsRow)
- }
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
- stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
- ])
- if style == .home {
- NSLayoutConstraint.activate([
- stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
- ])
- }
- stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
- return sidebar
- }
- @objc private func homeSidebarItemTapped(_ sender: NSButton) {
- guard let item = sender.identifier?.rawValue else { return }
- selectedHomeSidebarItem = item
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- @objc private func newMeetingTapped() {
- menuBarTriggerNewMeeting()
- }
- func menuBarIsUserLoggedIn() -> Bool {
- isUserLoggedIn()
- }
- func menuBarShowSection(_ section: HomeSection) {
- selectedHomeSidebarItem = section.rawValue
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- func menuBarTriggerNewMeeting() {
- guard isUserLoggedIn() else {
- showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
- return
- }
- guard let url = URL(string: "https://zoom.us/start/videomeeting"), NSWorkspace.shared.open(url) else {
- showSimpleAlert(title: "Unable to open", message: "Could not start a new Zoom meeting right now.")
- return
- }
- }
- func menuBarTriggerJoinMeeting() {
- joinMeetingTapped()
- }
- func menuBarTriggerScheduleMeeting() {
- scheduleMeetingTapped()
- }
- func menuBarTriggerAuthAction() {
- if isUserLoggedIn() {
- logoutTapped()
- } else {
- zoomPrimarySignInTapped()
- }
- }
- @MainActor
- private func updateHomeSidebarHighlight() {
- for (item, row) in homeSidebarRowViews {
- let selected = item == selectedHomeSidebarItem
- row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
- homeSidebarLabels[item]?.textColor = selected ? primaryText : secondaryText
- homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : secondaryText
- let symbolName = sidebarSymbolName(for: item, filled: selected)
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: item) {
- homeSidebarIconViews[item]?.image = image
- }
- }
- }
- @MainActor
- private func updateSelectedHomeSectionUI() {
- let isHome = selectedHomeSidebarItem == "Home"
- let isMeetings = selectedHomeSidebarItem == "Meetings"
- let isSettings = selectedHomeSidebarItem == "Settings"
- let isScheduler = selectedHomeSidebarItem == "Scheduler"
- let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
- homeWelcomeLabel?.stringValue = "Home"
- homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
- let dashboardViews: [NSView?] = [
- homeTimeLabelView,
- homeDateLabelView,
- homeActionsRow,
- homeMeetingsPanel,
- meetingsDayHeaderLabel,
- meetingsStatusLabel,
- meetingsPrevDayButton,
- meetingsTodayButton,
- meetingsNextDayButton,
- meetingsScrollView,
- refreshMeetingsButton
- ]
- let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler
- dashboardViews.forEach { $0?.isHidden = hideDashboard }
- // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
- // and showed "No meetings…" on top of meeting cards when returning to Home.
- if hideDashboard {
- emptyMeetingLabel?.isHidden = true
- } else {
- let hasMeetingCards = (meetingsListStack?.arrangedSubviews.isEmpty == false)
- emptyMeetingLabel?.isHidden = premiumLocked || hasMeetingCards
- }
- homeSettingsView?.isHidden = isSettings == false
- meetingsPageRootView?.isHidden = isMeetings == false
- schedulerRootView?.isHidden = isScheduler == false
- if isHome {
- homePlaceholderLabel?.isHidden = true
- } else {
- // Keep non-Home pages empty for now.
- homePlaceholderLabel?.isHidden = true
- }
- if isScheduler {
- updateSchedulerDateHeader()
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- }
- if isMeetings {
- applyMeetingsPageFilters(resetVisible: false)
- }
- }
- private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
- switch item {
- case "Home":
- return filled ? "house.fill" : "house"
- case "Meetings":
- return filled ? "video.fill" : "video"
- case "Chat":
- return filled ? "message.fill" : "message"
- case "Scheduler":
- // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
- return "calendar.badge.clock"
- case "Settings":
- // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
- return filled ? "gearshape.fill" : "gearshape"
- case "Hub":
- return "square.grid.3x3"
- case "More":
- return "ellipsis"
- default:
- return "circle"
- }
- }
- private func makeSidebarBadge(text: String) -> NSView {
- let badge = NSView()
- badge.wantsLayer = true
- badge.layer?.backgroundColor = NSColor.systemRed.cgColor
- badge.layer?.cornerRadius = 12 / 2
- badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
- badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
- let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- badge.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
- ])
- return badge
- }
- @MainActor
- private func alignNativeTrafficLights() {
- guard let window = view.window else { return }
- guard let closeButton = window.standardWindowButton(.closeButton),
- let miniButton = window.standardWindowButton(.miniaturizeButton),
- let zoomButton = window.standardWindowButton(.zoomButton) else { return }
- guard let buttonContainer = closeButton.superview else { return }
- let buttons = [closeButton, miniButton, zoomButton]
- // Compute from top inset so moving "down" is stable in titlebar coordinates.
- let containerHeight = buttonContainer.bounds.height
- let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
- var nextX = nativeTrafficLightsLeading
- for button in buttons {
- button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
- nextX += button.frame.width + 8
- }
- }
- private func makeUpgradeToProButton(action: Selector?) -> NSButton {
- let title = "Upgrade to Pro"
- let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
- button.isBordered = false
- button.focusRingType = .none
- button.wantsLayer = true
- button.layer?.backgroundColor = accentBlue.cgColor
- button.layer?.cornerRadius = 14
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: NSColor.white,
- .font: font
- ])
- button.toolTip = title
- button.translatesAutoresizingMaskIntoConstraints = false
- button.heightAnchor.constraint(equalToConstant: 28).isActive = true
- button.widthAnchor.constraint(greaterThanOrEqualToConstant: 124).isActive = true
- return button
- }
- /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
- private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
- let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.focusRingType = .none
- button.contentTintColor = palette.isDarkMode
- ? NSColor(calibratedWhite: 0.84, alpha: 1)
- : NSColor(calibratedWhite: 0.22, alpha: 1)
- if let toolTip {
- button.toolTip = toolTip
- }
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
- if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
- let image = base.withSymbolConfiguration(symbolConfig) {
- image.isTemplate = true
- button.image = image
- }
- button.imageScaling = .scaleProportionallyUpOrDown
- button.imagePosition = .imageOnly
- button.translatesAutoresizingMaskIntoConstraints = false
- button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
- button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
- return button
- }
-
- private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
- let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.focusRingType = .none
- button.wantsLayer = true
- button.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.06)
- : NSColor.black.withAlphaComponent(0.06)
- ).cgColor
- button.layer?.cornerRadius = 10
- button.layer?.borderWidth = 1
- button.layer?.borderColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor.black.withAlphaComponent(0.12)
- ).cgColor
- let tint = palette.isDarkMode
- ? NSColor(calibratedWhite: 0.9, alpha: 1)
- : NSColor(calibratedWhite: 0.18, alpha: 1)
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.contentTintColor = tint
- button.font = font
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: tint,
- .font: font
- ])
- if let symbol {
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
- let image = base.withSymbolConfiguration(symbolConfig) {
- image.isTemplate = true
- button.image = image
- button.imagePosition = .imageLeading
- }
- button.imageHugsTitle = true
- button.imageScaling = .scaleNone
- }
- button.translatesAutoresizingMaskIntoConstraints = false
- button.heightAnchor.constraint(equalToConstant: 24).isActive = true
- button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
- return button
- }
- private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
- let root = HoverTileView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.widthAnchor.constraint(equalToConstant: 88).isActive = true
- // Give caption text a little more vertical room so descenders (e.g. "g") are never clipped.
- root.heightAnchor.constraint(equalToConstant: 76).isActive = true
- let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
- iconButton.isBordered = false
- iconButton.wantsLayer = true
- iconButton.layer?.backgroundColor = color.cgColor
- iconButton.layer?.cornerRadius = 15
- iconButton.layer?.shadowOpacity = 0.2
- iconButton.layer?.shadowRadius = 7
- iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
- if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
- let configured = baseImage.withSymbolConfiguration(symbolConfig) {
- iconButton.image = configured
- }
- iconButton.contentTintColor = .white
- iconButton.imageScaling = .scaleNone
- let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
- [iconButton, label].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- root.addSubview($0)
- }
- let iconTopConstraint = iconButton.topAnchor.constraint(equalTo: root.topAnchor)
- NSLayoutConstraint.activate([
- iconTopConstraint,
- iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
- iconButton.widthAnchor.constraint(equalToConstant: 50),
- iconButton.heightAnchor.constraint(equalToConstant: 50),
- label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
- label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
- ])
- root.onHoverChanged = { [weak iconButton, weak label] hovering in
- NSAnimationContext.runAnimationGroup { context in
- context.duration = hovering ? 0.16 : 0.14
- context.timingFunction = CAMediaTimingFunction(name: hovering ? .easeOut : .easeInEaseOut)
- iconTopConstraint.animator().constant = hovering ? -3 : 0
- label?.animator().alphaValue = hovering ? 1.0 : 0.96
- }
- guard let iconButton else { return }
- iconButton.layer?.shadowOpacity = hovering ? 0.28 : 0.2
- iconButton.layer?.shadowRadius = hovering ? 10 : 7
- iconButton.layer?.shadowOffset = hovering ? NSSize(width: 0, height: -3) : NSSize(width: 0, height: -1)
- }
- return root
- }
- private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
- let hoverBackground = palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.075)
- : NSColor.black.withAlphaComponent(0.10)
- let hoverBorder = palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.85)
- : NSColor.black.withAlphaComponent(0.22)
- let card = MeetingCardView(url: meeting.webURL, hoverBackground: hoverBackground, hoverBorder: hoverBorder)
- card.wantsLayer = true
- card.layer?.backgroundColor = meetingCardBackground.cgColor
- card.layer?.cornerRadius = 13
- card.layer?.borderWidth = 1
- card.layer?.borderColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.10)).cgColor
- card.translatesAutoresizingMaskIntoConstraints = false
- card.heightAnchor.constraint(equalToConstant: 116).isActive = true
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "EEE, MMM d"
- let timeFormatter = DateFormatter()
- timeFormatter.dateFormat = "h:mm a"
- let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
- dateFormatter.timeZone = displayTimeZone
- timeFormatter.timeZone = displayTimeZone
- let startText = timeFormatter.string(from: meeting.start)
- let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
- let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
- let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
- let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
- detail.maximumNumberOfLines = 2
- let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
- [title, detail, host].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- card.addSubview($0)
- }
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
- title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
- detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
- detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
- host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
- host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
- host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
- ])
- return card
- }
- private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.font = .systemFont(ofSize: size, weight: weight)
- label.textColor = color
- label.alignment = centered ? .center : .left
- return label
- }
- private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
- let wrapper = NSView()
- let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
- button.font = .systemFont(ofSize: 20, weight: .medium)
- button.isBordered = false
- button.wantsLayer = true
- button.layer?.cornerRadius = 12
- button.layer?.backgroundColor = cardBackground.cgColor
- button.contentTintColor = primaryText
- button.translatesAutoresizingMaskIntoConstraints = false
- let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- wrapper.addSubview(button)
- wrapper.addSubview(label)
- NSLayoutConstraint.activate([
- button.topAnchor.constraint(equalTo: wrapper.topAnchor),
- button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
- button.widthAnchor.constraint(equalToConstant: 52),
- button.heightAnchor.constraint(equalToConstant: 52),
- label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
- label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- return (wrapper, action == nil ? nil : button)
- }
- }
- extension ViewController: NSWindowDelegate {
- func windowWillClose(_ notification: Notification) {
- guard let window = notification.object as? NSWindow else { return }
- if window === joinMeetingWindow {
- resetJoinMeetingPanelReferences()
- }
- if window === scheduleMeetingWindow {
- resetScheduleMeetingPanelReferences()
- }
- }
- }
- private extension Array {
- subscript(safe index: Int) -> Element? {
- guard index >= 0, index < count else { return nil }
- return self[index]
- }
- }
- /// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
- private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
- private func lineHeight() -> CGFloat {
- guard let font = font else { return 0 }
- return ceil(font.ascender - font.descender + font.leading)
- }
- private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
- let lh = lineHeight()
- guard lh > 0, h > lh else { return 0 }
- return max(0, floor((h - lh) / 2))
- }
- private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
- let lh = lineHeight()
- guard lh > 0 else { return cellFrame }
- let m = verticalMargin(forBoundsHeight: cellFrame.height)
- var r = cellFrame
- r.origin.y += m
- r.size.height = lh
- return r
- }
- override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
- super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
- }
- override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
- let text = super.setUpFieldEditorAttributes(textObj)
- guard let tv = text as? NSTextView else { return text }
- // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
- let h = max(controlView?.bounds.height ?? 0, 42)
- let margin = verticalMargin(forBoundsHeight: h)
- if margin > 0 {
- // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
- tv.textContainerInset = NSSize(width: 0, height: margin)
- }
- return text
- }
- }
- /// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
- private final class JoinPanelFieldContainer: NSView {
- let textField: NSTextField
- private let normalBorder: NSColor
- private let focusBorder: NSColor
- private let fill: NSColor
- private var beginObserver: NSObjectProtocol?
- private var endObserver: NSObjectProtocol?
- init(
- placeholder: String,
- normalBorder: NSColor,
- focusBorder: NSColor,
- fill: NSColor,
- primaryText: NSColor,
- mutedText: NSColor
- ) {
- self.normalBorder = normalBorder
- self.focusBorder = focusBorder
- self.fill = fill
- textField = NSTextField()
- textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
- layer?.masksToBounds = false
- layer?.cornerRadius = 23
- layer?.backgroundColor = fill.cgColor
- applyBorder(focused: false)
- textField.font = .systemFont(ofSize: 14, weight: .regular)
- textField.textColor = primaryText
- textField.alignment = .center
- textField.isEditable = true
- textField.isSelectable = true
- textField.refusesFirstResponder = false
- textField.focusRingType = .none
- textField.isBordered = false
- textField.drawsBackground = false
- textField.translatesAutoresizingMaskIntoConstraints = false
- if let cell = textField.cell as? NSTextFieldCell {
- cell.isBezeled = false
- cell.drawsBackground = false
- cell.alignment = .center
- cell.usesSingleLineMode = true
- cell.lineBreakMode = .byTruncatingTail
- let placeholderParagraph = NSMutableParagraphStyle()
- placeholderParagraph.alignment = .center
- cell.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: mutedText.withAlphaComponent(0.88),
- .font: NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: placeholderParagraph
- ]
- )
- }
- addSubview(textField)
- NSLayoutConstraint.activate([
- textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
- textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
- textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
- textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
- heightAnchor.constraint(equalToConstant: 46)
- ])
- beginObserver = NotificationCenter.default.addObserver(
- forName: NSControl.textDidBeginEditingNotification,
- object: textField,
- queue: .main
- ) { [weak self] _ in
- self?.applyBorder(focused: true)
- }
- endObserver = NotificationCenter.default.addObserver(
- forName: NSControl.textDidEndEditingNotification,
- object: textField,
- queue: .main
- ) { [weak self] _ in
- self?.applyBorder(focused: false)
- }
- }
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- deinit {
- if let beginObserver {
- NotificationCenter.default.removeObserver(beginObserver)
- }
- if let endObserver {
- NotificationCenter.default.removeObserver(endObserver)
- }
- }
- private func applyBorder(focused: Bool) {
- layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
- layer?.borderWidth = focused ? 1.5 : 1
- }
- override func viewDidMoveToWindow() {
- super.viewDidMoveToWindow()
- if window == nil {
- applyBorder(focused: false)
- }
- }
- }
- private final class SearchPillTextField: NSTextField {
- var onFocusChange: ((Bool) -> Void)?
- private(set) var isSearchFocused = false
- func forceClearFocusState() {
- isSearchFocused = false
- onFocusChange?(false)
- }
- override func becomeFirstResponder() -> Bool {
- let ok = super.becomeFirstResponder()
- if ok {
- isSearchFocused = true
- onFocusChange?(true)
- }
- return ok
- }
- override func resignFirstResponder() -> Bool {
- let ok = super.resignFirstResponder()
- if ok {
- isSearchFocused = false
- onFocusChange?(false)
- }
- return ok
- }
- }
- private final class FlippedView: NSView {
- override var isFlipped: Bool { true }
- }
- private final class MeetingCardView: NSView {
- private let url: URL?
- private let hoverBackground: NSColor
- private let hoverBorder: NSColor
- private var tracking: NSTrackingArea?
- private var isHovering = false
- private var normalBackgroundColor: CGColor?
- private var normalBorderColor: CGColor?
- private var normalBorderWidth: CGFloat?
- init(url: URL?, hoverBackground: NSColor, hoverBorder: NSColor) {
- self.url = url
- self.hoverBackground = hoverBackground
- self.hoverBorder = hoverBorder
- super.init(frame: .zero)
- }
- required init?(coder: NSCoder) {
- self.url = nil
- self.hoverBackground = NSColor.white.withAlphaComponent(0.075)
- self.hoverBorder = NSColor.white.withAlphaComponent(0.85)
- super.init(coder: coder)
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let tracking { removeTrackingArea(tracking) }
- let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
- let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
- addTrackingArea(area)
- tracking = area
- }
- override func resetCursorRects() {
- super.resetCursorRects()
- if url != nil {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- guard url != nil else { return }
- isHovering = true
- applyHoverState()
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- guard url != nil else { return }
- isHovering = false
- applyHoverState()
- }
- override func mouseUp(with event: NSEvent) {
- super.mouseUp(with: event)
- guard let url else { return }
- NSWorkspace.shared.open(url)
- }
- private func applyHoverState() {
- guard let layer else { return }
- if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
- if normalBorderColor == nil { normalBorderColor = layer.borderColor }
- if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
- if isHovering {
- layer.backgroundColor = hoverBackground.cgColor
- layer.borderColor = hoverBorder.cgColor
- layer.borderWidth = max(1.5, layer.borderWidth)
- } else {
- layer.backgroundColor = normalBackgroundColor
- layer.borderColor = normalBorderColor
- if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
- }
- }
- }
- struct GoogleOAuthTokens: Codable, Equatable {
- var accessToken: String
- var refreshToken: String?
- var expiresAt: Date
- var scope: String?
- var tokenType: String?
- }
- struct GoogleUserProfile: Codable, Equatable {
- var name: String?
- var email: String?
- var picture: String?
- }
- struct ZoomOAuthTokens: Codable, Equatable {
- var accessToken: String
- var refreshToken: String?
- var expiresAt: Date
- var scope: String?
- var tokenType: String?
- }
- enum ZoomOAuthError: Error {
- case missingClientId
- case missingClientSecret
- case invalidCallbackURL
- case missingAuthorizationCode
- case tokenExchangeFailed(String)
- case missingRequiredScope(String)
- case rateLimited(retryAfterSeconds: Int?)
- case unableToOpenBrowser
- case authenticationTimedOut
- }
- final class ZoomOAuthTokenStore {
- private let defaultsKey: String
- private let defaults: UserDefaults
- init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
- account: String = "zoomOAuthTokens",
- defaults: UserDefaults = .standard) {
- self.defaultsKey = "\(service).\(account)"
- self.defaults = defaults
- }
- func readTokens() throws -> ZoomOAuthTokens? {
- guard let data = defaults.data(forKey: defaultsKey) else { return nil }
- return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
- }
- func writeTokens(_ tokens: ZoomOAuthTokens) throws {
- let data = try JSONEncoder().encode(tokens)
- defaults.set(data, forKey: defaultsKey)
- }
- func clearTokens() {
- defaults.removeObject(forKey: defaultsKey)
- }
- }
- final class ZoomOAuthService: NSObject {
- static let shared = ZoomOAuthService()
- private var inAppOAuthWindowController: InAppOAuthWindowController?
- private let tokenStore = ZoomOAuthTokenStore()
- private let clientIdDefaultsKey = "zoom.oauth.clientId"
- private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
- private let infoPlistClientIdKey = "ZoomOAuthClientId"
- private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
- // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
- /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
- private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
- /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
- private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
- func setClientCredentials(clientId: String, clientSecret: String) {
- UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
- UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
- }
- func configuredClientId() -> String? {
- if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
- let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty == false { return trimmed }
- }
- let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if let value, value.isEmpty == false { return value }
- return bundledClientId.isEmpty ? nil : bundledClientId
- }
- func configuredClientSecret() -> String? {
- if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
- let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty == false { return trimmed }
- }
- let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if let value, value.isEmpty == false { return value }
- return bundledClientSecret.isEmpty ? nil : bundledClientSecret
- }
- func clearSavedTokens() {
- tokenStore.clearTokens()
- }
- func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
- if let tokens = try tokenStore.readTokens(),
- tokens.expiresAt.timeIntervalSinceNow > 60,
- tokenHasRequiredScope(tokens.scope) {
- return tokens.accessToken
- } else if var tokens = try tokenStore.readTokens(),
- let refreshed = try await refreshTokens(tokens) {
- tokens = refreshed
- try tokenStore.writeTokens(tokens)
- return tokens.accessToken
- }
- let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
- try tokenStore.writeTokens(tokens)
- return tokens.accessToken
- }
- private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
- _ = presentingWindow
- let oauthWindow = await MainActor.run { makeOAuthWindowController() }
- guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
- guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
- // Keep a provider-compatible redirect URI while capturing it in-app (no local server required).
- let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
- let state = UUID().uuidString
- var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
- // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
- components.queryItems = [
- URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "client_id", value: clientId),
- URLQueryItem(name: "redirect_uri", value: redirectURI),
- URLQueryItem(name: "state", value: state)
- ]
- guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
- do {
- let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
- let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
- guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
- guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
- throw ZoomOAuthError.missingAuthorizationCode
- }
- return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
- } catch GoogleOAuthError.authenticationTimedOut {
- throw ZoomOAuthError.authenticationTimedOut
- } catch {
- throw error
- }
- }
- @MainActor
- private func makeOAuthWindowController() -> InAppOAuthWindowController {
- if let existing = inAppOAuthWindowController { return existing }
- let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
- inAppOAuthWindowController = controller
- return controller
- }
- private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
- var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
- request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
- request.httpBody = Self.formURLEncoded([
- "grant_type": "authorization_code",
- "code": code,
- "redirect_uri": redirectURI
- ])
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
- }
- struct TokenResponse: Decodable {
- let access_token: String
- let refresh_token: String?
- let expires_in: Double
- let scope: String?
- let token_type: String?
- }
- let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
- return ZoomOAuthTokens(
- accessToken: decoded.access_token,
- refreshToken: decoded.refresh_token,
- expiresAt: Date().addingTimeInterval(decoded.expires_in),
- scope: decoded.scope,
- tokenType: decoded.token_type
- )
- }
- private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
- guard let refreshToken = tokens.refreshToken else { return nil }
- guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
- guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
- var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
- request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
- request.httpBody = Self.formURLEncoded([
- "grant_type": "refresh_token",
- "refresh_token": refreshToken
- ])
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- return nil
- }
- struct RefreshResponse: Decodable {
- let access_token: String
- let refresh_token: String?
- let expires_in: Double
- let scope: String?
- let token_type: String?
- }
- let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
- return ZoomOAuthTokens(
- accessToken: decoded.access_token,
- refreshToken: decoded.refresh_token ?? refreshToken,
- expiresAt: Date().addingTimeInterval(decoded.expires_in),
- scope: decoded.scope ?? tokens.scope,
- tokenType: decoded.token_type ?? tokens.tokenType
- )
- }
- private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
- guard let scopeValue, scopeValue.isEmpty == false else { return false }
- let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
- return parts.contains { part in
- part == "meeting:read"
- || part == "meeting:read:admin"
- || part == "meeting:write"
- || part == "meeting:write:admin"
- || part.contains("meeting:read")
- || part.contains("meeting:write")
- || part.contains("list_meetings")
- || part.contains("list_user_meetings")
- }
- }
- private static func basicAuth(clientId: String, clientSecret: String) -> String {
- let joined = "\(clientId):\(clientSecret)"
- return Data(joined.utf8).base64EncodedString()
- }
- private static func formURLEncoded(_ params: [String: String]) -> Data {
- let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
- let pairs = params.map { key, value in
- let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
- let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
- return "\(k)=\(v)"
- }.joined(separator: "&")
- return Data(pairs.utf8)
- }
- }
- enum GoogleOAuthError: Error {
- case missingClientId
- case missingClientSecret
- case invalidCallbackURL
- case missingAuthorizationCode
- case tokenExchangeFailed(String)
- case unableToOpenBrowser
- case authenticationTimedOut
- }
- final class GoogleOAuthService: NSObject {
- static let shared = GoogleOAuthService()
- private var inAppOAuthWindowController: InAppOAuthWindowController?
- private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
- private let clientSecret = "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"
- private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
- private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
- private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
- func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
- func clearSavedTokens() {
- tokenStore.clearTokens()
- }
- func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
- var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
- }
- return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
- }
- func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
- if let tokens = try tokenStore.readTokens(),
- tokens.expiresAt.timeIntervalSinceNow > 60,
- tokenHasCalendarScope(tokens.scope) {
- return tokens.accessToken
- }
- let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
- try tokenStore.writeTokens(tokens)
- return tokens.accessToken
- }
- private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
- _ = presentingWindow
- let oauthWindow = await MainActor.run { makeOAuthWindowController() }
- let codeVerifier = Self.randomURLSafeString(length: 64)
- let codeChallenge = Self.pkceChallenge(for: codeVerifier)
- let state = Self.randomURLSafeString(length: 32)
- // Google OAuth app settings often already include this callback from prior loopback setup.
- let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
- var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
- components.queryItems = [
- URLQueryItem(name: "client_id", value: clientId),
- URLQueryItem(name: "redirect_uri", value: redirectURI),
- URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
- URLQueryItem(name: "state", value: state),
- URLQueryItem(name: "code_challenge", value: codeChallenge),
- URLQueryItem(name: "code_challenge_method", value: "S256"),
- URLQueryItem(name: "access_type", value: "offline"),
- URLQueryItem(name: "prompt", value: "consent")
- ]
- guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
- let callbackURL = try await oauthWindow.authenticate(startURL: authURL, callbackURLPrefix: redirectURI)
- let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
- guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
- guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
- throw GoogleOAuthError.missingAuthorizationCode
- }
- return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
- }
- @MainActor
- private func makeOAuthWindowController() -> InAppOAuthWindowController {
- if let existing = inAppOAuthWindowController { return existing }
- let controller = InAppOAuthWindowController(title: "Google Sign-In")
- inAppOAuthWindowController = controller
- return controller
- }
- private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
- var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
- request.httpBody = Self.formURLEncoded([
- "client_id": clientId,
- "client_secret": clientSecret,
- "code": code,
- "code_verifier": codeVerifier,
- "redirect_uri": redirectURI,
- "grant_type": "authorization_code"
- ])
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
- }
- struct TokenResponse: Decodable {
- let access_token: String
- let expires_in: Double
- let refresh_token: String?
- let scope: String?
- let token_type: String?
- }
- let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
- return GoogleOAuthTokens(
- accessToken: decoded.access_token,
- refreshToken: decoded.refresh_token,
- expiresAt: Date().addingTimeInterval(decoded.expires_in),
- scope: decoded.scope,
- tokenType: decoded.token_type
- )
- }
- private static func pkceChallenge(for verifier: String) -> String {
- let digest = SHA256.hash(data: Data(verifier.utf8))
- return Data(digest).base64URLEncodedString()
- }
- private static func randomURLSafeString(length: Int) -> String {
- var bytes = [UInt8](repeating: 0, count: length)
- _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- return Data(bytes).base64URLEncodedString()
- }
- private static func formURLEncoded(_ params: [String: String]) -> Data {
- let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
- let pairs = params.map { key, value in
- let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
- let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
- return "\(k)=\(v)"
- }.joined(separator: "&")
- return Data(pairs.utf8)
- }
- private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
- guard let scopeValue else { return false }
- return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
- }
- }
- private extension Data {
- func base64URLEncodedString() -> String {
- base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
- }
- }
- final class KeychainTokenStore {
- private let defaultsKey: String
- private let defaults: UserDefaults
- init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
- account: String = "googleOAuthTokens",
- defaults: UserDefaults = .standard) {
- self.defaultsKey = "\(service).\(account)"
- self.defaults = defaults
- }
- func readTokens() throws -> GoogleOAuthTokens? {
- guard let data = defaults.data(forKey: defaultsKey) else { return nil }
- return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
- }
- func writeTokens(_ tokens: GoogleOAuthTokens) throws {
- let data = try JSONEncoder().encode(tokens)
- defaults.set(data, forKey: defaultsKey)
- }
- func clearTokens() {
- defaults.removeObject(forKey: defaultsKey)
- }
- }
- @MainActor
- private final class OAuthWebViewContainerView: NSView {
- private let webView: WKWebView
- init(webView: WKWebView) {
- self.webView = webView
- super.init(frame: .zero)
- addSubview(webView)
- }
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
- override func layout() {
- super.layout()
- webView.frame = bounds
- }
- }
- @MainActor
- private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate {
- private let webView: WKWebView
- private var callbackPrefix: String?
- private var callbackContinuation: CheckedContinuation<URL, Error>?
- init(title: String) {
- let configuration = WKWebViewConfiguration()
- configuration.websiteDataStore = .nonPersistent()
- self.webView = WKWebView(frame: .zero, configuration: configuration)
- let container = OAuthWebViewContainerView(webView: webView)
- let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
- window.title = title
- window.contentView = container
- super.init(window: window)
- webView.navigationDelegate = self
- }
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
- func authenticate(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double = 120) async throws -> URL {
- self.callbackPrefix = callbackURLPrefix
- showWindow(nil)
- window?.makeKeyAndOrderFront(nil)
- window?.center()
- webView.load(URLRequest(url: startURL))
- return try await withThrowingTaskGroup(of: URL.self) { group in
- group.addTask { [weak self] in
- guard let self else { throw GoogleOAuthError.invalidCallbackURL }
- return try await self.awaitCallback()
- }
- group.addTask {
- try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
- throw GoogleOAuthError.authenticationTimedOut
- }
- let url = try await group.next()!
- group.cancelAll()
- self.webView.stopLoading()
- self.close()
- return url
- }
- }
- private func awaitCallback() async throws -> URL {
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
- callbackContinuation = continuation
- }
- }
- func webView(_ webView: WKWebView,
- decidePolicyFor navigationAction: WKNavigationAction,
- decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
- guard let targetURL = navigationAction.request.url,
- let callbackPrefix,
- targetURL.absoluteString.hasPrefix(callbackPrefix) else {
- decisionHandler(.allow)
- return
- }
- callbackContinuation?.resume(returning: targetURL)
- callbackContinuation = nil
- decisionHandler(.cancel)
- }
- }
- extension GoogleOAuthError: LocalizedError {
- var errorDescription: String? {
- switch self {
- case .missingClientId: return "Missing Google OAuth Client ID."
- case .missingClientSecret: return "Missing Google OAuth Client Secret."
- case .invalidCallbackURL: return "Invalid OAuth callback URL."
- case .missingAuthorizationCode: return "Google did not return an authorization code."
- case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
- case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
- case .authenticationTimedOut: return "Google sign-in timed out."
- }
- }
- }
- extension ZoomOAuthError: LocalizedError {
- var errorDescription: String? {
- switch self {
- case .missingClientId:
- return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
- case .missingClientSecret:
- return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
- case .invalidCallbackURL:
- return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
- case .missingAuthorizationCode:
- return "Zoom did not return an authorization code."
- case .tokenExchangeFailed(let details):
- return details
- case .missingRequiredScope(let details):
- return "The Zoom access token is missing required scopes. \(details)"
- case .rateLimited(let retryAfterSeconds):
- if let retryAfterSeconds {
- return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
- }
- return "Zoom rate limit reached. Try again later."
- case .unableToOpenBrowser:
- return "Could not open the system browser for Zoom sign-in."
- case .authenticationTimedOut:
- return "Zoom sign-in timed out waiting for the browser redirect."
- }
- }
- }
|