| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080 |
- //
- // ViewController.swift
- // zoom_app
- //
- // Created by Dev Mac 1 on 14/04/2026.
- //
- import Cocoa
- import Combine
- import CryptoKit
- import StoreKit
- import SwiftUI
- import UserNotifications
- import WebKit
- /// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
- private enum AppWindowMetrics {
- static let defaultContentWidth: CGFloat = 1020
- static let defaultContentHeight: CGFloat = 690
- }
- /// Errors from the main-window embedded OAuth web session (right panel).
- private enum EmbeddedOAuthFlowError: Error {
- case webViewUnavailable
- case userCancelled
- case timedOut
- }
- @MainActor
- protocol EmbeddedOAuthPresenting: AnyObject {
- func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL
- }
- class ViewController: NSViewController {
- enum HomeSection: String {
- case home = "Home"
- case meetings = "Meetings"
- case scheduler = "Scheduler"
- case widgets = "Widgets"
- case aiCompanion = "Ai Companion"
- 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 legacyNotificationsEnabledDefaultsKey = "settings.notificationsEnabled"
- private let remindersEnabledDefaultsKey = "reminders.enabled"
- private let remind1DayDefaultsKey = "reminders.1day"
- private let remind12HoursDefaultsKey = "reminders.12hours"
- private let remind1HourDefaultsKey = "reminders.1hour"
- 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) }
- var sidebarInactiveText: NSColor {
- isDarkMode ? secondaryText : NSColor(calibratedWhite: 0.12, 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 var remindersEnabled: Bool {
- get {
- let defaults = UserDefaults.standard
- if defaults.object(forKey: remindersEnabledDefaultsKey) != nil {
- return defaults.bool(forKey: remindersEnabledDefaultsKey)
- }
- // Migration: if the old single-toggle key exists, treat it as master reminders toggle.
- if defaults.object(forKey: legacyNotificationsEnabledDefaultsKey) != nil {
- return defaults.bool(forKey: legacyNotificationsEnabledDefaultsKey)
- }
- return true
- }
- set { UserDefaults.standard.set(newValue, forKey: remindersEnabledDefaultsKey) }
- }
- private var remind1Day: Bool {
- get {
- let defaults = UserDefaults.standard
- let stored = defaults.object(forKey: remind1DayDefaultsKey)
- return stored != nil ? defaults.bool(forKey: remind1DayDefaultsKey) : true
- }
- set { UserDefaults.standard.set(newValue, forKey: remind1DayDefaultsKey) }
- }
- private var remind12Hours: Bool {
- get {
- let defaults = UserDefaults.standard
- let stored = defaults.object(forKey: remind12HoursDefaultsKey)
- return stored != nil ? defaults.bool(forKey: remind12HoursDefaultsKey) : true
- }
- set { UserDefaults.standard.set(newValue, forKey: remind12HoursDefaultsKey) }
- }
- private var remind1Hour: Bool {
- get {
- let defaults = UserDefaults.standard
- let stored = defaults.object(forKey: remind1HourDefaultsKey)
- return stored != nil ? defaults.bool(forKey: remind1HourDefaultsKey) : true
- }
- set { UserDefaults.standard.set(newValue, forKey: remind1HourDefaultsKey) }
- }
- 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 var sidebarInactiveText: NSColor { palette.sidebarInactiveText }
- 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 homeSettingsHostColumn: 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 widgetsPageRootView: NSView?
- private weak var aiCompanionPageRootView: NSView?
- private weak var aiCompanionListStack: NSStackView?
- private weak var aiCompanionListScroll: NSScrollView?
- private weak var aiCompanionDetailScroll: NSScrollView?
- private weak var aiCompanionDetailStack: NSStackView?
- private weak var aiCompanionStatusLabel: NSTextField?
- private weak var aiCompanionCountLabel: NSTextField?
- private weak var aiCompanionRefreshButton: NSButton?
- private weak var aiCompanionRangePopup: NSPopUpButton?
- private weak var aiCompanionEmptyLabel: NSTextField?
- private weak var aiCompanionLoadingIndicator: NSProgressIndicator?
- private weak var aiCompanionDetailEmptyLabel: NSTextField?
- private weak var aiCompanionOpenDocButton: NSButton?
- private weak var aiCompanionCopyMarkdownButton: NSButton?
- private var aiCompanionRowButtons: [NSButton] = []
- private var aiCompanionSummaries: [AICompanionSummary] = []
- private var aiCompanionSelectedIndex: Int?
- private var aiCompanionIsLoading: Bool = false
- private var aiCompanionLastLoadedAt: Date?
- private var aiCompanionSelectedRange: String = "Last 30 days"
- 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 embeddedBrowserWebView: WKWebView?
- private weak var embeddedBrowserPanel: NSView?
- private weak var homeBodyRowView: NSView?
- private weak var homeMainContentColumnView: NSView?
- private weak var embeddedBrowserBackButton: NSButton?
- private weak var embeddedBrowserForwardButton: NSButton?
- private var embeddedBrowserPanelWidthConstraint: NSLayoutConstraint?
- private var embeddedBrowserMainColumnCollapsedWidthConstraint: NSLayoutConstraint?
- private var oauthSessionContinuation: CheckedContinuation<URL, Error>?
- private var oauthSessionCallbackPrefix: String?
- /// Gap between main column and right web panel (must match Auto Layout constant).
- private let homeEmbeddedBrowserColumnGap: CGFloat = 10
- 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 settingsRemindersMasterSwitch: NSSwitch?
- private weak var settingsReminders1DaySwitch: NSSwitch?
- private weak var settingsReminders12HoursSwitch: NSSwitch?
- private weak var settingsReminders1HourSwitch: NSSwitch?
- private weak var settingsReminders1DayRow: NSView?
- private weak var settingsReminders12HoursRow: NSView?
- private weak var settingsReminders1HourRow: NSView?
- private weak var settingsUpgradeButton: NSButton?
- private weak var settingsUpgradeTitleLabel: NSTextField?
- private weak var settingsNotificationsBlock: NSStackView?
- 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: StoreKit.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()
- setupWidgetBridge()
- startStoreKit()
- }
- override func viewDidAppear() {
- super.viewDidAppear()
- if let window = view.window {
- window.setContentSize(
- NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
- )
- 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()
- if storeKitCoordinator.hasPremiumAccess {
- DesktopWidgetWindowManager.shared.restore()
- }
- }
- override func viewDidLayout() {
- super.viewDidLayout()
- alignNativeTrafficLights()
- if paywallOverlayView != nil {
- updatePaywallPanelSize()
- updatePaywallResponsiveLayout()
- }
- if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
- updateEmbeddedRightPanelWidthForOpenState()
- }
- }
- 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
- widgetsPageRootView = nil
- aiCompanionPageRootView = nil
- aiCompanionListStack = nil
- aiCompanionListScroll = nil
- aiCompanionDetailScroll = nil
- aiCompanionDetailStack = nil
- aiCompanionStatusLabel = nil
- aiCompanionCountLabel = nil
- aiCompanionRefreshButton = nil
- aiCompanionRangePopup = nil
- aiCompanionEmptyLabel = nil
- aiCompanionLoadingIndicator = nil
- aiCompanionDetailEmptyLabel = nil
- aiCompanionOpenDocButton = nil
- aiCompanionCopyMarkdownButton = nil
- aiCompanionRowButtons.removeAll()
- aiCompanionLastLoadedAt = 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.resetLoginSigningInState()
- if case GoogleOAuthError.authenticationCancelled = error { return }
- 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()
- if case ZoomOAuthError.authenticationCancelled = error { return }
- 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 oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
- let token = try await zoomOAuth.validAccessToken(
- presentingWindow: oauthParent,
- requiringAnyOf: ["meeting:write:meeting"]
- )
- 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 if case ZoomOAuthError.authenticationCancelled = error {
- // User closed the sign-in window; no alert.
- } 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
- }
- /// Opens non-sign-in web content in the system default browser.
- @MainActor
- private func openWebURLPreferringInApp(_ url: URL) {
- _ = openURLInDefaultBrowser(url)
- }
- private func openZoomMeetingInDefaultBrowser(_ url: URL) {
- let opened = openURLInDefaultBrowser(url)
- if opened {
- joinMeetingWindow?.performClose(nil)
- } else {
- showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
- }
- }
- @discardableResult
- private func openURLInDefaultBrowser(_ url: URL) -> Bool {
- NSWorkspace.shared.open(url)
- }
- @MainActor
- private func presentURLInEmbeddedRightPanel(_ url: URL) {
- guard let webView = embeddedBrowserWebView else {
- NSWorkspace.shared.open(url)
- return
- }
- updateEmbeddedRightPanelWidthForOpenState()
- webView.load(URLRequest(url: url))
- refreshEmbeddedBrowserNavButtons()
- view.window?.contentView?.layoutSubtreeIfNeeded()
- }
- /// Makes the embedded web panel fill the entire right-side content area (to match the reference behavior).
- @MainActor
- private func updateEmbeddedRightPanelWidthForOpenState() {
- guard let widthConstraint = embeddedBrowserPanelWidthConstraint else { return }
- view.layoutSubtreeIfNeeded()
- view.window?.contentView?.layoutSubtreeIfNeeded()
- var rowW = homeBodyRowView?.bounds.width ?? 0
- if rowW < 1 {
- rowW = view.bounds.width
- }
- // Fill the entire body row so the embedded web experience matches a dedicated right-side panel.
- widthConstraint.constant = max(320, floor(rowW))
- // Collapse the left main content column while web is visible.
- homeMainContentColumnView?.isHidden = true
- embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = true
- }
- @MainActor
- private func refreshEmbeddedBrowserNavButtons() {
- embeddedBrowserBackButton?.isEnabled = embeddedBrowserWebView?.canGoBack == true
- embeddedBrowserForwardButton?.isEnabled = embeddedBrowserWebView?.canGoForward == true
- }
- @objc private func embeddedBrowserBackTapped() {
- embeddedBrowserWebView?.goBack()
- }
- @objc private func embeddedBrowserForwardTapped() {
- embeddedBrowserWebView?.goForward()
- }
- @objc private func embeddedBrowserReloadTapped() {
- embeddedBrowserWebView?.reload()
- }
- @objc private func embeddedBrowserClosePanelTapped() {
- closeEmbeddedBrowserPanel()
- }
- @MainActor
- private func closeEmbeddedBrowserPanel() {
- if let cont = oauthSessionContinuation {
- oauthSessionContinuation = nil
- oauthSessionCallbackPrefix = nil
- cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
- }
- embeddedBrowserWebView?.stopLoading()
- embeddedBrowserPanelWidthConstraint?.constant = 0
- embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
- homeMainContentColumnView?.isHidden = false
- refreshEmbeddedBrowserNavButtons()
- view.window?.contentView?.layoutSubtreeIfNeeded()
- }
- func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL {
- guard let webView = embeddedBrowserWebView else {
- throw EmbeddedOAuthFlowError.webViewUnavailable
- }
- do {
- return try await withThrowingTaskGroup(of: URL.self) { group in
- group.addTask { @MainActor in
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
- self.oauthSessionContinuation = continuation
- self.oauthSessionCallbackPrefix = callbackURLPrefix
- self.updateEmbeddedRightPanelWidthForOpenState()
- webView.load(URLRequest(url: startURL))
- self.refreshEmbeddedBrowserNavButtons()
- }
- }
- group.addTask {
- try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
- await MainActor.run {
- if let cont = self.oauthSessionContinuation {
- self.oauthSessionContinuation = nil
- self.oauthSessionCallbackPrefix = nil
- webView.stopLoading()
- cont.resume(throwing: EmbeddedOAuthFlowError.timedOut)
- }
- }
- throw EmbeddedOAuthFlowError.timedOut
- }
- let url = try await group.next()!
- group.cancelAll()
- await MainActor.run {
- self.oauthSessionContinuation = nil
- self.oauthSessionCallbackPrefix = nil
- self.closeEmbeddedBrowserPanel()
- webView.load(URLRequest(url: URL(string: "about:blank")!))
- }
- return url
- }
- } catch {
- await MainActor.run {
- self.oauthSessionContinuation = nil
- self.oauthSessionCallbackPrefix = nil
- webView.stopLoading()
- self.embeddedBrowserPanelWidthConstraint?.constant = 0
- self.refreshEmbeddedBrowserNavButtons()
- webView.load(URLRequest(url: URL(string: "about:blank")!))
- self.view.window?.contentView?.layoutSubtreeIfNeeded()
- }
- throw error
- }
- }
- 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 = []
- aiCompanionSummaries = []
- aiCompanionSelectedIndex = nil
- aiCompanionLastLoadedAt = nil
- applyAuthenticationUIState(triggerInitialRefresh: false)
- Task { @MainActor in
- self.applyMeetings([])
- self.renderAiCompanionList()
- self.renderAiCompanionDetail()
- self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
- }
- }
- @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") {
- openWebURLPreferringInApp(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 {
- openWebURLPreferringInApp(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
- googleButton?.title = "G"
- 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)
- } else if selectedDayHasMeetings == false,
- let latest = meetings.max(by: { $0.start < $1.start }) {
- // All instances are in the past: jump to the most recent meeting day so the list is not stuck empty.
- selectedMeetingsDayStart = calendar.startOfDay(for: latest.start)
- }
- }
- applyFilteredMeetings()
- applyMeetingsPageFilters(resetVisible: true)
- updateWidgetMeetingStoreFromCurrentMeetings()
- rescheduleMeetingRemindersIfNeeded()
- }
- @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,
- requiringAnyOf: ["meeting:read:list_meetings"]
- )
- 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 granular scopes for listing meetings (meeting:read:list_meetings), scheduling (meeting:write:meeting), listing AI summaries (meeting:read:list_summaries), and reading a single summary (meeting:read:summary)."
- 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)
- }
- // MARK: - AI Companion Meeting Summaries (Zoom API)
- /// In-memory model for an AI Companion meeting summary. Mirrors the fields returned by
- /// `GET /v2/meetings/meeting_summaries` and `GET /v2/meetings/{meetingId}/meeting_summary`.
- /// Docs: https://developers.zoom.us/changelog/meetings/november-20-2023/
- struct AICompanionSummary: Equatable {
- struct SummarySection: Equatable {
- let label: String
- let summary: String
- }
- let meetingUUID: String?
- let meetingID: String?
- let meetingTopic: String?
- let meetingHostEmail: String?
- let meetingStartTime: Date?
- let meetingEndTime: Date?
- let summaryTitle: String?
- let summaryOverview: String?
- let summaryDetails: [SummarySection]
- let nextSteps: [String]
- let summaryContent: String?
- let summaryDocURL: URL?
- let summaryCreatedTime: Date?
- let summaryLastModifiedTime: Date?
- }
- private struct ZoomMeetingSummaryAPIItem: Decodable {
- struct SummaryDetail: Decodable {
- let label: String?
- let summary: String?
- }
- let meeting_host_id: String?
- let meeting_host_email: String?
- let meeting_uuid: String?
- let meeting_id: Int64?
- let meeting_topic: String?
- let meeting_start_time: String?
- let meeting_end_time: String?
- let summary_start_time: String?
- let summary_end_time: String?
- let summary_created_time: String?
- let summary_last_modified_time: String?
- let summary_title: String?
- let summary_overview: String?
- let summary_details: [SummaryDetail]?
- let next_steps: [String]?
- let summary_content: String?
- let summary_doc_url: String?
- func toModel(using iso: ISO8601DateFormatter, fallback: ISO8601DateFormatter) -> ViewController.AICompanionSummary {
- func parseDate(_ s: String?) -> Date? {
- guard let s, s.isEmpty == false else { return nil }
- return iso.date(from: s) ?? fallback.date(from: s)
- }
- let details: [AICompanionSummary.SummarySection] = (summary_details ?? []).compactMap { detail in
- let label = (detail.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
- let body = (detail.summary ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
- if label.isEmpty && body.isEmpty { return nil }
- return AICompanionSummary.SummarySection(label: label, summary: body)
- }
- let steps: [String] = (next_steps ?? [])
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { $0.isEmpty == false }
- let docURL: URL? = {
- guard let raw = summary_doc_url?.trimmingCharacters(in: .whitespacesAndNewlines),
- raw.isEmpty == false else { return nil }
- return URL(string: raw)
- }()
- return AICompanionSummary(
- meetingUUID: meeting_uuid,
- meetingID: meeting_id.map(String.init),
- meetingTopic: meeting_topic,
- meetingHostEmail: meeting_host_email,
- meetingStartTime: parseDate(meeting_start_time),
- meetingEndTime: parseDate(meeting_end_time),
- summaryTitle: summary_title,
- summaryOverview: summary_overview,
- summaryDetails: details,
- nextSteps: steps,
- summaryContent: summary_content,
- summaryDocURL: docURL,
- summaryCreatedTime: parseDate(summary_created_time),
- summaryLastModifiedTime: parseDate(summary_last_modified_time)
- )
- }
- }
- /// Fetches AI Companion summaries: prefers `GET /v2/meetings/meeting_summaries` when the token has
- /// `meeting:read:list_summaries`. User-managed apps usually lack that scope; we then list **past**
- /// meetings via `meeting:read:list_meetings` and load each `meeting:read:summary`.
- private func fetchZoomMeetingSummaries(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
- if let bulk = try await fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: accessToken, from: from, to: to) {
- return bulk
- }
- return try await fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: accessToken, from: from, to: to)
- }
- private func zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: Data, statusCode: Int) -> Bool {
- guard (200..<300).contains(statusCode) == false else { return false }
- let raw = String(data: data, encoding: .utf8) ?? ""
- if raw.localizedCaseInsensitiveContains("does not contain scopes") { return true }
- if raw.localizedCaseInsensitiveContains("list_summaries") { return true }
- if raw.localizedCaseInsensitiveContains("meeting_summaries") && raw.localizedCaseInsensitiveContains("scope") {
- return true
- }
- return false
- }
- /// Returns `nil` when the token cannot use the bulk list endpoint (typical for user-managed apps).
- private func fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary]? {
- struct Page: Decodable {
- let summaries: [ZoomMeetingSummaryAPIItem]?
- let meeting_summaries: [ZoomMeetingSummaryAPIItem]?
- let next_page_token: String?
- }
- let iso = ISO8601DateFormatter()
- iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let fallbackISO = ISO8601DateFormatter()
- fallbackISO.formatOptions = [.withInternetDateTime]
- let dayFormatter = DateFormatter()
- dayFormatter.dateFormat = "yyyy-MM-dd"
- dayFormatter.timeZone = TimeZone(identifier: "UTC")
- var collected: [AICompanionSummary] = []
- var seenUUIDs = Set<String>()
- var nextPageToken: String?
- var safetyCounter = 0
- repeat {
- var components = URLComponents(string: "https://api.zoom.us/v2/meetings/meeting_summaries")!
- var items: [URLQueryItem] = [
- URLQueryItem(name: "page_size", value: "30")
- ]
- if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
- if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
- 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 else {
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
- }
- 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 {
- if safetyCounter == 0, zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: data, statusCode: http.statusCode) {
- return nil
- }
- let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summaries"
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- let decoded = try JSONDecoder().decode(Page.self, from: data)
- let raw = decoded.summaries ?? decoded.meeting_summaries ?? []
- for item in raw {
- let model = item.toModel(using: iso, fallback: fallbackISO)
- let dedupKey = model.meetingUUID ?? (model.meetingID ?? UUID().uuidString)
- if seenUUIDs.insert(dedupKey).inserted {
- collected.append(model)
- }
- }
- let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
- nextPageToken = (token?.isEmpty == false) ? token : nil
- safetyCounter += 1
- if safetyCounter >= 10 { break }
- } while nextPageToken != nil
- return collected
- }
- private func fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
- struct PastMeeting: Decodable {
- let id: Int?
- let uuid: String?
- let topic: String?
- let start_time: String?
- }
- struct PastMeetingsPage: Decodable {
- let meetings: [PastMeeting]
- let next_page_token: String?
- }
- let iso = ISO8601DateFormatter()
- iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let fallbackISO = ISO8601DateFormatter()
- fallbackISO.formatOptions = [.withInternetDateTime]
- let dayFormatter = DateFormatter()
- dayFormatter.dateFormat = "yyyy-MM-dd"
- dayFormatter.timeZone = TimeZone(identifier: "UTC")
- func parseStart(_ raw: String?) -> Date? {
- guard let raw, raw.isEmpty == false else { return nil }
- return iso.date(from: raw) ?? fallbackISO.date(from: raw)
- }
- func isInSelectedRange(start: Date?) -> Bool {
- guard let start else { return true }
- if let from, start < from { return false }
- if let to, start > to { return false }
- return true
- }
- var candidates: [(meetingId: String, start: Date?)] = []
- var nextPageToken: String?
- var pageIdx = 0
- repeat {
- var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
- var items: [URLQueryItem] = [
- URLQueryItem(name: "type", value: "past"),
- URLQueryItem(name: "page_size", value: "30")
- ]
- if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
- if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
- 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 else {
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (past meetings)")
- }
- 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 load past meetings"
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- let decoded = try JSONDecoder().decode(PastMeetingsPage.self, from: data)
- for m in decoded.meetings {
- guard let numericId = m.id else { continue }
- let start = parseStart(m.start_time)
- guard isInSelectedRange(start: start) else { continue }
- candidates.append((String(numericId), start))
- }
- let t = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
- nextPageToken = (t?.isEmpty == false) ? t : nil
- pageIdx += 1
- if pageIdx >= 15 { break }
- if candidates.count >= 80 { break }
- } while nextPageToken != nil
- candidates.sort { ($0.start ?? .distantPast) > ($1.start ?? .distantPast) }
- var summaries: [AICompanionSummary] = []
- let maxDetailFetches = 40
- for c in candidates.prefix(maxDetailFetches) {
- if let detail = try await fetchZoomMeetingSummaryDetail(accessToken: accessToken, meetingIdOrUUID: c.meetingId) {
- summaries.append(detail)
- }
- }
- return summaries
- }
- /// Fetches a single AI Companion summary by meeting ID or UUID via
- /// `GET /v2/meetings/{meetingId}/meeting_summary`. UUIDs containing `/` or `//` must be
- /// double-URL-encoded per Zoom's API conventions.
- private func fetchZoomMeetingSummaryDetail(accessToken: String, meetingIdOrUUID: String) async throws -> AICompanionSummary? {
- let trimmed = meetingIdOrUUID.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { return nil }
- let isLikelyUUID = trimmed.contains("/") || trimmed.contains("=")
- let pathSegment: String
- if isLikelyUUID {
- // Zoom requires double encoding for UUIDs that begin with `/` or contain `//`.
- let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
- let once = trimmed.addingPercentEncoding(withAllowedCharacters: allowed) ?? trimmed
- pathSegment = once.addingPercentEncoding(withAllowedCharacters: allowed) ?? once
- } else {
- pathSegment = trimmed
- }
- let urlString = "https://api.zoom.us/v2/meetings/\(pathSegment)/meeting_summary"
- guard let url = URL(string: urlString) else {
- throw GoogleOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
- }
- 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 else {
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
- }
- if http.statusCode == 404 || http.statusCode == 204 { return nil }
- 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 load AI summary"
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- let iso = ISO8601DateFormatter()
- iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let fallbackISO = ISO8601DateFormatter()
- fallbackISO.formatOptions = [.withInternetDateTime]
- let item = try JSONDecoder().decode(ZoomMeetingSummaryAPIItem.self, from: data)
- return item.toModel(using: iso, fallback: fallbackISO)
- }
- 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 notificationsTitle = textLabel("Notifications", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- let remindersSection = makeSettingsRemindersSection()
- let notificationsBlock = NSStackView()
- notificationsBlock.translatesAutoresizingMaskIntoConstraints = false
- notificationsBlock.orientation = .vertical
- notificationsBlock.spacing = 18
- notificationsBlock.alignment = .leading
- notificationsBlock.addArrangedSubview(notificationsTitle)
- notificationsBlock.addArrangedSubview(remindersSection)
- remindersSection.widthAnchor.constraint(equalTo: notificationsBlock.widthAnchor).isActive = true
- stack.addArrangedSubview(notificationsBlock)
- notificationsBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- settingsNotificationsBlock = notificationsBlock
- stack.setCustomSpacing(24, after: notificationsBlock)
- 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
- }
- @MainActor
- private func rebuildHomeSettingsViewPreservingSelection() {
- guard let column = homeSettingsHostColumn else { return }
- homeSettingsView?.removeFromSuperview()
- let newSettings = makeSettingsView()
- newSettings.translatesAutoresizingMaskIntoConstraints = false
- column.addSubview(newSettings)
- NSLayoutConstraint.activate([
- newSettings.topAnchor.constraint(equalTo: column.topAnchor),
- newSettings.leadingAnchor.constraint(equalTo: column.leadingAnchor),
- newSettings.trailingAnchor.constraint(equalTo: column.trailingAnchor),
- newSettings.bottomAnchor.constraint(equalTo: column.bottomAnchor)
- ])
- homeSettingsView = newSettings
- updateSelectedHomeSectionUI()
- }
- 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 makeSettingsRemindersSection() -> NSView {
- let container = NSStackView()
- container.translatesAutoresizingMaskIntoConstraints = false
- container.orientation = .vertical
- container.spacing = 1
- container.alignment = .leading
- let masterRow = makeReminderToggleRow(
- icon: "🔔",
- title: "Reminders",
- state: remindersEnabled,
- action: #selector(settingsReminderMasterToggled(_:)),
- isSubRow: false
- )
- container.addArrangedSubview(masterRow)
- masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
- let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
- let day1Row = makeReminderToggleRow(
- icon: "📅",
- title: "1 Day Before",
- state: remind1Day,
- action: #selector(settingsReminder1DayToggled(_:)),
- isSubRow: true
- )
- day1Row.alphaValue = subAlpha
- container.addArrangedSubview(day1Row)
- day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
- let hours12Row = makeReminderToggleRow(
- icon: "🕛",
- title: "12 Hours Before",
- state: remind12Hours,
- action: #selector(settingsReminder12HoursToggled(_:)),
- isSubRow: true
- )
- hours12Row.alphaValue = subAlpha
- container.addArrangedSubview(hours12Row)
- hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
- let hour1Row = makeReminderToggleRow(
- icon: "⏰",
- title: "1 Hour Before",
- state: remind1Hour,
- action: #selector(settingsReminder1HourToggled(_:)),
- isSubRow: true
- )
- hour1Row.alphaValue = subAlpha
- container.addArrangedSubview(hour1Row)
- hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
- // Keep references so toggles can update UI without rebuilding the whole settings view.
- if let masterSwitch = (masterRow.subviews.compactMap { $0 as? NSSwitch }.first) {
- settingsRemindersMasterSwitch = masterSwitch
- }
- if let day1Switch = (day1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
- settingsReminders1DaySwitch = day1Switch
- }
- if let hours12Switch = (hours12Row.subviews.compactMap { $0 as? NSSwitch }.first) {
- settingsReminders12HoursSwitch = hours12Switch
- }
- if let hour1Switch = (hour1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
- settingsReminders1HourSwitch = hour1Switch
- }
- settingsReminders1DayRow = day1Row
- settingsReminders12HoursRow = hours12Row
- settingsReminders1HourRow = hour1Row
- return container
- }
- private func makeReminderToggleRow(
- icon: String,
- title: String,
- state: Bool,
- action: Selector,
- isSubRow: Bool
- ) -> 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 iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: isSubRow ? .medium : .semibold), color: primaryText)
- let toggle = NSSwitch()
- toggle.translatesAutoresizingMaskIntoConstraints = false
- toggle.state = state ? .on : .off
- toggle.target = self
- toggle.action = action
- row.addSubview(iconLabel)
- row.addSubview(titleLabel)
- row.addSubview(toggle)
- let leading: CGFloat = isSubRow ? 26 : 14
- NSLayoutConstraint.activate([
- iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: leading),
- iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
- titleLabel.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 != newIsPremium {
- self.rebuildHomeSettingsViewPreservingSelection()
- }
- self.reconcileDesktopWidgetsForPremiumAccess()
- if (wasPremium == false) && newIsPremium {
- self.startMeetingsAutoRefresh()
- self.triggerMeetingsRefresh(force: true)
- self.schedulePremiumRatingPromptIfNeeded()
- }
- if wasPremium && !newIsPremium {
- self.cancelAllMeetingReminders()
- 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?.reconcileDesktopWidgetsForPremiumAccess()
- 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
- settingsNotificationsBlock?.isHidden = isPremium == false
- 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)
- }
- @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
- let enabled = sender.state == .on
- remindersEnabled = enabled
- updateRemindersSubRowState()
- if enabled {
- requestNotificationPermissionIfNeeded { [weak self] granted in
- guard let self else { return }
- if granted {
- self.rescheduleMeetingRemindersIfNeeded()
- } else {
- self.remindersEnabled = false
- self.settingsRemindersMasterSwitch?.state = .off
- self.updateRemindersSubRowState()
- self.cancelAllMeetingReminders()
- self.showSimpleAlert(
- title: "Notifications disabled",
- message: "To enable reminders, allow notifications for this app in System Settings → Notifications."
- )
- }
- }
- } else {
- cancelAllMeetingReminders()
- }
- }
- @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
- remind1Day = sender.state == .on
- rescheduleMeetingRemindersIfNeeded()
- }
- @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
- remind12Hours = sender.state == .on
- rescheduleMeetingRemindersIfNeeded()
- }
- @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
- remind1Hour = sender.state == .on
- rescheduleMeetingRemindersIfNeeded()
- }
- private func updateRemindersSubRowState() {
- let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
- settingsReminders1DayRow?.alphaValue = subAlpha
- settingsReminders12HoursRow?.alphaValue = subAlpha
- settingsReminders1HourRow?.alphaValue = subAlpha
- let enabled = remindersEnabled
- settingsReminders1DaySwitch?.isEnabled = enabled
- settingsReminders12HoursSwitch?.isEnabled = enabled
- settingsReminders1HourSwitch?.isEnabled = enabled
- }
- private func requestNotificationPermissionIfNeeded(completion: @escaping (Bool) -> Void) {
- let center = UNUserNotificationCenter.current()
- center.getNotificationSettings { settings in
- switch settings.authorizationStatus {
- case .notDetermined:
- center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
- DispatchQueue.main.async { completion(granted) }
- }
- case .authorized, .provisional, .ephemeral:
- DispatchQueue.main.async { completion(true) }
- default:
- DispatchQueue.main.async { completion(false) }
- }
- }
- }
- private func rescheduleMeetingRemindersIfNeeded() {
- guard storeKitCoordinator.hasPremiumAccess else {
- cancelAllMeetingReminders()
- return
- }
- guard remindersEnabled else {
- cancelAllMeetingReminders()
- return
- }
- // If nothing is selected, don't schedule anything (but keep master toggle on).
- if remind1Day == false, remind12Hours == false, remind1Hour == false {
- cancelAllMeetingReminders()
- return
- }
- scheduleMeetingReminders(for: allScheduledMeetings)
- }
- private func cancelAllMeetingReminders() {
- UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
- }
- private func scheduleMeetingReminders(for meetings: [ScheduledMeeting]) {
- let center = UNUserNotificationCenter.current()
- let now = Date()
- struct Interval {
- let offset: TimeInterval
- let label: String
- let suffix: String
- let isEnabled: () -> Bool
- }
- let intervals: [Interval] = [
- Interval(offset: 60 * 60 * 24, label: "Starts in 1 day", suffix: "1d", isEnabled: { self.remind1Day }),
- Interval(offset: 60 * 60 * 12, label: "Starts in 12 hours", suffix: "12h", isEnabled: { self.remind12Hours }),
- Interval(offset: 60 * 60, label: "Starts in 1 hour", suffix: "1h", isEnabled: { self.remind1Hour })
- ]
- var requests: [UNNotificationRequest] = []
- for meeting in meetings {
- let title = meeting.title.trimmingCharacters(in: .whitespacesAndNewlines)
- guard title.isEmpty == false else { continue }
- for interval in intervals where interval.isEnabled() {
- let triggerDate = meeting.start.addingTimeInterval(-interval.offset)
- guard triggerDate > now else { continue }
- let content = UNMutableNotificationContent()
- content.title = "📅 \(title)"
- let subtitle = meeting.host.trimmingCharacters(in: .whitespacesAndNewlines)
- content.subtitle = subtitle.isEmpty ? meeting.source : subtitle
- content.body = interval.label
- content.sound = .default
- let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate)
- let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
- let id = "zoom.meeting.reminder.\(stableReminderKey(for: meeting)).\(interval.suffix)"
- requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
- }
- }
- center.removeAllPendingNotificationRequests()
- for request in requests {
- center.add(request, withCompletionHandler: nil)
- }
- }
- private func stableReminderKey(for meeting: ScheduledMeeting) -> String {
- if let id = meeting.meetingID?.trimmingCharacters(in: .whitespacesAndNewlines), id.isEmpty == false {
- return id
- }
- if let url = meeting.webURL?.absoluteString, url.isEmpty == false {
- return sha256Prefix(url)
- }
- return sha256Prefix("\(meeting.title)|\(meeting.start.timeIntervalSince1970)")
- }
- private func sha256Prefix(_ input: String) -> String {
- let digest = SHA256.hash(data: Data(input.utf8))
- let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
- return String(hex.prefix(24))
- }
- 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
- // Theme-aware surface so Light Mode doesn't render on a black backdrop.
- panel.layer?.backgroundColor = palette.appBackground.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", "Widgets", "Ai Companion", "Settings"], 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 widgetsPageView = makeWidgetsPageView()
- widgetsPageView.isHidden = selectedHomeSidebarItem != "Widgets"
- let aiCompanionPageView = makeAiCompanionPageView()
- aiCompanionPageView.isHidden = selectedHomeSidebarItem != HomeSection.aiCompanion.rawValue
- 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 bodyRow = NSView()
- bodyRow.translatesAutoresizingMaskIntoConstraints = false
- let mainContentColumn = NSView()
- mainContentColumn.translatesAutoresizingMaskIntoConstraints = false
- mainContentColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- let rightWebPanel = NSView()
- rightWebPanel.translatesAutoresizingMaskIntoConstraints = false
- rightWebPanel.wantsLayer = true
- rightWebPanel.layer?.cornerRadius = 16
- rightWebPanel.layer?.masksToBounds = true
- rightWebPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- rightWebPanel.layer?.borderWidth = 1
- rightWebPanel.layer?.borderColor = palette.inputBorder.cgColor
- let webToolbar = NSView()
- webToolbar.translatesAutoresizingMaskIntoConstraints = false
- webToolbar.wantsLayer = true
- webToolbar.layer?.backgroundColor = chromeUnifiedBackground.withAlphaComponent(0.55).cgColor
- func prepareEmbeddedToolButton(_ button: NSButton, symbol: String) {
- button.translatesAutoresizingMaskIntoConstraints = false
- button.bezelStyle = .toolbar
- if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) {
- button.image = image
- }
- }
- let browserBack = NSButton()
- let browserForward = NSButton()
- let browserReload = NSButton()
- let browserClose = NSButton()
- prepareEmbeddedToolButton(browserBack, symbol: "chevron.left")
- prepareEmbeddedToolButton(browserForward, symbol: "chevron.right")
- prepareEmbeddedToolButton(browserReload, symbol: "arrow.clockwise")
- prepareEmbeddedToolButton(browserClose, symbol: "xmark.circle.fill")
- browserClose.toolTip = "Close web panel"
- browserBack.target = self
- browserBack.action = #selector(embeddedBrowserBackTapped)
- browserForward.target = self
- browserForward.action = #selector(embeddedBrowserForwardTapped)
- browserReload.target = self
- browserReload.action = #selector(embeddedBrowserReloadTapped)
- browserClose.target = self
- browserClose.action = #selector(embeddedBrowserClosePanelTapped)
- let toolbarSpring = NSView()
- toolbarSpring.translatesAutoresizingMaskIntoConstraints = false
- toolbarSpring.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let webToolBarStack = NSStackView(views: [browserBack, browserForward, browserReload, toolbarSpring, browserClose])
- webToolBarStack.orientation = .horizontal
- webToolBarStack.spacing = 6
- webToolBarStack.alignment = .centerY
- webToolBarStack.translatesAutoresizingMaskIntoConstraints = false
- let embeddedBrowserConfiguration = WKWebViewConfiguration()
- embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
- let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
- embeddedWK.translatesAutoresizingMaskIntoConstraints = false
- embeddedWK.setContentCompressionResistancePriority(.init(1), for: .horizontal)
- embeddedWK.setContentHuggingPriority(.init(1), for: .horizontal)
- embeddedWK.navigationDelegate = self
- embeddedWK.uiDelegate = self
- content.addSubview(topBar)
- content.addSubview(topBarDivider)
- content.addSubview(bodyRow)
- bodyRow.addSubview(mainContentColumn)
- bodyRow.addSubview(rightWebPanel)
- rightWebPanel.addSubview(webToolbar)
- webToolbar.addSubview(webToolBarStack)
- rightWebPanel.addSubview(embeddedWK)
- let rightPanelWidthConstraint = rightWebPanel.widthAnchor.constraint(equalToConstant: 0)
- let mainColumnCollapsedWidthConstraint = mainContentColumn.widthAnchor.constraint(equalToConstant: 0)
- mainColumnCollapsedWidthConstraint.isActive = false
- [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, widgetsPageView, aiCompanionPageView, schedulerRoot].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- mainContentColumn.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),
- bodyRow.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
- bodyRow.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
- bodyRow.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
- bodyRow.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
- mainContentColumn.leadingAnchor.constraint(equalTo: bodyRow.leadingAnchor),
- mainContentColumn.topAnchor.constraint(equalTo: bodyRow.topAnchor),
- mainContentColumn.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
- mainContentColumn.trailingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor, constant: -homeEmbeddedBrowserColumnGap),
- rightWebPanel.topAnchor.constraint(equalTo: bodyRow.topAnchor),
- rightWebPanel.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
- rightWebPanel.trailingAnchor.constraint(equalTo: bodyRow.trailingAnchor),
- rightPanelWidthConstraint,
- webToolbar.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
- webToolbar.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
- webToolbar.topAnchor.constraint(equalTo: rightWebPanel.topAnchor),
- webToolbar.heightAnchor.constraint(equalToConstant: 40),
- webToolBarStack.leadingAnchor.constraint(equalTo: webToolbar.leadingAnchor, constant: 8),
- webToolBarStack.trailingAnchor.constraint(equalTo: webToolbar.trailingAnchor, constant: -8),
- webToolBarStack.centerYAnchor.constraint(equalTo: webToolbar.centerYAnchor),
- embeddedWK.topAnchor.constraint(equalTo: webToolbar.bottomAnchor),
- embeddedWK.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
- embeddedWK.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
- embeddedWK.bottomAnchor.constraint(equalTo: rightWebPanel.bottomAnchor),
- 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: mainContentColumn.topAnchor, constant: 20),
- timeTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
- dateTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
- actions.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- actions.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 12),
- actions.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -12),
- actions.heightAnchor.constraint(equalToConstant: 100),
- panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
- panel.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- panel.widthAnchor.constraint(equalToConstant: 610),
- panel.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 6),
- panel.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -6),
- panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
- panel.bottomAnchor.constraint(equalTo: mainContentColumn.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: mainContentColumn.centerXAnchor),
- placeholder.centerYAnchor.constraint(equalTo: mainContentColumn.centerYAnchor),
- settingsView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
- settingsView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
- settingsView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
- settingsView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
- meetingsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
- meetingsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
- meetingsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
- meetingsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
- widgetsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
- widgetsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
- widgetsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
- widgetsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
- aiCompanionPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
- aiCompanionPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
- aiCompanionPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
- aiCompanionPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
- schedulerRoot.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 8),
- schedulerRoot.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor, constant: 8),
- schedulerRoot.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor, constant: -8),
- schedulerRoot.bottomAnchor.constraint(equalTo: mainContentColumn.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
- homeSettingsHostColumn = mainContentColumn
- meetingsPageRootView = meetingsPageView
- widgetsPageRootView = widgetsPageView
- aiCompanionPageRootView = aiCompanionPageView
- 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()
- embeddedBrowserWebView = embeddedWK
- embeddedBrowserPanel = rightWebPanel
- homeBodyRowView = bodyRow
- homeMainContentColumnView = mainContentColumn
- embeddedBrowserPanelWidthConstraint = rightPanelWidthConstraint
- embeddedBrowserMainColumnCollapsedWidthConstraint = mainColumnCollapsedWidthConstraint
- embeddedBrowserBackButton = browserBack
- embeddedBrowserForwardButton = browserForward
- refreshEmbeddedBrowserNavButtons()
- return root
- }
- @objc private func premiumLockedAreaTapped(_ sender: NSButton) {
- showPaywall()
- }
- @MainActor
- private func updatePremiumLockedSections() {
- let shouldLock = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
- refreshWidgetsPagePremiumState()
- 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 makeWidgetsPageView() -> NSView {
- let canAdd = storeKitCoordinator.hasPremiumAccess
- let host = NSHostingView(
- rootView: WidgetsRootView(
- canAddWidgets: canAdd,
- onAddBlocked: { [weak self] in
- self?.showPaywall()
- }
- )
- )
- host.wantsLayer = true
- host.layer?.backgroundColor = NSColor.clear.cgColor
- return host
- }
- private func makeAiCompanionPageView() -> 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)
- // Header row: title / subtitle / count + range selector + refresh.
- let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
- let subtitle = makeLabel("AI-generated notes from your past Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
- let count = makeLabel("0 summaries", 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 rangePopup = NSPopUpButton()
- rangePopup.translatesAutoresizingMaskIntoConstraints = false
- rangePopup.addItems(withTitles: ["Last 7 days", "Last 30 days", "Last 90 days", "All available"])
- rangePopup.selectItem(withTitle: aiCompanionSelectedRange)
- rangePopup.target = self
- rangePopup.action = #selector(aiCompanionRangeChanged(_:))
- let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
- refresh.translatesAutoresizingMaskIntoConstraints = false
- refresh.isBordered = false
- refresh.wantsLayer = true
- refresh.layer?.backgroundColor = accentBlue.cgColor
- refresh.layer?.cornerRadius = 10
- refresh.contentTintColor = .white
- refresh.font = .systemFont(ofSize: 13, weight: .semibold)
- let loadingIndicator = NSProgressIndicator()
- loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
- loadingIndicator.style = .spinning
- loadingIndicator.controlSize = .small
- loadingIndicator.isDisplayedWhenStopped = false
- loadingIndicator.isIndeterminate = true
- let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, refresh])
- toolbarRight.orientation = .horizontal
- toolbarRight.alignment = .centerY
- toolbarRight.spacing = 10
- toolbarRight.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(titleStack)
- container.addSubview(toolbarRight)
- // Status label below header.
- let status = makeLabel("Sign in with Zoom to view AI notes.", size: 12, color: secondaryText, weight: .regular, centered: false)
- status.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(status)
- // List scroll view (left column).
- let listScroll = NSScrollView()
- listScroll.translatesAutoresizingMaskIntoConstraints = false
- listScroll.drawsBackground = false
- listScroll.hasHorizontalScroller = false
- listScroll.hasVerticalScroller = true
- listScroll.autohidesScrollers = true
- listScroll.borderType = .noBorder
- listScroll.scrollerStyle = .overlay
- listScroll.automaticallyAdjustsContentInsets = false
- let listClip = TopAlignedClipView()
- listClip.drawsBackground = false
- listScroll.contentView = listClip
- let listStack = NSStackView()
- listStack.translatesAutoresizingMaskIntoConstraints = false
- listStack.orientation = .vertical
- listStack.alignment = .leading
- listStack.spacing = 10
- listScroll.documentView = listStack
- container.addSubview(listScroll)
- let listEmpty = makeLabel("No AI notes yet for the selected range.", size: 12, color: secondaryText, weight: .regular, centered: true)
- listEmpty.translatesAutoresizingMaskIntoConstraints = false
- listEmpty.maximumNumberOfLines = 3
- listEmpty.lineBreakMode = .byWordWrapping
- listEmpty.isHidden = true
- container.addSubview(listEmpty)
- // Detail scroll view (right column).
- let detailScroll = NSScrollView()
- detailScroll.translatesAutoresizingMaskIntoConstraints = false
- detailScroll.drawsBackground = false
- detailScroll.hasHorizontalScroller = false
- detailScroll.hasVerticalScroller = true
- detailScroll.autohidesScrollers = true
- detailScroll.borderType = .noBorder
- detailScroll.scrollerStyle = .overlay
- detailScroll.automaticallyAdjustsContentInsets = false
- let detailClip = TopAlignedClipView()
- detailClip.drawsBackground = false
- detailScroll.contentView = detailClip
- let detailStack = NSStackView()
- detailStack.translatesAutoresizingMaskIntoConstraints = false
- detailStack.orientation = .vertical
- detailStack.alignment = .leading
- detailStack.spacing = 14
- detailStack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 20, right: 20)
- detailScroll.documentView = detailStack
- container.addSubview(detailScroll)
- let detailEmpty = makeLabel("Select a meeting on the left to view its AI notes.", size: 13, color: secondaryText, weight: .regular, centered: true)
- detailEmpty.translatesAutoresizingMaskIntoConstraints = false
- detailEmpty.maximumNumberOfLines = 3
- detailEmpty.lineBreakMode = .byWordWrapping
- container.addSubview(detailEmpty)
- let divider = NSView()
- divider.translatesAutoresizingMaskIntoConstraints = false
- divider.wantsLayer = true
- divider.layer?.backgroundColor = palette.inputBorder.cgColor
- container.addSubview(divider)
- 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: 24),
- titleStack.trailingAnchor.constraint(lessThanOrEqualTo: toolbarRight.leadingAnchor, constant: -16),
- toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
- toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
- refresh.heightAnchor.constraint(equalToConstant: 30),
- refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
- rangePopup.heightAnchor.constraint(equalToConstant: 28),
- rangePopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
- status.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
- status.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- status.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
- listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
- listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
- listScroll.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.34),
- listStack.topAnchor.constraint(equalTo: listClip.topAnchor, constant: 8),
- listStack.leadingAnchor.constraint(equalTo: listClip.leadingAnchor, constant: 8),
- listStack.trailingAnchor.constraint(equalTo: listClip.trailingAnchor, constant: -8),
- listStack.widthAnchor.constraint(equalTo: listClip.widthAnchor, constant: -16),
- listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
- listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
- listEmpty.widthAnchor.constraint(lessThanOrEqualTo: listScroll.widthAnchor, constant: -24),
- divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
- divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
- divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
- divider.widthAnchor.constraint(equalToConstant: 1),
- detailScroll.topAnchor.constraint(equalTo: listScroll.topAnchor),
- detailScroll.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
- detailScroll.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 10),
- detailScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- detailStack.topAnchor.constraint(equalTo: detailClip.topAnchor),
- detailStack.leadingAnchor.constraint(equalTo: detailClip.leadingAnchor),
- detailStack.trailingAnchor.constraint(equalTo: detailClip.trailingAnchor),
- detailStack.widthAnchor.constraint(equalTo: detailClip.widthAnchor),
- detailEmpty.centerXAnchor.constraint(equalTo: detailScroll.centerXAnchor),
- detailEmpty.centerYAnchor.constraint(equalTo: detailScroll.centerYAnchor),
- detailEmpty.widthAnchor.constraint(lessThanOrEqualTo: detailScroll.widthAnchor, constant: -32)
- ])
- aiCompanionListScroll = listScroll
- aiCompanionListStack = listStack
- aiCompanionDetailScroll = detailScroll
- aiCompanionDetailStack = detailStack
- aiCompanionStatusLabel = status
- aiCompanionCountLabel = count
- aiCompanionRefreshButton = refresh
- aiCompanionRangePopup = rangePopup
- aiCompanionEmptyLabel = listEmpty
- aiCompanionLoadingIndicator = loadingIndicator
- aiCompanionDetailEmptyLabel = detailEmpty
- renderAiCompanionList()
- renderAiCompanionDetail()
- return root
- }
- // MARK: - AI Companion list / detail rendering
- @MainActor
- private func renderAiCompanionList() {
- guard let stack = aiCompanionListStack else { return }
- aiCompanionRowButtons.removeAll()
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- let count = aiCompanionSummaries.count
- aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
- let showEmpty: Bool
- if isUserLoggedIn() == false {
- showEmpty = true
- aiCompanionEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
- } else if count == 0 {
- showEmpty = true
- aiCompanionEmptyLabel?.stringValue = "No AI notes yet for the selected range."
- } else {
- showEmpty = false
- }
- aiCompanionEmptyLabel?.isHidden = !showEmpty
- for (idx, summary) in aiCompanionSummaries.enumerated() {
- let row = makeAiCompanionListRow(summary: summary, index: idx, isSelected: idx == aiCompanionSelectedIndex)
- stack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- @MainActor
- private func makeAiCompanionListRow(summary: AICompanionSummary, index: Int, isSelected: Bool) -> NSButton {
- let button = NSButton(title: "", target: self, action: #selector(aiCompanionRowTapped(_:)))
- button.translatesAutoresizingMaskIntoConstraints = false
- button.bezelStyle = .regularSquare
- button.isBordered = false
- button.setButtonType(.momentaryChange)
- button.tag = index
- button.wantsLayer = true
- button.layer?.cornerRadius = 12
- button.layer?.borderWidth = 1
- let baseBg: NSColor = palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.04)
- : NSColor.black.withAlphaComponent(0.03)
- let selectedBg: NSColor = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.22 : 0.14)
- let baseBorder: NSColor = palette.inputBorder
- let selectedBorder: NSColor = accentBlue
- button.layer?.backgroundColor = (isSelected ? selectedBg : baseBg).cgColor
- button.layer?.borderColor = (isSelected ? selectedBorder : baseBorder).cgColor
- let topic = summary.summaryTitle?.isEmpty == false
- ? summary.summaryTitle!
- : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
- let titleLabel = makeLabel(topic, size: 14, color: primaryText, weight: .semibold, centered: false)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- titleLabel.maximumNumberOfLines = 2
- titleLabel.lineBreakMode = .byTruncatingTail
- let dateText = aiCompanionDateText(for: summary)
- let dateLabel = makeLabel(dateText, size: 11, color: secondaryText, weight: .regular, centered: false)
- dateLabel.translatesAutoresizingMaskIntoConstraints = false
- let badge = makeLabel("AI notes", size: 10, color: accentBlue, weight: .semibold, centered: false)
- badge.translatesAutoresizingMaskIntoConstraints = false
- button.addSubview(titleLabel)
- button.addSubview(dateLabel)
- button.addSubview(badge)
- NSLayoutConstraint.activate([
- titleLabel.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
- titleLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
- titleLabel.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
- dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
- dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- dateLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
- badge.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
- badge.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- badge.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
- ])
- aiCompanionRowButtons.append(button)
- return button
- }
- @MainActor
- private func renderAiCompanionDetail() {
- guard let stack = aiCompanionDetailStack else { return }
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- guard isUserLoggedIn() else {
- aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
- aiCompanionDetailEmptyLabel?.isHidden = false
- return
- }
- guard let index = aiCompanionSelectedIndex,
- aiCompanionSummaries.indices.contains(index) else {
- aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
- ? "Run an AI Companion-enabled meeting to see notes here."
- : "Select a meeting on the left to view its AI notes."
- aiCompanionDetailEmptyLabel?.isHidden = false
- return
- }
- aiCompanionDetailEmptyLabel?.isHidden = true
- let summary = aiCompanionSummaries[index]
- let topic = summary.summaryTitle?.isEmpty == false
- ? summary.summaryTitle!
- : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
- let titleLabel = makeLabel(topic, size: 22, color: primaryText, weight: .bold, centered: false)
- titleLabel.maximumNumberOfLines = 0
- titleLabel.lineBreakMode = .byWordWrapping
- stack.addArrangedSubview(titleLabel)
- let metaText = aiCompanionDateText(for: summary)
- let metaLabel = makeLabel(metaText, size: 12, color: secondaryText, weight: .regular, centered: false)
- metaLabel.maximumNumberOfLines = 0
- stack.addArrangedSubview(metaLabel)
- let actionsRow = NSStackView()
- actionsRow.orientation = .horizontal
- actionsRow.spacing = 10
- actionsRow.alignment = .centerY
- actionsRow.translatesAutoresizingMaskIntoConstraints = false
- let openDocButton = NSButton(title: "Open in Zoom Docs", target: self, action: #selector(aiCompanionOpenDocTapped))
- openDocButton.translatesAutoresizingMaskIntoConstraints = false
- openDocButton.isBordered = false
- openDocButton.wantsLayer = true
- openDocButton.layer?.backgroundColor = accentBlue.cgColor
- openDocButton.layer?.cornerRadius = 10
- openDocButton.contentTintColor = .white
- openDocButton.font = .systemFont(ofSize: 12, weight: .semibold)
- openDocButton.isEnabled = summary.summaryDocURL != nil
- openDocButton.alphaValue = summary.summaryDocURL != nil ? 1.0 : 0.45
- let copyButton = NSButton(title: "Copy as Markdown", target: self, action: #selector(aiCompanionCopyMarkdownTapped))
- copyButton.translatesAutoresizingMaskIntoConstraints = false
- copyButton.isBordered = false
- copyButton.wantsLayer = true
- copyButton.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
- copyButton.layer?.cornerRadius = 10
- copyButton.layer?.borderWidth = 1
- copyButton.layer?.borderColor = palette.inputBorder.cgColor
- copyButton.contentTintColor = primaryText
- copyButton.font = .systemFont(ofSize: 12, weight: .semibold)
- [openDocButton, copyButton].forEach { btn in
- btn.heightAnchor.constraint(equalToConstant: 30).isActive = true
- btn.widthAnchor.constraint(greaterThanOrEqualToConstant: 130).isActive = true
- }
- actionsRow.addArrangedSubview(openDocButton)
- actionsRow.addArrangedSubview(copyButton)
- stack.addArrangedSubview(actionsRow)
- aiCompanionOpenDocButton = openDocButton
- aiCompanionCopyMarkdownButton = copyButton
- if let overview = summary.summaryOverview?.trimmingCharacters(in: .whitespacesAndNewlines), overview.isEmpty == false {
- stack.addArrangedSubview(makeAiCompanionSectionHeader("Overview"))
- stack.addArrangedSubview(makeAiCompanionBodyLabel(overview))
- }
- for section in summary.summaryDetails {
- let labelText = section.label.isEmpty ? "Details" : section.label
- stack.addArrangedSubview(makeAiCompanionSectionHeader(labelText))
- if section.summary.isEmpty == false {
- stack.addArrangedSubview(makeAiCompanionBodyLabel(section.summary))
- }
- }
- if summary.nextSteps.isEmpty == false {
- stack.addArrangedSubview(makeAiCompanionSectionHeader("Next steps"))
- let bullets = summary.nextSteps.map { "• \($0)" }.joined(separator: "\n")
- stack.addArrangedSubview(makeAiCompanionBodyLabel(bullets))
- }
- if summary.summaryOverview == nil && summary.summaryDetails.isEmpty && summary.nextSteps.isEmpty,
- let fullContent = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
- fullContent.isEmpty == false {
- stack.addArrangedSubview(makeAiCompanionSectionHeader("Summary"))
- stack.addArrangedSubview(makeAiCompanionBodyLabel(fullContent))
- }
- }
- @MainActor
- private func makeAiCompanionSectionHeader(_ text: String) -> NSTextField {
- let label = makeLabel(text.uppercased(), size: 11, color: accentBlue, weight: .bold, centered: false)
- label.maximumNumberOfLines = 1
- return label
- }
- @MainActor
- private func makeAiCompanionBodyLabel(_ text: String) -> NSTextField {
- let label = makeLabel(text, size: 13, color: primaryText, weight: .regular, centered: false)
- label.maximumNumberOfLines = 0
- label.lineBreakMode = .byWordWrapping
- label.isSelectable = true
- return label
- }
- private func aiCompanionDateText(for summary: AICompanionSummary) -> String {
- let formatter = DateFormatter()
- formatter.dateStyle = .medium
- formatter.timeStyle = .short
- formatter.timeZone = TimeZone.current
- let startText = summary.meetingStartTime.map { formatter.string(from: $0) }
- let endText = summary.meetingEndTime.map { formatter.string(from: $0) }
- switch (startText, endText) {
- case let (start?, end?): return "\(start) → \(end)"
- case let (start?, nil): return start
- case let (nil, end?): return "Ended \(end)"
- case (nil, nil): return "Date unavailable"
- }
- }
- // MARK: - AI Companion data loading & actions
- private func aiCompanionDateRange() -> (from: Date?, to: Date?) {
- let now = Date()
- let calendar = Calendar.current
- let days: Int
- switch aiCompanionSelectedRange {
- case "Last 7 days": days = 7
- case "Last 30 days": days = 30
- case "Last 90 days": days = 90
- case "All available": return (nil, nil)
- default: days = 30
- }
- let from = calendar.date(byAdding: .day, value: -days, to: now) ?? now
- return (from, now)
- }
- @MainActor
- private func setAiCompanionLoadingUI(_ loading: Bool) {
- if loading {
- aiCompanionLoadingIndicator?.startAnimation(nil)
- } else {
- aiCompanionLoadingIndicator?.stopAnimation(nil)
- }
- aiCompanionRefreshButton?.isEnabled = !loading
- aiCompanionRefreshButton?.alphaValue = loading ? 0.55 : 1.0
- aiCompanionRangePopup?.isEnabled = !loading
- }
- private func loadAICompanionSummaries(force: Bool) async {
- guard isUserLoggedIn() else {
- await MainActor.run {
- self.aiCompanionSummaries = []
- self.aiCompanionSelectedIndex = nil
- self.renderAiCompanionList()
- self.renderAiCompanionDetail()
- self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
- }
- return
- }
- if aiCompanionIsLoading && force == false { return }
- aiCompanionIsLoading = true
- await MainActor.run {
- self.setAiCompanionLoadingUI(true)
- self.aiCompanionStatusLabel?.stringValue = "Loading AI notes…"
- }
- defer {
- aiCompanionIsLoading = false
- Task { @MainActor in self.setAiCompanionLoadingUI(false) }
- }
- do {
- let token = try await zoomOAuth.validAccessTokenForMeetingSummaries(presentingWindow: view.window)
- let (from, to) = aiCompanionDateRange()
- let summaries = try await fetchZoomMeetingSummaries(accessToken: token, from: from, to: to)
- await MainActor.run {
- self.aiCompanionSummaries = summaries.sorted(by: { lhs, rhs in
- (lhs.meetingStartTime ?? .distantPast) > (rhs.meetingStartTime ?? .distantPast)
- })
- self.aiCompanionLastLoadedAt = Date()
- if self.aiCompanionSummaries.isEmpty {
- self.aiCompanionSelectedIndex = nil
- } else {
- if let idx = self.aiCompanionSelectedIndex, idx >= self.aiCompanionSummaries.count {
- self.aiCompanionSelectedIndex = 0
- } else if self.aiCompanionSelectedIndex == nil {
- self.aiCompanionSelectedIndex = 0
- }
- }
- self.renderAiCompanionList()
- self.renderAiCompanionDetail()
- if self.aiCompanionSummaries.isEmpty {
- self.aiCompanionStatusLabel?.stringValue = "No AI notes found for this range yet."
- } else {
- let formatter = DateFormatter()
- formatter.dateStyle = .none
- formatter.timeStyle = .short
- self.aiCompanionStatusLabel?.stringValue = "Updated \(formatter.string(from: Date()))"
- }
- }
- } catch {
- await MainActor.run {
- self.aiCompanionSummaries = []
- self.aiCompanionSelectedIndex = nil
- self.renderAiCompanionList()
- self.renderAiCompanionDetail()
- if case ZoomOAuthError.missingRequiredScope = error {
- self.zoomOAuth.clearSavedTokens()
- self.aiCompanionStatusLabel?.stringValue = "Zoom permissions are missing. Enable \"View a user's meetings\" and \"View a meeting's summary\" on your Marketplace app, then sign in again."
- } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
- let minutes = max(1, Int(ceil(Double(retryAfterSeconds ?? 300) / 60.0)))
- self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
- } else if case ZoomOAuthError.missingClientId = error {
- self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
- } else if case ZoomOAuthError.missingClientSecret = error {
- self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
- } else if case ZoomOAuthError.authenticationCancelled = error {
- self.aiCompanionStatusLabel?.stringValue = "Sign-in cancelled. Please sign in with Zoom to load AI notes."
- } else {
- self.aiCompanionStatusLabel?.stringValue = "Unable to load AI notes right now. Please try again shortly."
- }
- }
- }
- }
- private func aiCompanionRefreshIfStale() {
- let stale: Bool
- if let lastLoaded = aiCompanionLastLoadedAt {
- stale = Date().timeIntervalSince(lastLoaded) > 60
- } else {
- stale = true
- }
- if stale {
- Task { await self.loadAICompanionSummaries(force: false) }
- }
- }
- @objc private func aiCompanionRefreshTapped() {
- Task { await self.loadAICompanionSummaries(force: true) }
- }
- @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
- aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
- Task { await self.loadAICompanionSummaries(force: true) }
- }
- @objc private func aiCompanionRowTapped(_ sender: NSButton) {
- let index = sender.tag
- guard aiCompanionSummaries.indices.contains(index) else { return }
- aiCompanionSelectedIndex = index
- renderAiCompanionList()
- renderAiCompanionDetail()
- }
- @objc private func aiCompanionOpenDocTapped() {
- guard let index = aiCompanionSelectedIndex,
- aiCompanionSummaries.indices.contains(index),
- let url = aiCompanionSummaries[index].summaryDocURL else { return }
- openWebURLPreferringInApp(url)
- }
- @objc private func aiCompanionCopyMarkdownTapped() {
- guard let index = aiCompanionSelectedIndex,
- aiCompanionSummaries.indices.contains(index) else { return }
- let summary = aiCompanionSummaries[index]
- let markdown = aiCompanionMarkdown(for: summary)
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- pasteboard.setString(markdown, forType: .string)
- aiCompanionStatusLabel?.stringValue = "Copied AI notes as Markdown."
- }
- private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
- if let content = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
- content.isEmpty == false {
- return content
- }
- var lines: [String] = []
- let title = summary.summaryTitle?.isEmpty == false ? summary.summaryTitle! : (summary.meetingTopic ?? "Meeting summary")
- lines.append("# \(title)")
- lines.append("")
- lines.append("_\(aiCompanionDateText(for: summary))_")
- lines.append("")
- if let overview = summary.summaryOverview, overview.isEmpty == false {
- lines.append("## Overview")
- lines.append(overview)
- lines.append("")
- }
- for section in summary.summaryDetails {
- let header = section.label.isEmpty ? "Details" : section.label
- lines.append("## \(header)")
- if section.summary.isEmpty == false {
- lines.append(section.summary)
- }
- lines.append("")
- }
- if summary.nextSteps.isEmpty == false {
- lines.append("## Next steps")
- for step in summary.nextSteps {
- lines.append("- \(step)")
- }
- lines.append("")
- }
- if let url = summary.summaryDocURL {
- lines.append("Full document: \(url.absoluteString)")
- }
- return lines.joined(separator: "\n")
- }
- @MainActor
- private func refreshWidgetsPagePremiumState() {
- guard let host = widgetsPageRootView as? NSHostingView<WidgetsRootView> else { return }
- host.rootView = WidgetsRootView(
- canAddWidgets: storeKitCoordinator.hasPremiumAccess,
- onAddBlocked: { [weak self] in
- self?.showPaywall()
- }
- )
- }
- @MainActor
- private func reconcileDesktopWidgetsForPremiumAccess() {
- refreshWidgetsPagePremiumState()
- if storeKitCoordinator.hasPremiumAccess {
- DesktopWidgetWindowManager.shared.restore()
- } else {
- DesktopWidgetWindowManager.shared.removeAllWidgets()
- }
- }
- @MainActor
- private func updateWidgetMeetingStoreFromCurrentMeetings() {
- let upcoming = allScheduledMeetings
- .filter { $0.start >= Date() }
- .sorted(by: { $0.start < $1.start })
- .prefix(3)
- .map { meeting in
- WidgetMeetingSnapshot(
- id: meeting.meetingID ?? meeting.title,
- title: meeting.title,
- timeText: widgetTimeText(for: meeting),
- joinLink: meeting.webURL?.absoluteString
- )
- }
- WidgetMeetingStore.save(Array(upcoming))
- DesktopWidgetWindowManager.shared.refreshForMeetingsChange()
- }
- private func widgetTimeText(for meeting: ScheduledMeeting) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "h:mm a"
- formatter.timeZone = meeting.meetingTimeZone ?? TimeZone.current
- return formatter.string(from: meeting.start)
- }
- private func setupWidgetBridge() {
- let center = NotificationCenter.default
- center.addObserver(forName: .widgetOpenHomePage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.home)
- }
- center.addObserver(forName: .widgetOpenMeetingsPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.meetings)
- }
- center.addObserver(forName: .widgetOpenSchedulerPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.scheduler)
- }
- center.addObserver(forName: .widgetOpenWidgetsPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.widgets)
- }
- center.addObserver(forName: .widgetOpenSettingsPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.settings)
- }
- center.addObserver(forName: .widgetStartNewMeetingRequested, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarTriggerNewMeeting()
- }
- center.addObserver(forName: .widgetJoinMeetingRequested, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarTriggerJoinMeeting()
- }
- center.addObserver(forName: .widgetScheduleMeetingRequested, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarTriggerScheduleMeeting()
- }
- center.addObserver(forName: .widgetRefreshRequested, object: nil, queue: .main) { [weak self] _ in
- self?.triggerMeetingsRefresh(force: true)
- }
- center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
- guard let self else { return }
- guard let link = notification.userInfo?["link"] as? String,
- let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
- self.openWebURLPreferringInApp(url)
- }
- }
- 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 : sidebarInactiveText
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
- let preferredSymbol = sidebarSymbolName(for: item, filled: selectedRow)
- let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
- iconView.image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
- ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item)
- iconContainer.addSubview(iconView)
- let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : sidebarInactiveText, 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 : sidebarInactiveText, 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)
- }
- 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 }
- closeEmbeddedBrowserPanel()
- selectedHomeSidebarItem = item
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- @objc private func newMeetingTapped() {
- menuBarTriggerNewMeeting()
- }
- func menuBarIsUserLoggedIn() -> Bool {
- isUserLoggedIn()
- }
- func menuBarShowSection(_ section: HomeSection) {
- closeEmbeddedBrowserPanel()
- 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") else {
- showSimpleAlert(title: "Unable to open", message: "Could not start a new Zoom meeting right now.")
- return
- }
- openWebURLPreferringInApp(url)
- }
- 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 : sidebarInactiveText
- homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : sidebarInactiveText
- let preferredSymbol = sidebarSymbolName(for: item, filled: selected)
- let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
- if let image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
- ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item) {
- homeSidebarIconViews[item]?.image = image
- }
- }
- }
- @MainActor
- private func updateSelectedHomeSectionUI() {
- let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
- let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
- let isSettings = selectedHomeSidebarItem == HomeSection.settings.rawValue
- let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
- let isWidgets = selectedHomeSidebarItem == HomeSection.widgets.rawValue
- let isAiCompanion = selectedHomeSidebarItem == HomeSection.aiCompanion.rawValue
- 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 || isWidgets || isAiCompanion
- 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
- widgetsPageRootView?.isHidden = isWidgets == false
- aiCompanionPageRootView?.isHidden = isAiCompanion == 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)
- }
- if isAiCompanion {
- aiCompanionRefreshIfStale()
- }
- }
- 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 "Widgets":
- return filled ? "rectangle.grid.2x2.fill" : "rectangle.grid.2x2"
- case "Ai Companion":
- return filled ? "sparkles" : "sparkles"
- 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: 148).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,
- onTapURL: { [weak self] url in
- self?.openWebURLPreferringInApp(url)
- }
- )
- 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: EmbeddedOAuthPresenting {}
- extension ViewController: WKNavigationDelegate, WKUIDelegate {
- func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
- guard webView === embeddedBrowserWebView else {
- decisionHandler(.allow)
- return
- }
- if let prefix = oauthSessionCallbackPrefix,
- let targetURL = navigationAction.request.url,
- targetURL.absoluteString.hasPrefix(prefix) {
- oauthSessionContinuation?.resume(returning: targetURL)
- oauthSessionContinuation = nil
- oauthSessionCallbackPrefix = nil
- decisionHandler(.cancel)
- return
- }
- decisionHandler(.allow)
- }
- func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
- guard webView === embeddedBrowserWebView else { return }
- refreshEmbeddedBrowserNavButtons()
- }
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- guard webView === embeddedBrowserWebView else { return }
- refreshEmbeddedBrowserNavButtons()
- }
- func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
- guard webView === embeddedBrowserWebView else { return nil }
- if navigationAction.targetFrame == nil {
- webView.load(navigationAction.request)
- }
- return nil
- }
- }
- 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 let onTapURL: ((URL) -> Void)?
- 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, onTapURL: ((URL) -> Void)? = nil) {
- self.url = url
- self.hoverBackground = hoverBackground
- self.hoverBorder = hoverBorder
- self.onTapURL = onTapURL
- 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)
- self.onTapURL = nil
- 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 }
- if let onTapURL {
- onTapURL(url)
- } else {
- 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
- case authenticationCancelled
- }
- 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 {
- // Default: baseline meeting access used throughout the app.
- try await validAccessToken(presentingWindow: presentingWindow, requiringAnyOf: [
- // Zoom's newer granular scopes for listing and scheduling meetings.
- "meeting:read:list_meetings",
- "meeting:write:meeting"
- ])
- }
- func validAccessToken(presentingWindow: NSWindow?, requiringAnyOf requiredScopes: [String]) async throws -> String {
- if let tokens = try tokenStore.readTokens(),
- tokens.expiresAt.timeIntervalSinceNow > 60,
- tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
- return tokens.accessToken
- } else if var tokens = try tokenStore.readTokens(),
- let refreshed = try await refreshTokens(tokens) {
- tokens = refreshed
- try tokenStore.writeTokens(tokens)
- if tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
- return tokens.accessToken
- }
- }
- let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
- try tokenStore.writeTokens(tokens)
- guard tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) else {
- throw ZoomOAuthError.missingRequiredScope(tokens.scope ?? "Missing required scope(s).")
- }
- return tokens.accessToken
- }
- /// User-managed OAuth apps often **cannot** add `meeting:read:list_summaries` (admin/master only).
- /// In that case we load AI summaries by listing past meetings (`meeting:read:list_meetings`) and
- /// calling `meeting:read:summary` per meeting.
- func validAccessTokenForMeetingSummaries(presentingWindow: NSWindow?) async throws -> String {
- func canLoadSummaries(_ scope: String?) -> Bool {
- tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_summaries"])
- || (tokenHasScopes(scope, requiringAnyOf: ["meeting:read:summary"])
- && tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_meetings"]))
- }
- if let tokens = try tokenStore.readTokens(),
- tokens.expiresAt.timeIntervalSinceNow > 60,
- canLoadSummaries(tokens.scope) {
- return tokens.accessToken
- } else if var tokens = try tokenStore.readTokens(),
- let refreshed = try await refreshTokens(tokens) {
- tokens = refreshed
- try tokenStore.writeTokens(tokens)
- if canLoadSummaries(tokens.scope) {
- return tokens.accessToken
- }
- }
- let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
- try tokenStore.writeTokens(tokens)
- guard canLoadSummaries(tokens.scope) else {
- let hint = " Grant meeting:read:list_summaries (if available), or both meeting:read:list_meetings and meeting:read:summary."
- throw ZoomOAuthError.missingRequiredScope((tokens.scope ?? "Missing required scope(s).") + hint)
- }
- return tokens.accessToken
- }
- private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
- guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
- guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
- guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
- let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
- let state = UUID().uuidString
- // Explicitly request the scopes we rely on.
- // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
- // Note: `meeting:read:list_summaries` is often unavailable on user-managed General apps; we
- // fall back to per-meeting summary fetches using `meeting:read:summary` + past meeting list.
- let requestedScopes = [
- "meeting:read:list_meetings",
- "meeting:write:meeting",
- "meeting:read:summary",
- "zoomapp:inmeeting"
- ]
- var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
- components.queryItems = [
- URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "client_id", value: clientId),
- URLQueryItem(name: "redirect_uri", value: redirectURI),
- URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
- // Force consent so updated scopes are actually granted.
- URLQueryItem(name: "prompt", value: "consent"),
- // Zoom-specific: forces interactive verification even if already signed in.
- URLQueryItem(name: "force_verify", value: "true"),
- URLQueryItem(name: "state", value: state)
- ]
- guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
- let oauthWindow = await MainActor.run { makeOAuthWindowController() }
- do {
- let callbackURL = try await oauthWindow.authenticate(
- startURL: authURL,
- callbackURLPrefix: redirectURI,
- presentingWindow: presentingWindow,
- timeoutSeconds: 180
- )
- 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 GoogleOAuthError.authenticationCancelled {
- throw ZoomOAuthError.authenticationCancelled
- } 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 normalizedGrantedScopes(_ scopeValue: String?) -> Set<String> {
- guard let scopeValue, scopeValue.isEmpty == false else { return [] }
- let parts = scopeValue.split { $0 == " " || $0 == "," }
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { $0.isEmpty == false }
- return Set(parts)
- }
- /// Whether `granted` includes the Zoom scope `req` (granular exact match, admin/master variants,
- /// or legacy broad scopes Zoom still returns on some tokens).
- private func grantedScopeSatisfies(_ granted: Set<String>, requirement req: String) -> Bool {
- if granted.contains(req)
- || granted.contains("\(req):admin")
- || granted.contains("\(req):master") {
- return true
- }
- // Classic scopes: listing / reading meetings and summaries often appear as `meeting:read`
- // instead of `meeting:read:list_meetings` / `meeting:read:summary` on the token string.
- switch req {
- case "meeting:read:list_meetings", "meeting:read:summary":
- return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
- case "meeting:write:meeting":
- return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
- default:
- return false
- }
- }
- private func tokenHasScopes(_ scopeValue: String?, requiringAnyOf requiredScopes: [String]) -> Bool {
- let granted = normalizedGrantedScopes(scopeValue)
- if granted.isEmpty { return false }
- return requiredScopes.contains { grantedScopeSatisfies(granted, requirement: $0) }
- }
- 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
- case authenticationCancelled
- }
- 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 {
- guard let presentingWindow else { throw GoogleOAuthError.unableToOpenBrowser }
- 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,
- presentingWindow: presentingWindow
- )
- 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)
- }
- }
- // MARK: - OAuth window (previous sign-in behavior)
- @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
- }
- }
- /// Separate titled window matching the app’s default content size (see `AppWindowMetrics`); when a parent window is provided, the OAuth content area is aligned to the parent’s content rect.
- @MainActor
- private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
- private let webView: WKWebView
- private var callbackPrefix: String?
- private var callbackContinuation: CheckedContinuation<URL, Error>?
- private weak var parentWindowForChild: NSWindow?
- /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
- private var suppressUserCloseCancellation = false
- init(title: String) {
- let configuration = WKWebViewConfiguration()
- configuration.websiteDataStore = .nonPersistent()
- self.webView = WKWebView(frame: .zero, configuration: configuration)
- let container = OAuthWebViewContainerView(webView: webView)
- let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
- let window = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
- window.title = title
- window.contentView = container
- window.contentMinSize = contentSize
- window.contentMaxSize = contentSize
- window.isReleasedWhenClosed = false
- super.init(window: window)
- window.delegate = self
- webView.navigationDelegate = self
- }
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
- func windowWillClose(_ notification: Notification) {
- guard suppressUserCloseCancellation == false else { return }
- removeFromParentIfNeeded()
- webView.stopLoading()
- if let continuation = callbackContinuation {
- callbackContinuation = nil
- continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
- }
- }
- func authenticate(startURL: URL, callbackURLPrefix: String, presentingWindow: NSWindow?, timeoutSeconds: Double = 120) async throws -> URL {
- self.callbackPrefix = callbackURLPrefix
- parentWindowForChild = presentingWindow
- applyFixedOAuthFrame(presentingWindow: presentingWindow)
- if let parent = presentingWindow, let w = window {
- parent.addChildWindow(w, ordered: .above)
- }
- showWindow(nil)
- window?.makeKeyAndOrderFront(nil)
- webView.load(URLRequest(url: startURL))
- do {
- 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.closeOAuthWindowAfterFlow()
- return url
- }
- } catch {
- self.closeOAuthWindowAfterFlow()
- throw error
- }
- }
- private func closeOAuthWindowAfterFlow() {
- suppressUserCloseCancellation = true
- webView.stopLoading()
- removeFromParentIfNeeded()
- close()
- suppressUserCloseCancellation = false
- }
- private func awaitCallback() async throws -> URL {
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
- callbackContinuation = continuation
- }
- }
- private func removeFromParentIfNeeded() {
- if let parent = parentWindowForChild, let w = window {
- parent.removeChildWindow(w)
- }
- parentWindowForChild = nil
- }
- private func applyFixedOAuthFrame(presentingWindow: NSWindow?) {
- guard let window else { return }
- if let parent = presentingWindow {
- let parentContent = parent.contentRect(forFrameRect: parent.frame)
- let oauthFrame = window.frameRect(forContentRect: parentContent)
- window.setFrame(oauthFrame, display: true)
- } else {
- let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
- window.setContentSize(contentSize)
- window.center()
- }
- let lockedContentSize = window.contentRect(forFrameRect: window.frame).size
- window.contentMinSize = lockedContentSize
- window.contentMaxSize = lockedContentSize
- }
- 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."
- case .authenticationCancelled: return "Sign-in was cancelled."
- }
- }
- }
- 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 Zoom sign-in (no window to present from)."
- case .authenticationTimedOut:
- return "Zoom sign-in timed out waiting for the browser redirect."
- case .authenticationCancelled:
- return "Zoom sign-in was cancelled."
- }
- }
- }
- // MARK: - Desktop Widgets (Zoom)
- private struct WidgetQuickAction: Identifiable, Hashable {
- enum Destination: String, Hashable {
- case home
- case meetings
- case scheduler
- case widgets
- case settings
- case newMeeting
- case joinMeeting
- case scheduleMeeting
- case refreshMeetings
- }
- let id: String
- let title: String
- let systemImage: String
- let destination: Destination
- }
- private struct WidgetVariant: Identifiable, Hashable {
- let id: String
- let title: String
- let subtitle: String
- let size: WidgetSize
- let quickActions: [WidgetQuickAction]
- }
- private enum WidgetTemplates {
- static let zoomVariants: [WidgetVariant] = [
- WidgetVariant(
- id: "zoom_small",
- title: "Zoom",
- subtitle: "Quick actions",
- size: .small,
- quickActions: [
- WidgetQuickAction(id: "new", title: "New meeting", systemImage: "video.fill", destination: .newMeeting),
- WidgetQuickAction(id: "join", title: "Join", systemImage: "plus", destination: .joinMeeting),
- WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "calendar", destination: .scheduleMeeting),
- WidgetQuickAction(id: "open", title: "Meetings", systemImage: "list.bullet.rectangle", destination: .meetings)
- ]
- ),
- WidgetVariant(
- id: "zoom_medium",
- title: "Zoom",
- subtitle: "Upcoming meetings",
- size: .medium,
- quickActions: [
- WidgetQuickAction(id: "meetings", title: "Meetings", systemImage: "video", destination: .meetings),
- WidgetQuickAction(id: "scheduler", title: "Scheduler", systemImage: "calendar.badge.clock", destination: .scheduler),
- WidgetQuickAction(id: "widgets", title: "Widgets", systemImage: "rectangle.grid.2x2", destination: .widgets),
- WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
- ]
- )
- ]
- static func variant(for variantID: String) -> WidgetVariant {
- zoomVariants.first(where: { $0.id == variantID }) ?? zoomVariants[0]
- }
- }
- private extension WidgetVariant {
- var previewCardSize: CGSize {
- switch size {
- case .small:
- return CGSize(width: 196, height: 196)
- case .medium:
- return CGSize(width: 340, height: 288)
- }
- }
- // Simple Zoom-blue palette shared by all widget sizes.
- var gradientStops: [Color] {
- [
- Color(red: 0.04, green: 0.32, blue: 0.86),
- Color(red: 0.10, green: 0.50, blue: 0.96)
- ]
- }
- // Neutral highlight keeps the blue clean without washing out the logo.
- var highlightTint: Color {
- Color.white.opacity(0.16)
- }
- }
- private enum WidgetSize: String, CaseIterable, Codable, Hashable {
- case small
- case medium
- var title: String {
- switch self {
- case .small: return "Small"
- case .medium: return "Medium"
- }
- }
- }
- private struct WidgetInstance: Identifiable, Codable, Hashable {
- struct Origin: Codable, Hashable {
- var x: Double
- var y: Double
- }
- let id: UUID
- let variantID: String
- var size: WidgetSize
- var origin: Origin?
- init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
- self.id = id
- self.variantID = variantID
- self.size = size
- self.origin = origin
- }
- }
- private struct WidgetMeetingSnapshot: Codable, Identifiable {
- let id: String
- let title: String
- let timeText: String
- let joinLink: String?
- }
- private enum WidgetMeetingStore {
- static let key = "zoom_app.widget.topMeetings"
- static func load() -> [WidgetMeetingSnapshot] {
- guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
- return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
- }
- static func save(_ meetings: [WidgetMeetingSnapshot]) {
- guard let data = try? JSONEncoder().encode(meetings) else { return }
- UserDefaults.standard.set(data, forKey: key)
- }
- }
- private enum WidgetAppNavigator {
- static func open(target: WidgetQuickAction.Destination) {
- DispatchQueue.main.async {
- bringAppToFront()
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
- NotificationCenter.default.post(name: notificationName(for: target), object: nil)
- }
- }
- }
- static func openMeetingLink(_ link: String) {
- DispatchQueue.main.async {
- bringAppToFront()
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
- NotificationCenter.default.post(
- name: .widgetOpenMeetingLinkRequested,
- object: nil,
- userInfo: ["link": link]
- )
- }
- }
- }
- private static func notificationName(for target: WidgetQuickAction.Destination) -> Notification.Name {
- switch target {
- case .home: return .widgetOpenHomePage
- case .meetings: return .widgetOpenMeetingsPage
- case .scheduler: return .widgetOpenSchedulerPage
- case .widgets: return .widgetOpenWidgetsPage
- case .settings: return .widgetOpenSettingsPage
- case .newMeeting: return .widgetStartNewMeetingRequested
- case .joinMeeting: return .widgetJoinMeetingRequested
- case .scheduleMeeting: return .widgetScheduleMeetingRequested
- case .refreshMeetings: return .widgetRefreshRequested
- }
- }
- private static func bringAppToFront() {
- NSApp.unhide(nil)
- NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
- NSApp.activate(ignoringOtherApps: true)
- NSApp.arrangeInFront(nil)
- for window in NSApp.windows where window.contentViewController is ViewController {
- if window.isMiniaturized {
- window.deminiaturize(nil)
- }
- window.orderFrontRegardless()
- window.makeKeyAndOrderFront(nil)
- }
- }
- }
- private extension Notification.Name {
- static let widgetOpenHomePage = Notification.Name("widgetOpenHomePage")
- static let widgetOpenMeetingsPage = Notification.Name("widgetOpenMeetingsPage")
- static let widgetOpenSchedulerPage = Notification.Name("widgetOpenSchedulerPage")
- static let widgetOpenWidgetsPage = Notification.Name("widgetOpenWidgetsPage")
- static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
- static let widgetStartNewMeetingRequested = Notification.Name("widgetStartNewMeetingRequested")
- static let widgetJoinMeetingRequested = Notification.Name("widgetJoinMeetingRequested")
- static let widgetScheduleMeetingRequested = Notification.Name("widgetScheduleMeetingRequested")
- static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
- static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
- }
- private final class DesktopWidgetWindowManager: ObservableObject {
- static let shared = DesktopWidgetWindowManager()
- private let storageKey = "zoom_app.widget.instancesData"
- private var windowsByInstanceID: [UUID: NSPanel] = [:]
- @Published private(set) var instancesChangeToken: Int = 0
- @Published private(set) var meetingsRefreshToken: Int = 0
- private init() {}
- func hasInstance(variantID: String) -> Bool {
- loadInstances().contains { $0.variantID == variantID }
- }
- func show(instance: WidgetInstance) {
- let variant = WidgetTemplates.variant(for: instance.variantID)
- var resolved = instance
- if resolved.origin == nil {
- resolved.origin = nextAutoPlacement(for: variant)
- }
- let panel = makePanel(for: resolved, variant: variant)
- windowsByInstanceID[resolved.id] = panel
- persistUpsert(instance: resolved)
- if let origin = resolved.origin {
- panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
- persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
- } else {
- panel.center()
- persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
- }
- panel.orderFront(nil)
- panel.orderFrontRegardless()
- instancesChangeToken += 1
- }
- @discardableResult
- func removeInstances(variantID: String) -> Int {
- let instances = loadInstances()
- let removed = instances.filter { $0.variantID == variantID }
- let remaining = instances.filter { $0.variantID != variantID }
- for instance in removed {
- if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
- panel.close()
- }
- }
- saveInstances(remaining)
- instancesChangeToken += 1
- return removed.count
- }
- @discardableResult
- func removeAllWidgets() -> Int {
- let removedCount = loadInstances().count
- for panel in windowsByInstanceID.values {
- panel.close()
- }
- windowsByInstanceID.removeAll()
- saveInstances([])
- instancesChangeToken += 1
- return removedCount
- }
- func restore() {
- for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
- show(instance: instance)
- }
- }
- func refreshForMeetingsChange() {
- resizeVisiblePanelsForCurrentContent()
- meetingsRefreshToken += 1
- }
- private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
- let size = panelSize(for: variant.size)
- let panel = DesktopWidgetPanel(
- contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false
- )
- panel.becomesKeyOnlyIfNeeded = false
- panel.isReleasedWhenClosed = false
- panel.isFloatingPanel = false
- panel.isMovable = true
- panel.hasShadow = false
- panel.isOpaque = false
- panel.backgroundColor = .clear
- panel.isMovableByWindowBackground = true
- panel.hidesOnDeactivate = false
- panel.ignoresMouseEvents = false
- let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
- panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
- panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
- let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
- host.translatesAutoresizingMaskIntoConstraints = false
- let container = NSView()
- container.wantsLayer = true
- container.layer?.backgroundColor = NSColor.clear.cgColor
- container.layer?.cornerRadius = 22
- container.layer?.masksToBounds = true
- container.addSubview(host)
- NSLayoutConstraint.activate([
- host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
- host.topAnchor.constraint(equalTo: container.topAnchor),
- host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
- ])
- panel.contentView = container
- NotificationCenter.default.addObserver(
- forName: NSWindow.didMoveNotification,
- object: panel,
- queue: .main
- ) { [weak self, weak panel] _ in
- guard let self, let panel else { return }
- self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
- }
- return panel
- }
- private func panelSize(for size: WidgetSize) -> CGSize {
- switch size {
- case .small:
- return CGSize(width: 220, height: 204)
- case .medium:
- return CGSize(width: 390, height: mediumPanelHeight())
- }
- }
- private func mediumPanelHeight() -> CGFloat {
- let visibleMeetingCount = min(WidgetMeetingStore.load().count, 2)
- switch visibleMeetingCount {
- case 0:
- return 252
- case 1:
- return 270
- default:
- return 288
- }
- }
- private func resizeVisiblePanelsForCurrentContent() {
- let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
- for (instanceID, panel) in windowsByInstanceID {
- guard let instance = instancesByID[instanceID] else { continue }
- let variant = WidgetTemplates.variant(for: instance.variantID)
- let targetSize = panelSize(for: variant.size)
- let currentSize = panel.frame.size
- guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
- continue
- }
- var frame = panel.frame
- let oldMaxY = frame.maxY
- frame.size = targetSize
- frame.origin.y = oldMaxY - targetSize.height
- panel.setFrame(frame, display: true)
- persistOrigin(instanceID: instanceID, origin: frame.origin)
- }
- }
- private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
- let size = panelSize(for: variant.size)
- let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
- let margin: CGFloat = 18
- let gapX: CGFloat = 22
- let gapY: CGFloat = 18
- let cellW = size.width + gapX
- let cellH = size.height + gapY
- let existingRects = loadInstances().compactMap { instance -> NSRect? in
- guard let origin = instance.origin else { return nil }
- let instanceSize = panelSize(for: instance.size)
- return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
- }
- let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
- let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
- let maxSlots = max(120, cols * rows)
- for slot in 0..<maxSlots {
- let row = slot / cols
- let col = cols - 1 - (slot % cols)
- let x = frame.minX + margin + CGFloat(col) * cellW
- let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
- let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
- guard candidate.minX >= frame.minX + margin,
- candidate.maxX <= frame.maxX - margin,
- candidate.minY >= frame.minY + margin else {
- continue
- }
- if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
- return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
- }
- }
- return WidgetInstance.Origin(
- x: frame.midX - size.width / 2,
- y: frame.midY - size.height / 2
- )
- }
- private func close(instanceID: UUID) {
- if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
- panel.close()
- }
- let remaining = loadInstances().filter { $0.id != instanceID }
- saveInstances(remaining)
- instancesChangeToken += 1
- }
- private func loadInstances() -> [WidgetInstance] {
- guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
- let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
- let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.zoomVariants.map { ($0.id, $0.size) })
- let sanitized = decoded.compactMap { instance -> WidgetInstance? in
- guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
- var normalized = instance
- normalized.size = expectedSize
- return normalized
- }
- if sanitized != decoded {
- saveInstances(sanitized)
- }
- return sanitized
- }
- private func saveInstances(_ instances: [WidgetInstance]) {
- guard let data = try? JSONEncoder().encode(instances) else { return }
- UserDefaults.standard.set(data, forKey: storageKey)
- }
- private func persistUpsert(instance: WidgetInstance) {
- var instances = loadInstances()
- if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
- instances[idx] = instance
- } else {
- instances.append(instance)
- }
- saveInstances(instances)
- }
- private func persistOrigin(instanceID: UUID, origin: NSPoint) {
- var instances = loadInstances()
- guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
- instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
- saveInstances(instances)
- }
- private struct DesktopWidgetHostView: View {
- let instanceID: UUID
- let variant: WidgetVariant
- @ObservedObject private var manager = DesktopWidgetWindowManager.shared
- var body: some View {
- DesktopWidgetView(variant: variant, isPreview: false)
- .id(manager.meetingsRefreshToken)
- .contextMenu {
- Button("Remove Widget", role: .destructive) {
- DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
- }
- }
- }
- }
- }
- private final class DesktopWidgetPanel: NSPanel {
- override var canBecomeKey: Bool { true }
- override var canBecomeMain: Bool { false }
- override func mouseDown(with event: NSEvent) {
- NSApp.activate(ignoringOtherApps: true)
- makeKeyAndOrderFront(nil)
- super.mouseDown(with: event)
- }
- }
- private struct WidgetsRootView: View {
- @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
- @Environment(\.colorScheme) private var colorScheme
- @State private var toastMessage: String?
- @State private var toastTask: Task<Void, Never>?
- let canAddWidgets: Bool
- let onAddBlocked: (() -> Void)?
- private let variants = WidgetTemplates.zoomVariants
- init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
- self.canAddWidgets = canAddWidgets
- self.onAddBlocked = onAddBlocked
- }
- var body: some View {
- VStack(alignment: .leading, spacing: 18) {
- HStack {
- VStack(alignment: .leading, spacing: 3) {
- Text("Add Widget to Desktop")
- .font(.system(size: 20, weight: .bold))
- Text("Choose a size and add your Zoom desktop widget.")
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(secondaryTextColor)
- }
- Spacer()
- }
- GeometryReader { geo in
- let count = max(variants.count, 1)
- let spacing: CGFloat = 24
- let baseRowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width }
- let usableWidth = max(1, geo.size.width)
- let usableHeight = max(1, geo.size.height)
- let maxPreviewHeight = variants.map(\.previewCardSize.height).max() ?? 320
- let widthScale = (usableWidth - CGFloat(count - 1) * spacing) / max(1, baseRowWidth)
- let heightScale = usableHeight / max(1, maxPreviewHeight)
- let scale = max(0.45, min(1.0, min(widthScale, heightScale)))
- HStack(alignment: .top, spacing: spacing) {
- ForEach(variants) { variant in
- WidgetPreviewAddCard(
- variant: variant,
- layoutScale: scale,
- isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
- isLocked: canAddWidgets == false,
- onToggle: { toggleWidget(variant: variant) }
- )
- }
- Spacer(minLength: 0)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- Text("Tip: Right-click a desktop widget to remove it.")
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(secondaryTextColor)
- }
- .padding(22)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
- )
- .padding(.horizontal, 12)
- .padding(.vertical, 12)
- .overlay(alignment: .top) {
- if let toastMessage {
- Text(toastMessage)
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
- .padding(.horizontal, 14)
- .padding(.vertical, 9)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
- )
- .padding(.top, 8)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- }
- private var secondaryTextColor: Color {
- colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
- }
- private func toggleWidget(variant: WidgetVariant) {
- guard canAddWidgets else {
- showToast("Widgets are Premium only. Upgrade to add widgets.")
- onAddBlocked?()
- return
- }
- if desktopWidgetManager.hasInstance(variantID: variant.id) {
- let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
- guard removed > 0 else { return }
- showToast("Removed \(variant.size.title) widget from desktop.")
- } else {
- desktopWidgetManager.show(
- instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
- )
- showToast("Added \(variant.size.title) widget to desktop.")
- }
- }
- private func showToast(_ message: String) {
- toastTask?.cancel()
- toastMessage = message
- toastTask = Task { @MainActor in
- try? await Task.sleep(nanoseconds: 1_800_000_000)
- guard !Task.isCancelled else { return }
- toastMessage = nil
- }
- }
- }
- private struct WidgetPreviewAddCard: View {
- let variant: WidgetVariant
- var layoutScale: CGFloat = 1
- let isAdded: Bool
- let isLocked: Bool
- let onToggle: () -> Void
- @State private var hovering = false
- var body: some View {
- ZStack(alignment: .topTrailing) {
- Button(action: onToggle) {
- WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
- .opacity(isLocked ? 0.52 : 1)
- .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
- .overlay {
- if isLocked {
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(Color.black.opacity(0.28))
- .frame(
- width: variant.previewCardSize.width * max(0.01, layoutScale),
- height: variant.previewCardSize.height * max(0.01, layoutScale)
- )
- }
- }
- .overlay {
- if isLocked {
- VStack(spacing: 6) {
- Image(systemName: "lock.fill")
- .font(.system(size: 18, weight: .bold))
- Text("Premium")
- .font(.system(size: 13, weight: .bold))
- }
- .foregroundStyle(.white.opacity(0.96))
- .padding(.horizontal, 14)
- .padding(.vertical, 10)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color.black.opacity(0.46))
- )
- .frame(
- width: variant.previewCardSize.width * max(0.01, layoutScale),
- height: variant.previewCardSize.height * max(0.01, layoutScale)
- )
- }
- }
- }
- .buttonStyle(.plain)
- Button(action: onToggle) {
- Image(systemName: isLocked ? "lock.fill" : (isAdded ? "minus" : "plus"))
- .font(.system(size: 12, weight: .bold))
- .foregroundStyle(.white.opacity(0.95))
- .frame(width: 26, height: 26)
- .background(
- Circle().fill(
- isLocked
- ? Color.orange.opacity(0.95)
- : (isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95))
- )
- )
- .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
- }
- .buttonStyle(.plain)
- .offset(x: 10 * layoutScale, y: -10 * layoutScale)
- .opacity((isAdded || isLocked) ? 1 : (hovering ? 1 : 0))
- .animation(.easeOut(duration: 0.12), value: hovering)
- }
- .onHover { hovering = $0 }
- }
- }
- private struct WidgetPreviewCard: View {
- let variant: WidgetVariant
- var layoutScale: CGFloat = 1
- var body: some View {
- let size = variant.previewCardSize
- let scale = max(0.01, layoutScale)
- DesktopWidgetView(variant: variant, isPreview: true)
- .frame(width: size.width, height: size.height)
- .scaleEffect(scale)
- .frame(width: size.width * scale, height: size.height * scale)
- .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.12), lineWidth: 1)
- )
- }
- }
- private struct DesktopWidgetView: View {
- let variant: WidgetVariant
- var isPreview: Bool
- private var topMeetings: [WidgetMeetingSnapshot] {
- WidgetMeetingStore.load().prefix(3).map { $0 }
- }
- var body: some View {
- ZStack {
- RoundedRectangle(cornerRadius: 22, style: .continuous)
- .fill(
- LinearGradient(
- colors: variant.gradientStops,
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 22, style: .continuous)
- .fill(
- RadialGradient(
- colors: [variant.highlightTint, Color.clear],
- center: .topLeading,
- startRadius: 4,
- endRadius: 200
- )
- )
- .blendMode(.plusLighter)
- .allowsHitTesting(false)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 22, style: .continuous)
- .fill(
- LinearGradient(
- colors: [Color.clear, Color.black.opacity(0.18)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- .allowsHitTesting(false)
- )
- .overlay(
- RoundedRectangle(cornerRadius: 22, style: .continuous)
- .stroke(Color.white.opacity(0.16), lineWidth: 1)
- )
- VStack(alignment: .leading, spacing: 12) {
- widgetHeader
- contentBody
- if variant.size != .small {
- Spacer(minLength: 0)
- }
- }
- .padding(.horizontal, 14)
- .padding(.bottom, variant.size == .medium ? 16 : 14)
- .padding(.top, variant.size == .medium ? 18 : 10)
- }
- }
- @ViewBuilder
- private var contentBody: some View {
- switch variant.size {
- case .small:
- VStack(alignment: .leading, spacing: 10) {
- Text("Quick actions")
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(.white.opacity(0.78))
- compactActionButton(title: "New meeting", icon: "video.fill", destination: .newMeeting)
- HStack(spacing: 8) {
- compactActionButton(title: "Join", icon: "plus", destination: .joinMeeting)
- compactActionButton(title: "Schedule", icon: "calendar", destination: .scheduleMeeting)
- }
- }
- case .medium:
- meetingBlock(maxRows: 2)
- LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
- ForEach(variant.quickActions.prefix(4)) { action in
- actionTile(action)
- }
- }
- }
- }
- private var widgetHeader: some View {
- HStack(spacing: 10) {
- Image(systemName: "video.fill")
- .font(.system(size: 16, weight: .bold))
- .foregroundStyle(.white.opacity(0.95))
- .frame(width: 30, height: 30)
- .background(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(
- LinearGradient(
- colors: [Color.white.opacity(0.22), Color.black.opacity(0.18)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .stroke(Color.white.opacity(0.18), lineWidth: 1)
- )
- VStack(alignment: .leading, spacing: 2) {
- Text(variant.title)
- .font(.system(size: 16, weight: .bold))
- .foregroundStyle(.white.opacity(0.95))
- Text(variant.subtitle)
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(.white.opacity(0.8))
- }
- Spacer(minLength: 0)
- }
- }
- private func meetingBlock(maxRows: Int) -> some View {
- VStack(alignment: .leading, spacing: 9) {
- Text("Upcoming meetings")
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(.white.opacity(0.80))
- if topMeetings.isEmpty {
- Text("No upcoming meetings")
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(.white.opacity(0.82))
- } else {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(topMeetings.prefix(maxRows)) { meeting in
- meetingRow(meeting)
- }
- }
- }
- }
- }
- private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
- Button(action: {
- if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
- WidgetAppNavigator.openMeetingLink(link)
- } else {
- WidgetAppNavigator.open(target: .meetings)
- }
- }) {
- HStack(spacing: 8) {
- Image(systemName: "video.fill")
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(.white.opacity(0.88))
- VStack(alignment: .leading, spacing: 1) {
- Text(meeting.title)
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(.white.opacity(0.93))
- .lineLimit(1)
- Text(meeting.timeText)
- .font(.system(size: 10.5, weight: .medium))
- .foregroundStyle(.white.opacity(0.74))
- .lineLimit(1)
- }
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 7)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(
- LinearGradient(
- colors: [Color.white.opacity(0.14), Color.black.opacity(0.22)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .stroke(Color.white.opacity(0.12), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(isPreview)
- .allowsHitTesting(!isPreview)
- }
- private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
- Button(action: { WidgetAppNavigator.open(target: destination) }) {
- HStack(spacing: 5) {
- Image(systemName: icon)
- .font(.system(size: 10.5, weight: .semibold))
- .frame(width: 14)
- Text(title)
- .font(.system(size: 11.5, weight: .bold))
- .lineLimit(1)
- .minimumScaleFactor(0.82)
- .layoutPriority(1)
- }
- .foregroundStyle(.white.opacity(0.97))
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(
- LinearGradient(
- colors: [Color.white.opacity(0.20), Color.black.opacity(0.28)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.26), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(isPreview)
- .allowsHitTesting(!isPreview)
- }
- private func actionTile(_ action: WidgetQuickAction) -> some View {
- Button(action: { open(action: action) }) {
- HStack(spacing: 8) {
- Image(systemName: action.systemImage)
- .font(.system(size: 11, weight: .semibold))
- Text(action.title)
- .font(.system(size: 12, weight: .bold))
- .lineLimit(1)
- Spacer(minLength: 0)
- }
- .foregroundStyle(.white.opacity(0.95))
- .padding(.horizontal, 10)
- .padding(.vertical, 9)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(
- LinearGradient(
- colors: [Color.white.opacity(0.16), Color.black.opacity(0.22)],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.16), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(isPreview)
- .allowsHitTesting(!isPreview)
- }
- private func open(action: WidgetQuickAction) {
- guard !isPreview else { return }
- WidgetAppNavigator.open(target: action.destination)
- }
- }
|