| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596105971059810599106001060110602106031060410605106061060710608106091061010611106121061310614106151061610617106181061910620106211062210623106241062510626106271062810629106301063110632106331063410635106361063710638106391064010641106421064310644106451064610647106481064910650106511065210653106541065510656106571065810659106601066110662106631066410665106661066710668106691067010671106721067310674106751067610677106781067910680106811068210683106841068510686106871068810689106901069110692106931069410695106961069710698106991070010701107021070310704107051070610707107081070910710107111071210713107141071510716107171071810719107201072110722107231072410725107261072710728107291073010731107321073310734107351073610737107381073910740107411074210743107441074510746107471074810749107501075110752107531075410755107561075710758107591076010761107621076310764107651076610767107681076910770107711077210773107741077510776107771077810779107801078110782107831078410785107861078710788107891079010791107921079310794107951079610797107981079910800108011080210803108041080510806108071080810809108101081110812108131081410815108161081710818108191082010821108221082310824108251082610827108281082910830108311083210833108341083510836108371083810839108401084110842108431084410845108461084710848108491085010851108521085310854108551085610857108581085910860108611086210863108641086510866108671086810869108701087110872108731087410875108761087710878108791088010881108821088310884108851088610887108881088910890108911089210893108941089510896108971089810899109001090110902109031090410905109061090710908109091091010911109121091310914109151091610917109181091910920109211092210923109241092510926109271092810929109301093110932109331093410935109361093710938109391094010941109421094310944109451094610947109481094910950109511095210953109541095510956109571095810959109601096110962109631096410965109661096710968109691097010971109721097310974109751097610977109781097910980109811098210983109841098510986109871098810989109901099110992109931099410995109961099710998109991100011001110021100311004110051100611007110081100911010110111101211013110141101511016110171101811019110201102111022110231102411025110261102711028110291103011031110321103311034110351103611037110381103911040110411104211043110441104511046110471104811049110501105111052110531105411055110561105711058110591106011061110621106311064110651106611067110681106911070110711107211073110741107511076110771107811079110801108111082110831108411085110861108711088110891109011091110921109311094110951109611097110981109911100111011110211103111041110511106111071110811109111101111111112111131111411115111161111711118111191112011121111221112311124111251112611127111281112911130111311113211133111341113511136111371113811139111401114111142111431114411145111461114711148111491115011151111521115311154111551115611157111581115911160111611116211163111641116511166111671116811169111701117111172111731117411175111761117711178111791118011181111821118311184111851118611187111881118911190111911119211193111941119511196111971119811199112001120111202112031120411205112061120711208112091121011211112121121311214112151121611217112181121911220112211122211223112241122511226112271122811229112301123111232112331123411235112361123711238112391124011241112421124311244112451124611247112481124911250112511125211253112541125511256112571125811259112601126111262112631126411265112661126711268112691127011271112721127311274112751127611277112781127911280112811128211283112841128511286112871128811289112901129111292112931129411295112961129711298112991130011301113021130311304113051130611307113081130911310113111131211313113141131511316113171131811319113201132111322113231132411325113261132711328113291133011331113321133311334113351133611337113381133911340113411134211343113441134511346113471134811349113501135111352113531135411355113561135711358113591136011361113621136311364113651136611367113681136911370113711137211373113741137511376113771137811379113801138111382113831138411385113861138711388113891139011391113921139311394113951139611397113981139911400114011140211403114041140511406114071140811409114101141111412114131141411415114161141711418114191142011421114221142311424114251142611427114281142911430114311143211433114341143511436114371143811439114401144111442114431144411445114461144711448114491145011451114521145311454114551145611457114581145911460114611146211463114641146511466114671146811469114701147111472114731147411475114761147711478114791148011481114821148311484114851148611487114881148911490114911149211493114941149511496114971149811499115001150111502115031150411505115061150711508115091151011511115121151311514115151151611517115181151911520115211152211523115241152511526115271152811529115301153111532115331153411535115361153711538115391154011541115421154311544115451154611547115481154911550115511155211553115541155511556115571155811559115601156111562115631156411565115661156711568115691157011571115721157311574115751157611577115781157911580115811158211583115841158511586115871158811589115901159111592115931159411595115961159711598115991160011601116021160311604116051160611607116081160911610116111161211613116141161511616116171161811619116201162111622116231162411625116261162711628116291163011631116321163311634116351163611637116381163911640116411164211643116441164511646116471164811649116501165111652116531165411655116561165711658116591166011661116621166311664116651166611667116681166911670116711167211673116741167511676116771167811679116801168111682116831168411685116861168711688116891169011691116921169311694116951169611697116981169911700117011170211703117041170511706117071170811709117101171111712117131171411715117161171711718117191172011721117221172311724117251172611727117281172911730117311173211733117341173511736117371173811739117401174111742117431174411745117461174711748117491175011751117521175311754117551175611757117581175911760117611176211763117641176511766117671176811769117701177111772117731177411775117761177711778117791178011781117821178311784117851178611787117881178911790117911179211793117941179511796117971179811799118001180111802118031180411805118061180711808118091181011811118121181311814118151181611817118181181911820118211182211823118241182511826118271182811829118301183111832118331183411835118361183711838118391184011841118421184311844118451184611847118481184911850118511185211853118541185511856118571185811859118601186111862118631186411865118661186711868118691187011871118721187311874118751187611877118781187911880118811188211883118841188511886118871188811889118901189111892118931189411895118961189711898118991190011901119021190311904119051190611907119081190911910119111191211913119141191511916119171191811919119201192111922119231192411925119261192711928119291193011931119321193311934119351193611937119381193911940119411194211943119441194511946119471194811949119501195111952119531195411955119561195711958119591196011961119621196311964119651196611967119681196911970119711197211973119741197511976119771197811979119801198111982119831198411985119861198711988119891199011991119921199311994119951199611997119981199912000120011200212003120041200512006120071200812009120101201112012120131201412015120161201712018120191202012021120221202312024120251202612027120281202912030120311203212033120341203512036120371203812039120401204112042120431204412045120461204712048120491205012051120521205312054120551205612057120581205912060120611206212063120641206512066120671206812069120701207112072120731207412075120761207712078120791208012081120821208312084120851208612087120881208912090120911209212093120941209512096120971209812099121001210112102121031210412105121061210712108121091211012111121121211312114121151211612117121181211912120121211212212123121241212512126121271212812129121301213112132121331213412135121361213712138121391214012141121421214312144121451214612147121481214912150121511215212153121541215512156121571215812159121601216112162121631216412165121661216712168121691217012171121721217312174121751217612177121781217912180121811218212183121841218512186121871218812189121901219112192121931219412195121961219712198121991220012201122021220312204122051220612207122081220912210122111221212213122141221512216122171221812219122201222112222122231222412225122261222712228122291223012231122321223312234122351223612237122381223912240122411224212243122441224512246122471224812249122501225112252122531225412255122561225712258122591226012261122621226312264122651226612267122681226912270122711227212273122741227512276122771227812279122801228112282122831228412285122861228712288122891229012291122921229312294122951229612297122981229912300123011230212303123041230512306123071230812309123101231112312123131231412315123161231712318123191232012321123221232312324123251232612327123281232912330123311233212333123341233512336123371233812339123401234112342123431234412345123461234712348123491235012351123521235312354123551235612357123581235912360123611236212363123641236512366123671236812369123701237112372123731237412375123761237712378123791238012381123821238312384123851238612387123881238912390123911239212393123941239512396123971239812399124001240112402124031240412405124061240712408124091241012411124121241312414124151241612417124181241912420124211242212423124241242512426124271242812429124301243112432124331243412435124361243712438124391244012441124421244312444124451244612447124481244912450124511245212453124541245512456124571245812459124601246112462124631246412465 |
- //
- // ViewController.swift
- // zoom_app
- //
- // Created by Dev Mac 1 on 14/04/2026.
- //
- import Cocoa
- import Combine
- import CryptoKit
- import AVFoundation
- import Speech
- 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
- }
- private enum AppExternalURLs {
- static let privacyPolicy = URL(string: "https://sites.google.com/view/aicompanionforzoom/privacy-policy")!
- static let termsOfServices = URL(string: "https://sites.google.com/view/aicompanionforzoom/terms-and-condition")!
- static let support = URL(string: "https://sites.google.com/view/aicompanionforzoom/get-support")!
- }
- /// Zoom shows "browser not supported" for the default macOS `WKWebView` UA. Use a desktop Safari string so join/start (including `/s/` host links) load in-app.
- private let embeddedZoomWebViewUserAgent =
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15"
- /// Shared cookie jar for embedded Zoom meetings and Zoom OAuth so the in-app browser reuses the same Zoom web session.
- private let zoomEmbeddedWebsiteDataStore = WKWebsiteDataStore.default()
- /// Shared renderer process so OAuth cookies are visible to the embedded meeting web client.
- private let zoomEmbeddedWebProcessPool = WKProcessPool()
- /// 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 {
- private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
- private static let embeddedZoomMeetingLifecycleJavaScript = """
- (function() {
- function notify() {
- try {
- window.webkit.messageHandlers.\(embeddedZoomMeetingLifecycleHandlerName).postMessage(window.location.href || '');
- } catch (e) {}
- }
- var pushState = history.pushState;
- history.pushState = function() { pushState.apply(this, arguments); notify(); };
- var replaceState = history.replaceState;
- history.replaceState = function() { replaceState.apply(this, arguments); notify(); };
- window.addEventListener('popstate', notify);
- window.addEventListener('hashchange', notify);
- setInterval(notify, 1500);
- })();
- """
- /// Stops camera/mic tracks and attempts to leave an in-progress Zoom web meeting before tearing down the web view.
- private static let embeddedZoomWebClientTeardownJavaScript = """
- (function() {
- function stopAllMediaTracks() {
- try {
- document.querySelectorAll('video, audio').forEach(function(node) {
- var stream = node.srcObject;
- if (stream && typeof stream.getTracks === 'function') {
- stream.getTracks().forEach(function(track) { try { track.stop(); } catch (e) {} });
- }
- node.srcObject = null;
- });
- } catch (e) {}
- }
- function tryLeaveMeeting() {
- var selectors = [
- 'button[aria-label*="Leave"]',
- 'button[aria-label*="leave"]',
- 'button[aria-label*="End meeting"]',
- 'button[aria-label*="end meeting"]',
- 'button[aria-label*="Leave Meeting"]',
- '[data-testid="leave-meeting-button"]'
- ];
- for (var i = 0; i < selectors.length; i++) {
- try {
- var btn = document.querySelector(selectors[i]);
- if (btn && typeof btn.click === 'function') {
- btn.click();
- return;
- }
- } catch (e) {}
- }
- }
- tryLeaveMeeting();
- stopAllMediaTracks();
- })();
- """
- 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"
- // New AI Companion mode: local meeting recordings (audio)
- private var recordedMeetings: [MeetingRecordingManager.RecordedMeeting] = []
- private var recordedMeetingsPlayer: AVAudioPlayer?
- private var recordedMeetingsPlayingID: String?
- private weak var aiCompanionStopRecordingButton: NSButton?
- private weak var aiCompanionLocalTranscriptPanel: NSPanel?
- private weak var aiCompanionLocalTranscriptTextView: NSTextView?
- private weak var aiCompanionLocalTranscriptRootView: NSView?
- private weak var aiCompanionLocalTranscriptHeaderView: NSView?
- private weak var aiCompanionLocalTranscriptSubtitleLabel: NSTextField?
- private var aiCompanionLocalTranscriptPanelRecordingID: String?
- private weak var aiCompanionLocalNotesPanel: NSPanel?
- private weak var aiCompanionLocalNotesTextView: NSTextView?
- private weak var aiCompanionLocalNotesRootView: NSView?
- private weak var aiCompanionLocalNotesHeaderView: NSView?
- private weak var aiCompanionLocalNotesSubtitleLabel: NSTextField?
- private var aiCompanionLocalNotesPanelRecordingID: String?
- private enum AiCompanionRecordingFilter: String, CaseIterable {
- case all = "All"
- case today = "Today's meetings"
- case previousWeek = "Previous week"
- case thisWeek = "This week"
- case previousMonth = "Previous month"
- case custom = "Custom date"
- }
- private var aiCompanionRecordingFilter: AiCompanionRecordingFilter = .today
- private weak var aiCompanionFilterPopup: NSPopUpButton?
- private weak var aiCompanionFilterFromPicker: NSDatePicker?
- private weak var aiCompanionFilterToPicker: NSDatePicker?
- private weak var aiCompanionFilterApplyButton: NSButton?
- private weak var aiCompanionFilterResetButton: NSButton?
- private weak var aiCompanionFilterRefreshButton: NSButton?
- private let meetingNotesService = MeetingNotesService()
- private var localRecordingNotesTaskByRecordingId: [String: Task<Void, Never>] = [:]
- private var localRecordingNotesRequestIdByRecordingId: [String: UUID] = [:]
- private var localRecordingNotesProgressByRecordingId: [String: String] = [:]
- 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?
- /// ZAK + display name for the next embedded join; used to re-auth the web client if sign-in or guest join appears.
- private var embeddedZoomPendingWebAuth: (zak: String, displayName: String?)?
- /// Normalized `/wc/join/` URL for the in-flight embedded join (used to recover from web sign-in redirects).
- private var embeddedZoomPendingJoinURL: URL?
- private var zoomSignedInDisplayName: 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 settingsPagePrimaryLanguagePopup: NSPopUpButton?
- private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
- 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: NSPanel?
- 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 joinMeetingDisplayNameField: NSTextField?
- private weak var joinURLFieldsContainer: NSView?
- private weak var joinIDFieldsContainer: NSView?
- private weak var joinModeSegment: NSSegmentedControl?
- private var didTapInlineMeetingConsentClose = false
- private weak var activeMeetingConsentWindow: NSWindow?
- private weak var meetingConsentPrimaryLanguagePopup: NSPopUpButton?
- private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
- private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
- /// True after the embedded web client loaded a join/in-call URL during the current recording session.
- private var embeddedMeetingReachedInCallPage = false
- private var embeddedMeetingRecordingMonitorTimer: Timer?
- /// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
- private var embeddedMeetingAbsentCallConsecutiveMisses = 0
- private static let embeddedMeetingAbsentCallMissThreshold = 5
- /// Armed after recording consent; capture starts once the user is in the call (not on the join/lobby page).
- private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
- /// Bumped when the embedded web panel closes or loads a new meeting URL so stale async work cannot start recording.
- private var embeddedZoomWebSessionID: UInt = 0
- private enum LocalRecordingNotesStatus: String {
- case notRequested
- case processing
- case ready
- case failed
- }
- 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 lastObservedCalendarDayStart: Date = Calendar.current.startOfDay(for: Date())
- private var calendarDayChangeObservers: [NSObjectProtocol] = []
- private var workspaceWakeObserver: NSObjectProtocol?
- 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 meetingsLoadTask: Task<Void, Never>?
- private var hasBuiltInitialHomeView = 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 = 120
- private let scrollRefreshCooldown: TimeInterval = 30
- private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
- private let maxMeetingListPages = 5
- private var meetingsRateLimitedUntil: Date?
- private static let zoomAccountTimeZoneDefaultsKey = "zoomAccountTimeZoneIdentifier"
- 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()
- recordedMeetings = MeetingRecordingManager.shared.recordings
- zoomOAuth.onInteractiveSignInCompleted = { [weak self] in
- self?.primeZoomEmbeddedWebSession()
- }
- NotificationCenter.default.addObserver(
- forName: MeetingRecordingManager.recordingsDidChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- Task { @MainActor [weak self] in
- guard let self else { return }
- self.recordedMeetings = MeetingRecordingManager.shared.recordings
- for rec in self.recordedMeetings where rec.transcriptText != nil || rec.transcriptError != nil {
- self.aiCompanionTranscriptionActiveRecordingIDs.remove(rec.id)
- }
- self.renderAiCompanionList()
- self.refreshLocalRecordingTranscriptPanelContentIfNeeded()
- self.refreshLocalRecordingNotesPanelContentIfNeeded()
- }
- }
- NotificationCenter.default.addObserver(
- forName: MeetingRecordingManager.transcriptionDidBeginNotification,
- object: nil,
- queue: .main
- ) { [weak self] note in
- guard let id = note.userInfo?["recordingId"] as? String else { return }
- Task { @MainActor [weak self] in
- guard let self else { return }
- self.aiCompanionTranscriptionActiveRecordingIDs.insert(id)
- self.renderAiCompanionList()
- }
- }
- NotificationCenter.default.addObserver(
- forName: MeetingRecordingManager.transcriptionDidEndNotification,
- object: nil,
- queue: .main
- ) { [weak self] note in
- guard let id = note.userInfo?["recordingId"] as? String else { return }
- Task { @MainActor [weak self] in
- guard let self else { return }
- self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
- self.renderAiCompanionList()
- }
- }
- NotificationCenter.default.addObserver(
- forName: MeetingRecordingManager.recordingStateDidChangeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- Task { @MainActor [weak self] in
- guard let self else { return }
- if MeetingRecordingManager.shared.isRecording == false {
- self.stopEmbeddedMeetingRecordingMonitor()
- }
- self.updateAiCompanionRecordingUI()
- }
- }
- setupUI()
- setupWidgetBridge()
- installCalendarDayChangeObservers()
- if let tzId = UserDefaults.standard.string(forKey: Self.zoomAccountTimeZoneDefaultsKey),
- let tz = TimeZone(identifier: tzId) {
- zoomAccountTimeZone = tz
- }
- 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()
- if hasBuiltInitialHomeView == false {
- hasBuiltInitialHomeView = true
- showHomeView(profile: nil, refreshMeetings: true)
- } else if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
- triggerMeetingsRefresh(force: false)
- }
- syncBrandPositionForCurrentWindowState(animated: false)
- scheduleLaunchPaywallIfNeeded()
- if storeKitCoordinator.hasPremiumAccess {
- DesktopWidgetWindowManager.shared.restore()
- }
- refreshHomeForCurrentDayIfNeeded()
- }
- override func viewDidLayout() {
- super.viewDidLayout()
- alignNativeTrafficLights()
- if paywallOverlayView != nil {
- updatePaywallPanelSize()
- updatePaywallResponsiveLayout()
- }
- if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
- updateEmbeddedRightPanelWidthForOpenState()
- }
- if aiCompanionPageRootView?.isHidden == false {
- refreshAiCompanionStatusPreferredMaxLayoutWidth()
- }
- }
- 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?, refreshMeetings: Bool = true) {
- 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
- aiCompanionStopRecordingButton = nil
- aiCompanionFilterPopup = nil
- aiCompanionFilterFromPicker = nil
- aiCompanionFilterToPicker = nil
- aiCompanionFilterApplyButton = nil
- aiCompanionFilterResetButton = nil
- aiCompanionFilterRefreshButton = 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: refreshMeetings)
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- if isUserLoggedIn() {
- primeZoomEmbeddedWebSession()
- }
- }
- 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
- if let profileTimeZone {
- UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
- }
- 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
- )
- }
- private func presentJoinMeetingPanel(_ panel: NSPanel) {
- guard let host = view.window else {
- panel.center()
- panel.makeKeyAndOrderFront(nil)
- return
- }
- if host.attachedSheet !== panel {
- host.beginSheet(panel, completionHandler: nil)
- }
- panel.makeKeyAndOrderFront(nil)
- }
- private func dismissJoinMeetingPanel() {
- guard let panel = joinMeetingWindow else { return }
- if let host = view.window, host.attachedSheet === panel {
- host.endSheet(panel)
- } else {
- panel.performClose(nil)
- }
- }
- @objc private func joinMeetingTapped() {
- if let existing = joinMeetingWindow {
- presentJoinMeetingPanel(existing)
- 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: 500),
- 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.isFloatingPanel = false
- panel.isMovable = false
- panel.isMovableByWindowBackground = false
- // 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.contentViewController = controller
- panel.delegate = self
- applyWindowBackgroundForCurrentTheme(panel)
- joinMeetingWindow = panel
- presentJoinMeetingPanel(panel)
- NSApp.activate(ignoringOtherApps: true)
- 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 the app web panel", 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 nameBox = makeJoinFormField(placeholder: "e.g. Weekly standup (optional — used for the saved file)")
- joinMeetingDisplayNameField = nameBox.textField
- let nameLabel = makeLabel("Meeting name", size: 12, color: secondaryText, weight: .medium, centered: false)
- let nameStack = NSStackView(views: [nameLabel, nameBox])
- nameStack.orientation = .vertical
- nameStack.spacing = 8
- nameStack.alignment = .leading
- nameStack.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(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, nameStack, 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),
- nameBox.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() {
- dismissJoinMeetingPanel()
- }
- @objc private func joinMeetingSubmitTapped() {
- guard let segment = joinModeSegment else { return }
- let meetingName = joinMeetingDisplayNameField?.stringValue
- let zoomURL: URL?
- 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
- }
- zoomURL = 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
- }
- zoomURL = url
- }
- guard let url = zoomURL else { return }
- joinZoomMeetingFromLink(url.absoluteString, displayName: meetingName)
- }
- private enum MeetingRecordingConsentChoice {
- case allowAndContinue
- case continueWithoutRecording
- case dismissed
- }
- private func requestMeetingRecordingConsent(title: String) -> MeetingRecordingConsentChoice {
- let alert = NSAlert()
- alert.messageText = "Record this meeting locally?"
- alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting."
- alert.addButton(withTitle: "Allow and Continue")
- alert.addButton(withTitle: "Continue Without Recording")
- alert.window.title = title
- let speechOptions = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
- let languageContainer = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 96))
- languageContainer.translatesAutoresizingMaskIntoConstraints = false
- languageContainer.widthAnchor.constraint(equalToConstant: 320).isActive = true
- languageContainer.heightAnchor.constraint(equalToConstant: 96).isActive = true
- let languageTitle = NSTextField(labelWithString: "Transcription Languages")
- languageTitle.translatesAutoresizingMaskIntoConstraints = false
- languageTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- languageTitle.textColor = NSColor.secondaryLabelColor
- let language1Popup = NSPopUpButton(frame: .zero, pullsDown: false)
- language1Popup.translatesAutoresizingMaskIntoConstraints = false
- language1Popup.controlSize = .small
- language1Popup.target = self
- language1Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
- for option in speechOptions {
- language1Popup.addItem(withTitle: option.displayName)
- language1Popup.lastItem?.representedObject = option.identifier
- }
- let selectedPrimary = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
- ?? speechOptions.first?.identifier
- ?? Locale.current.identifier
- selectTranscriptionLanguage(identifier: selectedPrimary, in: language1Popup)
- let language2Popup = NSPopUpButton(frame: .zero, pullsDown: false)
- language2Popup.translatesAutoresizingMaskIntoConstraints = false
- language2Popup.controlSize = .small
- language2Popup.target = self
- language2Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
- language2Popup.addItem(withTitle: "None")
- language2Popup.lastItem?.representedObject = ""
- for option in speechOptions {
- language2Popup.addItem(withTitle: option.displayName)
- language2Popup.lastItem?.representedObject = option.identifier
- }
- if let selectedSecondary = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
- selectTranscriptionLanguage(identifier: selectedSecondary, in: language2Popup)
- } else {
- language2Popup.selectItem(at: 0)
- }
- let language1Label = NSTextField(labelWithString: "Preferred Language 1")
- language1Label.translatesAutoresizingMaskIntoConstraints = false
- language1Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- language1Label.textColor = NSColor.secondaryLabelColor
- language1Label.alignment = .center
- let language2Label = NSTextField(labelWithString: "Preferred Language 2")
- language2Label.translatesAutoresizingMaskIntoConstraints = false
- language2Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- language2Label.textColor = NSColor.secondaryLabelColor
- language2Label.alignment = .center
- languageContainer.addSubview(languageTitle)
- languageContainer.addSubview(language1Label)
- languageContainer.addSubview(language1Popup)
- languageContainer.addSubview(language2Label)
- languageContainer.addSubview(language2Popup)
- NSLayoutConstraint.activate([
- languageTitle.topAnchor.constraint(equalTo: languageContainer.topAnchor, constant: 0),
- languageTitle.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
- language1Label.topAnchor.constraint(equalTo: languageTitle.bottomAnchor, constant: 2),
- language1Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
- language1Popup.topAnchor.constraint(equalTo: language1Label.bottomAnchor, constant: 2),
- language1Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
- language1Popup.widthAnchor.constraint(equalToConstant: 240),
- language2Label.topAnchor.constraint(equalTo: language1Popup.bottomAnchor, constant: 3),
- language2Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
- language2Popup.topAnchor.constraint(equalTo: language2Label.bottomAnchor, constant: 2),
- language2Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
- language2Popup.widthAnchor.constraint(equalToConstant: 240),
- language2Popup.bottomAnchor.constraint(equalTo: languageContainer.bottomAnchor, constant: -1)
- ])
- alert.accessoryView = languageContainer
- didTapInlineMeetingConsentClose = false
- activeMeetingConsentWindow = alert.window
- meetingConsentPrimaryLanguagePopup = language1Popup
- meetingConsentSecondaryLanguagePopup = language2Popup
- if let contentView = alert.window.contentView {
- let topCloseButton = NSButton(title: "✕", target: self, action: #selector(meetingConsentInlineCloseTapped(_:)))
- topCloseButton.translatesAutoresizingMaskIntoConstraints = false
- topCloseButton.isBordered = false
- topCloseButton.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
- topCloseButton.contentTintColor = NSColor.tertiaryLabelColor
- topCloseButton.wantsLayer = true
- topCloseButton.layer?.cornerRadius = 20
- topCloseButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.35).cgColor
- contentView.addSubview(topCloseButton)
- NSLayoutConstraint.activate([
- topCloseButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
- topCloseButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
- topCloseButton.widthAnchor.constraint(equalToConstant: 40),
- topCloseButton.heightAnchor.constraint(equalToConstant: 40)
- ])
- }
- let response = alert.runModal()
- activeMeetingConsentWindow = nil
- meetingConsentPrimaryLanguagePopup = nil
- meetingConsentSecondaryLanguagePopup = nil
- if didTapInlineMeetingConsentClose {
- return .dismissed
- }
- if let primary = language1Popup.selectedItem?.representedObject as? String,
- primary.isEmpty == false {
- var secondary = language2Popup.selectedItem?.representedObject as? String
- if secondary?.isEmpty == true {
- secondary = nil
- }
- TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
- syncSettingsSpeechLanguagePopupsFromStoredPreferences()
- }
- switch response {
- case .alertFirstButtonReturn:
- return .allowAndContinue
- case .alertSecondButtonReturn:
- return .continueWithoutRecording
- default:
- return .dismissed
- }
- }
- @objc private func meetingConsentInlineCloseTapped(_ sender: NSButton) {
- didTapInlineMeetingConsentClose = true
- guard let consentWindow = activeMeetingConsentWindow else { return }
- NSApp.stopModal(withCode: .abort)
- consentWindow.orderOut(nil)
- }
- @objc private func meetingConsentSpeechLanguageChanged(_ sender: NSPopUpButton) {
- guard let primary = meetingConsentPrimaryLanguagePopup?.selectedItem?.representedObject as? String,
- primary.isEmpty == false else { return }
- var secondary: String? = meetingConsentSecondaryLanguagePopup?.selectedItem?.representedObject as? String
- if secondary?.isEmpty == true {
- secondary = nil
- }
- if let secondaryValue = secondary,
- secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
- secondary = nil
- meetingConsentSecondaryLanguagePopup?.selectItem(at: 0)
- }
- TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
- syncSettingsSpeechLanguagePopupsFromStoredPreferences()
- }
- /// Keeps Settings language menus aligned when preferences change from the pre-join consent sheet.
- private func syncSettingsSpeechLanguagePopupsFromStoredPreferences() {
- let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
- if let primaryPopup = settingsPagePrimaryLanguagePopup {
- let id = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
- ?? options.first?.identifier
- ?? Locale.current.identifier
- selectTranscriptionLanguage(identifier: id, in: primaryPopup)
- }
- if let secondaryPopup = settingsPageSecondaryLanguagePopup {
- if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
- selectTranscriptionLanguage(identifier: selected, in: secondaryPopup)
- } else {
- secondaryPopup.selectItem(at: 0)
- }
- }
- }
- private func selectTranscriptionLanguage(identifier: String, in popup: NSPopUpButton) {
- for item in popup.itemArray {
- if let value = item.representedObject as? String, value == identifier {
- popup.select(item)
- return
- }
- }
- }
- /// Maps `zoom.us` join links to `app.zoom.us` (Workplace web client host).
- private func zoomEmbeddedWebHostURL(for url: URL) -> URL {
- guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
- let host = components.host?.lowercased() ?? ""
- if host == "zoom.us" || host.hasSuffix(".zoom.us") {
- components.host = "app.zoom.us"
- }
- return components.url ?? url
- }
- private func zakQueryValueFromZoomURL(_ url: URL) -> String? {
- let fromQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name.caseInsensitiveCompare("zak") == .orderedSame })?
- .value?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if let fromQuery, fromQuery.isEmpty == false { return fromQuery }
- return nil
- }
- /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd` + optional `zak`).
- private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String, zak: String? = nil, displayName: String? = nil) -> URL? {
- guard meetingIdDigits.isEmpty == false else { return nil }
- var components = URLComponents()
- components.scheme = "https"
- components.host = "app.zoom.us"
- components.path = "/wc/join/\(meetingIdDigits)"
- var queryItems: [URLQueryItem] = []
- let trimmedPasscode = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmedPasscode.isEmpty == false {
- queryItems.append(URLQueryItem(name: "pwd", value: trimmedPasscode))
- }
- let trimmedZAK = zak?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if trimmedZAK.isEmpty == false {
- queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
- }
- let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if trimmedName.isEmpty == false {
- queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
- }
- if queryItems.isEmpty == false {
- components.queryItems = queryItems
- }
- return components.url
- }
- /// Adds ZAK and display name so the embedded web client joins as the OAuth-signed-in user (not a guest).
- private func zoomMeetingURLByAppendingWebAuth(_ url: URL, zak: String, displayName: String?) -> URL {
- let normalizedHost = zoomEmbeddedWebHostURL(for: url)
- let trimmedZAK = zak.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmedZAK.isEmpty == false else { return normalizedHost }
- var components = URLComponents(url: normalizedHost, resolvingAgainstBaseURL: false) ?? URLComponents()
- var queryItems = components.queryItems ?? []
- queryItems.removeAll { $0.name.caseInsensitiveCompare("zak") == .orderedSame }
- queryItems.removeAll { $0.name.caseInsensitiveCompare("uname") == .orderedSame }
- queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
- let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if trimmedName.isEmpty == false {
- queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
- }
- components.queryItems = queryItems
- return components.url ?? normalizedHost
- }
- private func javaScriptStringLiteral(_ value: String) -> String {
- let escaped = value
- .replacingOccurrences(of: "\\", with: "\\\\")
- .replacingOccurrences(of: "'", with: "\\'")
- .replacingOccurrences(of: "\n", with: "\\n")
- .replacingOccurrences(of: "\r", with: "")
- return "'\(escaped)'"
- }
- /// Loads a Zoom Workplace page in the shared web view so OAuth cookies apply to later joins (panel may stay closed).
- @MainActor
- private func primeZoomEmbeddedWebSession() {
- guard isUserLoggedIn(), let webView = embeddedBrowserWebView else { return }
- guard embeddedZoomPendingJoinURL == nil, isEmbeddedBrowserPanelOpen == false else { return }
- guard let homeURL = URL(string: "https://app.zoom.us/wc/home") else { return }
- webView.load(URLRequest(url: homeURL))
- }
- private func zoomOAuthErrorIndicatesMissingZAKScope(_ error: Error) -> Bool {
- if case ZoomOAuthError.missingRequiredScope(let raw) = error {
- let lower = raw.lowercased()
- return lower.contains("user_zak") || lower.contains("zak")
- }
- let lower = error.localizedDescription.lowercased()
- return lower.contains("user_zak") || lower.contains("does not contain scopes")
- }
- /// Fetches ZAK for embedded join. If the saved token predates `user_zak:read`, opens Zoom OAuth once (you stay signed in to the app).
- @MainActor
- private func fetchEmbeddedZoomZAKAllowingReConsent() async -> String? {
- do {
- let accessToken = try await zoomOAuth.validAccessToken(
- presentingWindow: view.window,
- requiringAnyOf: ["user_zak:read"]
- )
- return try await fetchZoomUserZAK(accessToken: accessToken)
- } catch {
- if case ZoomOAuthError.authenticationCancelled = error { return nil }
- guard zoomOAuthErrorIndicatesMissingZAKScope(error) else {
- NSLog("ViewController: ZAK unavailable for embedded join — \(error.localizedDescription)")
- return nil
- }
- }
- do {
- zoomOAuth.clearSavedTokens()
- let accessToken = try await zoomOAuth.validAccessToken(
- presentingWindow: view.window,
- requiringAnyOf: ["user_zak:read"]
- )
- return try await fetchZoomUserZAK(accessToken: accessToken)
- } catch {
- if case ZoomOAuthError.authenticationCancelled = error { return nil }
- NSLog("ViewController: ZAK unavailable after OAuth refresh — \(error.localizedDescription)")
- return nil
- }
- }
- @MainActor
- private func zoomMeetingURLWithSignedInDisplayNameFallback(_ url: URL) -> URL {
- guard let name = zoomSignedInDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines),
- name.isEmpty == false else { return url }
- var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents()
- var queryItems = components.queryItems ?? []
- if queryItems.contains(where: { $0.name.caseInsensitiveCompare("uname") == .orderedSame }) == false {
- queryItems.append(URLQueryItem(name: "uname", value: name))
- }
- components.queryItems = queryItems
- return components.url ?? url
- }
- /// When signed in, attach a fresh ZAK so `/wc/join/` loads with the same Zoom account as native OAuth.
- @MainActor
- private func embeddedZoomMeetingLoadURL(for url: URL) async -> URL {
- let normalized = zoomEmbeddedWebHostURL(for: url)
- guard isZoomMeetingJoinOrStartURL(normalized), isUserLoggedIn() else {
- embeddedZoomPendingJoinURL = nil
- embeddedZoomPendingWebAuth = nil
- return normalized
- }
- embeddedZoomPendingJoinURL = normalized
- if let existingZAK = zakQueryValueFromZoomURL(normalized) {
- let displayName = zoomSignedInDisplayName
- embeddedZoomPendingWebAuth = (zak: existingZAK, displayName: displayName)
- return zoomMeetingURLByAppendingWebAuth(normalized, zak: existingZAK, displayName: displayName)
- }
- if let zak = await fetchEmbeddedZoomZAKAllowingReConsent() {
- let displayName = zoomSignedInDisplayName
- embeddedZoomPendingWebAuth = (zak: zak, displayName: displayName)
- return zoomMeetingURLByAppendingWebAuth(normalized, zak: zak, displayName: displayName)
- }
- // No ZAK (scope not on OAuth app yet): still pre-fill name and rely on shared web cookies.
- embeddedZoomPendingWebAuth = nil
- return zoomMeetingURLWithSignedInDisplayNameFallback(normalized)
- }
- private func embeddedWebViewShowsUnauthenticatedJoinPrompt(webView: WKWebView) -> Bool {
- let title = webView.title ?? ""
- if title.localizedCaseInsensitiveContains("Enter Meeting Info")
- || title.localizedCaseInsensitiveContains("Enter your name") {
- return true
- }
- if title.localizedCaseInsensitiveContains("sign in") {
- return true
- }
- if let path = webView.url?.path.lowercased(), path.hasPrefix("/signin") {
- return true
- }
- return false
- }
- @MainActor
- private func applyEmbeddedZoomWebAuthenticationIfNeeded(webView: WKWebView) {
- guard isUserLoggedIn() else { return }
- guard embeddedWebViewShowsUnauthenticatedJoinPrompt(webView: webView)
- || (webView.url.map { isZoomMeetingJoinOrStartURL($0) } ?? false) else { return }
- if let pending = embeddedZoomPendingWebAuth,
- let joinURL = embeddedZoomPendingJoinURL {
- let authURL = zoomMeetingURLByAppendingWebAuth(joinURL, zak: pending.zak, displayName: pending.displayName)
- if webView.url?.absoluteString != authURL.absoluteString {
- webView.load(URLRequest(url: authURL))
- return
- }
- } else if embeddedZoomPendingJoinURL != nil {
- Task { @MainActor in
- guard self.embeddedBrowserWebView === webView else { return }
- guard let zak = await self.fetchEmbeddedZoomZAKAllowingReConsent() else { return }
- self.embeddedZoomPendingWebAuth = (zak: zak, displayName: self.zoomSignedInDisplayName)
- self.applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
- }
- return
- } else {
- return
- }
- guard let pending = embeddedZoomPendingWebAuth else { return }
- guard let pageURL = webView.url, isZoomMeetingJoinOrStartURL(pageURL) else { return }
- let zakLiteral = javaScriptStringLiteral(pending.zak)
- let nameLiteral = pending.displayName.map(javaScriptStringLiteral) ?? "null"
- let script = """
- (function() {
- var zak = \(zakLiteral);
- var uname = \(nameLiteral);
- try {
- var next = new URL(window.location.href);
- if (!next.searchParams.get('zak')) {
- next.searchParams.set('zak', zak);
- if (uname) next.searchParams.set('uname', uname);
- window.location.replace(next.toString());
- return;
- }
- } catch (e) {}
- try {
- sessionStorage.setItem('zak', zak);
- if (uname) sessionStorage.setItem('uname', uname);
- } catch (e2) {}
- })();
- """
- webView.evaluateJavaScript(script, completionHandler: nil)
- }
- /// Prefer `/wc/join/` so the embedded web client loads directly (avoids Workplace app vs browser chooser on `/s/` start links).
- private func embeddedWebClientURLForZoomMeeting(
- meetingId: Int?,
- joinURLString: String?,
- startURLString: String? = nil
- ) -> URL? {
- let trimmedJoin = joinURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- let trimmedStart = startURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- var passcode: String?
- var zakFromAPI: String?
- if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
- zakFromAPI = zakQueryValueFromZoomURL(startURL)
- }
- if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
- if joinURL.path.contains("/wc/join/") {
- return zoomEmbeddedWebHostURL(for: joinURL)
- }
- passcode = URLComponents(url: joinURL, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "pwd" })?
- .value
- if zakFromAPI == nil {
- zakFromAPI = zakQueryValueFromZoomURL(joinURL)
- }
- }
- let numericId: String? = {
- if let meetingId { return String(meetingId) }
- if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
- return meetingIdDigitsFromZoomJoinPath(joinURL.path)
- }
- if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
- return meetingIdDigitsFromZoomJoinPath(startURL.path)
- }
- return nil
- }()
- guard let numericId, numericId.isEmpty == false else { return nil }
- return zoomWebClientJoinURL(
- meetingIdDigits: numericId,
- passcode: passcode ?? "",
- zak: zakFromAPI,
- displayName: zoomSignedInDisplayName
- )
- }
- private func meetingIdDigitsFromZoomJoinPath(_ path: String) -> String? {
- let parts = path.split(separator: "/").map(String.init)
- for (index, segment) in parts.enumerated() {
- let lowered = segment.lowercased()
- if (lowered == "j" || lowered == "join" || lowered == "wc"), index + 1 < parts.count {
- let digits = parts[index + 1].filter(\.isNumber)
- if digits.count >= 9 { return digits }
- }
- }
- return nil
- }
- 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
- }
- /// Converts invite links (`zoom.us/j/…`) into embedded web-client join URLs (`app.zoom.us/wc/join/…`).
- private func normalizedEmbeddedJoinURL(for url: URL) -> URL {
- if let embedded = embeddedWebClientURLForZoomMeeting(meetingId: nil, joinURLString: url.absoluteString) {
- return embedded
- }
- return zoomEmbeddedWebHostURL(for: url)
- }
- /// Join lobby / invite URL — not an active call (recording must not start here).
- private func isZoomEmbeddedWebClientJoinLobbyURL(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
- let path = url.path.lowercased()
- if path.contains("/wc/join") { return true }
- if path.range(of: #"/j/\d"#, options: .regularExpression) != nil { return true }
- return false
- }
- private func embeddedWebViewTitleIndicatesActiveCall(_ title: String) -> Bool {
- let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard t.isEmpty == false else { return false }
- return t.contains("zoom meeting") || t.contains("zoom webinar")
- }
- /// Opens a Zoom join link in the embedded web panel (skips the Join sheet).
- @MainActor
- private func joinZoomMeetingFromLink(_ rawLink: String, displayName: String?) {
- guard let parsed = parseZoomJoinURLFromUserInput(rawLink) else {
- showSimpleAlert(
- title: "Invalid link",
- message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…)."
- )
- return
- }
- let url = normalizedEmbeddedJoinURL(for: parsed)
- openWebURLPreferringInApp(
- url,
- zoomMeetingDisplayName: displayName,
- recordingConsentWindowTitle: "Join Meeting"
- )
- }
- @MainActor
- private func joinScheduledMeeting(_ meeting: ScheduledMeeting) {
- guard let url = meeting.webURL else { return }
- joinZoomMeetingFromLink(url.absoluteString, displayName: meeting.title)
- }
- /// True for zoom.us / app.zoom.us pages (not API hosts). When signed in, these load in the shared embedded browser so OAuth cookies apply.
- private func isZoomWebsiteURL(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
- if host.hasPrefix("api.") { return false }
- let path = url.path.lowercased()
- if path.hasPrefix("/oauth") { return false }
- return true
- }
- /// Opens Zoom meeting join/start links in the right-panel `WKWebView` (same store as embedded Zoom OAuth).
- /// Other zoom.us pages open in that web view when signed in; everything else uses the system browser.
- /// For Zoom meeting join/start links, asks for recording consent first, persists transcription languages from that sheet,
- /// then optionally begins local audio recording (when the user allows and recording is not already active).
- @MainActor
- private func openWebURLPreferringInApp(
- _ url: URL,
- zoomMeetingDisplayName: String? = nil,
- recordingConsentWindowTitle: String = "Meeting"
- ) {
- let targetURL = isZoomMeetingJoinOrStartURL(url) ? normalizedEmbeddedJoinURL(for: url) : url
- let shouldRecordLocally: Bool
- if isZoomMeetingJoinOrStartURL(targetURL) {
- let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
- if consentChoice == .dismissed { return }
- shouldRecordLocally = (consentChoice == .allowAndContinue)
- } else {
- shouldRecordLocally = false
- }
- let useEmbeddedBrowser = embeddedBrowserWebView != nil
- && (isZoomMeetingJoinOrStartURL(targetURL) || (isUserLoggedIn() && isZoomWebsiteURL(targetURL)))
- let opened: Bool
- if useEmbeddedBrowser {
- presentURLInEmbeddedRightPanel(targetURL)
- opened = true
- } else {
- opened = openURLInDefaultBrowser(targetURL)
- }
- if opened {
- dismissJoinMeetingPanel()
- if shouldRecordLocally {
- armZoomMeetingRecordingIfAppropriate(
- sourceURL: targetURL,
- meetingDisplayName: zoomMeetingDisplayName
- )
- }
- } else {
- showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
- }
- }
- /// True when `url` looks like joining or starting a Zoom call (not OAuth, API, or generic marketing pages).
- private func isZoomMeetingJoinOrStartURL(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
- if host.hasPrefix("api.") { return false }
- let path = url.path.lowercased()
- if path.hasPrefix("/oauth") || path.hasPrefix("/signin") { return false }
- if path.contains("/j/") { return true }
- if path.contains("/wc/join") { return true }
- if path.contains("/join/") { return true }
- if path.contains("/my/") { return true }
- if path.contains("/w/") { return true }
- if path.contains("/s/") { return true }
- if path.contains("videomeeting") { return true }
- if path.contains("/start") && path.contains("meeting") { return true }
- return false
- }
- /// Active in-call pages in the embedded Zoom web client (not join lobby or Workplace hub routes).
- private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
- if isZoomMeetingExplicitLeaveURL(url) { return false }
- if isZoomEmbeddedWebClientJoinLobbyURL(url) { return false }
- let path = url.path.lowercased()
- if path.contains("/wc/inmeeting") { return true }
- if path.contains("/wc/video") { return true }
- // Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
- if path.contains("/wc/") {
- return isZoomWorkplaceHubURL(url) == false
- }
- // Non–web-client join links opened in the embedded panel (rare).
- return isZoomMeetingJoinOrStartURL(url)
- }
- /// User explicitly left or the meeting ended (stop recording soon after this is detected).
- private func isZoomMeetingExplicitLeaveURL(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
- let path = url.path.lowercased()
- let absolute = url.absoluteString.lowercased()
- if path.contains("/leave") || path.contains("postattendee") || path.contains("/feedback") { return true }
- return absolute.contains("meeting_ended") || absolute.contains("meeting-ended")
- }
- /// Workplace hub, sign-in, or marketing home — not an active call (may appear briefly during join).
- private func isZoomWorkplaceHubURL(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
- let path = url.path.lowercased()
- let workplaceHubMarkers = [
- "/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
- "/wc/whiteboard", "/wc/meeting", "/wc/recordings", "/wc/notes",
- "/wc/contacts", "/wc/scheduler", "/wc/documents", "/wc/apps"
- ]
- if workplaceHubMarkers.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) { return true }
- if path == "/wc" || path == "/wc/" { return true }
- if path.contains("/wc/") == false, path.isEmpty || path == "/" || path.hasPrefix("/signin") { return true }
- return false
- }
- /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
- private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
- isZoomMeetingExplicitLeaveURL(url) || isZoomWorkplaceHubURL(url)
- }
- private func embeddedWebViewTitleIndicatesLeftMeeting(_ title: String) -> Bool {
- let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- guard t.isEmpty == false else { return false }
- if t.contains("zoom meeting") || t.contains("zoom webinar") { return false }
- if t.contains("waiting for the host") || t.contains("waiting for host") { return false }
- let hubTitles = [
- "zoom workplace", "meetings | zoom", "home | zoom", "calendar | zoom",
- "team chat | zoom", "chat | zoom", "sign in", "sign-in", "thank you for"
- ]
- return hubTitles.contains(where: { t.contains($0) })
- }
- @MainActor
- private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
- startPendingZoomMeetingRecordingIfNeeded(for: url, webViewTitle: webViewTitle)
- guard MeetingRecordingManager.shared.isRecording else {
- if pendingZoomMeetingRecording == nil {
- stopEmbeddedMeetingRecordingMonitor()
- }
- return
- }
- let inCall = (url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false)
- || (webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false)
- let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
- let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
- let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
- if inCall {
- embeddedMeetingReachedInCallPage = true
- embeddedMeetingAbsentCallConsecutiveMisses = 0
- }
- MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
- // Definite leave/end pages and hub titles stop recording immediately.
- if explicitLeaveURL || leftByTitle {
- endZoomMeetingRecordingAutomatically()
- return
- }
- let elapsed = MeetingRecordingManager.shared.currentRecordingElapsed
- if elapsed < MeetingRecordingManager.recordingLeaveGraceInterval {
- return
- }
- // User may have joined via the desktop Zoom app while the embedded panel shows a hub page.
- if ZoomMeetingPresenceWatcher.callPresenceLikelyVisible() {
- embeddedMeetingAbsentCallConsecutiveMisses = 0
- return
- }
- let absentFromCall = workplaceHubURL || (embeddedMeetingReachedInCallPage && !inCall)
- if absentFromCall {
- embeddedMeetingAbsentCallConsecutiveMisses += 1
- } else {
- embeddedMeetingAbsentCallConsecutiveMisses = 0
- }
- guard embeddedMeetingAbsentCallConsecutiveMisses >= Self.embeddedMeetingAbsentCallMissThreshold else { return }
- endZoomMeetingRecordingAutomatically()
- }
- @MainActor
- private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
- stopEmbeddedMeetingRecordingMonitor()
- guard MeetingRecordingManager.shared.isRecording || pendingZoomMeetingRecording != nil else { return }
- guard embeddedBrowserWebView != nil else { return }
- embeddedMeetingReachedInCallPage = false
- embeddedMeetingAbsentCallConsecutiveMisses = 0
- let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
- guard let self else { return }
- let webView = self.embeddedBrowserWebView
- self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
- }
- RunLoop.main.add(timer, forMode: .common)
- embeddedMeetingRecordingMonitorTimer = timer
- }
- @MainActor
- private func stopEmbeddedMeetingRecordingMonitor() {
- embeddedMeetingRecordingMonitorTimer?.invalidate()
- embeddedMeetingRecordingMonitorTimer = nil
- embeddedMeetingReachedInCallPage = false
- embeddedMeetingAbsentCallConsecutiveMisses = 0
- }
- @MainActor
- private func endZoomMeetingRecordingAutomatically() {
- guard MeetingRecordingManager.shared.isRecording else { return }
- stopEmbeddedMeetingRecordingMonitor()
- Task { @MainActor in
- guard MeetingRecordingManager.shared.isRecording else { return }
- do {
- try await MeetingRecordingManager.shared.stopRecording()
- self.recordedMeetings = MeetingRecordingManager.shared.recordings
- self.renderAiCompanionList()
- self.updateAiCompanionRecordingUI()
- } catch {
- NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
- }
- }
- }
- @MainActor
- private func armZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
- guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
- guard MeetingRecordingManager.shared.isRecording == false else { return }
- pendingZoomMeetingRecording = (normalizedEmbeddedJoinURL(for: sourceURL), meetingDisplayName)
- updateAiCompanionRecordingUI()
- if embeddedBrowserWebView != nil,
- (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
- startEmbeddedMeetingRecordingMonitorIfNeeded()
- }
- }
- @MainActor
- private func startPendingZoomMeetingRecordingIfNeeded(for url: URL?, webViewTitle: String?) {
- guard pendingZoomMeetingRecording != nil else { return }
- if MeetingRecordingManager.shared.isRecording {
- pendingZoomMeetingRecording = nil
- updateAiCompanionRecordingUI()
- return
- }
- let urlInCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
- let titleInCall = webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false
- let desktopInCall = ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
- guard urlInCall || titleInCall || desktopInCall else { return }
- guard let pending = pendingZoomMeetingRecording else { return }
- pendingZoomMeetingRecording = nil
- let sessionID = embeddedZoomWebSessionID
- Task { @MainActor in
- guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else { return }
- do {
- try await MeetingRecordingManager.shared.startRecording(
- sourceURL: pending.sourceURL,
- meetingDisplayName: pending.displayName
- )
- guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else {
- self.endZoomMeetingRecordingAutomatically()
- return
- }
- self.updateAiCompanionRecordingUI()
- self.startEmbeddedMeetingRecordingMonitorIfNeeded()
- } catch {
- self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
- }
- }
- }
- @discardableResult
- private func openURLInDefaultBrowser(_ url: URL) -> Bool {
- NSWorkspace.shared.open(url)
- }
- @MainActor
- private var isEmbeddedBrowserPanelOpen: Bool {
- (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
- }
- @MainActor
- private func presentURLInEmbeddedRightPanel(_ url: URL) {
- guard let webView = embeddedBrowserWebView else {
- NSWorkspace.shared.open(url)
- return
- }
- embeddedZoomWebSessionID &+= 1
- updateEmbeddedRightPanelWidthForOpenState()
- if isZoomMeetingJoinOrStartURL(url) {
- Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
- }
- Task { @MainActor in
- let loadURL = await self.embeddedZoomMeetingLoadURL(for: url)
- guard self.embeddedBrowserWebView === webView else { return }
- webView.load(URLRequest(url: loadURL))
- self.updateEmbeddedMeetingRecordingPresence(for: loadURL)
- self.refreshEmbeddedBrowserNavButtons()
- self.view.window?.contentView?.layoutSubtreeIfNeeded()
- }
- }
- /// Sandbox camera entitlement + TCC prompt so `WKWebView` can enumerate devices for Zoom web meetings.
- private func ensureMediaCaptureAccessForEmbeddedZoomWebClient() async {
- _ = await cameraAccessGrantedForWebCapture()
- _ = await microphoneAccessGrantedForWebCapture()
- }
- private func cameraAccessGrantedForWebCapture() async -> Bool {
- switch AVCaptureDevice.authorizationStatus(for: .video) {
- case .authorized:
- return true
- case .notDetermined:
- return await AVCaptureDevice.requestAccess(for: .video)
- case .denied, .restricted:
- return false
- @unknown default:
- return false
- }
- }
- private func microphoneAccessGrantedForWebCapture() async -> Bool {
- switch AVCaptureDevice.authorizationStatus(for: .audio) {
- case .authorized:
- return true
- case .notDetermined:
- return await AVCaptureDevice.requestAccess(for: .audio)
- case .denied, .restricted:
- return false
- @unknown default:
- return false
- }
- }
- /// 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
- }
- // Right panel spans from (bodyRow.leading + gap) to bodyRow.trailing when the main column is collapsed (width 0).
- // Using the full row width here over-constrains by the column gap and nudges the window wider.
- let available = max(0, rowW - homeEmbeddedBrowserColumnGap)
- widthConstraint.constant = max(320, floor(available))
- // 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()
- }
- /// Leaves any in-call Zoom web session, stops camera/mic tracks, and navigates away so WKWebView releases hardware.
- @MainActor
- private func teardownEmbeddedZoomWebClient() {
- guard let webView = embeddedBrowserWebView else { return }
- webView.evaluateJavaScript(Self.embeddedZoomWebClientTeardownJavaScript) { [weak self] _, _ in
- Task { @MainActor in
- guard let self, self.embeddedBrowserWebView === webView else { return }
- webView.stopLoading()
- if let blankURL = URL(string: "about:blank") {
- webView.load(URLRequest(url: blankURL))
- }
- }
- }
- }
- @MainActor
- private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
- embeddedZoomWebSessionID &+= 1
- pendingZoomMeetingRecording = nil
- stopEmbeddedMeetingRecordingMonitor()
- MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
- if MeetingRecordingManager.shared.isRecording {
- MeetingRecordingManager.shared.stopCaptureHardwareImmediately()
- endZoomMeetingRecordingAutomatically()
- }
- updateAiCompanionRecordingUI()
- if let cont = oauthSessionContinuation {
- oauthSessionContinuation = nil
- oauthSessionCallbackPrefix = nil
- cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
- }
- if stoppingWebLoad {
- teardownEmbeddedZoomWebClient()
- }
- embeddedZoomPendingWebAuth = nil
- embeddedZoomPendingJoinURL = nil
- 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
- if let primeURL = URL(string: "https://app.zoom.us/wc/home") {
- webView.load(URLRequest(url: primeURL))
- }
- self.closeEmbeddedBrowserPanel(stoppingWebLoad: false)
- }
- 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
- joinMeetingDisplayNameField = nil
- joinURLFieldsContainer = nil
- joinIDFieldsContainer = nil
- joinModeSegment = nil
- }
- @objc private func logoutTapped() {
- meetingsRefreshTimer?.invalidate()
- meetingsRefreshTimer = nil
- meetingsLoadTask?.cancel()
- meetingsLoadTask = nil
- isLoadingMeetings = false
- meetingsRateLimitedUntil = nil
- clearMeetingsScrollObserver()
- googleOAuth.clearSavedTokens()
- zoomOAuth.clearSavedTokens()
- zoomSignedInDisplayName = nil
- embeddedZoomPendingWebAuth = nil
- embeddedZoomPendingJoinURL = nil
- persistLoggedInState(false)
- allScheduledMeetings = []
- applyAuthenticationUIState(triggerInitialRefresh: false)
- Task { @MainActor in
- self.applyMeetings([])
- self.renderAiCompanionList()
- self.updateAiCompanionRecordingUI()
- }
- }
- @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
- Task { @MainActor [weak self] in
- self?.triggerMeetingsRefresh()
- }
- }
- }
- @MainActor
- private func triggerMeetingsRefresh(force: Bool = false) {
- guard isUserLoggedIn() else { return }
- guard storeKitCoordinator.hasPremiumAccess else { return }
- let now = Date()
- if let until = meetingsRateLimitedUntil, now < until {
- applyMeetingsRateLimitStatusLabels()
- return
- }
- if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
- return
- }
- if isLoadingMeetings { return }
- lastMeetingsRefreshAt = now
- isLoadingMeetings = true
- meetingsLoadTask?.cancel()
- meetingsLoadTask = 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 meeting.webURL != nil {
- joinScheduledMeeting(meeting)
- } 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)
- let trimmedName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- zoomSignedInDisplayName = trimmedName.isEmpty ? nil : trimmedName
- showHomeView(profile: profile)
- primeZoomEmbeddedWebSession()
- 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
- if loggedIn == false {
- meetingsRateLimitedUntil = nil
- }
- if loggedIn {
- if storeKitCoordinator.hasPremiumAccess {
- startMeetingsAutoRefresh()
- if triggerInitialRefresh {
- triggerMeetingsRefresh(force: false)
- }
- } else {
- applyMeetings([])
- applyPremiumLockMessages()
- }
- } else {
- applyGuestLoginMessages()
- }
- }
- @MainActor
- private func applyMeetingsRateLimitStatusLabels() {
- guard let until = meetingsRateLimitedUntil, until > Date() else { return }
- let minutes = max(1, Int(ceil(until.timeIntervalSinceNow / 60.0)))
- let message = "Zoom rate limit reached. Please try again in \(minutes) min."
- meetingsStatusLabel?.stringValue = message
- meetingsPageStatusLabel?.stringValue = message
- }
- @MainActor
- private func persistZoomAccountTimeZone(_ timeZone: TimeZone?) {
- zoomAccountTimeZone = timeZone
- if let timeZone {
- UserDefaults.standard.set(timeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
- } else {
- UserDefaults.standard.removeObject(forKey: Self.zoomAccountTimeZoneDefaultsKey)
- }
- }
- /// 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
- }
- if let until = meetingsRateLimitedUntil, until > Date() {
- applyMeetingsRateLimitStatusLabels()
- }
- 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
- let isRateLimited = meetingsRateLimitedUntil.map { $0 > Date() } ?? false
- if isRateLimited == 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.isLoadingMeetings = false
- self.setMeetingsLoadingUI(false)
- self.applyMeetings([])
- self.applyGuestLoginMessages()
- }
- return
- }
- let hasPremium = await MainActor.run { self.storeKitCoordinator.hasPremiumAccess }
- guard hasPremium else {
- await MainActor.run {
- self.isLoadingMeetings = false
- self.setMeetingsLoadingUI(false)
- self.applyMeetings([])
- self.applyPremiumLockMessages()
- }
- return
- }
- let shouldSkipForRateLimit = await MainActor.run { () -> Bool in
- if let until = self.meetingsRateLimitedUntil, until > Date() {
- self.applyMeetingsRateLimitStatusLabels()
- return true
- }
- self.setMeetingsLoadingUI(true)
- return false
- }
- if shouldSkipForRateLimit {
- await MainActor.run {
- self.isLoadingMeetings = false
- self.setMeetingsLoadingUI(false)
- }
- return
- }
- defer {
- Task { @MainActor in
- self.isLoadingMeetings = false
- self.setMeetingsLoadingUI(false)
- }
- }
- do {
- let zoomToken = try await zoomOAuth.validAccessToken(
- presentingWindow: view.window,
- requiringAnyOf: ["meeting:read:list_meetings"]
- )
- let needsProfileTimeZone = await MainActor.run { self.zoomAccountTimeZone == nil }
- if needsProfileTimeZone {
- if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
- let profileTZ = parseZoomTimeZone(profile.timezone)
- await MainActor.run { self.persistZoomAccountTimeZone(profileTZ) }
- }
- }
- let fallbackTZ = await MainActor.run { self.zoomAccountTimeZone }
- let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: fallbackTZ)
- await MainActor.run {
- self.meetingsRateLimitedUntil = nil
- self.applyMeetings(zoomMeetings)
- }
- } catch {
- await MainActor.run {
- 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))
- self.applyMeetingsRateLimitStatusLabels()
- } 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."
- }
- if case ZoomOAuthError.missingClientId = error {
- self.applyMeetings([])
- } else if case ZoomOAuthError.missingClientSecret = error {
- self.applyMeetings([])
- } else if case ZoomOAuthError.missingRequiredScope = error {
- self.applyMeetings([])
- }
- self.applyFilteredMeetings()
- self.applyMeetingsPageFilters(resetVisible: false)
- }
- }
- }
- @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), reading a single summary (meeting:read:summary), and embedded web join (user_zak:read)."
- 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..."
- triggerMeetingsRefresh(force: true)
- }
- }
- 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 ZoomZAKTokenResponse: Decodable {
- let token: String
- }
- /// ZAK authenticates the embedded Zoom web client as the signed-in OAuth user ([`GET /users/me/token`](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/userZAK)).
- private func fetchZoomUserZAK(accessToken: String) async throws -> String {
- var components = URLComponents(string: "https://api.zoom.us/v2/users/me/token")!
- components.queryItems = [URLQueryItem(name: "type", value: "zak")]
- guard let url = components.url else {
- throw GoogleOAuthError.tokenExchangeFailed("Invalid ZAK request 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 (ZAK)")
- }
- 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 ZAK"
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- let decoded = try JSONDecoder().decode(ZoomZAKTokenResponse.self, from: data)
- let trimmed = decoded.token.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else {
- throw GoogleOAuthError.tokenExchangeFailed("Empty ZAK from Zoom")
- }
- return trimmed
- }
- 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?
- let start_url: String?
- }
- private struct ZoomInstantMeetingBody: Encodable {
- let topic: String
- let type: Int
- }
- /// 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)
- }
- /// [Instant meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate) (type 1). Use `join_url` → `/wc/join/` in the UI (same as scheduled meetings); `start_url` shows a Workplace app chooser in `WKWebView`.
- private func createZoomInstantMeeting(accessToken: String, topic: String = "Instant meeting") 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")
- request.httpBody = try JSONEncoder().encode(ZoomInstantMeetingBody(topic: topic, type: 1))
- 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 = embeddedWebClientURLForZoomMeeting(meetingId: meeting.id, joinURLString: meeting.join_url)
- 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?
- var pageCount = 0
- repeat {
- pageCount += 1
- 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 && pageCount < maxMeetingListPages
- 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 }
- // Bulk list is often unavailable; a generic 403 still means "try past meetings + per-meeting summary".
- if statusCode == 403 { return true }
- 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 {
- // Zoom expects `previous_meetings` (not `past`) for past instances; see List meetings API.
- var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
- var items: [URLQueryItem] = [
- URLQueryItem(name: "type", value: "previous_meetings"),
- 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 transcriptionTitle = textLabel("AI Companion transcription", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- let transcriptionSubtitle = textLabel(
- "Preferred languages are used for Apple on-device speech recognition after each meeting recording.",
- font: .systemFont(ofSize: 12, weight: .regular),
- color: secondaryText
- )
- transcriptionSubtitle.maximumNumberOfLines = 0
- transcriptionSubtitle.lineBreakMode = .byWordWrapping
- let transcriptionBlock = NSStackView()
- transcriptionBlock.translatesAutoresizingMaskIntoConstraints = false
- transcriptionBlock.orientation = .vertical
- transcriptionBlock.spacing = 12
- transcriptionBlock.alignment = .leading
- transcriptionBlock.addArrangedSubview(transcriptionTitle)
- transcriptionBlock.addArrangedSubview(transcriptionSubtitle)
- let lang1Row = makeSettingsSpeechLanguageRow(isPrimary: true)
- let lang2Row = makeSettingsSpeechLanguageRow(isPrimary: false)
- transcriptionBlock.addArrangedSubview(lang1Row)
- transcriptionBlock.addArrangedSubview(lang2Row)
- lang1Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
- lang2Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
- stack.addArrangedSubview(transcriptionBlock)
- transcriptionBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- stack.setCustomSpacing(24, after: transcriptionBlock)
- 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 makeSettingsSpeechLanguageRow(isPrimary: Bool) -> NSView {
- let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
- row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 72).isActive = true
- styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let titleText = isPrimary ? "Preferred Language 1" : "Preferred Language 2"
- let titleLabel = textLabel(titleText, font: .systemFont(ofSize: 13, weight: .semibold), color: primaryText)
- let popup = NSPopUpButton(frame: .zero, pullsDown: false)
- popup.translatesAutoresizingMaskIntoConstraints = false
- popup.target = self
- popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
- let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
- if isPrimary == false {
- popup.addItem(withTitle: "None")
- popup.lastItem?.representedObject = ""
- }
- for option in options {
- popup.addItem(withTitle: option.displayName)
- popup.lastItem?.representedObject = option.identifier
- }
- if isPrimary {
- let selected = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
- ?? options.first?.identifier
- ?? Locale.current.identifier
- selectTranscriptionLanguage(identifier: selected, in: popup)
- settingsPagePrimaryLanguagePopup = popup
- } else {
- if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
- selectTranscriptionLanguage(identifier: selected, in: popup)
- } else {
- popup.selectItem(at: 0)
- }
- settingsPageSecondaryLanguagePopup = popup
- }
- row.addSubview(titleLabel)
- row.addSubview(popup)
- NSLayoutConstraint.activate([
- titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
- titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
- titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
- popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
- popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
- popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
- popup.heightAnchor.constraint(equalToConstant: 28)
- ])
- return row
- }
- @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
- guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
- primary.isEmpty == false else { return }
- var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
- if secondary?.isEmpty == true {
- secondary = nil
- }
- if let secondaryValue = secondary,
- secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
- secondary = nil
- settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
- }
- TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
- }
- 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:
- openURLInDefaultBrowser(AppExternalURLs.privacyPolicy)
- case .support:
- openURLInDefaultBrowser(AppExternalURLs.support)
- case .termsOfServices:
- openURLInDefaultBrowser(AppExternalURLs.termsOfServices)
- }
- }
- @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: false)
- 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, refreshMeetings: false)
- 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?) {
- openURLInDefaultBrowser(AppExternalURLs.privacyPolicy)
- }
- @objc private func paywallSupportClicked(_ sender: Any?) {
- openURLInDefaultBrowser(AppExternalURLs.support)
- }
- @objc private func paywallTermsOfServicesClicked(_ sender: Any?) {
- openURLInDefaultBrowser(AppExternalURLs.termsOfServices)
- }
- @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.websiteDataStore = zoomEmbeddedWebsiteDataStore
- embeddedBrowserConfiguration.processPool = zoomEmbeddedWebProcessPool
- embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
- embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
- let meetingLifecycleController = WKUserContentController()
- meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
- let lifecycleScript = WKUserScript(
- source: Self.embeddedZoomMeetingLifecycleJavaScript,
- injectionTime: .atDocumentEnd,
- forMainFrameOnly: true
- )
- meetingLifecycleController.addUserScript(lifecycleScript)
- embeddedBrowserConfiguration.userContentController = meetingLifecycleController
- let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
- embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
- 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 the window dictate width; long status copy must not inflate the layout minimum.
- root.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- 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: recordings list (AI Companion mode).
- let title = makeLabel("AI Companion", size: 28, color: primaryText, weight: .bold, centered: false)
- let subtitle = makeLabel("Get notes from your previous meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
- let count = makeLabel("0 recordings", 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
- // Old Zoom AI summaries controls are intentionally hidden/unused.
- let loadingIndicator = NSProgressIndicator()
- loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
- loadingIndicator.style = .spinning
- loadingIndicator.controlSize = .small
- loadingIndicator.isDisplayedWhenStopped = false
- loadingIndicator.isIndeterminate = true
- loadingIndicator.isHidden = true
- let stopRecording = NSButton(title: "Stop & save", target: self, action: #selector(aiCompanionStopRecordingTapped))
- stopRecording.translatesAutoresizingMaskIntoConstraints = false
- stopRecording.isBordered = false
- stopRecording.wantsLayer = true
- stopRecording.layer?.backgroundColor = accentBlue.cgColor
- stopRecording.layer?.cornerRadius = 10
- stopRecording.contentTintColor = .white
- stopRecording.font = .systemFont(ofSize: 13, weight: .semibold)
- stopRecording.isHidden = true
- let toolbarRight = NSStackView(views: [stopRecording, loadingIndicator])
- toolbarRight.orientation = .horizontal
- toolbarRight.alignment = .centerY
- toolbarRight.spacing = 10
- toolbarRight.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(titleStack)
- container.addSubview(toolbarRight)
- // Filter row matches `makeMeetingsPageView` styling; preset list is Ai Companion–specific.
- let categoryFilter = NSPopUpButton()
- categoryFilter.translatesAutoresizingMaskIntoConstraints = false
- categoryFilter.addItems(withTitles: AiCompanionRecordingFilter.allCases.map(\.rawValue))
- categoryFilter.selectItem(withTitle: AiCompanionRecordingFilter.today.rawValue)
- categoryFilter.target = self
- categoryFilter.action = #selector(aiCompanionFilterPresetChanged(_:))
- 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(aiCompanionFilterApplyTapped))
- applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
- applyFiltersButton.isBordered = false
- applyFiltersButton.wantsLayer = true
- applyFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
- applyFiltersButton.layer?.cornerRadius = 10
- applyFiltersButton.layer?.borderWidth = 1
- applyFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
- applyFiltersButton.contentTintColor = primaryText
- applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(aiCompanionFilterResetTapped))
- 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(aiCompanionFilterRefreshTapped))
- 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 filterBar = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
- filterBar.orientation = .horizontal
- filterBar.spacing = 10
- filterBar.alignment = .centerY
- filterBar.translatesAutoresizingMaskIntoConstraints = false
- configureResponsiveMeetingsStyleFilterRow(
- filterRow: filterBar,
- categoryFilter: categoryFilter,
- fromDatePicker: fromDatePicker,
- toDatePicker: toDatePicker,
- applyFiltersButton: applyFiltersButton,
- resetFiltersButton: resetFiltersButton,
- refreshButton: refreshButton
- )
- container.addSubview(filterBar)
- // Hint text below filters (recording help — not Zoom API status).
- let status = makeLabel("", size: 12, color: secondaryText, weight: .regular, centered: false)
- status.translatesAutoresizingMaskIntoConstraints = false
- status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- status.lineBreakMode = .byWordWrapping
- status.maximumNumberOfLines = 0
- if let cell = status.cell as? NSTextFieldCell {
- cell.wraps = true
- }
- 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
- // Wrapper document view (same pattern as Settings scroll): without a bottom
- // `>= clip` chain, a vertical stack as documentView can stretch to the clip height
- // and leave rows pinned to the bottom.
- let listDocument = NSView()
- listDocument.translatesAutoresizingMaskIntoConstraints = false
- let listStack = NSStackView()
- listStack.translatesAutoresizingMaskIntoConstraints = false
- listStack.orientation = .vertical
- listStack.alignment = .leading
- listStack.spacing = 10
- listScroll.documentView = listDocument
- listDocument.addSubview(listStack)
- container.addSubview(listScroll)
- let listEmpty = makeLabel("No recordings yet.", size: 12, color: secondaryText, weight: .regular, centered: true)
- listEmpty.translatesAutoresizingMaskIntoConstraints = false
- listEmpty.maximumNumberOfLines = 3
- listEmpty.lineBreakMode = .byWordWrapping
- listEmpty.isHidden = true
- container.addSubview(listEmpty)
- // Old detail pane (AI notes) removed.
- let divider = NSView()
- divider.translatesAutoresizingMaskIntoConstraints = false
- divider.wantsLayer = true
- divider.layer?.backgroundColor = palette.inputBorder.cgColor
- divider.isHidden = true
- 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),
- stopRecording.heightAnchor.constraint(equalToConstant: 30),
- stopRecording.widthAnchor.constraint(greaterThanOrEqualToConstant: 110),
- filterBar.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
- filterBar.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
- filterBar.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
- status.topAnchor.constraint(equalTo: filterBar.bottomAnchor, constant: 12),
- status.leadingAnchor.constraint(equalTo: filterBar.leadingAnchor),
- status.trailingAnchor.constraint(equalTo: filterBar.trailingAnchor),
- 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.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- listDocument.topAnchor.constraint(equalTo: listClip.topAnchor),
- listDocument.leadingAnchor.constraint(equalTo: listClip.leadingAnchor),
- listDocument.trailingAnchor.constraint(equalTo: listClip.trailingAnchor),
- listDocument.widthAnchor.constraint(equalTo: listClip.widthAnchor),
- listDocument.bottomAnchor.constraint(greaterThanOrEqualTo: listClip.bottomAnchor),
- listStack.topAnchor.constraint(equalTo: listDocument.topAnchor, constant: 8),
- listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
- listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
- listDocument.bottomAnchor.constraint(greaterThanOrEqualTo: listStack.bottomAnchor, constant: 8),
- 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)
- ])
- aiCompanionListScroll = listScroll
- aiCompanionListStack = listStack
- aiCompanionStatusLabel = status
- aiCompanionCountLabel = count
- aiCompanionEmptyLabel = listEmpty
- aiCompanionLoadingIndicator = loadingIndicator
- aiCompanionDetailEmptyLabel = nil
- aiCompanionDetailScroll = nil
- aiCompanionDetailStack = nil
- aiCompanionStopRecordingButton = stopRecording
- aiCompanionFilterPopup = categoryFilter
- aiCompanionFilterFromPicker = fromDatePicker
- aiCompanionFilterToPicker = toDatePicker
- aiCompanionFilterApplyButton = applyFiltersButton
- aiCompanionFilterResetButton = resetFiltersButton
- aiCompanionFilterRefreshButton = refreshButton
- updateAiCompanionDatePickersEnabledState()
- renderAiCompanionList()
- updateAiCompanionRecordingUI()
- return root
- }
- /// Mirrors `updateMeetingsDatePickersEnabledState` for the Ai Companion recordings filter row.
- @MainActor
- private func updateAiCompanionDatePickersEnabledState() {
- let isCustomRange = aiCompanionRecordingFilter == .custom
- aiCompanionFilterFromPicker?.isEnabled = isCustomRange
- aiCompanionFilterToPicker?.isEnabled = isCustomRange
- aiCompanionFilterFromPicker?.alphaValue = isCustomRange ? 1.0 : 0.55
- aiCompanionFilterToPicker?.alphaValue = isCustomRange ? 1.0 : 0.55
- }
- @objc private func aiCompanionFilterPresetChanged(_ sender: NSPopUpButton) {
- guard let title = sender.titleOfSelectedItem,
- let preset = AiCompanionRecordingFilter(rawValue: title) else { return }
- aiCompanionRecordingFilter = preset
- updateAiCompanionDatePickersEnabledState()
- renderAiCompanionList()
- }
- @objc private func aiCompanionFilterApplyTapped() {
- renderAiCompanionList()
- }
- @objc private func aiCompanionFilterResetTapped() {
- aiCompanionRecordingFilter = .today
- aiCompanionFilterPopup?.selectItem(withTitle: AiCompanionRecordingFilter.today.rawValue)
- aiCompanionFilterFromPicker?.dateValue = Date()
- aiCompanionFilterToPicker?.dateValue = Date()
- updateAiCompanionDatePickersEnabledState()
- renderAiCompanionList()
- }
- @objc private func aiCompanionFilterRefreshTapped() {
- recordedMeetings = MeetingRecordingManager.shared.recordings
- renderAiCompanionList()
- updateAiCompanionRecordingUI()
- }
- private func aiCompanionDateFilterHalfOpenInterval() -> (start: Date, endExclusive: Date)? {
- let cal = Calendar.current
- let now = Date()
- switch aiCompanionRecordingFilter {
- case .all:
- return nil
- case .today:
- let d = cal.startOfDay(for: now)
- guard let end = cal.date(byAdding: .day, value: 1, to: d) else { return nil }
- return (d, end)
- case .thisWeek:
- guard let week = cal.dateInterval(of: .weekOfYear, for: now) else { return nil }
- return (week.start, week.end)
- case .previousWeek:
- guard let thisWeek = cal.dateInterval(of: .weekOfYear, for: now) else { return nil }
- let prevStart = cal.date(byAdding: .day, value: -7, to: thisWeek.start) ?? thisWeek.start
- return (prevStart, thisWeek.start)
- case .previousMonth:
- let comps = cal.dateComponents([.year, .month], from: now)
- guard let thisMonthStart = cal.date(from: comps) else { return nil }
- guard let prevMonthStart = cal.date(byAdding: .month, value: -1, to: thisMonthStart) else { return nil }
- return (prevMonthStart, thisMonthStart)
- case .custom:
- guard let fromPicker = aiCompanionFilterFromPicker, let toPicker = aiCompanionFilterToPicker else { return nil }
- let a = cal.startOfDay(for: fromPicker.dateValue)
- let b = cal.startOfDay(for: toPicker.dateValue)
- let lo = min(a, b)
- let hi = max(a, b)
- guard let endExclusive = cal.date(byAdding: .day, value: 1, to: hi) else { return nil }
- return (lo, endExclusive)
- }
- }
- private func aiCompanionFilteredRecordings() -> [MeetingRecordingManager.RecordedMeeting] {
- guard let interval = aiCompanionDateFilterHalfOpenInterval() else {
- return recordedMeetings
- }
- return recordedMeetings.filter { $0.startedAt >= interval.start && $0.startedAt < interval.endExclusive }
- }
- @MainActor
- private func updateAiCompanionRecordingUI() {
- let mgr = MeetingRecordingManager.shared
- aiCompanionStopRecordingButton?.isHidden = !mgr.isRecording
- if mgr.isRecording {
- aiCompanionStatusLabel?.stringValue =
- "Recording… Stops automatically when you leave or the meeting ends."
- aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
- aiCompanionStatusLabel?.textColor = secondaryText
- } else if pendingZoomMeetingRecording != nil {
- aiCompanionStatusLabel?.stringValue =
- "Waiting to join… Recording starts when you enter the meeting."
- aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
- aiCompanionStatusLabel?.textColor = secondaryText
- } else {
- aiCompanionStatusLabel?.stringValue = "Previous meetings"
- aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
- aiCompanionStatusLabel?.textColor = primaryText
- }
- refreshAiCompanionStatusPreferredMaxLayoutWidth()
- }
- /// Keeps the status field wrapping inside the card so a long path or error string cannot widen the window.
- @MainActor
- private func refreshAiCompanionStatusPreferredMaxLayoutWidth() {
- guard let status = aiCompanionStatusLabel, let container = status.superview else { return }
- let w = container.bounds.width
- guard w > 1 else { return }
- let margin: CGFloat = 48
- status.preferredMaxLayoutWidth = max(160, w - margin)
- }
- @objc private func aiCompanionStopRecordingTapped() {
- stopEmbeddedMeetingRecordingMonitor()
- Task { @MainActor in
- do {
- try await MeetingRecordingManager.shared.stopRecording()
- self.recordedMeetings = MeetingRecordingManager.shared.recordings
- self.renderAiCompanionList()
- self.updateAiCompanionRecordingUI()
- } catch {
- self.showSimpleAlert(title: "Could not save recording", message: error.localizedDescription)
- }
- }
- }
- // 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 displayed = aiCompanionFilteredRecordings()
- let totalCount = recordedMeetings.count
- let displayedCount = displayed.count
- if aiCompanionRecordingFilter == .all {
- aiCompanionCountLabel?.stringValue = totalCount == 1 ? "1 recording" : "\(totalCount) recordings"
- } else {
- aiCompanionCountLabel?.stringValue = "\(displayedCount) of \(totalCount) recordings"
- }
- let showEmpty = displayed.isEmpty
- if showEmpty {
- if totalCount == 0 {
- aiCompanionEmptyLabel?.stringValue = "No previous meetings yet."
- } else {
- aiCompanionEmptyLabel?.stringValue = "No previous meetings matched the selected filter."
- }
- }
- aiCompanionEmptyLabel?.isHidden = !showEmpty
- for recording in displayed {
- guard let idx = recordedMeetings.firstIndex(where: { $0.id == recording.id }) else { continue }
- let row = makeRecordedMeetingCard(recording: recording, index: idx)
- stack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- refreshLocalRecordingTranscriptPanelContentIfNeeded()
- refreshLocalRecordingNotesPanelContentIfNeeded()
- }
- private func localRecordingNotesStatus(for recording: MeetingRecordingManager.RecordedMeeting) -> LocalRecordingNotesStatus {
- if let raw = recording.notesStatusRaw, let s = LocalRecordingNotesStatus(rawValue: raw) {
- if s == .notRequested,
- let t = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
- t.isEmpty == false {
- return .ready
- }
- return s
- }
- if let t = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false {
- return .ready
- }
- return .notRequested
- }
- private func aiCompanionCardDateLabel(for date: Date) -> String {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "en_GB")
- formatter.dateFormat = "d-MMMM-yyyy"
- return "Date: \(formatter.string(from: date))"
- }
- private func aiCompanionCardSavedTimeLabel(for date: Date) -> String {
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.dateStyle = .none
- formatter.timeStyle = .short
- return "Saved time: \(formatter.string(from: date))"
- }
- private let aiCompanionGetNotesButtonHeight: CGFloat = 28
- private func styleAiCompanionGetNotesButton(_ button: NSButton, enabled: Bool) {
- let pillHeight = aiCompanionGetNotesButtonHeight
- button.isBordered = false
- button.wantsLayer = true
- button.layer?.cornerRadius = pillHeight / 2
- button.layer?.borderWidth = 1
- button.font = .systemFont(ofSize: 12, weight: .semibold)
- button.imagePosition = .imageLeading
- button.imageHugsTitle = true
- let enabledFill = palette.isDarkMode
- ? NSColor(calibratedRed: 22 / 255, green: 34 / 255, blue: 54 / 255, alpha: 1)
- : NSColor(calibratedRed: 236 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
- let disabledFill = palette.isDarkMode
- ? NSColor(calibratedRed: 18 / 255, green: 26 / 255, blue: 40 / 255, alpha: 1)
- : NSColor(calibratedRed: 245 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
- if enabled {
- button.layer?.backgroundColor = enabledFill.cgColor
- button.layer?.borderColor = accentBlue.cgColor
- button.contentTintColor = accentBlue
- if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- .applying(NSImage.SymbolConfiguration(hierarchicalColor: accentBlue))
- button.image = icon.withSymbolConfiguration(config)
- }
- } else {
- let mutedBlue = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.38 : 0.45)
- button.layer?.backgroundColor = disabledFill.cgColor
- button.layer?.borderColor = mutedBlue.cgColor
- button.contentTintColor = mutedBlue
- if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
- let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- .applying(NSImage.SymbolConfiguration(hierarchicalColor: mutedBlue))
- button.image = icon.withSymbolConfiguration(config)
- }
- }
- }
- private func localRecordingNotesStatusLabel(
- for recording: MeetingRecordingManager.RecordedMeeting,
- notesStatus: LocalRecordingNotesStatus
- ) -> (text: String, color: NSColor) {
- if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
- return ("Generating transcript…", secondaryText)
- }
- if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
- return ("Transcript unavailable", NSColor.systemRed)
- }
- switch notesStatus {
- case .notRequested:
- let hasTranscript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
- if hasTranscript {
- return ("Notes not requested", secondaryText)
- }
- return ("Waiting for transcript…", secondaryText)
- case .processing:
- let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
- return (msg, secondaryText)
- case .ready:
- return ("Notes ready", secondaryText)
- case .failed:
- let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if err.isEmpty {
- return ("Notes could not be generated", NSColor.systemRed)
- }
- return (err, NSColor.systemRed)
- }
- }
- @MainActor
- private func makeRecordedMeetingCard(recording: MeetingRecordingManager.RecordedMeeting, index: Int) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 12
- card.layer?.borderWidth = 1
- card.layer?.borderColor = palette.inputBorder.cgColor
- card.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.04) : NSColor.black.withAlphaComponent(0.03)).cgColor
- let title = makeLabel(recording.resolvedDisplayTitle, size: 15, color: primaryText, weight: .semibold, centered: false)
- title.translatesAutoresizingMaskIntoConstraints = false
- title.maximumNumberOfLines = 2
- title.lineBreakMode = .byTruncatingTail
- let dateLabel = makeLabel(aiCompanionCardDateLabel(for: recording.startedAt), size: 12, color: secondaryText, weight: .regular, centered: false)
- dateLabel.translatesAutoresizingMaskIntoConstraints = false
- let savedTimeLabel = makeLabel(aiCompanionCardSavedTimeLabel(for: recording.endedAt), size: 12, color: secondaryText, weight: .regular, centered: false)
- savedTimeLabel.translatesAutoresizingMaskIntoConstraints = false
- let notesStatus = localRecordingNotesStatus(for: recording)
- let hasTranscript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
- let transcriptionBusy = aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id)
- let notesReady = notesStatus == .ready
- let getNotesTitle = notesReady ? "View Notes" : "Get Notes"
- let getNotesBtn = NSButton(title: getNotesTitle, target: self, action: #selector(recordingGetNotesTapped(_:)))
- getNotesBtn.translatesAutoresizingMaskIntoConstraints = false
- getNotesBtn.tag = index
- let getNotesEnabled = (notesStatus != .processing)
- && (notesReady || notesStatus == .failed || (hasTranscript && !transcriptionBusy))
- getNotesBtn.isEnabled = getNotesEnabled
- styleAiCompanionGetNotesButton(getNotesBtn, enabled: getNotesEnabled)
- let statusInfo = localRecordingNotesStatusLabel(for: recording, notesStatus: notesStatus)
- let statusLabel = makeLabel(statusInfo.text, size: 12, color: statusInfo.color, weight: .regular, centered: false)
- statusLabel.translatesAutoresizingMaskIntoConstraints = false
- statusLabel.maximumNumberOfLines = 3
- statusLabel.lineBreakMode = .byWordWrapping
- if let cell = statusLabel.cell as? NSTextFieldCell {
- cell.wraps = true
- }
- card.addSubview(title)
- card.addSubview(dateLabel)
- card.addSubview(savedTimeLabel)
- card.addSubview(getNotesBtn)
- card.addSubview(statusLabel)
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
- title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8),
- dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- dateLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- savedTimeLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 4),
- savedTimeLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- savedTimeLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- getNotesBtn.topAnchor.constraint(equalTo: savedTimeLabel.bottomAnchor, constant: 12),
- getNotesBtn.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- getNotesBtn.heightAnchor.constraint(equalToConstant: aiCompanionGetNotesButtonHeight),
- getNotesBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 108),
- statusLabel.topAnchor.constraint(equalTo: getNotesBtn.bottomAnchor, constant: 8),
- statusLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- statusLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- statusLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
- ])
- return card
- }
- private func bodyTextForLocalRecordingTranscript(recording: MeetingRecordingManager.RecordedMeeting) -> String {
- if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
- return "Generating transcript…"
- }
- if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
- return "Transcript could not be generated.\n\n\(err)"
- }
- if let text = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false {
- return text
- }
- return "Transcript will appear here after the audio is processed. You can leave this window open; it updates when the transcript is ready. If nothing appears, check Speech Recognition in System Settings → Privacy & Security."
- }
- private func bodyTextForLocalRecordingNotes(recording: MeetingRecordingManager.RecordedMeeting) -> String {
- switch localRecordingNotesStatus(for: recording) {
- case .processing:
- return localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
- case .failed:
- let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- return err.isEmpty ? "Notes could not be generated." : "Notes unavailable.\n\n\(err)"
- case .ready:
- return recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- case .notRequested:
- return "Tap Get notes on the recording card after the transcript is ready."
- }
- }
- @MainActor
- private func refreshLocalRecordingNotesPanelContentIfNeeded() {
- guard let recordingID = aiCompanionLocalNotesPanelRecordingID,
- let textView = aiCompanionLocalNotesTextView,
- let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
- textView.string = bodyTextForLocalRecordingNotes(recording: recording)
- }
- @MainActor
- private func presentLocalRecordingNotesPanel(recording: MeetingRecordingManager.RecordedMeeting, initialText: String?) {
- guard let hostWindow = view.window else { return }
- let meetingTitle = recording.resolvedDisplayTitle
- let trimmedInitial = initialText?.trimmingCharacters(in: .whitespacesAndNewlines)
- let body: String = {
- if let trimmedInitial, trimmedInitial.isEmpty == false { return trimmedInitial }
- return bodyTextForLocalRecordingNotes(recording: recording)
- }()
- let hostIsDarkMode = darkModeEnabled
- let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
- let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
- let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
- if let panel = aiCompanionLocalNotesPanel, let textView = aiCompanionLocalNotesTextView {
- panel.title = "Notes - \(meetingTitle)"
- panel.appearance = NSAppearance(named: panelAppearanceName)
- panel.backgroundColor = outerBackgroundColor
- textView.string = body
- aiCompanionLocalNotesPanelRecordingID = recording.id
- aiCompanionLocalNotesRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
- aiCompanionLocalNotesHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
- aiCompanionLocalNotesSubtitleLabel?.stringValue = meetingTitle
- if hostWindow.attachedSheet !== panel {
- hostWindow.beginSheet(panel, completionHandler: nil)
- }
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- return
- }
- let panelWidth: CGFloat = 720
- let panelHeight: CGFloat = 540
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- panel.isReleasedWhenClosed = false
- panel.title = "Notes - \(meetingTitle)"
- panel.isFloatingPanel = false
- panel.hidesOnDeactivate = false
- panel.delegate = self
- panel.appearance = NSAppearance(named: panelAppearanceName)
- panel.backgroundColor = outerBackgroundColor
- panel.standardWindowButton(.zoomButton)?.isHidden = true
- panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = outerBackgroundColor.cgColor
- let header = NSView()
- header.translatesAutoresizingMaskIntoConstraints = false
- header.wantsLayer = true
- header.layer?.backgroundColor = headerBackgroundColor.cgColor
- header.layer?.cornerRadius = 10
- let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
- let titleLabel = textLabel("Meeting notes", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- subtitleLabel.lineBreakMode = .byTruncatingTail
- let scroll = NSScrollView()
- scroll.translatesAutoresizingMaskIntoConstraints = false
- scroll.drawsBackground = true
- scroll.backgroundColor = NSColor.textBackgroundColor
- scroll.hasVerticalScroller = true
- scroll.hasHorizontalScroller = false
- scroll.borderType = .bezelBorder
- let textView = NSTextView()
- textView.isEditable = false
- textView.isSelectable = true
- textView.backgroundColor = NSColor.textBackgroundColor
- textView.textColor = NSColor.labelColor
- textView.font = .systemFont(ofSize: 13, weight: .regular)
- textView.string = body
- textView.textContainer?.widthTracksTextView = true
- textView.textContainerInset = NSSize(width: 10, height: 10)
- let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalNotesCopyTapped(_:)))
- copyButton.translatesAutoresizingMaskIntoConstraints = false
- copyButton.bezelStyle = .rounded
- let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalNotesCloseTapped(_:)))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.bezelStyle = .rounded
- scroll.documentView = textView
- header.addSubview(titleLabel)
- header.addSubview(subtitleLabel)
- header.addSubview(copyButton)
- header.addSubview(closeButton)
- root.addSubview(header)
- root.addSubview(scroll)
- NSLayoutConstraint.activate([
- header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
- header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
- header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
- titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
- subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
- subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
- subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
- subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
- copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
- copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
- closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
- closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
- scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
- scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
- scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
- ])
- panel.contentView = root
- aiCompanionLocalNotesPanel = panel
- aiCompanionLocalNotesTextView = textView
- aiCompanionLocalNotesRootView = root
- aiCompanionLocalNotesHeaderView = header
- aiCompanionLocalNotesSubtitleLabel = subtitleLabel
- aiCompanionLocalNotesPanelRecordingID = recording.id
- hostWindow.beginSheet(panel, completionHandler: nil)
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- }
- @MainActor
- private func startLocalRecordingNotesProcessing(recordingId: String, transcript: String, requestId: UUID, autoPresentOnReady: Bool) {
- localRecordingNotesTaskByRecordingId[recordingId]?.cancel()
- localRecordingNotesRequestIdByRecordingId[recordingId] = requestId
- localRecordingNotesProgressByRecordingId[recordingId] = "Generating notes…"
- MeetingRecordingManager.shared.setMeetingNotes(
- recordingId: recordingId,
- notesStatusRaw: LocalRecordingNotesStatus.processing.rawValue,
- notesText: nil,
- notesError: nil
- )
- recordedMeetings = MeetingRecordingManager.shared.recordings
- renderAiCompanionList()
- let task = Task { [weak self] in
- guard let self else { return }
- do {
- // Notes language must follow the transcript text, not Apple Speech locale prefs
- // (e.g. Urdu selected for recognition must not force Urdu notes for an English transcript).
- let notes = try await self.meetingNotesService.generateNotes(from: transcript)
- if Task.isCancelled {
- await MainActor.run { [weak self] in
- guard let self else { return }
- if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
- self.localRecordingNotesTaskByRecordingId[recordingId] = nil
- }
- }
- return
- }
- await MainActor.run { [weak self] in
- guard let self else { return }
- guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
- self.localRecordingNotesProgressByRecordingId[recordingId] = nil
- MeetingRecordingManager.shared.setMeetingNotes(
- recordingId: recordingId,
- notesStatusRaw: LocalRecordingNotesStatus.ready.rawValue,
- notesText: notes,
- notesError: nil
- )
- self.recordedMeetings = MeetingRecordingManager.shared.recordings
- self.refreshLocalRecordingNotesPanelContentIfNeeded()
- self.renderAiCompanionList()
- if autoPresentOnReady, let rec = self.recordedMeetings.first(where: { $0.id == recordingId }) {
- self.presentLocalRecordingNotesPanel(recording: rec, initialText: notes)
- }
- if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
- self.localRecordingNotesTaskByRecordingId[recordingId] = nil
- }
- }
- } catch {
- if error is CancellationError {
- await MainActor.run { [weak self] in
- guard let self else { return }
- if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
- self.localRecordingNotesTaskByRecordingId[recordingId] = nil
- }
- }
- return
- }
- await MainActor.run { [weak self] in
- guard let self else { return }
- guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
- self.localRecordingNotesProgressByRecordingId[recordingId] = nil
- let msg = error.localizedDescription.isEmpty ? "Failed to generate notes." : error.localizedDescription
- MeetingRecordingManager.shared.setMeetingNotes(
- recordingId: recordingId,
- notesStatusRaw: LocalRecordingNotesStatus.failed.rawValue,
- notesText: nil,
- notesError: msg
- )
- self.recordedMeetings = MeetingRecordingManager.shared.recordings
- self.refreshLocalRecordingNotesPanelContentIfNeeded()
- self.renderAiCompanionList()
- if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
- self.localRecordingNotesTaskByRecordingId[recordingId] = nil
- }
- }
- }
- }
- localRecordingNotesTaskByRecordingId[recordingId] = task
- }
- @objc private func recordingGetNotesTapped(_ sender: NSButton) {
- let idx = sender.tag
- guard recordedMeetings.indices.contains(idx) else { return }
- let recording = recordedMeetings[idx]
- let status = localRecordingNotesStatus(for: recording)
- if status == .ready,
- let text = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
- text.isEmpty == false {
- presentLocalRecordingNotesPanel(recording: recording, initialText: text)
- return
- }
- if status == .processing { return }
- if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
- showSimpleAlert(title: "Transcript not ready", message: "Wait until the transcript finishes generating, then try Get notes again.")
- return
- }
- let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if transcript.isEmpty {
- if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
- showSimpleAlert(title: "No transcript", message: "Transcription failed for this recording, so notes cannot be generated.")
- } else {
- showSimpleAlert(title: "No transcript yet", message: "Wait until the transcript is available (or open Transcript to check progress), then tap Get notes.")
- }
- return
- }
- let requestId = UUID()
- startLocalRecordingNotesProcessing(recordingId: recording.id, transcript: transcript, requestId: requestId, autoPresentOnReady: true)
- }
- @objc private func aiCompanionLocalNotesCopyTapped(_ sender: NSButton) {
- let text = aiCompanionLocalNotesTextView?.string ?? ""
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- }
- @objc private func aiCompanionLocalNotesCloseTapped(_ sender: NSButton) {
- guard let panel = aiCompanionLocalNotesPanel, let host = view.window else { return }
- host.endSheet(panel)
- }
- @MainActor
- private func refreshLocalRecordingTranscriptPanelContentIfNeeded() {
- guard let recordingID = aiCompanionLocalTranscriptPanelRecordingID,
- let textView = aiCompanionLocalTranscriptTextView,
- let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
- textView.string = bodyTextForLocalRecordingTranscript(recording: recording)
- }
- @MainActor
- private func presentLocalRecordingTranscriptPanel(recording: MeetingRecordingManager.RecordedMeeting) {
- guard let hostWindow = view.window else { return }
- let meetingTitle = recording.resolvedDisplayTitle
- let body = bodyTextForLocalRecordingTranscript(recording: recording)
- let hostIsDarkMode = darkModeEnabled
- let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
- let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
- let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
- if let panel = aiCompanionLocalTranscriptPanel, let textView = aiCompanionLocalTranscriptTextView {
- panel.title = "Transcript - \(meetingTitle)"
- panel.appearance = NSAppearance(named: panelAppearanceName)
- panel.backgroundColor = outerBackgroundColor
- textView.string = body
- aiCompanionLocalTranscriptPanelRecordingID = recording.id
- aiCompanionLocalTranscriptRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
- aiCompanionLocalTranscriptHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
- aiCompanionLocalTranscriptSubtitleLabel?.stringValue = meetingTitle
- if hostWindow.attachedSheet !== panel {
- hostWindow.beginSheet(panel, completionHandler: nil)
- }
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- return
- }
- let panelWidth: CGFloat = 720
- let panelHeight: CGFloat = 540
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
- styleMask: [.titled, .closable],
- backing: .buffered,
- defer: false
- )
- panel.isReleasedWhenClosed = false
- panel.title = "Transcript - \(meetingTitle)"
- panel.isFloatingPanel = false
- panel.hidesOnDeactivate = false
- panel.delegate = self
- panel.appearance = NSAppearance(named: panelAppearanceName)
- panel.backgroundColor = outerBackgroundColor
- panel.standardWindowButton(.zoomButton)?.isHidden = true
- panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = outerBackgroundColor.cgColor
- let header = NSView()
- header.translatesAutoresizingMaskIntoConstraints = false
- header.wantsLayer = true
- header.layer?.backgroundColor = headerBackgroundColor.cgColor
- header.layer?.cornerRadius = 10
- let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
- let titleLabel = textLabel("Transcript", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
- titleLabel.translatesAutoresizingMaskIntoConstraints = false
- let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
- subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
- subtitleLabel.lineBreakMode = .byTruncatingTail
- let scroll = NSScrollView()
- scroll.translatesAutoresizingMaskIntoConstraints = false
- scroll.drawsBackground = true
- scroll.backgroundColor = NSColor.textBackgroundColor
- scroll.hasVerticalScroller = true
- scroll.hasHorizontalScroller = false
- scroll.borderType = .bezelBorder
- let textView = NSTextView()
- textView.isEditable = false
- textView.isSelectable = true
- textView.backgroundColor = NSColor.textBackgroundColor
- textView.textColor = NSColor.labelColor
- textView.font = .systemFont(ofSize: 13, weight: .regular)
- textView.string = body
- textView.textContainer?.widthTracksTextView = true
- textView.textContainerInset = NSSize(width: 10, height: 10)
- let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalTranscriptCopyTapped(_:)))
- copyButton.translatesAutoresizingMaskIntoConstraints = false
- copyButton.bezelStyle = .rounded
- let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalTranscriptCloseTapped(_:)))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.bezelStyle = .rounded
- scroll.documentView = textView
- header.addSubview(titleLabel)
- header.addSubview(subtitleLabel)
- header.addSubview(copyButton)
- header.addSubview(closeButton)
- root.addSubview(header)
- root.addSubview(scroll)
- NSLayoutConstraint.activate([
- header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
- header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
- header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
- titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
- titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
- subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
- subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
- subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
- subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
- copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
- copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
- closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
- closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
- scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
- scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
- scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
- ])
- panel.contentView = root
- aiCompanionLocalTranscriptPanel = panel
- aiCompanionLocalTranscriptTextView = textView
- aiCompanionLocalTranscriptRootView = root
- aiCompanionLocalTranscriptHeaderView = header
- aiCompanionLocalTranscriptSubtitleLabel = subtitleLabel
- aiCompanionLocalTranscriptPanelRecordingID = recording.id
- hostWindow.beginSheet(panel, completionHandler: nil)
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- }
- @objc private func recordingTranscriptTapped(_ sender: NSButton) {
- let idx = sender.tag
- guard recordedMeetings.indices.contains(idx) else { return }
- presentLocalRecordingTranscriptPanel(recording: recordedMeetings[idx])
- }
- @objc private func aiCompanionLocalTranscriptCopyTapped(_ sender: NSButton) {
- let text = aiCompanionLocalTranscriptTextView?.string ?? ""
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(text, forType: .string)
- }
- @objc private func aiCompanionLocalTranscriptCloseTapped(_ sender: NSButton) {
- guard let panel = aiCompanionLocalTranscriptPanel, let host = view.window else { return }
- host.endSheet(panel)
- }
- @objc
- private func recordingPlayTapped(_ sender: NSButton) {
- let idx = sender.tag
- guard recordedMeetings.indices.contains(idx) else { return }
- let recording = recordedMeetings[idx]
- if recordedMeetingsPlayingID == recording.id {
- recordedMeetingsPlayer?.stop()
- recordedMeetingsPlayer = nil
- recordedMeetingsPlayingID = nil
- renderAiCompanionList()
- return
- }
- do {
- let url = try MeetingRecordingManager.shared.audioURL(for: recording)
- recordedMeetingsPlayer?.stop()
- recordedMeetingsPlayer = try AVAudioPlayer(contentsOf: url)
- recordedMeetingsPlayer?.prepareToPlay()
- recordedMeetingsPlayer?.play()
- recordedMeetingsPlayingID = recording.id
- renderAiCompanionList()
- } catch {
- showSimpleAlert(title: "Unable to play", message: "Could not open the audio recording.")
- }
- }
- @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() {
- // Removed: Zoom AI summaries fetch (AI Companion now shows local recordings only).
- }
- @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
- // Removed: Zoom AI summaries range filter (AI Companion now shows local recordings only).
- }
- @objc private func aiCompanionRowTapped(_ sender: NSButton) {
- // Removed: Zoom AI summaries row selection (AI Companion now shows local recordings only).
- }
- @objc private func aiCompanionOpenDocTapped() {
- // Removed: Zoom Docs deep link (AI Companion now shows local recordings only).
- }
- @objc private func aiCompanionCopyMarkdownTapped() {
- // Removed: Zoom AI summary markdown export (AI Companion now shows local recordings only).
- }
- 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,
- startTime: meeting.start.timeIntervalSince1970,
- endTime: meeting.end?.timeIntervalSince1970,
- timeZoneIdentifier: meeting.meetingTimeZone?.identifier
- )
- }
- 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 else { return }
- self.joinZoomMeetingFromLink(link, displayName: nil)
- }
- }
- /// Meetings + Ai Companion filter rows: preset + dates stay near intrinsic width; flexible gap before Apply/Reset/Refresh.
- private func configureResponsiveMeetingsStyleFilterRow(
- filterRow: NSStackView,
- categoryFilter: NSPopUpButton,
- fromDatePicker: NSDatePicker,
- toDatePicker: NSDatePicker,
- applyFiltersButton: NSButton,
- resetFiltersButton: NSButton,
- refreshButton: NSButton
- ) {
- filterRow.distribution = .fill
- filterRow.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
- spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- filterRow.insertArrangedSubview(spacer, at: 3)
- categoryFilter.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- categoryFilter.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
- fromDatePicker.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- fromDatePicker.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
- toDatePicker.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- toDatePicker.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
- applyFiltersButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- applyFiltersButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- resetFiltersButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
- resetFiltersButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- refreshButton.setContentHuggingPriority(.required, for: .horizontal)
- refreshButton.setContentCompressionResistancePriority(.required, for: .horizontal)
- NSLayoutConstraint.activate([
- categoryFilter.widthAnchor.constraint(greaterThanOrEqualToConstant: 120),
- fromDatePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 170),
- toDatePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 170),
- fromDatePicker.widthAnchor.constraint(equalTo: toDatePicker.widthAnchor),
- applyFiltersButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
- resetFiltersButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
- refreshButton.widthAnchor.constraint(equalToConstant: 44),
- applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
- resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
- refreshButton.heightAnchor.constraint(equalToConstant: 34)
- ])
- }
- 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
- configureResponsiveMeetingsStyleFilterRow(
- filterRow: filterRow,
- categoryFilter: categoryFilter,
- fromDatePicker: fromDatePicker,
- toDatePicker: toDatePicker,
- applyFiltersButton: applyFiltersButton,
- resetFiltersButton: resetFiltersButton,
- refreshButton: refreshButton
- )
- 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),
- 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()
- for observer in calendarDayChangeObservers {
- NotificationCenter.default.removeObserver(observer)
- }
- calendarDayChangeObservers.removeAll()
- if let workspaceWakeObserver {
- NSWorkspace.shared.notificationCenter.removeObserver(workspaceWakeObserver)
- self.workspaceWakeObserver = nil
- }
- clockTimer?.invalidate()
- }
- 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 Companion AI for Zoom?"
- 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 installCalendarDayChangeObservers() {
- guard calendarDayChangeObservers.isEmpty else { return }
- let center = NotificationCenter.default
- let dayChangeNames: [Notification.Name] = [.NSCalendarDayChanged, .NSSystemClockDidChange]
- for name in dayChangeNames {
- let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
- Task { @MainActor [weak self] in
- self?.refreshHomeForCurrentDayIfNeeded(force: true)
- }
- }
- calendarDayChangeObservers.append(observer)
- }
- workspaceWakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
- forName: NSWorkspace.didWakeNotification,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- Task { @MainActor [weak self] in
- self?.refreshHomeForCurrentDayIfNeeded(force: true)
- }
- }
- }
- /// When the calendar day changes (midnight, wake from sleep, or return to the app), reset the home UI to today and reload meetings.
- @MainActor
- private func refreshHomeForCurrentDayIfNeeded(force: Bool = false) {
- let calendar = Calendar.current
- let todayStart = calendar.startOfDay(for: Date())
- let dayChanged = calendar.isDate(todayStart, inSameDayAs: lastObservedCalendarDayStart) == false
- guard dayChanged || force else { return }
- if dayChanged {
- lastObservedCalendarDayStart = todayStart
- applyCalendarDayRolloverRefresh()
- return
- }
- updateClock()
- guard hasBuiltInitialHomeView else { return }
- updateMeetingsDayUI()
- applyFilteredMeetings()
- applyMeetingsPageFilters(resetVisible: false)
- renderSchedulerCalendarGrid()
- if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
- triggerMeetingsRefresh(force: false)
- }
- }
- @MainActor
- private func applyCalendarDayRolloverRefresh() {
- updateClock()
- guard hasBuiltInitialHomeView else { return }
- setSelectedMeetingsDayStart(Date())
- applyMeetingsPageFilters(resetVisible: true)
- renderAiCompanionList()
- if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
- triggerMeetingsRefresh(force: true)
- }
- }
- private func startClock() {
- clockTimer?.invalidate()
- clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
- Task { @MainActor [weak self] in
- self?.updateClock()
- }
- }
- Task { @MainActor in
- updateClock()
- }
- }
- @MainActor
- private func updateClock() {
- refreshHomeForCurrentDayIfNeeded()
- 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 menuBarOpenTopMeeting(joinLink: String?) {
- if let link = joinLink?.trimmingCharacters(in: .whitespacesAndNewlines),
- link.isEmpty == false {
- joinZoomMeetingFromLink(link, displayName: nil)
- return
- }
- menuBarShowSection(.meetings)
- }
- func menuBarSyncTopMeetingsForStatusMenu() {
- updateWidgetMeetingStoreFromCurrentMeetings()
- }
- func menuBarTriggerNewMeeting() {
- guard isUserLoggedIn() else {
- showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
- return
- }
- Task {
- do {
- let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
- guard configured else { return }
- let parentWindow = await MainActor.run { self.view.window }
- let token = try await zoomOAuth.validAccessToken(
- presentingWindow: parentWindow,
- requiringAnyOf: ["meeting:write:meeting"]
- )
- let result = try await createZoomInstantMeeting(accessToken: token)
- guard let joinURL = self.embeddedWebClientURLForZoomMeeting(
- meetingId: result.id,
- joinURLString: result.join_url,
- startURLString: result.start_url
- ) else {
- await MainActor.run {
- self.showSimpleAlert(
- title: "Unable to start",
- message: "Zoom did not return a join link for this meeting. Confirm your Marketplace app includes meeting creation (meeting:write:meeting), then sign in again."
- )
- }
- return
- }
- await MainActor.run {
- self.openWebURLPreferringInApp(
- joinURL,
- zoomMeetingDisplayName: "Instant meeting",
- recordingConsentWindowTitle: "New Meeting"
- )
- }
- } catch {
- await MainActor.run {
- if case ZoomOAuthError.missingRequiredScope(_) = error {
- self.zoomOAuth.clearSavedTokens()
- self.showSimpleAlert(
- title: "Zoom permissions",
- message: "Your Zoom app needs the meeting:write:meeting scope to start meetings from the app. Add it in the Zoom Marketplace, 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 starting another meeting. Try again in about \(minutes) min.")
- } else if case ZoomOAuthError.authenticationCancelled = error {
- return
- } else {
- self.showSimpleError("Could not start meeting", error: error)
- }
- }
- }
- }
- }
- 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 {
- recordedMeetings = MeetingRecordingManager.shared.recordings
- renderAiCompanionList()
- updateAiCompanionRecordingUI()
- }
- }
- 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] _ in
- self?.joinScheduledMeeting(meeting)
- }
- )
- 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: WKScriptMessageHandler {
- func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
- guard message.name == Self.embeddedZoomMeetingLifecycleHandlerName else { return }
- guard let href = message.body as? String, let url = URL(string: href) else { return }
- Task { @MainActor in
- let title = self.embeddedBrowserWebView?.title
- self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
- }
- }
- }
- 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
- }
- if let targetURL = navigationAction.request.url {
- Task { @MainActor in
- self.updateEmbeddedMeetingRecordingPresence(for: targetURL, webViewTitle: webView.title)
- }
- }
- decisionHandler(.allow)
- }
- func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
- guard webView === embeddedBrowserWebView else { return }
- updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
- applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
- refreshEmbeddedBrowserNavButtons()
- }
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- guard webView === embeddedBrowserWebView else { return }
- updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
- applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
- if let pageURL = webView.url, isZoomEmbeddedWebClientInCallURL(pageURL) {
- embeddedZoomPendingWebAuth = nil
- embeddedZoomPendingJoinURL = nil
- }
- 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
- }
- func webView(
- _ webView: WKWebView,
- requestMediaCapturePermissionFor origin: WKSecurityOrigin,
- initiatedByFrame frame: WKFrameInfo,
- type: WKMediaCaptureType,
- decisionHandler: @escaping (WKPermissionDecision) -> Void
- ) {
- guard webView === embeddedBrowserWebView else {
- decisionHandler(.prompt)
- return
- }
- Task {
- let grant: Bool
- switch type {
- case .camera:
- grant = await cameraAccessGrantedForWebCapture()
- case .microphone:
- grant = await microphoneAccessGrantedForWebCapture()
- case .cameraAndMicrophone:
- let camera = await cameraAccessGrantedForWebCapture()
- let microphone = await microphoneAccessGrantedForWebCapture()
- grant = camera && microphone
- @unknown default:
- grant = false
- }
- await MainActor.run {
- decisionHandler(grant ? .grant : .deny)
- }
- }
- }
- }
- extension ViewController: NSWindowDelegate {
- func windowShouldClose(_ sender: NSWindow) -> Bool {
- if let panel = joinMeetingWindow, sender === panel, let host = view.window {
- host.endSheet(panel)
- return false
- }
- if let panel = aiCompanionLocalTranscriptPanel, sender === panel, let host = view.window {
- host.endSheet(panel)
- return false
- }
- if let panel = aiCompanionLocalNotesPanel, sender === panel, let host = view.window {
- host.endSheet(panel)
- return false
- }
- return true
- }
- 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?
- /// When set, Zoom OAuth runs in this session instead of a separate window (unused; sign-in uses `InAppOAuthWindowController`).
- @MainActor
- var embeddedOAuthSession: (@MainActor (URL, String, Double) async throws -> URL)?
- /// Called after a successful interactive OAuth flow (separate sign-in window).
- @MainActor
- var onInteractiveSignInCompleted: (() -> Void)?
- 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",
- "user_zak:read"
- ]
- 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 }
- do {
- let callbackURL: URL
- if let runEmbeddedOAuth = await MainActor.run(body: { embeddedOAuthSession }) {
- callbackURL = try await runEmbeddedOAuth(authURL, redirectURI, 180)
- } else {
- let oauthWindow = await MainActor.run { makeOAuthWindowController() }
- 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
- }
- let tokens = try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
- await MainActor.run { self.onInteractiveSignInCompleted?() }
- return tokens
- } 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",
- websiteDataStore: zoomEmbeddedWebsiteDataStore,
- processPool: zoomEmbeddedWebProcessPool
- )
- 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")
- case "user_zak:read":
- return granted.contains("user_zak") || granted.contains("user:read:zak")
- 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,
- websiteDataStore: WKWebsiteDataStore = .nonPersistent(),
- processPool: WKProcessPool? = nil
- ) {
- let configuration = WKWebViewConfiguration()
- configuration.websiteDataStore = websiteDataStore
- if let processPool {
- configuration.processPool = processPool
- }
- 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 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)
- }
- }
|