ViewController.swift 570 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596105971059810599106001060110602106031060410605106061060710608106091061010611106121061310614106151061610617106181061910620106211062210623106241062510626106271062810629106301063110632106331063410635106361063710638106391064010641106421064310644106451064610647106481064910650106511065210653106541065510656106571065810659106601066110662106631066410665106661066710668106691067010671106721067310674106751067610677106781067910680106811068210683106841068510686106871068810689106901069110692106931069410695106961069710698106991070010701107021070310704107051070610707107081070910710107111071210713107141071510716107171071810719107201072110722107231072410725107261072710728107291073010731107321073310734107351073610737107381073910740107411074210743107441074510746107471074810749107501075110752107531075410755107561075710758107591076010761107621076310764107651076610767107681076910770107711077210773107741077510776107771077810779107801078110782107831078410785107861078710788107891079010791107921079310794107951079610797107981079910800108011080210803108041080510806108071080810809108101081110812108131081410815108161081710818108191082010821108221082310824108251082610827108281082910830108311083210833108341083510836108371083810839108401084110842108431084410845108461084710848108491085010851108521085310854108551085610857108581085910860108611086210863108641086510866108671086810869108701087110872108731087410875108761087710878108791088010881108821088310884108851088610887108881088910890108911089210893108941089510896108971089810899109001090110902109031090410905109061090710908109091091010911109121091310914109151091610917109181091910920109211092210923109241092510926109271092810929109301093110932109331093410935109361093710938109391094010941109421094310944109451094610947109481094910950109511095210953109541095510956109571095810959109601096110962109631096410965109661096710968109691097010971109721097310974109751097610977109781097910980109811098210983109841098510986109871098810989109901099110992109931099410995109961099710998109991100011001110021100311004110051100611007110081100911010110111101211013110141101511016110171101811019110201102111022110231102411025110261102711028110291103011031110321103311034110351103611037110381103911040110411104211043110441104511046110471104811049110501105111052110531105411055110561105711058110591106011061110621106311064110651106611067110681106911070110711107211073110741107511076110771107811079110801108111082110831108411085110861108711088110891109011091110921109311094110951109611097110981109911100111011110211103111041110511106111071110811109111101111111112111131111411115111161111711118111191112011121111221112311124111251112611127111281112911130111311113211133111341113511136111371113811139111401114111142111431114411145111461114711148111491115011151111521115311154111551115611157111581115911160111611116211163111641116511166111671116811169111701117111172111731117411175111761117711178111791118011181111821118311184111851118611187111881118911190111911119211193111941119511196111971119811199112001120111202112031120411205112061120711208112091121011211112121121311214112151121611217112181121911220112211122211223112241122511226112271122811229112301123111232112331123411235112361123711238112391124011241112421124311244112451124611247112481124911250112511125211253112541125511256112571125811259112601126111262112631126411265112661126711268112691127011271112721127311274112751127611277112781127911280112811128211283112841128511286112871128811289112901129111292112931129411295112961129711298112991130011301113021130311304113051130611307113081130911310113111131211313113141131511316113171131811319113201132111322113231132411325113261132711328113291133011331113321133311334113351133611337113381133911340113411134211343113441134511346113471134811349113501135111352113531135411355113561135711358113591136011361113621136311364113651136611367113681136911370113711137211373113741137511376113771137811379113801138111382113831138411385113861138711388113891139011391113921139311394113951139611397113981139911400114011140211403114041140511406114071140811409114101141111412114131141411415114161141711418114191142011421114221142311424114251142611427114281142911430114311143211433114341143511436114371143811439114401144111442114431144411445114461144711448114491145011451114521145311454114551145611457114581145911460114611146211463114641146511466114671146811469114701147111472114731147411475114761147711478114791148011481114821148311484114851148611487114881148911490114911149211493114941149511496114971149811499115001150111502115031150411505115061150711508115091151011511115121151311514115151151611517115181151911520115211152211523115241152511526115271152811529115301153111532115331153411535115361153711538115391154011541115421154311544115451154611547115481154911550115511155211553115541155511556115571155811559115601156111562115631156411565115661156711568115691157011571115721157311574115751157611577115781157911580115811158211583115841158511586115871158811589115901159111592115931159411595115961159711598115991160011601116021160311604116051160611607116081160911610116111161211613116141161511616116171161811619116201162111622116231162411625116261162711628116291163011631116321163311634116351163611637116381163911640116411164211643116441164511646116471164811649116501165111652116531165411655116561165711658116591166011661116621166311664116651166611667116681166911670116711167211673116741167511676116771167811679116801168111682116831168411685116861168711688116891169011691116921169311694116951169611697116981169911700117011170211703117041170511706117071170811709117101171111712117131171411715117161171711718117191172011721117221172311724117251172611727117281172911730117311173211733117341173511736117371173811739117401174111742117431174411745117461174711748117491175011751117521175311754117551175611757117581175911760117611176211763117641176511766117671176811769117701177111772117731177411775117761177711778117791178011781117821178311784117851178611787117881178911790117911179211793117941179511796117971179811799118001180111802118031180411805118061180711808118091181011811118121181311814118151181611817118181181911820118211182211823118241182511826118271182811829118301183111832118331183411835118361183711838118391184011841118421184311844118451184611847118481184911850118511185211853118541185511856118571185811859118601186111862118631186411865118661186711868118691187011871118721187311874118751187611877118781187911880118811188211883118841188511886118871188811889118901189111892118931189411895118961189711898118991190011901119021190311904119051190611907119081190911910119111191211913119141191511916119171191811919119201192111922119231192411925119261192711928119291193011931119321193311934119351193611937119381193911940119411194211943119441194511946119471194811949119501195111952119531195411955119561195711958119591196011961119621196311964119651196611967119681196911970119711197211973119741197511976119771197811979119801198111982119831198411985119861198711988119891199011991119921199311994119951199611997119981199912000120011200212003120041200512006120071200812009120101201112012120131201412015120161201712018120191202012021120221202312024120251202612027120281202912030120311203212033120341203512036120371203812039120401204112042120431204412045120461204712048120491205012051120521205312054120551205612057120581205912060120611206212063120641206512066120671206812069120701207112072120731207412075120761207712078120791208012081120821208312084120851208612087120881208912090120911209212093120941209512096120971209812099121001210112102121031210412105121061210712108121091211012111121121211312114121151211612117121181211912120121211212212123121241212512126121271212812129121301213112132121331213412135121361213712138121391214012141121421214312144121451214612147121481214912150121511215212153121541215512156121571215812159121601216112162121631216412165121661216712168121691217012171121721217312174121751217612177121781217912180121811218212183121841218512186121871218812189121901219112192121931219412195121961219712198121991220012201122021220312204122051220612207122081220912210122111221212213122141221512216122171221812219122201222112222122231222412225122261222712228122291223012231122321223312234122351223612237122381223912240122411224212243122441224512246122471224812249122501225112252122531225412255122561225712258122591226012261122621226312264122651226612267122681226912270122711227212273122741227512276122771227812279122801228112282122831228412285122861228712288122891229012291122921229312294122951229612297122981229912300123011230212303123041230512306123071230812309123101231112312123131231412315123161231712318123191232012321123221232312324123251232612327123281232912330123311233212333123341233512336123371233812339123401234112342123431234412345123461234712348123491235012351123521235312354123551235612357123581235912360123611236212363123641236512366123671236812369123701237112372123731237412375123761237712378123791238012381123821238312384123851238612387123881238912390123911239212393123941239512396123971239812399124001240112402124031240412405124061240712408124091241012411124121241312414124151241612417124181241912420124211242212423124241242512426124271242812429124301243112432124331243412435124361243712438124391244012441124421244312444124451244612447124481244912450124511245212453124541245512456124571245812459124601246112462124631246412465
  1. //
  2. // ViewController.swift
  3. // zoom_app
  4. //
  5. // Created by Dev Mac 1 on 14/04/2026.
  6. //
  7. import Cocoa
  8. import Combine
  9. import CryptoKit
  10. import AVFoundation
  11. import Speech
  12. import StoreKit
  13. import SwiftUI
  14. import UserNotifications
  15. import WebKit
  16. /// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
  17. private enum AppWindowMetrics {
  18. static let defaultContentWidth: CGFloat = 1020
  19. static let defaultContentHeight: CGFloat = 690
  20. }
  21. private enum AppExternalURLs {
  22. static let privacyPolicy = URL(string: "https://sites.google.com/view/aicompanionforzoom/privacy-policy")!
  23. static let termsOfServices = URL(string: "https://sites.google.com/view/aicompanionforzoom/terms-and-condition")!
  24. static let support = URL(string: "https://sites.google.com/view/aicompanionforzoom/get-support")!
  25. }
  26. /// 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.
  27. private let embeddedZoomWebViewUserAgent =
  28. "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"
  29. /// Shared cookie jar for embedded Zoom meetings and Zoom OAuth so the in-app browser reuses the same Zoom web session.
  30. private let zoomEmbeddedWebsiteDataStore = WKWebsiteDataStore.default()
  31. /// Shared renderer process so OAuth cookies are visible to the embedded meeting web client.
  32. private let zoomEmbeddedWebProcessPool = WKProcessPool()
  33. /// Errors from the main-window embedded OAuth web session (right panel).
  34. private enum EmbeddedOAuthFlowError: Error {
  35. case webViewUnavailable
  36. case userCancelled
  37. case timedOut
  38. }
  39. @MainActor
  40. protocol EmbeddedOAuthPresenting: AnyObject {
  41. func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL
  42. }
  43. class ViewController: NSViewController {
  44. private static let embeddedZoomMeetingLifecycleHandlerName = "zoomAppMeetingLifecycle"
  45. private static let embeddedZoomMeetingLifecycleJavaScript = """
  46. (function() {
  47. function notify() {
  48. try {
  49. window.webkit.messageHandlers.\(embeddedZoomMeetingLifecycleHandlerName).postMessage(window.location.href || '');
  50. } catch (e) {}
  51. }
  52. var pushState = history.pushState;
  53. history.pushState = function() { pushState.apply(this, arguments); notify(); };
  54. var replaceState = history.replaceState;
  55. history.replaceState = function() { replaceState.apply(this, arguments); notify(); };
  56. window.addEventListener('popstate', notify);
  57. window.addEventListener('hashchange', notify);
  58. setInterval(notify, 1500);
  59. })();
  60. """
  61. /// Stops camera/mic tracks and attempts to leave an in-progress Zoom web meeting before tearing down the web view.
  62. private static let embeddedZoomWebClientTeardownJavaScript = """
  63. (function() {
  64. function stopAllMediaTracks() {
  65. try {
  66. document.querySelectorAll('video, audio').forEach(function(node) {
  67. var stream = node.srcObject;
  68. if (stream && typeof stream.getTracks === 'function') {
  69. stream.getTracks().forEach(function(track) { try { track.stop(); } catch (e) {} });
  70. }
  71. node.srcObject = null;
  72. });
  73. } catch (e) {}
  74. }
  75. function tryLeaveMeeting() {
  76. var selectors = [
  77. 'button[aria-label*="Leave"]',
  78. 'button[aria-label*="leave"]',
  79. 'button[aria-label*="End meeting"]',
  80. 'button[aria-label*="end meeting"]',
  81. 'button[aria-label*="Leave Meeting"]',
  82. '[data-testid="leave-meeting-button"]'
  83. ];
  84. for (var i = 0; i < selectors.length; i++) {
  85. try {
  86. var btn = document.querySelector(selectors[i]);
  87. if (btn && typeof btn.click === 'function') {
  88. btn.click();
  89. return;
  90. }
  91. } catch (e) {}
  92. }
  93. }
  94. tryLeaveMeeting();
  95. stopAllMediaTracks();
  96. })();
  97. """
  98. enum HomeSection: String {
  99. case home = "Home"
  100. case meetings = "Meetings"
  101. case scheduler = "Scheduler"
  102. case widgets = "Widgets"
  103. case aiCompanion = "AI Companion"
  104. case settings = "Settings"
  105. }
  106. private let googleOAuth = GoogleOAuthService.shared
  107. private let zoomOAuth = ZoomOAuthService.shared
  108. private let loginStateKey = "zoom_app.isLoggedIn"
  109. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  110. private let legacyNotificationsEnabledDefaultsKey = "settings.notificationsEnabled"
  111. private let remindersEnabledDefaultsKey = "reminders.enabled"
  112. private let remind1DayDefaultsKey = "reminders.1day"
  113. private let remind12HoursDefaultsKey = "reminders.12hours"
  114. private let remind1HourDefaultsKey = "reminders.1hour"
  115. private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
  116. private let ratingCompletedKey = "zoom_app.ratingCompleted"
  117. private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
  118. private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
  119. private struct Palette {
  120. let isDarkMode: Bool
  121. var appBackground: NSColor {
  122. isDarkMode
  123. ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  124. : NSColor(calibratedWhite: 0.96, alpha: 1)
  125. }
  126. var sidebarBackground: NSColor {
  127. isDarkMode
  128. ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
  129. : NSColor(calibratedWhite: 0.94, alpha: 1)
  130. }
  131. var sidebarActiveBackground: NSColor {
  132. isDarkMode
  133. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  134. : NSColor(calibratedWhite: 0.86, alpha: 1)
  135. }
  136. var cardBackground: NSColor {
  137. isDarkMode
  138. ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
  139. : NSColor.white
  140. }
  141. var secondaryCardBackground: NSColor {
  142. isDarkMode
  143. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  144. // Slightly darker than pure white so cards are visible on Light background.
  145. : NSColor(calibratedWhite: 0.93, alpha: 1)
  146. }
  147. var appShellBackground: NSColor { appBackground }
  148. var contentShellBackground: NSColor { appBackground }
  149. var topStripBackground: NSColor { chromeUnifiedBackground }
  150. var chromeUnifiedBackground: NSColor {
  151. isDarkMode
  152. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  153. : NSColor(calibratedWhite: 0.92, alpha: 1)
  154. }
  155. var searchPillBackground: NSColor {
  156. isDarkMode
  157. ? NSColor.white.withAlphaComponent(0.06)
  158. : NSColor.black.withAlphaComponent(0.06)
  159. }
  160. var meetingCardBackground: NSColor {
  161. isDarkMode
  162. ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
  163. : NSColor(calibratedWhite: 0.98, alpha: 1)
  164. }
  165. var sectionCard: NSColor {
  166. isDarkMode
  167. ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
  168. : NSColor.white
  169. }
  170. var inputBackground: NSColor {
  171. isDarkMode
  172. ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
  173. : NSColor(calibratedWhite: 0.97, alpha: 1)
  174. }
  175. var inputBorder: NSColor {
  176. isDarkMode
  177. ? NSColor.white.withAlphaComponent(0.08)
  178. : NSColor.black.withAlphaComponent(0.10)
  179. }
  180. var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
  181. var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
  182. var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
  183. var sidebarInactiveText: NSColor {
  184. isDarkMode ? secondaryText : NSColor(calibratedWhite: 0.12, alpha: 1)
  185. }
  186. }
  187. private final class TopAlignedClipView: NSClipView {
  188. override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
  189. var rect = super.constrainBoundsRect(proposedBounds)
  190. rect.origin.x = 0
  191. return rect
  192. }
  193. }
  194. private final class HoverButton: NSButton {
  195. var normalColor: NSColor = .clear { didSet { applyBackground() } }
  196. var hoverColor: NSColor = .clear
  197. var onHoverChanged: ((Bool) -> Void)?
  198. private var tracking: NSTrackingArea?
  199. private var hovering = false { didSet { applyBackground() } }
  200. override func updateTrackingAreas() {
  201. super.updateTrackingAreas()
  202. if let tracking { removeTrackingArea(tracking) }
  203. let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
  204. addTrackingArea(area)
  205. tracking = area
  206. }
  207. override func mouseEntered(with event: NSEvent) {
  208. hovering = true
  209. onHoverChanged?(true)
  210. }
  211. override func mouseExited(with event: NSEvent) {
  212. hovering = false
  213. onHoverChanged?(false)
  214. }
  215. private func applyBackground() {
  216. wantsLayer = true
  217. layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
  218. }
  219. }
  220. private final class HoverTileView: NSView {
  221. var onHoverChanged: ((Bool) -> Void)?
  222. private var tracking: NSTrackingArea?
  223. override func updateTrackingAreas() {
  224. super.updateTrackingAreas()
  225. if let tracking { removeTrackingArea(tracking) }
  226. let area = NSTrackingArea(
  227. rect: bounds,
  228. options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect],
  229. owner: self,
  230. userInfo: nil
  231. )
  232. addTrackingArea(area)
  233. tracking = area
  234. }
  235. override func mouseEntered(with event: NSEvent) {
  236. super.mouseEntered(with: event)
  237. onHoverChanged?(true)
  238. }
  239. override func mouseExited(with event: NSEvent) {
  240. super.mouseExited(with: event)
  241. onHoverChanged?(false)
  242. }
  243. }
  244. private var palette = Palette(isDarkMode: true)
  245. private var darkModeEnabled: Bool {
  246. get {
  247. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  248. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  249. }
  250. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  251. }
  252. private var remindersEnabled: Bool {
  253. get {
  254. let defaults = UserDefaults.standard
  255. if defaults.object(forKey: remindersEnabledDefaultsKey) != nil {
  256. return defaults.bool(forKey: remindersEnabledDefaultsKey)
  257. }
  258. // Migration: if the old single-toggle key exists, treat it as master reminders toggle.
  259. if defaults.object(forKey: legacyNotificationsEnabledDefaultsKey) != nil {
  260. return defaults.bool(forKey: legacyNotificationsEnabledDefaultsKey)
  261. }
  262. return true
  263. }
  264. set { UserDefaults.standard.set(newValue, forKey: remindersEnabledDefaultsKey) }
  265. }
  266. private var remind1Day: Bool {
  267. get {
  268. let defaults = UserDefaults.standard
  269. let stored = defaults.object(forKey: remind1DayDefaultsKey)
  270. return stored != nil ? defaults.bool(forKey: remind1DayDefaultsKey) : true
  271. }
  272. set { UserDefaults.standard.set(newValue, forKey: remind1DayDefaultsKey) }
  273. }
  274. private var remind12Hours: Bool {
  275. get {
  276. let defaults = UserDefaults.standard
  277. let stored = defaults.object(forKey: remind12HoursDefaultsKey)
  278. return stored != nil ? defaults.bool(forKey: remind12HoursDefaultsKey) : true
  279. }
  280. set { UserDefaults.standard.set(newValue, forKey: remind12HoursDefaultsKey) }
  281. }
  282. private var remind1Hour: Bool {
  283. get {
  284. let defaults = UserDefaults.standard
  285. let stored = defaults.object(forKey: remind1HourDefaultsKey)
  286. return stored != nil ? defaults.bool(forKey: remind1HourDefaultsKey) : true
  287. }
  288. set { UserDefaults.standard.set(newValue, forKey: remind1HourDefaultsKey) }
  289. }
  290. private let sidebarWidth: CGFloat = 78
  291. private var appBackground: NSColor { palette.appBackground }
  292. private var sidebarBackground: NSColor { palette.sidebarBackground }
  293. private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
  294. private var cardBackground: NSColor { palette.cardBackground }
  295. private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
  296. private var appShellBackground: NSColor { palette.appShellBackground }
  297. private var contentShellBackground: NSColor { palette.contentShellBackground }
  298. private var topStripBackground: NSColor { palette.topStripBackground }
  299. private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
  300. private var searchPillBackground: NSColor { palette.searchPillBackground }
  301. private var meetingCardBackground: NSColor { palette.meetingCardBackground }
  302. private let appShellCornerRadius: CGFloat = 20
  303. private let homeChromeHeaderHeight: CGFloat = 56
  304. private let nativeTrafficLightsLeading: CGFloat = 14
  305. private let nativeTrafficLightsTopInset: CGFloat = 20
  306. private let brandLeadingInset: CGFloat = 84
  307. private let brandFullScreenLeadingInset: CGFloat = 14
  308. private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
  309. private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
  310. private var primaryText: NSColor { palette.primaryText }
  311. private var secondaryText: NSColor { palette.secondaryText }
  312. private var mutedText: NSColor { palette.mutedText }
  313. private var sidebarInactiveText: NSColor { palette.sidebarInactiveText }
  314. private let rootContainer = NSView()
  315. private var homeView: NSView?
  316. private weak var googleButton: NSButton?
  317. private weak var timeLabel: NSTextField?
  318. private weak var dateLabel: NSTextField?
  319. private weak var meetingsDayHeaderLabel: NSTextField?
  320. private weak var emptyMeetingLabel: NSTextField?
  321. private weak var meetingsListStack: NSStackView?
  322. private weak var meetingsStatusLabel: NSTextField?
  323. private weak var meetingsScrollView: NSScrollView?
  324. private weak var meetingsPrevDayButton: NSButton?
  325. private weak var meetingsNextDayButton: NSButton?
  326. private weak var meetingsTodayButton: NSButton?
  327. private weak var refreshMeetingsButton: NSButton?
  328. private weak var homeWelcomeLabel: NSTextField?
  329. private weak var homeTimeLabelView: NSTextField?
  330. private weak var homeDateLabelView: NSTextField?
  331. private weak var homeActionsRow: NSView?
  332. private weak var homeMeetingsPanel: NSView?
  333. private weak var homePlaceholderLabel: NSTextField?
  334. private weak var homeSearchField: NSTextField?
  335. private weak var homeSearchPill: NSView?
  336. private weak var meetingSearchOverlayView: NSView?
  337. private weak var meetingSearchPanelView: NSView?
  338. private weak var meetingSearchField: NSTextField?
  339. private weak var meetingSearchResultsStack: NSStackView?
  340. private weak var meetingSearchEmptyLabel: NSTextField?
  341. private weak var homeSettingsView: NSView?
  342. private weak var homeSettingsHostColumn: NSView?
  343. private weak var schedulerRootView: NSView?
  344. private weak var schedulerMonthLabel: NSTextField?
  345. private weak var schedulerCalendarGridStack: NSStackView?
  346. private weak var schedulerSelectedDateLabel: NSTextField?
  347. private weak var schedulerMeetingsListStack: NSStackView?
  348. private weak var schedulerMeetingsEmptyLabel: NSTextField?
  349. private weak var schedulerTodayButton: NSButton?
  350. private weak var meetingsPageRootView: NSView?
  351. private weak var widgetsPageRootView: NSView?
  352. private weak var aiCompanionPageRootView: NSView?
  353. private weak var aiCompanionListStack: NSStackView?
  354. private weak var aiCompanionListScroll: NSScrollView?
  355. private weak var aiCompanionDetailScroll: NSScrollView?
  356. private weak var aiCompanionDetailStack: NSStackView?
  357. private weak var aiCompanionStatusLabel: NSTextField?
  358. private weak var aiCompanionCountLabel: NSTextField?
  359. private weak var aiCompanionRefreshButton: NSButton?
  360. private weak var aiCompanionRangePopup: NSPopUpButton?
  361. private weak var aiCompanionEmptyLabel: NSTextField?
  362. private weak var aiCompanionLoadingIndicator: NSProgressIndicator?
  363. private weak var aiCompanionDetailEmptyLabel: NSTextField?
  364. private weak var aiCompanionOpenDocButton: NSButton?
  365. private weak var aiCompanionCopyMarkdownButton: NSButton?
  366. private var aiCompanionRowButtons: [NSButton] = []
  367. private var aiCompanionSummaries: [AICompanionSummary] = []
  368. private var aiCompanionSelectedIndex: Int?
  369. private var aiCompanionIsLoading: Bool = false
  370. private var aiCompanionLastLoadedAt: Date?
  371. private var aiCompanionSelectedRange: String = "Last 30 days"
  372. // New AI Companion mode: local meeting recordings (audio)
  373. private var recordedMeetings: [MeetingRecordingManager.RecordedMeeting] = []
  374. private var recordedMeetingsPlayer: AVAudioPlayer?
  375. private var recordedMeetingsPlayingID: String?
  376. private weak var aiCompanionStopRecordingButton: NSButton?
  377. private weak var aiCompanionLocalTranscriptPanel: NSPanel?
  378. private weak var aiCompanionLocalTranscriptTextView: NSTextView?
  379. private weak var aiCompanionLocalTranscriptRootView: NSView?
  380. private weak var aiCompanionLocalTranscriptHeaderView: NSView?
  381. private weak var aiCompanionLocalTranscriptSubtitleLabel: NSTextField?
  382. private var aiCompanionLocalTranscriptPanelRecordingID: String?
  383. private weak var aiCompanionLocalNotesPanel: NSPanel?
  384. private weak var aiCompanionLocalNotesTextView: NSTextView?
  385. private weak var aiCompanionLocalNotesRootView: NSView?
  386. private weak var aiCompanionLocalNotesHeaderView: NSView?
  387. private weak var aiCompanionLocalNotesSubtitleLabel: NSTextField?
  388. private var aiCompanionLocalNotesPanelRecordingID: String?
  389. private enum AiCompanionRecordingFilter: String, CaseIterable {
  390. case all = "All"
  391. case today = "Today's meetings"
  392. case previousWeek = "Previous week"
  393. case thisWeek = "This week"
  394. case previousMonth = "Previous month"
  395. case custom = "Custom date"
  396. }
  397. private var aiCompanionRecordingFilter: AiCompanionRecordingFilter = .today
  398. private weak var aiCompanionFilterPopup: NSPopUpButton?
  399. private weak var aiCompanionFilterFromPicker: NSDatePicker?
  400. private weak var aiCompanionFilterToPicker: NSDatePicker?
  401. private weak var aiCompanionFilterApplyButton: NSButton?
  402. private weak var aiCompanionFilterResetButton: NSButton?
  403. private weak var aiCompanionFilterRefreshButton: NSButton?
  404. private let meetingNotesService = MeetingNotesService()
  405. private var localRecordingNotesTaskByRecordingId: [String: Task<Void, Never>] = [:]
  406. private var localRecordingNotesRequestIdByRecordingId: [String: UUID] = [:]
  407. private var localRecordingNotesProgressByRecordingId: [String: String] = [:]
  408. private weak var meetingsPageCategoryFilter: NSPopUpButton?
  409. private weak var meetingsPageFromDatePicker: NSDatePicker?
  410. private weak var meetingsPageToDatePicker: NSDatePicker?
  411. private weak var meetingsPageRefreshButton: NSButton?
  412. private weak var meetingsPageCountLabel: NSTextField?
  413. private weak var meetingsPageStatusLabel: NSTextField?
  414. private weak var meetingsPageListStack: NSStackView?
  415. private weak var meetingsPageEmptyLabel: NSTextField?
  416. private weak var meetingsPageLoadMoreButton: NSButton?
  417. private weak var meetingsPageScrollView: NSScrollView?
  418. private weak var embeddedBrowserWebView: WKWebView?
  419. private weak var embeddedBrowserPanel: NSView?
  420. private weak var homeBodyRowView: NSView?
  421. private weak var homeMainContentColumnView: NSView?
  422. private weak var embeddedBrowserBackButton: NSButton?
  423. private weak var embeddedBrowserForwardButton: NSButton?
  424. private var embeddedBrowserPanelWidthConstraint: NSLayoutConstraint?
  425. private var embeddedBrowserMainColumnCollapsedWidthConstraint: NSLayoutConstraint?
  426. private var oauthSessionContinuation: CheckedContinuation<URL, Error>?
  427. private var oauthSessionCallbackPrefix: String?
  428. /// ZAK + display name for the next embedded join; used to re-auth the web client if sign-in or guest join appears.
  429. private var embeddedZoomPendingWebAuth: (zak: String, displayName: String?)?
  430. /// Normalized `/wc/join/` URL for the in-flight embedded join (used to recover from web sign-in redirects).
  431. private var embeddedZoomPendingJoinURL: URL?
  432. private var zoomSignedInDisplayName: String?
  433. /// Gap between main column and right web panel (must match Auto Layout constant).
  434. private let homeEmbeddedBrowserColumnGap: CGFloat = 10
  435. private weak var homePremiumLockOverlay: NSButton?
  436. private weak var meetingsPremiumLockOverlay: NSButton?
  437. private weak var schedulerPremiumLockOverlay: NSButton?
  438. private var schedulerDayButtons: [Date: NSButton] = [:]
  439. private var schedulerDateByButtonTag: [Int: Date] = [:]
  440. private var nextSchedulerButtonTag: Int = 1
  441. private weak var settingsDarkModeSwitch: NSSwitch?
  442. private weak var settingsRemindersMasterSwitch: NSSwitch?
  443. private weak var settingsReminders1DaySwitch: NSSwitch?
  444. private weak var settingsReminders12HoursSwitch: NSSwitch?
  445. private weak var settingsReminders1HourSwitch: NSSwitch?
  446. private weak var settingsReminders1DayRow: NSView?
  447. private weak var settingsReminders12HoursRow: NSView?
  448. private weak var settingsReminders1HourRow: NSView?
  449. private weak var settingsUpgradeButton: NSButton?
  450. private weak var settingsUpgradeTitleLabel: NSTextField?
  451. private weak var settingsNotificationsBlock: NSStackView?
  452. private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
  453. private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
  454. private weak var settingsGoogleActionButton: NSButton?
  455. private weak var topBarPremiumButton: NSButton?
  456. private weak var topBarAuthButton: NSButton?
  457. private var topBarAuthButtonWidthConstraint: NSLayoutConstraint?
  458. private var profileMenuPopover: NSPopover?
  459. private weak var paywallOverlayView: NSView?
  460. private weak var paywallPanelView: NSView?
  461. private weak var paywallBodyStack: NSStackView?
  462. private weak var paywallPlansRowWide: NSStackView?
  463. private weak var paywallPlansGridNarrow: NSStackView?
  464. private var paywallIsDesktopLayout = false
  465. private var paywallPanelWidthConstraint: NSLayoutConstraint?
  466. private var paywallPanelHeightConstraint: NSLayoutConstraint?
  467. private var joinMeetingWindow: NSPanel?
  468. private var scheduleMeetingWindow: NSWindow?
  469. private weak var scheduleTopicField: NSTextField?
  470. private weak var scheduleDateField: NSTextField?
  471. private weak var scheduleTimeField: NSTextField?
  472. private weak var scheduleTimeZonePopup: NSPopUpButton?
  473. private weak var scheduleDurationField: NSTextField?
  474. private weak var scheduleSubmitButton: NSButton?
  475. private var scheduleTimeZoneDisplayToIdentifier: [String: String] = [:]
  476. private weak var joinURLField: NSTextField?
  477. private weak var joinMeetingIDField: NSTextField?
  478. private weak var joinPasscodeField: NSTextField?
  479. private weak var joinMeetingDisplayNameField: NSTextField?
  480. private weak var joinURLFieldsContainer: NSView?
  481. private weak var joinIDFieldsContainer: NSView?
  482. private weak var joinModeSegment: NSSegmentedControl?
  483. private var didTapInlineMeetingConsentClose = false
  484. private weak var activeMeetingConsentWindow: NSWindow?
  485. private weak var meetingConsentPrimaryLanguagePopup: NSPopUpButton?
  486. private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
  487. private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
  488. /// True after the embedded web client loaded a join/in-call URL during the current recording session.
  489. private var embeddedMeetingReachedInCallPage = false
  490. private var embeddedMeetingRecordingMonitorTimer: Timer?
  491. /// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
  492. private var embeddedMeetingAbsentCallConsecutiveMisses = 0
  493. private static let embeddedMeetingAbsentCallMissThreshold = 5
  494. /// Armed after recording consent; capture starts once the user is in the call (not on the join/lobby page).
  495. private var pendingZoomMeetingRecording: (sourceURL: URL, displayName: String?)?
  496. /// Bumped when the embedded web panel closes or loads a new meeting URL so stale async work cannot start recording.
  497. private var embeddedZoomWebSessionID: UInt = 0
  498. private enum LocalRecordingNotesStatus: String {
  499. case notRequested
  500. case processing
  501. case ready
  502. case failed
  503. }
  504. private let paywallContentWidth: CGFloat = 520
  505. private var selectedPremiumPlan: PremiumPlan = .yearly
  506. private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
  507. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  508. private var paywallPlanPriceLabels: [PremiumPlan: [NSTextField]] = [:]
  509. private var paywallPlanSubtitleLabels: [PremiumPlan: [NSTextField]] = [:]
  510. private var paywallPlanStrikeLabels: [PremiumPlan: [NSTextField]] = [:]
  511. private weak var paywallOfferLabel: NSTextField?
  512. private weak var paywallContinueLabel: NSTextField?
  513. private weak var paywallContinueButton: NSView?
  514. private var paywallPurchaseTask: Task<Void, Never>?
  515. private var paywallContinueEnabled = true
  516. private var lastKnownPremiumAccess = false
  517. private var allScheduledMeetings: [ScheduledMeeting] = []
  518. private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
  519. private var schedulerVisibleMonthStart: Date = {
  520. let calendar = Calendar.current
  521. let today = Date()
  522. let components = calendar.dateComponents([.year, .month], from: today)
  523. return calendar.date(from: components) ?? calendar.startOfDay(for: today)
  524. }()
  525. private var selectedHomeSidebarItem: String = "Home"
  526. private var homeSidebarRowViews: [String: NSView] = [:]
  527. private var homeSidebarIconViews: [String: NSImageView] = [:]
  528. private var homeSidebarLabels: [String: NSTextField] = [:]
  529. private var searchTextObserver: NSObjectProtocol?
  530. private var searchShortcutMonitor: Any?
  531. private var searchOutsideClickMonitor: Any?
  532. private var meetingSearchTextObserver: NSObjectProtocol?
  533. private var globalSearchResults: [ScheduledMeeting] = []
  534. private var topBarProfileInitial = "H"
  535. private var clockTimer: Timer?
  536. private var lastObservedCalendarDayStart: Date = Calendar.current.startOfDay(for: Date())
  537. private var calendarDayChangeObservers: [NSObjectProtocol] = []
  538. private var workspaceWakeObserver: NSObjectProtocol?
  539. private var meetingsRefreshTimer: Timer?
  540. private var brandLeadingConstraint: NSLayoutConstraint?
  541. private var windowFullScreenObservers: [NSObjectProtocol] = []
  542. private var isSigningIn = false
  543. private var isPromptingZoomCredentials = false
  544. private var isLoadingMeetings = false
  545. private var meetingsLoadTask: Task<Void, Never>?
  546. private var hasBuiltInitialHomeView = false
  547. private var meetingsScrollObserver: NSObjectProtocol?
  548. private var meetingsPageScrollObserver: NSObjectProtocol?
  549. private var lastMeetingsRefreshAt = Date.distantPast
  550. private var lastScrollEdgeRefreshAt = Date.distantPast
  551. private var lastMeetingsPageAutoLoadAt = Date.distantPast
  552. // Keep this conservative to avoid Zoom API rate limits.
  553. private let meetingsRefreshInterval: TimeInterval = 120
  554. private let scrollRefreshCooldown: TimeInterval = 30
  555. private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
  556. private let maxMeetingListPages = 5
  557. private var meetingsRateLimitedUntil: Date?
  558. private static let zoomAccountTimeZoneDefaultsKey = "zoomAccountTimeZoneIdentifier"
  559. private var zoomAccountTimeZone: TimeZone?
  560. private var meetingTimeZoneOverrides: [String: String] = [:]
  561. private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
  562. private var meetingsPageSelectedRangePreset: String = "All"
  563. private var meetingsPageFromDateFilter: Date?
  564. private var meetingsPageToDateFilter: Date?
  565. private var meetingsPageVisibleCount: Int = 0
  566. private let meetingsPageSize: Int = 8
  567. private var premiumRatingPromptWorkItem: DispatchWorkItem?
  568. private var usageRatingPromptWorkItem: DispatchWorkItem?
  569. private let usageRatingPromptDelay: TimeInterval = 30 * 60
  570. private let premiumRatingPromptDelay: TimeInterval = 10
  571. private enum SidebarStyle {
  572. case home
  573. }
  574. private enum PremiumPlan: String, CaseIterable {
  575. case weekly = "com.mqldev.zoomapp.premium.weekly"
  576. case monthly = "com.mqldev.zoomapp.premium.monthly"
  577. case yearly = "com.mqldev.zoomapp.premium.yearly"
  578. case lifetime = "com.mqldev.zoomapp.premium.lifetime"
  579. var displayName: String {
  580. switch self {
  581. case .weekly: return "Premium Weekly"
  582. case .monthly: return "Premium Monthly"
  583. case .yearly: return "Premium Yearly"
  584. case .lifetime: return "Premium Lifetime"
  585. }
  586. }
  587. }
  588. private final class StoreKitCoordinator {
  589. enum PurchaseOutcome {
  590. case success
  591. case pending
  592. case cancelled
  593. case failed(String)
  594. }
  595. private(set) var productsByID: [String: Product] = [:]
  596. private(set) var activeProductIDs = Set<String>()
  597. var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
  598. var hasLifetimePremiumAccess: Bool { activeProductIDs.contains(PremiumPlan.lifetime.rawValue) }
  599. private var transactionUpdatesTask: Task<Void, Never>?
  600. var onEntitlementsChanged: ((Bool) -> Void)?
  601. deinit {
  602. transactionUpdatesTask?.cancel()
  603. }
  604. func start() async {
  605. await refreshProducts()
  606. await refreshEntitlements()
  607. observeTransactionUpdatesIfNeeded()
  608. }
  609. func refreshProducts() async {
  610. let productIDs = PremiumPlan.allCases.map(\.rawValue)
  611. var loadedProducts: [Product] = []
  612. var attempts = 0
  613. let maxAttempts = 3
  614. while attempts < maxAttempts {
  615. attempts += 1
  616. do {
  617. loadedProducts = try await Product.products(for: productIDs)
  618. } catch {
  619. loadedProducts = []
  620. }
  621. if loadedProducts.isEmpty == false {
  622. break
  623. }
  624. if attempts < maxAttempts {
  625. // StoreKit can transiently return empty right after launch/network changes.
  626. try? await Task.sleep(nanoseconds: UInt64(attempts) * 500_000_000)
  627. }
  628. }
  629. if loadedProducts.isEmpty == false {
  630. productsByID = Dictionary(uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) })
  631. }
  632. }
  633. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  634. guard let product = productsByID[plan.rawValue] else {
  635. await refreshProducts()
  636. guard let refreshed = productsByID[plan.rawValue] else {
  637. return .failed("Product not available. Check StoreKit configuration and product IDs.")
  638. }
  639. return await purchase(product: refreshed)
  640. }
  641. return await purchase(product: product)
  642. }
  643. func restorePurchases() async -> String {
  644. do {
  645. try await AppStore.sync()
  646. await refreshEntitlements()
  647. return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
  648. } catch {
  649. return "Restore failed. \(error.localizedDescription)"
  650. }
  651. }
  652. private func purchase(product: Product) async -> PurchaseOutcome {
  653. do {
  654. let result = try await product.purchase()
  655. switch result {
  656. case .success(let verification):
  657. guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
  658. await transaction.finish()
  659. await refreshEntitlements()
  660. return .success
  661. case .pending:
  662. return .pending
  663. case .userCancelled:
  664. return .cancelled
  665. @unknown default:
  666. return .failed("Unknown purchase state.")
  667. }
  668. } catch {
  669. return .failed(error.localizedDescription)
  670. }
  671. }
  672. private func observeTransactionUpdatesIfNeeded() {
  673. guard transactionUpdatesTask == nil else { return }
  674. transactionUpdatesTask = Task { [weak self] in
  675. for await update in Transaction.updates {
  676. guard case .verified(let transaction) = update else { continue }
  677. guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
  678. await self?.refreshEntitlements()
  679. await transaction.finish()
  680. }
  681. }
  682. }
  683. @MainActor
  684. private func refreshEntitlements() async {
  685. let previousHasPremiumAccess = hasPremiumAccess
  686. let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
  687. var active = Set<String>()
  688. for await entitlement in Transaction.currentEntitlements {
  689. guard case .verified(let transaction) = entitlement else { continue }
  690. guard allIDs.contains(transaction.productID) else { continue }
  691. if Self.isTransactionActive(transaction) {
  692. active.insert(transaction.productID)
  693. }
  694. }
  695. // StoreKit testing can briefly report empty current entitlements even though a latest
  696. // verified transaction exists for a non-consumable. Merge in latest transactions.
  697. for productID in allIDs {
  698. guard let latest = await Transaction.latest(for: productID),
  699. case .verified(let transaction) = latest else { continue }
  700. if Self.isTransactionActive(transaction) {
  701. active.insert(productID)
  702. }
  703. }
  704. activeProductIDs = active
  705. let newHasPremiumAccess = hasPremiumAccess
  706. if newHasPremiumAccess != previousHasPremiumAccess {
  707. onEntitlementsChanged?(newHasPremiumAccess)
  708. }
  709. }
  710. private static func isTransactionActive(_ transaction: StoreKit.Transaction) -> Bool {
  711. if transaction.revocationDate != nil { return false }
  712. if let expirationDate = transaction.expirationDate {
  713. return expirationDate > Date()
  714. }
  715. return true
  716. }
  717. }
  718. private let storeKitCoordinator = StoreKitCoordinator()
  719. private var storeKitStartupTask: Task<Void, Never>?
  720. private var launchPaywallWorkItem: DispatchWorkItem?
  721. private let launchPaywallDelay: TimeInterval = 2
  722. private var hasPresentedLaunchPaywall = false
  723. override func viewDidLoad() {
  724. super.viewDidLoad()
  725. darkModeEnabled = systemPrefersDarkMode()
  726. palette = Palette(isDarkMode: darkModeEnabled)
  727. NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  728. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  729. meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
  730. ensureUsageSessionStartDate()
  731. scheduleUsageBasedRatingPromptIfNeeded()
  732. recordedMeetings = MeetingRecordingManager.shared.recordings
  733. zoomOAuth.onInteractiveSignInCompleted = { [weak self] in
  734. self?.primeZoomEmbeddedWebSession()
  735. }
  736. NotificationCenter.default.addObserver(
  737. forName: MeetingRecordingManager.recordingsDidChangeNotification,
  738. object: nil,
  739. queue: .main
  740. ) { [weak self] _ in
  741. Task { @MainActor [weak self] in
  742. guard let self else { return }
  743. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  744. for rec in self.recordedMeetings where rec.transcriptText != nil || rec.transcriptError != nil {
  745. self.aiCompanionTranscriptionActiveRecordingIDs.remove(rec.id)
  746. }
  747. self.renderAiCompanionList()
  748. self.refreshLocalRecordingTranscriptPanelContentIfNeeded()
  749. self.refreshLocalRecordingNotesPanelContentIfNeeded()
  750. }
  751. }
  752. NotificationCenter.default.addObserver(
  753. forName: MeetingRecordingManager.transcriptionDidBeginNotification,
  754. object: nil,
  755. queue: .main
  756. ) { [weak self] note in
  757. guard let id = note.userInfo?["recordingId"] as? String else { return }
  758. Task { @MainActor [weak self] in
  759. guard let self else { return }
  760. self.aiCompanionTranscriptionActiveRecordingIDs.insert(id)
  761. self.renderAiCompanionList()
  762. }
  763. }
  764. NotificationCenter.default.addObserver(
  765. forName: MeetingRecordingManager.transcriptionDidEndNotification,
  766. object: nil,
  767. queue: .main
  768. ) { [weak self] note in
  769. guard let id = note.userInfo?["recordingId"] as? String else { return }
  770. Task { @MainActor [weak self] in
  771. guard let self else { return }
  772. self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
  773. self.renderAiCompanionList()
  774. }
  775. }
  776. NotificationCenter.default.addObserver(
  777. forName: MeetingRecordingManager.recordingStateDidChangeNotification,
  778. object: nil,
  779. queue: .main
  780. ) { [weak self] _ in
  781. Task { @MainActor [weak self] in
  782. guard let self else { return }
  783. if MeetingRecordingManager.shared.isRecording == false {
  784. self.stopEmbeddedMeetingRecordingMonitor()
  785. }
  786. self.updateAiCompanionRecordingUI()
  787. }
  788. }
  789. setupUI()
  790. setupWidgetBridge()
  791. installCalendarDayChangeObservers()
  792. if let tzId = UserDefaults.standard.string(forKey: Self.zoomAccountTimeZoneDefaultsKey),
  793. let tz = TimeZone(identifier: tzId) {
  794. zoomAccountTimeZone = tz
  795. }
  796. startStoreKit()
  797. }
  798. override func viewDidAppear() {
  799. super.viewDidAppear()
  800. if let window = view.window {
  801. window.setContentSize(
  802. NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  803. )
  804. applyWindowBackgroundForCurrentTheme(window)
  805. // Use full-size content view so custom top chrome sits in the titlebar region.
  806. window.titleVisibility = .hidden
  807. window.titlebarAppearsTransparent = true
  808. window.isMovableByWindowBackground = true
  809. window.styleMask.insert(.fullSizeContentView)
  810. installWindowFullScreenObservers(for: window)
  811. }
  812. alignNativeTrafficLights()
  813. if hasBuiltInitialHomeView == false {
  814. hasBuiltInitialHomeView = true
  815. showHomeView(profile: nil, refreshMeetings: true)
  816. } else if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
  817. triggerMeetingsRefresh(force: false)
  818. }
  819. syncBrandPositionForCurrentWindowState(animated: false)
  820. scheduleLaunchPaywallIfNeeded()
  821. if storeKitCoordinator.hasPremiumAccess {
  822. DesktopWidgetWindowManager.shared.restore()
  823. }
  824. refreshHomeForCurrentDayIfNeeded()
  825. }
  826. override func viewDidLayout() {
  827. super.viewDidLayout()
  828. alignNativeTrafficLights()
  829. if paywallOverlayView != nil {
  830. updatePaywallPanelSize()
  831. updatePaywallResponsiveLayout()
  832. }
  833. if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
  834. updateEmbeddedRightPanelWidthForOpenState()
  835. }
  836. if aiCompanionPageRootView?.isHidden == false {
  837. refreshAiCompanionStatusPreferredMaxLayoutWidth()
  838. }
  839. }
  840. private func setupUI() {
  841. view.wantsLayer = true
  842. view.layer?.backgroundColor = appBackground.cgColor
  843. rootContainer.translatesAutoresizingMaskIntoConstraints = false
  844. view.addSubview(rootContainer)
  845. NSLayoutConstraint.activate([
  846. rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  847. rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  848. rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
  849. rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  850. ])
  851. }
  852. private func installWindowFullScreenObservers(for window: NSWindow) {
  853. guard windowFullScreenObservers.isEmpty else { return }
  854. let center = NotificationCenter.default
  855. let enterObserver = center.addObserver(
  856. forName: NSWindow.didEnterFullScreenNotification,
  857. object: window,
  858. queue: .main
  859. ) { [weak self] _ in
  860. self?.syncBrandPositionForCurrentWindowState(animated: true)
  861. }
  862. let exitObserver = center.addObserver(
  863. forName: NSWindow.didExitFullScreenNotification,
  864. object: window,
  865. queue: .main
  866. ) { [weak self] _ in
  867. self?.syncBrandPositionForCurrentWindowState(animated: true)
  868. }
  869. windowFullScreenObservers = [enterObserver, exitObserver]
  870. }
  871. private func syncBrandPositionForCurrentWindowState(animated: Bool) {
  872. guard let constraint = brandLeadingConstraint else { return }
  873. let isFullScreen = view.window?.styleMask.contains(.fullScreen) == true
  874. let targetInset = isFullScreen ? brandFullScreenLeadingInset : brandLeadingInset
  875. guard abs(constraint.constant - targetInset) > 0.5 else { return }
  876. if animated {
  877. NSAnimationContext.runAnimationGroup { context in
  878. context.duration = 0.2
  879. constraint.animator().constant = targetInset
  880. view.layoutSubtreeIfNeeded()
  881. }
  882. } else {
  883. constraint.constant = targetInset
  884. }
  885. }
  886. private func showHomeView(profile: GoogleUserProfile?, refreshMeetings: Bool = true) {
  887. clearMeetingsScrollObserver()
  888. clearMeetingsPageScrollObserver()
  889. removeSearchFieldObserver()
  890. removeMeetingSearchObserver()
  891. removeSearchShortcutMonitor()
  892. homeSearchField = nil
  893. homeSearchPill = nil
  894. meetingSearchOverlayView = nil
  895. meetingSearchPanelView = nil
  896. meetingSearchField = nil
  897. meetingSearchResultsStack = nil
  898. meetingSearchEmptyLabel = nil
  899. meetingsPageRootView = nil
  900. widgetsPageRootView = nil
  901. aiCompanionPageRootView = nil
  902. aiCompanionListStack = nil
  903. aiCompanionListScroll = nil
  904. aiCompanionDetailScroll = nil
  905. aiCompanionDetailStack = nil
  906. aiCompanionStatusLabel = nil
  907. aiCompanionCountLabel = nil
  908. aiCompanionRefreshButton = nil
  909. aiCompanionRangePopup = nil
  910. aiCompanionEmptyLabel = nil
  911. aiCompanionLoadingIndicator = nil
  912. aiCompanionDetailEmptyLabel = nil
  913. aiCompanionOpenDocButton = nil
  914. aiCompanionCopyMarkdownButton = nil
  915. aiCompanionStopRecordingButton = nil
  916. aiCompanionFilterPopup = nil
  917. aiCompanionFilterFromPicker = nil
  918. aiCompanionFilterToPicker = nil
  919. aiCompanionFilterApplyButton = nil
  920. aiCompanionFilterResetButton = nil
  921. aiCompanionFilterRefreshButton = nil
  922. aiCompanionRowButtons.removeAll()
  923. aiCompanionLastLoadedAt = nil
  924. homeView?.removeFromSuperview()
  925. if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
  926. topBarProfileInitial = String(profileName.prefix(1)).uppercased()
  927. }
  928. selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
  929. homeView = makeHomeView(profile: profile)
  930. if let homeView { attachToRoot(homeView) }
  931. installSearchShortcutMonitor()
  932. startClock()
  933. applyAuthenticationUIState(triggerInitialRefresh: refreshMeetings)
  934. updateHomeSidebarHighlight()
  935. updateSelectedHomeSectionUI()
  936. if isUserLoggedIn() {
  937. primeZoomEmbeddedWebSession()
  938. }
  939. }
  940. private func isUserLoggedIn() -> Bool {
  941. UserDefaults.standard.bool(forKey: loginStateKey)
  942. }
  943. private func persistLoggedInState(_ loggedIn: Bool) {
  944. UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
  945. }
  946. private func attachToRoot(_ subview: NSView) {
  947. subview.translatesAutoresizingMaskIntoConstraints = false
  948. if subview.superview != rootContainer {
  949. rootContainer.addSubview(subview)
  950. }
  951. NSLayoutConstraint.activate([
  952. subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
  953. subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
  954. subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
  955. subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
  956. ])
  957. }
  958. @objc private func googleLoginTapped() {
  959. guard isSigningIn == false else { return }
  960. isSigningIn = true
  961. googleButton?.title = "..."
  962. googleButton?.isEnabled = false
  963. Task {
  964. do {
  965. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  966. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  967. await MainActor.run {
  968. self.googleButton?.title = "G"
  969. self.resetLoginSigningInState()
  970. self.completeLoginAndRestart(profile: profile)
  971. }
  972. } catch {
  973. await MainActor.run {
  974. self.resetLoginSigningInState()
  975. if case GoogleOAuthError.authenticationCancelled = error { return }
  976. self.showSimpleError("Google login failed", error: error)
  977. }
  978. }
  979. }
  980. }
  981. /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
  982. @objc private func zoomPrimarySignInTapped() {
  983. guard isSigningIn == false else { return }
  984. isSigningIn = true
  985. topBarAuthButton?.title = "Signing in..."
  986. topBarAuthButton?.isEnabled = false
  987. googleButton?.isEnabled = false
  988. Task {
  989. do {
  990. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  991. guard configured else {
  992. await MainActor.run { self.resetLoginSigningInState() }
  993. return
  994. }
  995. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  996. let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
  997. let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
  998. let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
  999. await MainActor.run {
  1000. self.resetLoginSigningInState()
  1001. self.zoomAccountTimeZone = profileTimeZone
  1002. if let profileTimeZone {
  1003. UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
  1004. }
  1005. self.completeLoginAndRestart(profile: profile)
  1006. }
  1007. } catch {
  1008. await MainActor.run {
  1009. self.resetLoginSigningInState()
  1010. if case ZoomOAuthError.authenticationCancelled = error { return }
  1011. self.showSimpleError("Zoom sign-in failed", error: error)
  1012. }
  1013. }
  1014. }
  1015. }
  1016. @objc private func scheduleMeetingTapped() {
  1017. guard isUserLoggedIn() else {
  1018. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to schedule a meeting.")
  1019. return
  1020. }
  1021. guard ensurePremiumAccess() else { return }
  1022. if let existing = scheduleMeetingWindow {
  1023. existing.makeKeyAndOrderFront(nil)
  1024. NSApp.activate(ignoringOtherApps: true)
  1025. DispatchQueue.main.async { [weak self] in
  1026. _ = self?.scheduleTopicField?.becomeFirstResponder()
  1027. }
  1028. return
  1029. }
  1030. let content = makeScheduleMeetingPanelContent()
  1031. let controller = NSViewController()
  1032. controller.view = content
  1033. let panel = NSPanel(
  1034. contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
  1035. styleMask: [.titled, .closable, .fullSizeContentView],
  1036. backing: .buffered,
  1037. defer: false
  1038. )
  1039. panel.title = "Schedule a meeting"
  1040. panel.titleVisibility = .hidden
  1041. panel.titlebarAppearsTransparent = true
  1042. panel.hidesOnDeactivate = true
  1043. panel.isReleasedWhenClosed = false
  1044. panel.standardWindowButton(.closeButton)?.isHidden = true
  1045. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  1046. panel.standardWindowButton(.zoomButton)?.isHidden = true
  1047. panel.isMovableByWindowBackground = true
  1048. panel.becomesKeyOnlyIfNeeded = false
  1049. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  1050. panel.center()
  1051. panel.contentViewController = controller
  1052. panel.delegate = self
  1053. applyWindowBackgroundForCurrentTheme(panel)
  1054. panel.makeKeyAndOrderFront(nil)
  1055. NSApp.activate(ignoringOtherApps: true)
  1056. scheduleMeetingWindow = panel
  1057. DispatchQueue.main.async { [weak self] in
  1058. _ = self?.scheduleTopicField?.becomeFirstResponder()
  1059. }
  1060. }
  1061. private func makeScheduleMeetingPanelContent() -> NSView {
  1062. let root = NSView()
  1063. root.translatesAutoresizingMaskIntoConstraints = false
  1064. root.wantsLayer = true
  1065. root.layer?.backgroundColor = appBackground.cgColor
  1066. let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  1067. let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
  1068. let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
  1069. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1070. closeButton.isBordered = false
  1071. closeButton.bezelStyle = .regularSquare
  1072. closeButton.wantsLayer = true
  1073. closeButton.layer?.cornerRadius = 14
  1074. closeButton.normalColor = palette.inputBackground
  1075. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  1076. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1077. closeButton.layer?.borderWidth = 1
  1078. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  1079. closeButton.contentTintColor = secondaryText
  1080. closeButton.toolTip = "Close"
  1081. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1082. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1083. let titleRow = NSStackView()
  1084. titleRow.orientation = .horizontal
  1085. titleRow.alignment = .centerY
  1086. titleRow.spacing = 10
  1087. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1088. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  1089. titleStack.orientation = .vertical
  1090. titleStack.spacing = 2
  1091. titleStack.alignment = .leading
  1092. let titleSpacer = NSView()
  1093. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  1094. titleRow.addArrangedSubview(titleStack)
  1095. titleRow.addArrangedSubview(titleSpacer)
  1096. titleRow.addArrangedSubview(closeButton)
  1097. let headerDivider = NSView()
  1098. headerDivider.wantsLayer = true
  1099. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  1100. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  1101. let topicBox = makeJoinFormField(placeholder: "Meeting topic")
  1102. scheduleTopicField = topicBox.textField
  1103. topicBox.textField.stringValue = "Zoom meeting"
  1104. let defaultStart = Date().addingTimeInterval(3600)
  1105. let dateOnlyFormatter = DateFormatter()
  1106. dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
  1107. dateOnlyFormatter.timeZone = TimeZone.current
  1108. dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
  1109. let time12Formatter = DateFormatter()
  1110. time12Formatter.locale = Locale(identifier: "en_US_POSIX")
  1111. time12Formatter.timeZone = TimeZone.current
  1112. time12Formatter.dateFormat = "h:mm a"
  1113. let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
  1114. scheduleDateField = dateBox.textField
  1115. dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
  1116. let timeBox = makeJoinFormField(placeholder: "2:30 PM")
  1117. scheduleTimeField = timeBox.textField
  1118. timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
  1119. let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
  1120. let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
  1121. let tzCombo = NSPopUpButton()
  1122. tzCombo.translatesAutoresizingMaskIntoConstraints = false
  1123. tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
  1124. tzCombo.wantsLayer = true
  1125. tzCombo.layer?.cornerRadius = 10
  1126. tzCombo.layer?.borderWidth = 1
  1127. tzCombo.layer?.borderColor = palette.inputBorder.cgColor
  1128. tzCombo.layer?.backgroundColor = palette.inputBackground.cgColor
  1129. let tzOptions = makeTimeZoneDisplayOptions(referenceDate: defaultStart)
  1130. scheduleTimeZoneDisplayToIdentifier = Dictionary(uniqueKeysWithValues: tzOptions.map { ($0.display, $0.identifier) })
  1131. tzCombo.removeAllItems()
  1132. tzCombo.addItems(withTitles: tzOptions.map(\.display))
  1133. if let selected = tzOptions.first(where: { $0.identifier == TimeZone.current.identifier }) {
  1134. tzCombo.selectItem(withTitle: selected.display)
  1135. } else {
  1136. tzCombo.selectItem(at: 0)
  1137. }
  1138. tzCombo.toolTip = "Timezone with GMT offset. Meeting start is interpreted in this zone."
  1139. scheduleTimeZonePopup = tzCombo
  1140. let tzHint = makeLabel("Includes GMT offset, e.g. GMT+05:00 - Asia/Karachi", size: 11, color: mutedText, weight: .regular, centered: false)
  1141. let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
  1142. tzStack.orientation = .vertical
  1143. tzStack.spacing = 8
  1144. tzStack.alignment = .leading
  1145. tzStack.translatesAutoresizingMaskIntoConstraints = false
  1146. let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
  1147. durationBox.textField.stringValue = "60"
  1148. scheduleDurationField = durationBox.textField
  1149. let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
  1150. let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
  1151. let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
  1152. let topicStack = NSStackView(views: [topicLabel, topicBox])
  1153. topicStack.orientation = .vertical
  1154. topicStack.spacing = 8
  1155. topicStack.alignment = .leading
  1156. topicStack.translatesAutoresizingMaskIntoConstraints = false
  1157. let dateTimeRow = NSStackView(views: [dateBox, timeBox])
  1158. dateTimeRow.orientation = .horizontal
  1159. dateTimeRow.spacing = 10
  1160. dateTimeRow.alignment = .top
  1161. dateTimeRow.distribution = .fillEqually
  1162. dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
  1163. let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
  1164. startStack.orientation = .vertical
  1165. startStack.spacing = 8
  1166. startStack.alignment = .leading
  1167. startStack.translatesAutoresizingMaskIntoConstraints = false
  1168. let durationStack = NSStackView(views: [durationLabel, durationBox])
  1169. durationStack.orientation = .vertical
  1170. durationStack.spacing = 8
  1171. durationStack.alignment = .leading
  1172. durationStack.translatesAutoresizingMaskIntoConstraints = false
  1173. let formCard = NSView()
  1174. formCard.translatesAutoresizingMaskIntoConstraints = false
  1175. formCard.wantsLayer = true
  1176. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  1177. formCard.layer?.cornerRadius = 14
  1178. formCard.layer?.borderWidth = 1
  1179. formCard.layer?.borderColor = palette.inputBorder.cgColor
  1180. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
  1181. cancelButton.isBordered = false
  1182. cancelButton.wantsLayer = true
  1183. cancelButton.layer?.cornerRadius = 10
  1184. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1185. cancelButton.layer?.borderWidth = 1
  1186. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  1187. cancelButton.contentTintColor = primaryText
  1188. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1189. let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
  1190. submitButton.isBordered = false
  1191. submitButton.wantsLayer = true
  1192. submitButton.layer?.cornerRadius = 10
  1193. submitButton.normalColor = accentBlue
  1194. submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  1195. submitButton.contentTintColor = .white
  1196. submitButton.font = .systemFont(ofSize: 13, weight: .bold)
  1197. submitButton.keyEquivalent = "\r"
  1198. scheduleSubmitButton = submitButton
  1199. let buttons = NSStackView(views: [cancelButton, submitButton])
  1200. buttons.orientation = .horizontal
  1201. buttons.spacing = 12
  1202. buttons.alignment = .centerY
  1203. buttons.distribution = .fillEqually
  1204. let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
  1205. innerStack.orientation = .vertical
  1206. innerStack.spacing = 18
  1207. innerStack.alignment = .leading
  1208. innerStack.translatesAutoresizingMaskIntoConstraints = false
  1209. formCard.addSubview(innerStack)
  1210. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  1211. NSLayoutConstraint.activate([
  1212. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1213. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  1214. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  1215. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  1216. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1217. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1218. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  1219. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  1220. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1221. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1222. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  1223. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1224. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1225. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1226. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1227. topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1228. tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1229. tzCombo.heightAnchor.constraint(equalToConstant: 34),
  1230. dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1231. durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1232. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1233. submitButton.heightAnchor.constraint(equalToConstant: 40),
  1234. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1235. ])
  1236. return root
  1237. }
  1238. @objc private func scheduleMeetingCancelTapped() {
  1239. scheduleMeetingWindow?.performClose(nil)
  1240. }
  1241. @objc private func scheduleMeetingSubmitTapped() {
  1242. let topicRaw = scheduleTopicField?.stringValue ?? ""
  1243. let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
  1244. guard topic.isEmpty == false else {
  1245. showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
  1246. return
  1247. }
  1248. let tzId = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1249. if tzId.isEmpty == false,
  1250. scheduleTimeZoneDisplayToIdentifier[tzId] == nil,
  1251. TimeZone(identifier: tzId) == nil {
  1252. showSimpleAlert(
  1253. title: "Timezone",
  1254. message: "Choose a timezone from the list (with GMT offset), for example GMT+05:00 - Asia/Karachi."
  1255. )
  1256. return
  1257. }
  1258. guard let draft = scheduleMeetingDraftFromFormFields() else {
  1259. showSimpleAlert(
  1260. title: "Date & time",
  1261. 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."
  1262. )
  1263. return
  1264. }
  1265. if draft.startDate < Date().addingTimeInterval(-30) {
  1266. showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
  1267. return
  1268. }
  1269. let durationRaw = scheduleDurationField?.stringValue ?? ""
  1270. let digits = durationRaw.filter(\.isNumber)
  1271. guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
  1272. showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
  1273. return
  1274. }
  1275. scheduleSubmitButton?.isEnabled = false
  1276. let tz = draft.timeZone
  1277. Task {
  1278. do {
  1279. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  1280. guard configured else {
  1281. await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
  1282. return
  1283. }
  1284. let oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
  1285. let token = try await zoomOAuth.validAccessToken(
  1286. presentingWindow: oauthParent,
  1287. requiringAnyOf: ["meeting:write:meeting"]
  1288. )
  1289. let result = try await createZoomMeeting(
  1290. accessToken: token,
  1291. topic: topic,
  1292. startTimeUTC: draft.startTimeUTC,
  1293. durationMinutes: duration,
  1294. timeZone: tz
  1295. )
  1296. await MainActor.run {
  1297. self.saveMeetingTimeZoneOverride(meetingID: result.id.map(String.init), timeZone: tz)
  1298. }
  1299. await MainActor.run {
  1300. self.scheduleSubmitButton?.isEnabled = true
  1301. self.scheduleMeetingWindow?.performClose(nil)
  1302. self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
  1303. self.triggerMeetingsRefresh(force: true)
  1304. if let join = result.join_url, join.isEmpty == false {
  1305. NSPasteboard.general.clearContents()
  1306. NSPasteboard.general.setString(join, forType: .string)
  1307. self.showSimpleAlert(
  1308. title: "Meeting scheduled",
  1309. message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
  1310. )
  1311. } else {
  1312. self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
  1313. }
  1314. }
  1315. } catch {
  1316. await MainActor.run {
  1317. self.scheduleSubmitButton?.isEnabled = true
  1318. if case ZoomOAuthError.missingRequiredScope(_) = error {
  1319. self.zoomOAuth.clearSavedTokens()
  1320. self.showSimpleAlert(
  1321. title: "Zoom permissions",
  1322. message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
  1323. )
  1324. } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
  1325. let seconds = max(retryAfter ?? 300, 30)
  1326. let minutes = Int(ceil(Double(seconds) / 60.0))
  1327. self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
  1328. } else if case ZoomOAuthError.authenticationCancelled = error {
  1329. // User closed the sign-in window; no alert.
  1330. } else {
  1331. self.showSimpleError("Could not schedule", error: error)
  1332. }
  1333. }
  1334. }
  1335. }
  1336. }
  1337. private func resetScheduleMeetingPanelReferences() {
  1338. scheduleMeetingWindow = nil
  1339. scheduleTopicField = nil
  1340. scheduleDateField = nil
  1341. scheduleTimeField = nil
  1342. scheduleTimeZonePopup = nil
  1343. scheduleDurationField = nil
  1344. scheduleSubmitButton = nil
  1345. scheduleTimeZoneDisplayToIdentifier = [:]
  1346. }
  1347. /// Resolves the schedule panel timezone (IANA id from combo, or system default).
  1348. private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
  1349. let raw = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1350. if let identifier = scheduleTimeZoneDisplayToIdentifier[raw],
  1351. let mapped = TimeZone(identifier: identifier) {
  1352. return mapped
  1353. }
  1354. guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
  1355. return tz
  1356. }
  1357. private struct TimeZoneDisplayOption {
  1358. let identifier: String
  1359. let display: String
  1360. }
  1361. private func makeTimeZoneDisplayOptions(referenceDate: Date) -> [TimeZoneDisplayOption] {
  1362. TimeZone.knownTimeZoneIdentifiers.compactMap { identifier in
  1363. guard let tz = TimeZone(identifier: identifier) else { return nil }
  1364. let seconds = tz.secondsFromGMT(for: referenceDate)
  1365. let sign = seconds >= 0 ? "+" : "-"
  1366. let absSeconds = abs(seconds)
  1367. let hours = absSeconds / 3600
  1368. let minutes = (absSeconds % 3600) / 60
  1369. let offset = String(format: "GMT%@%02d:%02d", sign, hours, minutes)
  1370. return TimeZoneDisplayOption(identifier: identifier, display: "\(offset) - \(identifier)")
  1371. }
  1372. .sorted { lhs, rhs in
  1373. if lhs.display == rhs.display { return lhs.identifier < rhs.identifier }
  1374. return lhs.display < rhs.display
  1375. }
  1376. }
  1377. /// Parses zoom timezone values like `Asia/Karachi`, `GMT+05:00`, `UTC+5`, `+0500`, or `Z`.
  1378. private func parseZoomTimeZone(_ raw: String?) -> TimeZone? {
  1379. guard let raw else { return nil }
  1380. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1381. guard trimmed.isEmpty == false else { return nil }
  1382. if let named = TimeZone(identifier: trimmed) { return named }
  1383. if trimmed.caseInsensitiveCompare("Z") == .orderedSame { return TimeZone(secondsFromGMT: 0) }
  1384. let normalized = trimmed.uppercased()
  1385. let pattern = #"^(?:GMT|UTC)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$"#
  1386. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1387. let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
  1388. guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
  1389. match.numberOfRanges == 4,
  1390. let signRange = Range(match.range(at: 1), in: normalized),
  1391. let hourRange = Range(match.range(at: 2), in: normalized) else {
  1392. return nil
  1393. }
  1394. let sign = normalized[signRange] == "-" ? -1 : 1
  1395. let hours = Int(normalized[hourRange]) ?? -1
  1396. let minutes: Int = {
  1397. if let minuteRange = Range(match.range(at: 3), in: normalized) {
  1398. return Int(normalized[minuteRange]) ?? -1
  1399. }
  1400. return 0
  1401. }()
  1402. guard (0...23).contains(hours), (0...59).contains(minutes) else { return nil }
  1403. return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60)))
  1404. }
  1405. private func saveMeetingTimeZoneOverride(meetingID: String?, timeZone: TimeZone) {
  1406. guard let meetingID, meetingID.isEmpty == false else { return }
  1407. meetingTimeZoneOverrides[meetingID] = timeZone.identifier
  1408. UserDefaults.standard.set(meetingTimeZoneOverrides, forKey: meetingTimeZoneOverrideKey)
  1409. }
  1410. private struct ScheduleMeetingDraft {
  1411. let startDate: Date
  1412. let startTimeUTC: String
  1413. let timeZone: TimeZone
  1414. }
  1415. /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
  1416. private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
  1417. let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1418. let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1419. guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
  1420. let tz = resolvedScheduleTimeZoneForMeeting()
  1421. let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
  1422. // Parse date strictly as yyyy-MM-dd.
  1423. let dateComponentsRaw = datePart.split(separator: "-")
  1424. guard dateComponentsRaw.count == 3,
  1425. let year = Int(dateComponentsRaw[0]),
  1426. let month = Int(dateComponentsRaw[1]),
  1427. let day = Int(dateComponentsRaw[2]) else {
  1428. return nil
  1429. }
  1430. // Parse time strictly as h:mm AM/PM (optional :ss).
  1431. // This avoids any locale/date formatter ambiguity that can flip AM/PM.
  1432. let pattern = #"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])\s*$"#
  1433. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1434. let nsRange = NSRange(normalizedTime.startIndex..<normalizedTime.endIndex, in: normalizedTime)
  1435. guard let match = regex.firstMatch(in: normalizedTime, options: [], range: nsRange),
  1436. match.numberOfRanges == 5,
  1437. let hourRange = Range(match.range(at: 1), in: normalizedTime),
  1438. let minuteRange = Range(match.range(at: 2), in: normalizedTime),
  1439. let meridiemRange = Range(match.range(at: 4), in: normalizedTime) else {
  1440. return nil
  1441. }
  1442. let rawHour = Int(normalizedTime[hourRange]) ?? -1
  1443. let minute = Int(normalizedTime[minuteRange]) ?? -1
  1444. let second: Int = {
  1445. if let secondRange = Range(match.range(at: 3), in: normalizedTime) {
  1446. return Int(normalizedTime[secondRange]) ?? -1
  1447. }
  1448. return 0
  1449. }()
  1450. let meridiem = normalizedTime[meridiemRange].uppercased()
  1451. guard (1...12).contains(rawHour),
  1452. (0...59).contains(minute),
  1453. (0...59).contains(second) else {
  1454. return nil
  1455. }
  1456. var hour24 = rawHour % 12
  1457. if meridiem == "PM" { hour24 += 12 }
  1458. var calendar = Calendar(identifier: .gregorian)
  1459. calendar.timeZone = tz
  1460. var components = DateComponents()
  1461. components.year = year
  1462. components.month = month
  1463. components.day = day
  1464. components.hour = hour24
  1465. components.minute = minute
  1466. components.second = second
  1467. components.timeZone = tz
  1468. guard let startDate = calendar.date(from: components) else { return nil }
  1469. let utcFormatter = DateFormatter()
  1470. utcFormatter.locale = Locale(identifier: "en_US_POSIX")
  1471. utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
  1472. utcFormatter.calendar = Calendar(identifier: .gregorian)
  1473. // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
  1474. utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
  1475. return ScheduleMeetingDraft(
  1476. startDate: startDate,
  1477. startTimeUTC: utcFormatter.string(from: startDate),
  1478. timeZone: tz
  1479. )
  1480. }
  1481. private func presentJoinMeetingPanel(_ panel: NSPanel) {
  1482. guard let host = view.window else {
  1483. panel.center()
  1484. panel.makeKeyAndOrderFront(nil)
  1485. return
  1486. }
  1487. if host.attachedSheet !== panel {
  1488. host.beginSheet(panel, completionHandler: nil)
  1489. }
  1490. panel.makeKeyAndOrderFront(nil)
  1491. }
  1492. private func dismissJoinMeetingPanel() {
  1493. guard let panel = joinMeetingWindow else { return }
  1494. if let host = view.window, host.attachedSheet === panel {
  1495. host.endSheet(panel)
  1496. } else {
  1497. panel.performClose(nil)
  1498. }
  1499. }
  1500. @objc private func joinMeetingTapped() {
  1501. if let existing = joinMeetingWindow {
  1502. presentJoinMeetingPanel(existing)
  1503. NSApp.activate(ignoringOtherApps: true)
  1504. DispatchQueue.main.async { [weak self] in
  1505. _ = self?.joinURLField?.becomeFirstResponder()
  1506. }
  1507. return
  1508. }
  1509. let content = makeJoinMeetingPanelContent()
  1510. let controller = NSViewController()
  1511. controller.view = content
  1512. let panel = NSPanel(
  1513. contentRect: NSRect(x: 0, y: 0, width: 520, height: 500),
  1514. styleMask: [.titled, .closable, .fullSizeContentView],
  1515. backing: .buffered,
  1516. defer: false
  1517. )
  1518. panel.title = "Join a meeting"
  1519. panel.titleVisibility = .hidden
  1520. panel.titlebarAppearsTransparent = true
  1521. panel.hidesOnDeactivate = true
  1522. panel.isReleasedWhenClosed = false
  1523. panel.standardWindowButton(.closeButton)?.isHidden = true
  1524. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  1525. panel.standardWindowButton(.zoomButton)?.isHidden = true
  1526. panel.isFloatingPanel = false
  1527. panel.isMovable = false
  1528. panel.isMovableByWindowBackground = false
  1529. // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
  1530. panel.becomesKeyOnlyIfNeeded = false
  1531. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  1532. panel.contentViewController = controller
  1533. panel.delegate = self
  1534. applyWindowBackgroundForCurrentTheme(panel)
  1535. joinMeetingWindow = panel
  1536. presentJoinMeetingPanel(panel)
  1537. NSApp.activate(ignoringOtherApps: true)
  1538. DispatchQueue.main.async { [weak self] in
  1539. guard let self else { return }
  1540. _ = self.joinURLField?.becomeFirstResponder()
  1541. }
  1542. }
  1543. private func makeJoinMeetingPanelContent() -> NSView {
  1544. let root = NSView()
  1545. root.translatesAutoresizingMaskIntoConstraints = false
  1546. root.wantsLayer = true
  1547. root.layer?.backgroundColor = appBackground.cgColor
  1548. let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  1549. let subtitleLabel = makeLabel("Opens in the app web panel", size: 12, color: mutedText, weight: .regular, centered: false)
  1550. let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
  1551. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1552. closeButton.isBordered = false
  1553. closeButton.bezelStyle = .regularSquare
  1554. closeButton.wantsLayer = true
  1555. closeButton.layer?.cornerRadius = 14
  1556. closeButton.normalColor = palette.inputBackground
  1557. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  1558. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1559. closeButton.layer?.borderWidth = 1
  1560. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  1561. closeButton.contentTintColor = secondaryText
  1562. closeButton.toolTip = "Close"
  1563. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1564. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1565. let titleRow = NSStackView()
  1566. titleRow.orientation = .horizontal
  1567. titleRow.alignment = .centerY
  1568. titleRow.spacing = 10
  1569. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1570. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  1571. titleStack.orientation = .vertical
  1572. titleStack.spacing = 2
  1573. titleStack.alignment = .leading
  1574. let titleSpacer = NSView()
  1575. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  1576. titleRow.addArrangedSubview(titleStack)
  1577. titleRow.addArrangedSubview(titleSpacer)
  1578. titleRow.addArrangedSubview(closeButton)
  1579. let headerDivider = NSView()
  1580. headerDivider.wantsLayer = true
  1581. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  1582. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  1583. let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
  1584. mode.segmentStyle = .rounded
  1585. mode.selectedSegment = 0
  1586. mode.translatesAutoresizingMaskIntoConstraints = false
  1587. mode.font = .systemFont(ofSize: 12, weight: .semibold)
  1588. mode.controlSize = .large
  1589. if #available(macOS 11.0, *) {
  1590. mode.selectedSegmentBezelColor = accentBlue
  1591. }
  1592. joinModeSegment = mode
  1593. let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
  1594. joinURLField = urlBox.textField
  1595. let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
  1596. let urlStack = NSStackView(views: [urlLabel, urlBox])
  1597. urlStack.orientation = .vertical
  1598. urlStack.spacing = 8
  1599. urlStack.alignment = .leading
  1600. urlStack.translatesAutoresizingMaskIntoConstraints = false
  1601. joinURLFieldsContainer = urlStack
  1602. let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
  1603. let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
  1604. joinMeetingIDField = idBox.textField
  1605. joinPasscodeField = passBox.textField
  1606. let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
  1607. let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
  1608. let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
  1609. idStack.orientation = .vertical
  1610. idStack.spacing = 8
  1611. idStack.alignment = .leading
  1612. idStack.translatesAutoresizingMaskIntoConstraints = false
  1613. idStack.isHidden = true
  1614. joinIDFieldsContainer = idStack
  1615. let nameBox = makeJoinFormField(placeholder: "e.g. Weekly standup (optional — used for the saved file)")
  1616. joinMeetingDisplayNameField = nameBox.textField
  1617. let nameLabel = makeLabel("Meeting name", size: 12, color: secondaryText, weight: .medium, centered: false)
  1618. let nameStack = NSStackView(views: [nameLabel, nameBox])
  1619. nameStack.orientation = .vertical
  1620. nameStack.spacing = 8
  1621. nameStack.alignment = .leading
  1622. nameStack.translatesAutoresizingMaskIntoConstraints = false
  1623. let formCard = NSView()
  1624. formCard.translatesAutoresizingMaskIntoConstraints = false
  1625. formCard.wantsLayer = true
  1626. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  1627. formCard.layer?.cornerRadius = 14
  1628. formCard.layer?.borderWidth = 1
  1629. formCard.layer?.borderColor = palette.inputBorder.cgColor
  1630. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
  1631. cancelButton.isBordered = false
  1632. cancelButton.wantsLayer = true
  1633. cancelButton.layer?.cornerRadius = 10
  1634. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1635. cancelButton.layer?.borderWidth = 1
  1636. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  1637. cancelButton.contentTintColor = primaryText
  1638. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1639. let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
  1640. joinButton.isBordered = false
  1641. joinButton.wantsLayer = true
  1642. joinButton.layer?.cornerRadius = 10
  1643. joinButton.normalColor = accentBlue
  1644. joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  1645. joinButton.contentTintColor = .white
  1646. joinButton.font = .systemFont(ofSize: 13, weight: .bold)
  1647. joinButton.keyEquivalent = "\r"
  1648. let buttons = NSStackView(views: [cancelButton, joinButton])
  1649. buttons.orientation = .horizontal
  1650. buttons.spacing = 12
  1651. buttons.alignment = .centerY
  1652. buttons.distribution = .fillEqually
  1653. let innerStack = NSStackView(views: [mode, urlStack, idStack, nameStack, buttons])
  1654. innerStack.orientation = .vertical
  1655. innerStack.spacing = 18
  1656. innerStack.alignment = .leading
  1657. innerStack.translatesAutoresizingMaskIntoConstraints = false
  1658. formCard.addSubview(innerStack)
  1659. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  1660. NSLayoutConstraint.activate([
  1661. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1662. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  1663. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  1664. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  1665. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1666. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1667. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  1668. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  1669. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1670. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1671. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  1672. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1673. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1674. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1675. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1676. mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1677. urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1678. idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1679. passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1680. nameBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1681. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1682. joinButton.heightAnchor.constraint(equalToConstant: 40),
  1683. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1684. ])
  1685. return root
  1686. }
  1687. private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
  1688. JoinPanelFieldContainer(
  1689. placeholder: placeholder,
  1690. normalBorder: palette.inputBorder,
  1691. focusBorder: accentBlue.withAlphaComponent(0.9),
  1692. fill: palette.inputBackground,
  1693. primaryText: primaryText,
  1694. mutedText: mutedText
  1695. )
  1696. }
  1697. @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
  1698. let urlMode = sender.selectedSegment == 0
  1699. joinURLFieldsContainer?.isHidden = urlMode == false
  1700. joinIDFieldsContainer?.isHidden = urlMode
  1701. }
  1702. @objc private func joinMeetingCancelTapped() {
  1703. dismissJoinMeetingPanel()
  1704. }
  1705. @objc private func joinMeetingSubmitTapped() {
  1706. guard let segment = joinModeSegment else { return }
  1707. let meetingName = joinMeetingDisplayNameField?.stringValue
  1708. let zoomURL: URL?
  1709. if segment.selectedSegment == 0 {
  1710. let raw = joinURLField?.stringValue ?? ""
  1711. guard let url = parseZoomJoinURLFromUserInput(raw) else {
  1712. showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
  1713. return
  1714. }
  1715. zoomURL = url
  1716. } else {
  1717. let idRaw = joinMeetingIDField?.stringValue ?? ""
  1718. let digits = idRaw.filter(\.isNumber)
  1719. guard digits.isEmpty == false else {
  1720. showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
  1721. return
  1722. }
  1723. let pass = joinPasscodeField?.stringValue ?? ""
  1724. guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
  1725. showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
  1726. return
  1727. }
  1728. zoomURL = url
  1729. }
  1730. guard let url = zoomURL else { return }
  1731. joinZoomMeetingFromLink(url.absoluteString, displayName: meetingName)
  1732. }
  1733. private enum MeetingRecordingConsentChoice {
  1734. case allowAndContinue
  1735. case continueWithoutRecording
  1736. case dismissed
  1737. }
  1738. private func requestMeetingRecordingConsent(title: String) -> MeetingRecordingConsentChoice {
  1739. let alert = NSAlert()
  1740. alert.messageText = "Record this meeting locally?"
  1741. alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in AI Companion after the meeting."
  1742. alert.addButton(withTitle: "Allow and Continue")
  1743. alert.addButton(withTitle: "Continue Without Recording")
  1744. alert.window.title = title
  1745. let speechOptions = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
  1746. let languageContainer = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 96))
  1747. languageContainer.translatesAutoresizingMaskIntoConstraints = false
  1748. languageContainer.widthAnchor.constraint(equalToConstant: 320).isActive = true
  1749. languageContainer.heightAnchor.constraint(equalToConstant: 96).isActive = true
  1750. let languageTitle = NSTextField(labelWithString: "Transcription Languages")
  1751. languageTitle.translatesAutoresizingMaskIntoConstraints = false
  1752. languageTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1753. languageTitle.textColor = NSColor.secondaryLabelColor
  1754. let language1Popup = NSPopUpButton(frame: .zero, pullsDown: false)
  1755. language1Popup.translatesAutoresizingMaskIntoConstraints = false
  1756. language1Popup.controlSize = .small
  1757. language1Popup.target = self
  1758. language1Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
  1759. for option in speechOptions {
  1760. language1Popup.addItem(withTitle: option.displayName)
  1761. language1Popup.lastItem?.representedObject = option.identifier
  1762. }
  1763. let selectedPrimary = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
  1764. ?? speechOptions.first?.identifier
  1765. ?? Locale.current.identifier
  1766. selectTranscriptionLanguage(identifier: selectedPrimary, in: language1Popup)
  1767. let language2Popup = NSPopUpButton(frame: .zero, pullsDown: false)
  1768. language2Popup.translatesAutoresizingMaskIntoConstraints = false
  1769. language2Popup.controlSize = .small
  1770. language2Popup.target = self
  1771. language2Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
  1772. language2Popup.addItem(withTitle: "None")
  1773. language2Popup.lastItem?.representedObject = ""
  1774. for option in speechOptions {
  1775. language2Popup.addItem(withTitle: option.displayName)
  1776. language2Popup.lastItem?.representedObject = option.identifier
  1777. }
  1778. if let selectedSecondary = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
  1779. selectTranscriptionLanguage(identifier: selectedSecondary, in: language2Popup)
  1780. } else {
  1781. language2Popup.selectItem(at: 0)
  1782. }
  1783. let language1Label = NSTextField(labelWithString: "Preferred Language 1")
  1784. language1Label.translatesAutoresizingMaskIntoConstraints = false
  1785. language1Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1786. language1Label.textColor = NSColor.secondaryLabelColor
  1787. language1Label.alignment = .center
  1788. let language2Label = NSTextField(labelWithString: "Preferred Language 2")
  1789. language2Label.translatesAutoresizingMaskIntoConstraints = false
  1790. language2Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1791. language2Label.textColor = NSColor.secondaryLabelColor
  1792. language2Label.alignment = .center
  1793. languageContainer.addSubview(languageTitle)
  1794. languageContainer.addSubview(language1Label)
  1795. languageContainer.addSubview(language1Popup)
  1796. languageContainer.addSubview(language2Label)
  1797. languageContainer.addSubview(language2Popup)
  1798. NSLayoutConstraint.activate([
  1799. languageTitle.topAnchor.constraint(equalTo: languageContainer.topAnchor, constant: 0),
  1800. languageTitle.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1801. language1Label.topAnchor.constraint(equalTo: languageTitle.bottomAnchor, constant: 2),
  1802. language1Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1803. language1Popup.topAnchor.constraint(equalTo: language1Label.bottomAnchor, constant: 2),
  1804. language1Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1805. language1Popup.widthAnchor.constraint(equalToConstant: 240),
  1806. language2Label.topAnchor.constraint(equalTo: language1Popup.bottomAnchor, constant: 3),
  1807. language2Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1808. language2Popup.topAnchor.constraint(equalTo: language2Label.bottomAnchor, constant: 2),
  1809. language2Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1810. language2Popup.widthAnchor.constraint(equalToConstant: 240),
  1811. language2Popup.bottomAnchor.constraint(equalTo: languageContainer.bottomAnchor, constant: -1)
  1812. ])
  1813. alert.accessoryView = languageContainer
  1814. didTapInlineMeetingConsentClose = false
  1815. activeMeetingConsentWindow = alert.window
  1816. meetingConsentPrimaryLanguagePopup = language1Popup
  1817. meetingConsentSecondaryLanguagePopup = language2Popup
  1818. if let contentView = alert.window.contentView {
  1819. let topCloseButton = NSButton(title: "✕", target: self, action: #selector(meetingConsentInlineCloseTapped(_:)))
  1820. topCloseButton.translatesAutoresizingMaskIntoConstraints = false
  1821. topCloseButton.isBordered = false
  1822. topCloseButton.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
  1823. topCloseButton.contentTintColor = NSColor.tertiaryLabelColor
  1824. topCloseButton.wantsLayer = true
  1825. topCloseButton.layer?.cornerRadius = 20
  1826. topCloseButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.35).cgColor
  1827. contentView.addSubview(topCloseButton)
  1828. NSLayoutConstraint.activate([
  1829. topCloseButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
  1830. topCloseButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
  1831. topCloseButton.widthAnchor.constraint(equalToConstant: 40),
  1832. topCloseButton.heightAnchor.constraint(equalToConstant: 40)
  1833. ])
  1834. }
  1835. let response = alert.runModal()
  1836. activeMeetingConsentWindow = nil
  1837. meetingConsentPrimaryLanguagePopup = nil
  1838. meetingConsentSecondaryLanguagePopup = nil
  1839. if didTapInlineMeetingConsentClose {
  1840. return .dismissed
  1841. }
  1842. if let primary = language1Popup.selectedItem?.representedObject as? String,
  1843. primary.isEmpty == false {
  1844. var secondary = language2Popup.selectedItem?.representedObject as? String
  1845. if secondary?.isEmpty == true {
  1846. secondary = nil
  1847. }
  1848. TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
  1849. syncSettingsSpeechLanguagePopupsFromStoredPreferences()
  1850. }
  1851. switch response {
  1852. case .alertFirstButtonReturn:
  1853. return .allowAndContinue
  1854. case .alertSecondButtonReturn:
  1855. return .continueWithoutRecording
  1856. default:
  1857. return .dismissed
  1858. }
  1859. }
  1860. @objc private func meetingConsentInlineCloseTapped(_ sender: NSButton) {
  1861. didTapInlineMeetingConsentClose = true
  1862. guard let consentWindow = activeMeetingConsentWindow else { return }
  1863. NSApp.stopModal(withCode: .abort)
  1864. consentWindow.orderOut(nil)
  1865. }
  1866. @objc private func meetingConsentSpeechLanguageChanged(_ sender: NSPopUpButton) {
  1867. guard let primary = meetingConsentPrimaryLanguagePopup?.selectedItem?.representedObject as? String,
  1868. primary.isEmpty == false else { return }
  1869. var secondary: String? = meetingConsentSecondaryLanguagePopup?.selectedItem?.representedObject as? String
  1870. if secondary?.isEmpty == true {
  1871. secondary = nil
  1872. }
  1873. if let secondaryValue = secondary,
  1874. secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
  1875. secondary = nil
  1876. meetingConsentSecondaryLanguagePopup?.selectItem(at: 0)
  1877. }
  1878. TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
  1879. syncSettingsSpeechLanguagePopupsFromStoredPreferences()
  1880. }
  1881. /// Keeps Settings language menus aligned when preferences change from the pre-join consent sheet.
  1882. private func syncSettingsSpeechLanguagePopupsFromStoredPreferences() {
  1883. let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
  1884. if let primaryPopup = settingsPagePrimaryLanguagePopup {
  1885. let id = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
  1886. ?? options.first?.identifier
  1887. ?? Locale.current.identifier
  1888. selectTranscriptionLanguage(identifier: id, in: primaryPopup)
  1889. }
  1890. if let secondaryPopup = settingsPageSecondaryLanguagePopup {
  1891. if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
  1892. selectTranscriptionLanguage(identifier: selected, in: secondaryPopup)
  1893. } else {
  1894. secondaryPopup.selectItem(at: 0)
  1895. }
  1896. }
  1897. }
  1898. private func selectTranscriptionLanguage(identifier: String, in popup: NSPopUpButton) {
  1899. for item in popup.itemArray {
  1900. if let value = item.representedObject as? String, value == identifier {
  1901. popup.select(item)
  1902. return
  1903. }
  1904. }
  1905. }
  1906. /// Maps `zoom.us` join links to `app.zoom.us` (Workplace web client host).
  1907. private func zoomEmbeddedWebHostURL(for url: URL) -> URL {
  1908. guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
  1909. let host = components.host?.lowercased() ?? ""
  1910. if host == "zoom.us" || host.hasSuffix(".zoom.us") {
  1911. components.host = "app.zoom.us"
  1912. }
  1913. return components.url ?? url
  1914. }
  1915. private func zakQueryValueFromZoomURL(_ url: URL) -> String? {
  1916. let fromQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)?
  1917. .queryItems?
  1918. .first(where: { $0.name.caseInsensitiveCompare("zak") == .orderedSame })?
  1919. .value?
  1920. .trimmingCharacters(in: .whitespacesAndNewlines)
  1921. if let fromQuery, fromQuery.isEmpty == false { return fromQuery }
  1922. return nil
  1923. }
  1924. /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd` + optional `zak`).
  1925. private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String, zak: String? = nil, displayName: String? = nil) -> URL? {
  1926. guard meetingIdDigits.isEmpty == false else { return nil }
  1927. var components = URLComponents()
  1928. components.scheme = "https"
  1929. components.host = "app.zoom.us"
  1930. components.path = "/wc/join/\(meetingIdDigits)"
  1931. var queryItems: [URLQueryItem] = []
  1932. let trimmedPasscode = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
  1933. if trimmedPasscode.isEmpty == false {
  1934. queryItems.append(URLQueryItem(name: "pwd", value: trimmedPasscode))
  1935. }
  1936. let trimmedZAK = zak?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1937. if trimmedZAK.isEmpty == false {
  1938. queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
  1939. }
  1940. let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1941. if trimmedName.isEmpty == false {
  1942. queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
  1943. }
  1944. if queryItems.isEmpty == false {
  1945. components.queryItems = queryItems
  1946. }
  1947. return components.url
  1948. }
  1949. /// Adds ZAK and display name so the embedded web client joins as the OAuth-signed-in user (not a guest).
  1950. private func zoomMeetingURLByAppendingWebAuth(_ url: URL, zak: String, displayName: String?) -> URL {
  1951. let normalizedHost = zoomEmbeddedWebHostURL(for: url)
  1952. let trimmedZAK = zak.trimmingCharacters(in: .whitespacesAndNewlines)
  1953. guard trimmedZAK.isEmpty == false else { return normalizedHost }
  1954. var components = URLComponents(url: normalizedHost, resolvingAgainstBaseURL: false) ?? URLComponents()
  1955. var queryItems = components.queryItems ?? []
  1956. queryItems.removeAll { $0.name.caseInsensitiveCompare("zak") == .orderedSame }
  1957. queryItems.removeAll { $0.name.caseInsensitiveCompare("uname") == .orderedSame }
  1958. queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
  1959. let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1960. if trimmedName.isEmpty == false {
  1961. queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
  1962. }
  1963. components.queryItems = queryItems
  1964. return components.url ?? normalizedHost
  1965. }
  1966. private func javaScriptStringLiteral(_ value: String) -> String {
  1967. let escaped = value
  1968. .replacingOccurrences(of: "\\", with: "\\\\")
  1969. .replacingOccurrences(of: "'", with: "\\'")
  1970. .replacingOccurrences(of: "\n", with: "\\n")
  1971. .replacingOccurrences(of: "\r", with: "")
  1972. return "'\(escaped)'"
  1973. }
  1974. /// Loads a Zoom Workplace page in the shared web view so OAuth cookies apply to later joins (panel may stay closed).
  1975. @MainActor
  1976. private func primeZoomEmbeddedWebSession() {
  1977. guard isUserLoggedIn(), let webView = embeddedBrowserWebView else { return }
  1978. guard embeddedZoomPendingJoinURL == nil, isEmbeddedBrowserPanelOpen == false else { return }
  1979. guard let homeURL = URL(string: "https://app.zoom.us/wc/home") else { return }
  1980. webView.load(URLRequest(url: homeURL))
  1981. }
  1982. private func zoomOAuthErrorIndicatesMissingZAKScope(_ error: Error) -> Bool {
  1983. if case ZoomOAuthError.missingRequiredScope(let raw) = error {
  1984. let lower = raw.lowercased()
  1985. return lower.contains("user_zak") || lower.contains("zak")
  1986. }
  1987. let lower = error.localizedDescription.lowercased()
  1988. return lower.contains("user_zak") || lower.contains("does not contain scopes")
  1989. }
  1990. /// Fetches ZAK for embedded join. If the saved token predates `user_zak:read`, opens Zoom OAuth once (you stay signed in to the app).
  1991. @MainActor
  1992. private func fetchEmbeddedZoomZAKAllowingReConsent() async -> String? {
  1993. do {
  1994. let accessToken = try await zoomOAuth.validAccessToken(
  1995. presentingWindow: view.window,
  1996. requiringAnyOf: ["user_zak:read"]
  1997. )
  1998. return try await fetchZoomUserZAK(accessToken: accessToken)
  1999. } catch {
  2000. if case ZoomOAuthError.authenticationCancelled = error { return nil }
  2001. guard zoomOAuthErrorIndicatesMissingZAKScope(error) else {
  2002. NSLog("ViewController: ZAK unavailable for embedded join — \(error.localizedDescription)")
  2003. return nil
  2004. }
  2005. }
  2006. do {
  2007. zoomOAuth.clearSavedTokens()
  2008. let accessToken = try await zoomOAuth.validAccessToken(
  2009. presentingWindow: view.window,
  2010. requiringAnyOf: ["user_zak:read"]
  2011. )
  2012. return try await fetchZoomUserZAK(accessToken: accessToken)
  2013. } catch {
  2014. if case ZoomOAuthError.authenticationCancelled = error { return nil }
  2015. NSLog("ViewController: ZAK unavailable after OAuth refresh — \(error.localizedDescription)")
  2016. return nil
  2017. }
  2018. }
  2019. @MainActor
  2020. private func zoomMeetingURLWithSignedInDisplayNameFallback(_ url: URL) -> URL {
  2021. guard let name = zoomSignedInDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines),
  2022. name.isEmpty == false else { return url }
  2023. var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents()
  2024. var queryItems = components.queryItems ?? []
  2025. if queryItems.contains(where: { $0.name.caseInsensitiveCompare("uname") == .orderedSame }) == false {
  2026. queryItems.append(URLQueryItem(name: "uname", value: name))
  2027. }
  2028. components.queryItems = queryItems
  2029. return components.url ?? url
  2030. }
  2031. /// When signed in, attach a fresh ZAK so `/wc/join/` loads with the same Zoom account as native OAuth.
  2032. @MainActor
  2033. private func embeddedZoomMeetingLoadURL(for url: URL) async -> URL {
  2034. let normalized = zoomEmbeddedWebHostURL(for: url)
  2035. guard isZoomMeetingJoinOrStartURL(normalized), isUserLoggedIn() else {
  2036. embeddedZoomPendingJoinURL = nil
  2037. embeddedZoomPendingWebAuth = nil
  2038. return normalized
  2039. }
  2040. embeddedZoomPendingJoinURL = normalized
  2041. if let existingZAK = zakQueryValueFromZoomURL(normalized) {
  2042. let displayName = zoomSignedInDisplayName
  2043. embeddedZoomPendingWebAuth = (zak: existingZAK, displayName: displayName)
  2044. return zoomMeetingURLByAppendingWebAuth(normalized, zak: existingZAK, displayName: displayName)
  2045. }
  2046. if let zak = await fetchEmbeddedZoomZAKAllowingReConsent() {
  2047. let displayName = zoomSignedInDisplayName
  2048. embeddedZoomPendingWebAuth = (zak: zak, displayName: displayName)
  2049. return zoomMeetingURLByAppendingWebAuth(normalized, zak: zak, displayName: displayName)
  2050. }
  2051. // No ZAK (scope not on OAuth app yet): still pre-fill name and rely on shared web cookies.
  2052. embeddedZoomPendingWebAuth = nil
  2053. return zoomMeetingURLWithSignedInDisplayNameFallback(normalized)
  2054. }
  2055. private func embeddedWebViewShowsUnauthenticatedJoinPrompt(webView: WKWebView) -> Bool {
  2056. let title = webView.title ?? ""
  2057. if title.localizedCaseInsensitiveContains("Enter Meeting Info")
  2058. || title.localizedCaseInsensitiveContains("Enter your name") {
  2059. return true
  2060. }
  2061. if title.localizedCaseInsensitiveContains("sign in") {
  2062. return true
  2063. }
  2064. if let path = webView.url?.path.lowercased(), path.hasPrefix("/signin") {
  2065. return true
  2066. }
  2067. return false
  2068. }
  2069. @MainActor
  2070. private func applyEmbeddedZoomWebAuthenticationIfNeeded(webView: WKWebView) {
  2071. guard isUserLoggedIn() else { return }
  2072. guard embeddedWebViewShowsUnauthenticatedJoinPrompt(webView: webView)
  2073. || (webView.url.map { isZoomMeetingJoinOrStartURL($0) } ?? false) else { return }
  2074. if let pending = embeddedZoomPendingWebAuth,
  2075. let joinURL = embeddedZoomPendingJoinURL {
  2076. let authURL = zoomMeetingURLByAppendingWebAuth(joinURL, zak: pending.zak, displayName: pending.displayName)
  2077. if webView.url?.absoluteString != authURL.absoluteString {
  2078. webView.load(URLRequest(url: authURL))
  2079. return
  2080. }
  2081. } else if embeddedZoomPendingJoinURL != nil {
  2082. Task { @MainActor in
  2083. guard self.embeddedBrowserWebView === webView else { return }
  2084. guard let zak = await self.fetchEmbeddedZoomZAKAllowingReConsent() else { return }
  2085. self.embeddedZoomPendingWebAuth = (zak: zak, displayName: self.zoomSignedInDisplayName)
  2086. self.applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
  2087. }
  2088. return
  2089. } else {
  2090. return
  2091. }
  2092. guard let pending = embeddedZoomPendingWebAuth else { return }
  2093. guard let pageURL = webView.url, isZoomMeetingJoinOrStartURL(pageURL) else { return }
  2094. let zakLiteral = javaScriptStringLiteral(pending.zak)
  2095. let nameLiteral = pending.displayName.map(javaScriptStringLiteral) ?? "null"
  2096. let script = """
  2097. (function() {
  2098. var zak = \(zakLiteral);
  2099. var uname = \(nameLiteral);
  2100. try {
  2101. var next = new URL(window.location.href);
  2102. if (!next.searchParams.get('zak')) {
  2103. next.searchParams.set('zak', zak);
  2104. if (uname) next.searchParams.set('uname', uname);
  2105. window.location.replace(next.toString());
  2106. return;
  2107. }
  2108. } catch (e) {}
  2109. try {
  2110. sessionStorage.setItem('zak', zak);
  2111. if (uname) sessionStorage.setItem('uname', uname);
  2112. } catch (e2) {}
  2113. })();
  2114. """
  2115. webView.evaluateJavaScript(script, completionHandler: nil)
  2116. }
  2117. /// Prefer `/wc/join/` so the embedded web client loads directly (avoids Workplace app vs browser chooser on `/s/` start links).
  2118. private func embeddedWebClientURLForZoomMeeting(
  2119. meetingId: Int?,
  2120. joinURLString: String?,
  2121. startURLString: String? = nil
  2122. ) -> URL? {
  2123. let trimmedJoin = joinURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  2124. let trimmedStart = startURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  2125. var passcode: String?
  2126. var zakFromAPI: String?
  2127. if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
  2128. zakFromAPI = zakQueryValueFromZoomURL(startURL)
  2129. }
  2130. if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
  2131. if joinURL.path.contains("/wc/join/") {
  2132. return zoomEmbeddedWebHostURL(for: joinURL)
  2133. }
  2134. passcode = URLComponents(url: joinURL, resolvingAgainstBaseURL: false)?
  2135. .queryItems?
  2136. .first(where: { $0.name == "pwd" })?
  2137. .value
  2138. if zakFromAPI == nil {
  2139. zakFromAPI = zakQueryValueFromZoomURL(joinURL)
  2140. }
  2141. }
  2142. let numericId: String? = {
  2143. if let meetingId { return String(meetingId) }
  2144. if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
  2145. return meetingIdDigitsFromZoomJoinPath(joinURL.path)
  2146. }
  2147. if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
  2148. return meetingIdDigitsFromZoomJoinPath(startURL.path)
  2149. }
  2150. return nil
  2151. }()
  2152. guard let numericId, numericId.isEmpty == false else { return nil }
  2153. return zoomWebClientJoinURL(
  2154. meetingIdDigits: numericId,
  2155. passcode: passcode ?? "",
  2156. zak: zakFromAPI,
  2157. displayName: zoomSignedInDisplayName
  2158. )
  2159. }
  2160. private func meetingIdDigitsFromZoomJoinPath(_ path: String) -> String? {
  2161. let parts = path.split(separator: "/").map(String.init)
  2162. for (index, segment) in parts.enumerated() {
  2163. let lowered = segment.lowercased()
  2164. if (lowered == "j" || lowered == "join" || lowered == "wc"), index + 1 < parts.count {
  2165. let digits = parts[index + 1].filter(\.isNumber)
  2166. if digits.count >= 9 { return digits }
  2167. }
  2168. }
  2169. return nil
  2170. }
  2171. private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
  2172. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  2173. guard trimmed.isEmpty == false else { return nil }
  2174. let normalized: String
  2175. if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
  2176. normalized = trimmed
  2177. } else {
  2178. normalized = "https://\(trimmed)"
  2179. }
  2180. guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
  2181. let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
  2182. || host == "zoom.com" || host.hasSuffix(".zoom.com")
  2183. guard isZoom else { return nil }
  2184. return url
  2185. }
  2186. /// Converts invite links (`zoom.us/j/…`) into embedded web-client join URLs (`app.zoom.us/wc/join/…`).
  2187. private func normalizedEmbeddedJoinURL(for url: URL) -> URL {
  2188. if let embedded = embeddedWebClientURLForZoomMeeting(meetingId: nil, joinURLString: url.absoluteString) {
  2189. return embedded
  2190. }
  2191. return zoomEmbeddedWebHostURL(for: url)
  2192. }
  2193. /// Join lobby / invite URL — not an active call (recording must not start here).
  2194. private func isZoomEmbeddedWebClientJoinLobbyURL(_ url: URL) -> Bool {
  2195. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2196. let path = url.path.lowercased()
  2197. if path.contains("/wc/join") { return true }
  2198. if path.range(of: #"/j/\d"#, options: .regularExpression) != nil { return true }
  2199. return false
  2200. }
  2201. private func embeddedWebViewTitleIndicatesActiveCall(_ title: String) -> Bool {
  2202. let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2203. guard t.isEmpty == false else { return false }
  2204. return t.contains("zoom meeting") || t.contains("zoom webinar")
  2205. }
  2206. /// Opens a Zoom join link in the embedded web panel (skips the Join sheet).
  2207. @MainActor
  2208. private func joinZoomMeetingFromLink(_ rawLink: String, displayName: String?) {
  2209. guard let parsed = parseZoomJoinURLFromUserInput(rawLink) else {
  2210. showSimpleAlert(
  2211. title: "Invalid link",
  2212. message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…)."
  2213. )
  2214. return
  2215. }
  2216. let url = normalizedEmbeddedJoinURL(for: parsed)
  2217. openWebURLPreferringInApp(
  2218. url,
  2219. zoomMeetingDisplayName: displayName,
  2220. recordingConsentWindowTitle: "Join Meeting"
  2221. )
  2222. }
  2223. @MainActor
  2224. private func joinScheduledMeeting(_ meeting: ScheduledMeeting) {
  2225. guard let url = meeting.webURL else { return }
  2226. joinZoomMeetingFromLink(url.absoluteString, displayName: meeting.title)
  2227. }
  2228. /// 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.
  2229. private func isZoomWebsiteURL(_ url: URL) -> Bool {
  2230. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2231. if host.hasPrefix("api.") { return false }
  2232. let path = url.path.lowercased()
  2233. if path.hasPrefix("/oauth") { return false }
  2234. return true
  2235. }
  2236. /// Opens Zoom meeting join/start links in the right-panel `WKWebView` (same store as embedded Zoom OAuth).
  2237. /// Other zoom.us pages open in that web view when signed in; everything else uses the system browser.
  2238. /// For Zoom meeting join/start links, asks for recording consent first, persists transcription languages from that sheet,
  2239. /// then optionally begins local audio recording (when the user allows and recording is not already active).
  2240. @MainActor
  2241. private func openWebURLPreferringInApp(
  2242. _ url: URL,
  2243. zoomMeetingDisplayName: String? = nil,
  2244. recordingConsentWindowTitle: String = "Meeting"
  2245. ) {
  2246. let targetURL = isZoomMeetingJoinOrStartURL(url) ? normalizedEmbeddedJoinURL(for: url) : url
  2247. let shouldRecordLocally: Bool
  2248. if isZoomMeetingJoinOrStartURL(targetURL) {
  2249. let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
  2250. if consentChoice == .dismissed { return }
  2251. shouldRecordLocally = (consentChoice == .allowAndContinue)
  2252. } else {
  2253. shouldRecordLocally = false
  2254. }
  2255. let useEmbeddedBrowser = embeddedBrowserWebView != nil
  2256. && (isZoomMeetingJoinOrStartURL(targetURL) || (isUserLoggedIn() && isZoomWebsiteURL(targetURL)))
  2257. let opened: Bool
  2258. if useEmbeddedBrowser {
  2259. presentURLInEmbeddedRightPanel(targetURL)
  2260. opened = true
  2261. } else {
  2262. opened = openURLInDefaultBrowser(targetURL)
  2263. }
  2264. if opened {
  2265. dismissJoinMeetingPanel()
  2266. if shouldRecordLocally {
  2267. armZoomMeetingRecordingIfAppropriate(
  2268. sourceURL: targetURL,
  2269. meetingDisplayName: zoomMeetingDisplayName
  2270. )
  2271. }
  2272. } else {
  2273. showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
  2274. }
  2275. }
  2276. /// True when `url` looks like joining or starting a Zoom call (not OAuth, API, or generic marketing pages).
  2277. private func isZoomMeetingJoinOrStartURL(_ url: URL) -> Bool {
  2278. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2279. if host.hasPrefix("api.") { return false }
  2280. let path = url.path.lowercased()
  2281. if path.hasPrefix("/oauth") || path.hasPrefix("/signin") { return false }
  2282. if path.contains("/j/") { return true }
  2283. if path.contains("/wc/join") { return true }
  2284. if path.contains("/join/") { return true }
  2285. if path.contains("/my/") { return true }
  2286. if path.contains("/w/") { return true }
  2287. if path.contains("/s/") { return true }
  2288. if path.contains("videomeeting") { return true }
  2289. if path.contains("/start") && path.contains("meeting") { return true }
  2290. return false
  2291. }
  2292. /// Active in-call pages in the embedded Zoom web client (not join lobby or Workplace hub routes).
  2293. private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
  2294. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2295. if isZoomMeetingExplicitLeaveURL(url) { return false }
  2296. if isZoomEmbeddedWebClientJoinLobbyURL(url) { return false }
  2297. let path = url.path.lowercased()
  2298. if path.contains("/wc/inmeeting") { return true }
  2299. if path.contains("/wc/video") { return true }
  2300. // Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
  2301. if path.contains("/wc/") {
  2302. return isZoomWorkplaceHubURL(url) == false
  2303. }
  2304. // Non–web-client join links opened in the embedded panel (rare).
  2305. return isZoomMeetingJoinOrStartURL(url)
  2306. }
  2307. /// User explicitly left or the meeting ended (stop recording soon after this is detected).
  2308. private func isZoomMeetingExplicitLeaveURL(_ url: URL) -> Bool {
  2309. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2310. let path = url.path.lowercased()
  2311. let absolute = url.absoluteString.lowercased()
  2312. if path.contains("/leave") || path.contains("postattendee") || path.contains("/feedback") { return true }
  2313. return absolute.contains("meeting_ended") || absolute.contains("meeting-ended")
  2314. }
  2315. /// Workplace hub, sign-in, or marketing home — not an active call (may appear briefly during join).
  2316. private func isZoomWorkplaceHubURL(_ url: URL) -> Bool {
  2317. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2318. let path = url.path.lowercased()
  2319. let workplaceHubMarkers = [
  2320. "/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
  2321. "/wc/whiteboard", "/wc/meeting", "/wc/recordings", "/wc/notes",
  2322. "/wc/contacts", "/wc/scheduler", "/wc/documents", "/wc/apps"
  2323. ]
  2324. if workplaceHubMarkers.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) { return true }
  2325. if path == "/wc" || path == "/wc/" { return true }
  2326. if path.contains("/wc/") == false, path.isEmpty || path == "/" || path.hasPrefix("/signin") { return true }
  2327. return false
  2328. }
  2329. /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
  2330. private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
  2331. isZoomMeetingExplicitLeaveURL(url) || isZoomWorkplaceHubURL(url)
  2332. }
  2333. private func embeddedWebViewTitleIndicatesLeftMeeting(_ title: String) -> Bool {
  2334. let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2335. guard t.isEmpty == false else { return false }
  2336. if t.contains("zoom meeting") || t.contains("zoom webinar") { return false }
  2337. if t.contains("waiting for the host") || t.contains("waiting for host") { return false }
  2338. let hubTitles = [
  2339. "zoom workplace", "meetings | zoom", "home | zoom", "calendar | zoom",
  2340. "team chat | zoom", "chat | zoom", "sign in", "sign-in", "thank you for"
  2341. ]
  2342. return hubTitles.contains(where: { t.contains($0) })
  2343. }
  2344. @MainActor
  2345. private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
  2346. startPendingZoomMeetingRecordingIfNeeded(for: url, webViewTitle: webViewTitle)
  2347. guard MeetingRecordingManager.shared.isRecording else {
  2348. if pendingZoomMeetingRecording == nil {
  2349. stopEmbeddedMeetingRecordingMonitor()
  2350. }
  2351. return
  2352. }
  2353. let inCall = (url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false)
  2354. || (webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false)
  2355. let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
  2356. let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
  2357. let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
  2358. if inCall {
  2359. embeddedMeetingReachedInCallPage = true
  2360. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2361. }
  2362. MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
  2363. // Definite leave/end pages and hub titles stop recording immediately.
  2364. if explicitLeaveURL || leftByTitle {
  2365. endZoomMeetingRecordingAutomatically()
  2366. return
  2367. }
  2368. let elapsed = MeetingRecordingManager.shared.currentRecordingElapsed
  2369. if elapsed < MeetingRecordingManager.recordingLeaveGraceInterval {
  2370. return
  2371. }
  2372. // User may have joined via the desktop Zoom app while the embedded panel shows a hub page.
  2373. if ZoomMeetingPresenceWatcher.callPresenceLikelyVisible() {
  2374. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2375. return
  2376. }
  2377. let absentFromCall = workplaceHubURL || (embeddedMeetingReachedInCallPage && !inCall)
  2378. if absentFromCall {
  2379. embeddedMeetingAbsentCallConsecutiveMisses += 1
  2380. } else {
  2381. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2382. }
  2383. guard embeddedMeetingAbsentCallConsecutiveMisses >= Self.embeddedMeetingAbsentCallMissThreshold else { return }
  2384. endZoomMeetingRecordingAutomatically()
  2385. }
  2386. @MainActor
  2387. private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
  2388. stopEmbeddedMeetingRecordingMonitor()
  2389. guard MeetingRecordingManager.shared.isRecording || pendingZoomMeetingRecording != nil else { return }
  2390. guard embeddedBrowserWebView != nil else { return }
  2391. embeddedMeetingReachedInCallPage = false
  2392. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2393. let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
  2394. guard let self else { return }
  2395. let webView = self.embeddedBrowserWebView
  2396. self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
  2397. }
  2398. RunLoop.main.add(timer, forMode: .common)
  2399. embeddedMeetingRecordingMonitorTimer = timer
  2400. }
  2401. @MainActor
  2402. private func stopEmbeddedMeetingRecordingMonitor() {
  2403. embeddedMeetingRecordingMonitorTimer?.invalidate()
  2404. embeddedMeetingRecordingMonitorTimer = nil
  2405. embeddedMeetingReachedInCallPage = false
  2406. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2407. }
  2408. @MainActor
  2409. private func endZoomMeetingRecordingAutomatically() {
  2410. guard MeetingRecordingManager.shared.isRecording else { return }
  2411. stopEmbeddedMeetingRecordingMonitor()
  2412. Task { @MainActor in
  2413. guard MeetingRecordingManager.shared.isRecording else { return }
  2414. do {
  2415. try await MeetingRecordingManager.shared.stopRecording()
  2416. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  2417. self.renderAiCompanionList()
  2418. self.updateAiCompanionRecordingUI()
  2419. } catch {
  2420. NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
  2421. }
  2422. }
  2423. }
  2424. @MainActor
  2425. private func armZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
  2426. guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
  2427. guard MeetingRecordingManager.shared.isRecording == false else { return }
  2428. pendingZoomMeetingRecording = (normalizedEmbeddedJoinURL(for: sourceURL), meetingDisplayName)
  2429. updateAiCompanionRecordingUI()
  2430. if embeddedBrowserWebView != nil,
  2431. (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
  2432. startEmbeddedMeetingRecordingMonitorIfNeeded()
  2433. }
  2434. }
  2435. @MainActor
  2436. private func startPendingZoomMeetingRecordingIfNeeded(for url: URL?, webViewTitle: String?) {
  2437. guard pendingZoomMeetingRecording != nil else { return }
  2438. if MeetingRecordingManager.shared.isRecording {
  2439. pendingZoomMeetingRecording = nil
  2440. updateAiCompanionRecordingUI()
  2441. return
  2442. }
  2443. let urlInCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
  2444. let titleInCall = webViewTitle.map { embeddedWebViewTitleIndicatesActiveCall($0) } ?? false
  2445. let desktopInCall = ZoomMeetingPresenceWatcher.callPresenceLikelyVisible()
  2446. guard urlInCall || titleInCall || desktopInCall else { return }
  2447. guard let pending = pendingZoomMeetingRecording else { return }
  2448. pendingZoomMeetingRecording = nil
  2449. let sessionID = embeddedZoomWebSessionID
  2450. Task { @MainActor in
  2451. guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else { return }
  2452. do {
  2453. try await MeetingRecordingManager.shared.startRecording(
  2454. sourceURL: pending.sourceURL,
  2455. meetingDisplayName: pending.displayName
  2456. )
  2457. guard sessionID == self.embeddedZoomWebSessionID, self.isEmbeddedBrowserPanelOpen else {
  2458. self.endZoomMeetingRecordingAutomatically()
  2459. return
  2460. }
  2461. self.updateAiCompanionRecordingUI()
  2462. self.startEmbeddedMeetingRecordingMonitorIfNeeded()
  2463. } catch {
  2464. self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
  2465. }
  2466. }
  2467. }
  2468. @discardableResult
  2469. private func openURLInDefaultBrowser(_ url: URL) -> Bool {
  2470. NSWorkspace.shared.open(url)
  2471. }
  2472. @MainActor
  2473. private var isEmbeddedBrowserPanelOpen: Bool {
  2474. (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5
  2475. }
  2476. @MainActor
  2477. private func presentURLInEmbeddedRightPanel(_ url: URL) {
  2478. guard let webView = embeddedBrowserWebView else {
  2479. NSWorkspace.shared.open(url)
  2480. return
  2481. }
  2482. embeddedZoomWebSessionID &+= 1
  2483. updateEmbeddedRightPanelWidthForOpenState()
  2484. if isZoomMeetingJoinOrStartURL(url) {
  2485. Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
  2486. }
  2487. Task { @MainActor in
  2488. let loadURL = await self.embeddedZoomMeetingLoadURL(for: url)
  2489. guard self.embeddedBrowserWebView === webView else { return }
  2490. webView.load(URLRequest(url: loadURL))
  2491. self.updateEmbeddedMeetingRecordingPresence(for: loadURL)
  2492. self.refreshEmbeddedBrowserNavButtons()
  2493. self.view.window?.contentView?.layoutSubtreeIfNeeded()
  2494. }
  2495. }
  2496. /// Sandbox camera entitlement + TCC prompt so `WKWebView` can enumerate devices for Zoom web meetings.
  2497. private func ensureMediaCaptureAccessForEmbeddedZoomWebClient() async {
  2498. _ = await cameraAccessGrantedForWebCapture()
  2499. _ = await microphoneAccessGrantedForWebCapture()
  2500. }
  2501. private func cameraAccessGrantedForWebCapture() async -> Bool {
  2502. switch AVCaptureDevice.authorizationStatus(for: .video) {
  2503. case .authorized:
  2504. return true
  2505. case .notDetermined:
  2506. return await AVCaptureDevice.requestAccess(for: .video)
  2507. case .denied, .restricted:
  2508. return false
  2509. @unknown default:
  2510. return false
  2511. }
  2512. }
  2513. private func microphoneAccessGrantedForWebCapture() async -> Bool {
  2514. switch AVCaptureDevice.authorizationStatus(for: .audio) {
  2515. case .authorized:
  2516. return true
  2517. case .notDetermined:
  2518. return await AVCaptureDevice.requestAccess(for: .audio)
  2519. case .denied, .restricted:
  2520. return false
  2521. @unknown default:
  2522. return false
  2523. }
  2524. }
  2525. /// Makes the embedded web panel fill the entire right-side content area (to match the reference behavior).
  2526. @MainActor
  2527. private func updateEmbeddedRightPanelWidthForOpenState() {
  2528. guard let widthConstraint = embeddedBrowserPanelWidthConstraint else { return }
  2529. view.layoutSubtreeIfNeeded()
  2530. view.window?.contentView?.layoutSubtreeIfNeeded()
  2531. var rowW = homeBodyRowView?.bounds.width ?? 0
  2532. if rowW < 1 {
  2533. rowW = view.bounds.width
  2534. }
  2535. // Right panel spans from (bodyRow.leading + gap) to bodyRow.trailing when the main column is collapsed (width 0).
  2536. // Using the full row width here over-constrains by the column gap and nudges the window wider.
  2537. let available = max(0, rowW - homeEmbeddedBrowserColumnGap)
  2538. widthConstraint.constant = max(320, floor(available))
  2539. // Collapse the left main content column while web is visible.
  2540. homeMainContentColumnView?.isHidden = true
  2541. embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = true
  2542. }
  2543. @MainActor
  2544. private func refreshEmbeddedBrowserNavButtons() {
  2545. embeddedBrowserBackButton?.isEnabled = embeddedBrowserWebView?.canGoBack == true
  2546. embeddedBrowserForwardButton?.isEnabled = embeddedBrowserWebView?.canGoForward == true
  2547. }
  2548. @objc private func embeddedBrowserBackTapped() {
  2549. embeddedBrowserWebView?.goBack()
  2550. }
  2551. @objc private func embeddedBrowserForwardTapped() {
  2552. embeddedBrowserWebView?.goForward()
  2553. }
  2554. @objc private func embeddedBrowserReloadTapped() {
  2555. embeddedBrowserWebView?.reload()
  2556. }
  2557. @objc private func embeddedBrowserClosePanelTapped() {
  2558. closeEmbeddedBrowserPanel()
  2559. }
  2560. /// Leaves any in-call Zoom web session, stops camera/mic tracks, and navigates away so WKWebView releases hardware.
  2561. @MainActor
  2562. private func teardownEmbeddedZoomWebClient() {
  2563. guard let webView = embeddedBrowserWebView else { return }
  2564. webView.evaluateJavaScript(Self.embeddedZoomWebClientTeardownJavaScript) { [weak self] _, _ in
  2565. Task { @MainActor in
  2566. guard let self, self.embeddedBrowserWebView === webView else { return }
  2567. webView.stopLoading()
  2568. if let blankURL = URL(string: "about:blank") {
  2569. webView.load(URLRequest(url: blankURL))
  2570. }
  2571. }
  2572. }
  2573. }
  2574. @MainActor
  2575. private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
  2576. embeddedZoomWebSessionID &+= 1
  2577. pendingZoomMeetingRecording = nil
  2578. stopEmbeddedMeetingRecordingMonitor()
  2579. MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
  2580. if MeetingRecordingManager.shared.isRecording {
  2581. MeetingRecordingManager.shared.stopCaptureHardwareImmediately()
  2582. endZoomMeetingRecordingAutomatically()
  2583. }
  2584. updateAiCompanionRecordingUI()
  2585. if let cont = oauthSessionContinuation {
  2586. oauthSessionContinuation = nil
  2587. oauthSessionCallbackPrefix = nil
  2588. cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
  2589. }
  2590. if stoppingWebLoad {
  2591. teardownEmbeddedZoomWebClient()
  2592. }
  2593. embeddedZoomPendingWebAuth = nil
  2594. embeddedZoomPendingJoinURL = nil
  2595. embeddedBrowserPanelWidthConstraint?.constant = 0
  2596. embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
  2597. homeMainContentColumnView?.isHidden = false
  2598. refreshEmbeddedBrowserNavButtons()
  2599. view.window?.contentView?.layoutSubtreeIfNeeded()
  2600. }
  2601. func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL {
  2602. guard let webView = embeddedBrowserWebView else {
  2603. throw EmbeddedOAuthFlowError.webViewUnavailable
  2604. }
  2605. do {
  2606. return try await withThrowingTaskGroup(of: URL.self) { group in
  2607. group.addTask { @MainActor in
  2608. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  2609. self.oauthSessionContinuation = continuation
  2610. self.oauthSessionCallbackPrefix = callbackURLPrefix
  2611. self.updateEmbeddedRightPanelWidthForOpenState()
  2612. webView.load(URLRequest(url: startURL))
  2613. self.refreshEmbeddedBrowserNavButtons()
  2614. }
  2615. }
  2616. group.addTask {
  2617. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  2618. await MainActor.run {
  2619. if let cont = self.oauthSessionContinuation {
  2620. self.oauthSessionContinuation = nil
  2621. self.oauthSessionCallbackPrefix = nil
  2622. webView.stopLoading()
  2623. cont.resume(throwing: EmbeddedOAuthFlowError.timedOut)
  2624. }
  2625. }
  2626. throw EmbeddedOAuthFlowError.timedOut
  2627. }
  2628. let url = try await group.next()!
  2629. group.cancelAll()
  2630. await MainActor.run {
  2631. self.oauthSessionContinuation = nil
  2632. self.oauthSessionCallbackPrefix = nil
  2633. if let primeURL = URL(string: "https://app.zoom.us/wc/home") {
  2634. webView.load(URLRequest(url: primeURL))
  2635. }
  2636. self.closeEmbeddedBrowserPanel(stoppingWebLoad: false)
  2637. }
  2638. return url
  2639. }
  2640. } catch {
  2641. await MainActor.run {
  2642. self.oauthSessionContinuation = nil
  2643. self.oauthSessionCallbackPrefix = nil
  2644. webView.stopLoading()
  2645. self.embeddedBrowserPanelWidthConstraint?.constant = 0
  2646. self.refreshEmbeddedBrowserNavButtons()
  2647. webView.load(URLRequest(url: URL(string: "about:blank")!))
  2648. self.view.window?.contentView?.layoutSubtreeIfNeeded()
  2649. }
  2650. throw error
  2651. }
  2652. }
  2653. private func resetJoinMeetingPanelReferences() {
  2654. joinMeetingWindow = nil
  2655. joinURLField = nil
  2656. joinMeetingIDField = nil
  2657. joinPasscodeField = nil
  2658. joinMeetingDisplayNameField = nil
  2659. joinURLFieldsContainer = nil
  2660. joinIDFieldsContainer = nil
  2661. joinModeSegment = nil
  2662. }
  2663. @objc private func logoutTapped() {
  2664. meetingsRefreshTimer?.invalidate()
  2665. meetingsRefreshTimer = nil
  2666. meetingsLoadTask?.cancel()
  2667. meetingsLoadTask = nil
  2668. isLoadingMeetings = false
  2669. meetingsRateLimitedUntil = nil
  2670. clearMeetingsScrollObserver()
  2671. googleOAuth.clearSavedTokens()
  2672. zoomOAuth.clearSavedTokens()
  2673. zoomSignedInDisplayName = nil
  2674. embeddedZoomPendingWebAuth = nil
  2675. embeddedZoomPendingJoinURL = nil
  2676. persistLoggedInState(false)
  2677. allScheduledMeetings = []
  2678. applyAuthenticationUIState(triggerInitialRefresh: false)
  2679. Task { @MainActor in
  2680. self.applyMeetings([])
  2681. self.renderAiCompanionList()
  2682. self.updateAiCompanionRecordingUI()
  2683. }
  2684. }
  2685. @objc private func topBarAuthButtonTapped() {
  2686. if isUserLoggedIn() {
  2687. showProfileMenu()
  2688. } else {
  2689. zoomPrimarySignInTapped()
  2690. }
  2691. }
  2692. @objc private func profileMenuLogoutTapped() {
  2693. profileMenuPopover?.performClose(nil)
  2694. logoutTapped()
  2695. }
  2696. private func showProfileMenu() {
  2697. guard let button = topBarAuthButton else { return }
  2698. // Recreate so the popover always matches current light/dark mode.
  2699. profileMenuPopover = makeProfileMenuPopover()
  2700. guard let popover = profileMenuPopover else { return }
  2701. if popover.isShown {
  2702. popover.performClose(nil)
  2703. return
  2704. }
  2705. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
  2706. }
  2707. private func makeProfileMenuPopover() -> NSPopover {
  2708. let popover = NSPopover()
  2709. popover.behavior = .transient
  2710. popover.animates = true
  2711. let contentController = NSViewController()
  2712. let root = NSView(frame: NSRect(x: 0, y: 0, width: 198, height: 86))
  2713. root.wantsLayer = true
  2714. root.layer?.cornerRadius = 12
  2715. root.layer?.backgroundColor = (palette.isDarkMode
  2716. ? NSColor(calibratedRed: 30 / 255, green: 33 / 255, blue: 40 / 255, alpha: 1)
  2717. : NSColor(calibratedRed: 246 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
  2718. ).cgColor
  2719. root.layer?.borderWidth = 1
  2720. root.layer?.borderColor = (palette.isDarkMode
  2721. ? NSColor.white.withAlphaComponent(0.08)
  2722. : NSColor.black.withAlphaComponent(0.08)
  2723. ).cgColor
  2724. let logoutButton = NSButton(title: "Logout", target: self, action: #selector(profileMenuLogoutTapped))
  2725. logoutButton.translatesAutoresizingMaskIntoConstraints = false
  2726. logoutButton.isBordered = false
  2727. logoutButton.wantsLayer = true
  2728. logoutButton.layer?.cornerRadius = 9
  2729. logoutButton.layer?.backgroundColor = (palette.isDarkMode
  2730. ? NSColor.white.withAlphaComponent(0.08)
  2731. : NSColor(calibratedWhite: 0.0, alpha: 0.06)
  2732. ).cgColor
  2733. logoutButton.contentTintColor = (palette.isDarkMode
  2734. ? .white
  2735. : NSColor(calibratedRed: 22 / 255, green: 24 / 255, blue: 29 / 255, alpha: 1)
  2736. )
  2737. logoutButton.font = .systemFont(ofSize: 13, weight: .semibold)
  2738. logoutButton.alignment = .left
  2739. logoutButton.imagePosition = .imageLeading
  2740. logoutButton.imageHugsTitle = true
  2741. logoutButton.bezelStyle = .shadowlessSquare
  2742. logoutButton.title = " Logout"
  2743. if let symbol = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: "Logout") {
  2744. logoutButton.image = symbol.withSymbolConfiguration(.init(pointSize: 13, weight: .semibold))
  2745. }
  2746. let subtitle = makeLabel(
  2747. "Account",
  2748. size: 11,
  2749. color: palette.isDarkMode
  2750. ? NSColor.white.withAlphaComponent(0.72)
  2751. : NSColor(calibratedRed: 88 / 255, green: 92 / 255, blue: 102 / 255, alpha: 1),
  2752. weight: .medium,
  2753. centered: false
  2754. )
  2755. subtitle.translatesAutoresizingMaskIntoConstraints = false
  2756. root.addSubview(subtitle)
  2757. root.addSubview(logoutButton)
  2758. NSLayoutConstraint.activate([
  2759. subtitle.topAnchor.constraint(equalTo: root.topAnchor, constant: 10),
  2760. subtitle.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12),
  2761. subtitle.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
  2762. logoutButton.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 8),
  2763. logoutButton.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 10),
  2764. logoutButton.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -10),
  2765. logoutButton.heightAnchor.constraint(equalToConstant: 34)
  2766. ])
  2767. contentController.view = root
  2768. popover.contentViewController = contentController
  2769. popover.contentSize = root.frame.size
  2770. return popover
  2771. }
  2772. @objc private func topBarPlaceholderTapped() {
  2773. // Reserved for future titlebar control actions.
  2774. }
  2775. @objc private func upgradeToProTapped() {
  2776. if storeKitCoordinator.hasLifetimePremiumAccess {
  2777. openManageSubscriptions()
  2778. } else {
  2779. showPaywall()
  2780. }
  2781. }
  2782. private func openManageSubscriptions() {
  2783. if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
  2784. openWebURLPreferringInApp(url)
  2785. }
  2786. }
  2787. @objc private func refreshMeetingsTapped() {
  2788. guard isUserLoggedIn() else {
  2789. applyGuestLoginMessages()
  2790. return
  2791. }
  2792. guard ensurePremiumAccess() else {
  2793. applyPremiumLockMessages()
  2794. return
  2795. }
  2796. Task { @MainActor in
  2797. self.animateMeetingsRefresh()
  2798. self.setMeetingsLoadingUI(true)
  2799. }
  2800. triggerMeetingsRefresh(force: true)
  2801. }
  2802. @MainActor
  2803. private func setMeetingsLoadingUI(_ loading: Bool) {
  2804. refreshMeetingsButton?.isEnabled = loading == false
  2805. meetingsPageRefreshButton?.isEnabled = loading == false
  2806. if loading {
  2807. meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
  2808. }
  2809. }
  2810. @MainActor
  2811. private func animateMeetingsRefresh() {
  2812. guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
  2813. let views = stack.arrangedSubviews
  2814. let dimmed: CGFloat = 0.86
  2815. NSAnimationContext.runAnimationGroup { context in
  2816. context.duration = 0.07
  2817. context.timingFunction = CAMediaTimingFunction(name: .linear)
  2818. views.forEach { $0.animator().alphaValue = dimmed }
  2819. } completionHandler: {
  2820. NSAnimationContext.runAnimationGroup { context in
  2821. context.duration = 0.08
  2822. context.timingFunction = CAMediaTimingFunction(name: .linear)
  2823. views.forEach { $0.animator().alphaValue = 1.0 }
  2824. }
  2825. }
  2826. }
  2827. private func startMeetingsAutoRefresh() {
  2828. guard isUserLoggedIn() else { return }
  2829. meetingsRefreshTimer?.invalidate()
  2830. // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
  2831. meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
  2832. Task { @MainActor [weak self] in
  2833. self?.triggerMeetingsRefresh()
  2834. }
  2835. }
  2836. }
  2837. @MainActor
  2838. private func triggerMeetingsRefresh(force: Bool = false) {
  2839. guard isUserLoggedIn() else { return }
  2840. guard storeKitCoordinator.hasPremiumAccess else { return }
  2841. let now = Date()
  2842. if let until = meetingsRateLimitedUntil, now < until {
  2843. applyMeetingsRateLimitStatusLabels()
  2844. return
  2845. }
  2846. if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
  2847. return
  2848. }
  2849. if isLoadingMeetings { return }
  2850. lastMeetingsRefreshAt = now
  2851. isLoadingMeetings = true
  2852. meetingsLoadTask?.cancel()
  2853. meetingsLoadTask = Task { await self.loadScheduledMeetings() }
  2854. }
  2855. private func clearMeetingsScrollObserver() {
  2856. if let meetingsScrollObserver {
  2857. NotificationCenter.default.removeObserver(meetingsScrollObserver)
  2858. }
  2859. meetingsScrollObserver = nil
  2860. meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
  2861. meetingsScrollView = nil
  2862. }
  2863. private func clearMeetingsPageScrollObserver() {
  2864. if let meetingsPageScrollObserver {
  2865. NotificationCenter.default.removeObserver(meetingsPageScrollObserver)
  2866. }
  2867. meetingsPageScrollObserver = nil
  2868. meetingsPageScrollView?.contentView.postsBoundsChangedNotifications = false
  2869. meetingsPageScrollView = nil
  2870. }
  2871. private func removeSearchFieldObserver() {
  2872. if let searchTextObserver {
  2873. NotificationCenter.default.removeObserver(searchTextObserver)
  2874. }
  2875. searchTextObserver = nil
  2876. }
  2877. private func removeMeetingSearchObserver() {
  2878. if let meetingSearchTextObserver {
  2879. NotificationCenter.default.removeObserver(meetingSearchTextObserver)
  2880. }
  2881. meetingSearchTextObserver = nil
  2882. }
  2883. private func installSearchShortcutMonitor() {
  2884. removeSearchShortcutMonitor()
  2885. searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  2886. guard let self else { return event }
  2887. if event.keyCode == 53, self.meetingSearchOverlayView?.isHidden == false {
  2888. DispatchQueue.main.async {
  2889. self.hideMeetingSearchPopup()
  2890. }
  2891. return nil
  2892. }
  2893. guard event.modifierFlags.contains(.command),
  2894. event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
  2895. guard self.homeSearchPill != nil else { return event }
  2896. DispatchQueue.main.async {
  2897. self.showMeetingSearchPopup(seedQuery: self.meetingSearchField?.stringValue ?? "")
  2898. }
  2899. return nil
  2900. }
  2901. searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  2902. guard let self else { return event }
  2903. guard let window = self.view.window, event.window === window else { return event }
  2904. guard self.meetingSearchOverlayView?.isHidden == false else { return event }
  2905. guard let panel = self.meetingSearchPanelView else { return event }
  2906. let rectInWindow = panel.convert(panel.bounds, to: nil)
  2907. if rectInWindow.contains(event.locationInWindow) == false {
  2908. DispatchQueue.main.async {
  2909. self.hideMeetingSearchPopup()
  2910. }
  2911. }
  2912. return event
  2913. }
  2914. }
  2915. private func removeSearchShortcutMonitor() {
  2916. if let searchShortcutMonitor {
  2917. NSEvent.removeMonitor(searchShortcutMonitor)
  2918. }
  2919. searchShortcutMonitor = nil
  2920. if let searchOutsideClickMonitor {
  2921. NSEvent.removeMonitor(searchOutsideClickMonitor)
  2922. }
  2923. searchOutsideClickMonitor = nil
  2924. }
  2925. private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
  2926. guard let fr = window.firstResponder else { return false }
  2927. if fr === field { return true }
  2928. if let editor = field.currentEditor(), fr === editor { return true }
  2929. return false
  2930. }
  2931. @MainActor
  2932. private func applySearchPillFocusBorder(focused: Bool) {
  2933. homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
  2934. homeSearchPill?.layer?.borderColor = accentBlue.cgColor
  2935. }
  2936. @MainActor
  2937. private func focusHomeSearchField() {
  2938. guard let field = homeSearchField else { return }
  2939. view.window?.makeFirstResponder(field)
  2940. }
  2941. @MainActor
  2942. private func showMeetingSearchPopup(seedQuery: String) {
  2943. guard let overlay = meetingSearchOverlayView else { return }
  2944. overlay.isHidden = false
  2945. overlay.alphaValue = 0
  2946. NSAnimationContext.runAnimationGroup { context in
  2947. context.duration = 0.16
  2948. overlay.animator().alphaValue = 1
  2949. }
  2950. if let field = meetingSearchField {
  2951. field.stringValue = seedQuery
  2952. view.window?.makeFirstResponder(field)
  2953. }
  2954. applyGlobalMeetingSearch(query: seedQuery)
  2955. }
  2956. @MainActor
  2957. private func hideMeetingSearchPopup() {
  2958. guard let overlay = meetingSearchOverlayView else { return }
  2959. overlay.isHidden = true
  2960. overlay.alphaValue = 1
  2961. view.window?.makeFirstResponder(nil)
  2962. }
  2963. private func applyGlobalMeetingSearch(query: String) {
  2964. let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2965. let now = Date()
  2966. let dateFormatter = DateFormatter()
  2967. dateFormatter.dateStyle = .medium
  2968. dateFormatter.timeStyle = .short
  2969. let filtered: [ScheduledMeeting]
  2970. if trimmed.isEmpty {
  2971. filtered = allScheduledMeetings
  2972. } else {
  2973. filtered = allScheduledMeetings.filter { meeting in
  2974. let dateText = dateFormatter.string(from: meeting.start).lowercased()
  2975. return meeting.title.lowercased().contains(trimmed)
  2976. || meeting.host.lowercased().contains(trimmed)
  2977. || meeting.source.lowercased().contains(trimmed)
  2978. || (meeting.meetingID?.lowercased().contains(trimmed) ?? false)
  2979. || dateText.contains(trimmed)
  2980. }
  2981. }
  2982. globalSearchResults = filtered.sorted { lhs, rhs in
  2983. let lhsIsUpcoming = lhs.start >= now
  2984. let rhsIsUpcoming = rhs.start >= now
  2985. if lhsIsUpcoming != rhsIsUpcoming {
  2986. return lhsIsUpcoming && !rhsIsUpcoming
  2987. }
  2988. if lhsIsUpcoming {
  2989. return lhs.start < rhs.start
  2990. }
  2991. return lhs.start > rhs.start
  2992. }
  2993. renderMeetingSearchResults()
  2994. }
  2995. private func renderMeetingSearchResults() {
  2996. guard let stack = meetingSearchResultsStack else { return }
  2997. stack.arrangedSubviews.forEach { row in
  2998. stack.removeArrangedSubview(row)
  2999. row.removeFromSuperview()
  3000. }
  3001. meetingSearchEmptyLabel?.isHidden = globalSearchResults.isEmpty == false
  3002. for (index, meeting) in globalSearchResults.enumerated() {
  3003. let row = makeMeetingSearchResultRow(meeting)
  3004. row.tag = index
  3005. stack.addArrangedSubview(row)
  3006. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3007. }
  3008. }
  3009. private func makeMeetingSearchResultRow(_ meeting: ScheduledMeeting) -> NSButton {
  3010. let row = NSButton(title: "", target: self, action: #selector(meetingSearchResultTapped(_:)))
  3011. row.isBordered = false
  3012. row.bezelStyle = .regularSquare
  3013. row.setButtonType(.momentaryChange)
  3014. row.wantsLayer = true
  3015. row.layer?.backgroundColor = meetingCardBackground.cgColor
  3016. row.layer?.cornerRadius = 10
  3017. row.layer?.borderWidth = 1
  3018. row.layer?.borderColor = palette.inputBorder.cgColor
  3019. row.translatesAutoresizingMaskIntoConstraints = false
  3020. row.heightAnchor.constraint(equalToConstant: 84).isActive = true
  3021. let title = makeLabel(meeting.title, size: 14, color: primaryText, weight: .semibold, centered: false)
  3022. title.lineBreakMode = .byTruncatingTail
  3023. let subtitle = makeLabel(compactMeetingSummary(meeting), size: 12, color: secondaryText, weight: .regular, centered: false)
  3024. subtitle.lineBreakMode = .byTruncatingTail
  3025. let meta = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: mutedText, weight: .regular, centered: false)
  3026. meta.lineBreakMode = .byTruncatingTail
  3027. [title, subtitle, meta].forEach {
  3028. $0.translatesAutoresizingMaskIntoConstraints = false
  3029. row.addSubview($0)
  3030. }
  3031. NSLayoutConstraint.activate([
  3032. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  3033. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12),
  3034. title.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12),
  3035. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  3036. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  3037. subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  3038. meta.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
  3039. meta.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  3040. meta.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  3041. ])
  3042. return row
  3043. }
  3044. private func compactMeetingSummary(_ meeting: ScheduledMeeting) -> String {
  3045. let dateFormatter = DateFormatter()
  3046. dateFormatter.dateFormat = "EEE, MMM d"
  3047. let timeFormatter = DateFormatter()
  3048. timeFormatter.dateFormat = "h:mm a"
  3049. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  3050. dateFormatter.timeZone = displayTimeZone
  3051. timeFormatter.timeZone = displayTimeZone
  3052. let start = timeFormatter.string(from: meeting.start)
  3053. let end = meeting.end.map { timeFormatter.string(from: $0) }
  3054. let range = end.map { "\(start) - \($0)" } ?? start
  3055. if let id = meeting.meetingID, id.isEmpty == false {
  3056. return "\(dateFormatter.string(from: meeting.start)) • \(range) • ID \(id)"
  3057. }
  3058. return "\(dateFormatter.string(from: meeting.start)) • \(range)"
  3059. }
  3060. @objc private func topSearchPillTapped() {
  3061. showMeetingSearchPopup(seedQuery: meetingSearchField?.stringValue ?? "")
  3062. }
  3063. @objc private func meetingSearchResultTapped(_ sender: NSButton) {
  3064. guard let meeting = globalSearchResults[safe: sender.tag] else { return }
  3065. if meeting.webURL != nil {
  3066. joinScheduledMeeting(meeting)
  3067. } else {
  3068. let pasteboard = NSPasteboard.general
  3069. pasteboard.clearContents()
  3070. if let id = meeting.meetingID, id.isEmpty == false {
  3071. pasteboard.setString(id, forType: .string)
  3072. } else {
  3073. pasteboard.setString(meeting.title, forType: .string)
  3074. }
  3075. }
  3076. hideMeetingSearchPopup()
  3077. }
  3078. private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
  3079. clearMeetingsScrollObserver()
  3080. meetingsScrollView = scrollView
  3081. scrollView.contentView.postsBoundsChangedNotifications = true
  3082. meetingsScrollObserver = NotificationCenter.default.addObserver(
  3083. forName: NSView.boundsDidChangeNotification,
  3084. object: scrollView.contentView,
  3085. queue: .main
  3086. ) { [weak self, weak scrollView] _ in
  3087. guard let self, let scrollView else { return }
  3088. self.refreshMeetingsIfScrolledToEdge(scrollView)
  3089. }
  3090. }
  3091. private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
  3092. guard let documentView = scrollView.documentView else { return }
  3093. let visibleRect = scrollView.contentView.bounds
  3094. let contentHeight = documentView.bounds.height
  3095. let viewportHeight = visibleRect.height
  3096. if contentHeight <= viewportHeight + 2 {
  3097. return
  3098. }
  3099. let maxOffset = max(contentHeight - viewportHeight, 0)
  3100. let y = visibleRect.origin.y
  3101. let threshold: CGFloat = 12
  3102. let reachedTop = y <= threshold
  3103. let reachedBottom = y >= (maxOffset - threshold)
  3104. guard reachedTop || reachedBottom else { return }
  3105. let now = Date()
  3106. if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
  3107. return
  3108. }
  3109. lastScrollEdgeRefreshAt = now
  3110. triggerMeetingsRefresh(force: true)
  3111. }
  3112. private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
  3113. clearMeetingsPageScrollObserver()
  3114. meetingsPageScrollView = scrollView
  3115. scrollView.contentView.postsBoundsChangedNotifications = true
  3116. meetingsPageScrollObserver = NotificationCenter.default.addObserver(
  3117. forName: NSView.boundsDidChangeNotification,
  3118. object: scrollView.contentView,
  3119. queue: .main
  3120. ) { [weak self, weak scrollView] _ in
  3121. guard let self, let scrollView else { return }
  3122. self.loadMoreMeetingsIfNeededForScroll(scrollView)
  3123. }
  3124. }
  3125. private func loadMoreMeetingsIfNeededForScroll(_ scrollView: NSScrollView) {
  3126. guard meetingsPageVisibleCount < meetingsPageFilteredMeetings.count else { return }
  3127. guard let documentView = scrollView.documentView else { return }
  3128. let visibleRect = scrollView.contentView.bounds
  3129. let contentHeight = documentView.bounds.height
  3130. let viewportHeight = visibleRect.height
  3131. guard contentHeight > 0, viewportHeight > 0 else { return }
  3132. let maxOffset = max(contentHeight - viewportHeight, 0)
  3133. let y = visibleRect.origin.y
  3134. let threshold: CGFloat = 72
  3135. guard y >= (maxOffset - threshold) else { return }
  3136. let now = Date()
  3137. guard now.timeIntervalSince(lastMeetingsPageAutoLoadAt) >= meetingsPageAutoLoadCooldown else { return }
  3138. lastMeetingsPageAutoLoadAt = now
  3139. let nextCount = meetingsPageVisibleCount + meetingsPageSize
  3140. meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
  3141. renderMeetingsPageList()
  3142. }
  3143. @MainActor
  3144. private func resetLoginSigningInState() {
  3145. isSigningIn = false
  3146. googleButton?.title = "G"
  3147. updateTopBarAuthButton()
  3148. googleButton?.isEnabled = true
  3149. }
  3150. @MainActor
  3151. private func completeLoginAndRestart(profile: GoogleUserProfile?) {
  3152. persistLoggedInState(true)
  3153. let trimmedName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  3154. zoomSignedInDisplayName = trimmedName.isEmpty ? nil : trimmedName
  3155. showHomeView(profile: profile)
  3156. primeZoomEmbeddedWebSession()
  3157. if let window = view.window {
  3158. window.makeKeyAndOrderFront(nil)
  3159. }
  3160. NSApp.activate(ignoringOtherApps: true)
  3161. }
  3162. @MainActor
  3163. private func updateTopBarAuthButton() {
  3164. guard let button = topBarAuthButton else { return }
  3165. let loggedIn = isUserLoggedIn()
  3166. if loggedIn {
  3167. button.title = topBarProfileInitial
  3168. button.toolTip = "Profile"
  3169. button.font = .systemFont(ofSize: 14, weight: .bold)
  3170. button.contentTintColor = primaryText
  3171. button.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
  3172. button.layer?.cornerRadius = 10
  3173. topBarAuthButtonWidthConstraint?.constant = 34
  3174. } else {
  3175. button.title = "Sign in with Zoom"
  3176. button.toolTip = "Sign in with Zoom"
  3177. button.font = .systemFont(ofSize: 12, weight: .semibold)
  3178. button.contentTintColor = .white
  3179. button.layer?.backgroundColor = accentBlue.cgColor
  3180. button.layer?.cornerRadius = 11
  3181. topBarAuthButtonWidthConstraint?.constant = 120
  3182. }
  3183. button.isEnabled = isSigningIn == false
  3184. }
  3185. @MainActor
  3186. private func applyGuestLoginMessages() {
  3187. let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
  3188. let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
  3189. let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
  3190. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  3191. emptyMeetingLabel?.isHidden = isHome == false
  3192. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  3193. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  3194. meetingsPageEmptyLabel?.isHidden = isMeetings == false
  3195. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  3196. meetingsPageCountLabel?.stringValue = "0 scheduled"
  3197. schedulerMeetingsEmptyLabel?.isHidden = isScheduler == false
  3198. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  3199. }
  3200. @MainActor
  3201. private func applyPremiumLockMessages() {
  3202. // Keep underlying section labels hidden while locked to avoid duplicate text beneath overlays.
  3203. meetingsStatusLabel?.stringValue = ""
  3204. emptyMeetingLabel?.isHidden = true
  3205. meetingsPageStatusLabel?.stringValue = ""
  3206. meetingsPageEmptyLabel?.isHidden = true
  3207. meetingsPageCountLabel?.stringValue = ""
  3208. schedulerMeetingsEmptyLabel?.isHidden = true
  3209. }
  3210. private func ensurePremiumAccess() -> Bool {
  3211. guard storeKitCoordinator.hasPremiumAccess else {
  3212. showPaywall()
  3213. return false
  3214. }
  3215. return true
  3216. }
  3217. @MainActor
  3218. private func applyAuthenticationUIState(triggerInitialRefresh: Bool) {
  3219. let loggedIn = isUserLoggedIn()
  3220. updateTopBarAuthButton()
  3221. settingsGoogleActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
  3222. settingsGoogleActionButton?.isEnabled = true
  3223. updatePremiumLockedSections()
  3224. meetingsRefreshTimer?.invalidate()
  3225. meetingsRefreshTimer = nil
  3226. if loggedIn == false {
  3227. meetingsRateLimitedUntil = nil
  3228. }
  3229. if loggedIn {
  3230. if storeKitCoordinator.hasPremiumAccess {
  3231. startMeetingsAutoRefresh()
  3232. if triggerInitialRefresh {
  3233. triggerMeetingsRefresh(force: false)
  3234. }
  3235. } else {
  3236. applyMeetings([])
  3237. applyPremiumLockMessages()
  3238. }
  3239. } else {
  3240. applyGuestLoginMessages()
  3241. }
  3242. }
  3243. @MainActor
  3244. private func applyMeetingsRateLimitStatusLabels() {
  3245. guard let until = meetingsRateLimitedUntil, until > Date() else { return }
  3246. let minutes = max(1, Int(ceil(until.timeIntervalSinceNow / 60.0)))
  3247. let message = "Zoom rate limit reached. Please try again in \(minutes) min."
  3248. meetingsStatusLabel?.stringValue = message
  3249. meetingsPageStatusLabel?.stringValue = message
  3250. }
  3251. @MainActor
  3252. private func persistZoomAccountTimeZone(_ timeZone: TimeZone?) {
  3253. zoomAccountTimeZone = timeZone
  3254. if let timeZone {
  3255. UserDefaults.standard.set(timeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
  3256. } else {
  3257. UserDefaults.standard.removeObject(forKey: Self.zoomAccountTimeZoneDefaultsKey)
  3258. }
  3259. }
  3260. /// Returns false if the user cancelled or left credentials empty.
  3261. @MainActor
  3262. private func ensureZoomOAuthClientConfigured() -> Bool {
  3263. if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
  3264. return true
  3265. }
  3266. return presentZoomOAuthCredentialPrompt()
  3267. }
  3268. private func showSimpleError(_ title: String, error: Error) {
  3269. let alert = NSAlert()
  3270. alert.alertStyle = .warning
  3271. alert.messageText = title
  3272. alert.informativeText = error.localizedDescription
  3273. alert.runModal()
  3274. }
  3275. private func showSimpleAlert(title: String, message: String) {
  3276. let alert = NSAlert()
  3277. alert.alertStyle = .informational
  3278. alert.messageText = title
  3279. alert.informativeText = message
  3280. alert.runModal()
  3281. }
  3282. private struct ScheduledMeeting {
  3283. let title: String
  3284. let start: Date
  3285. let end: Date?
  3286. let meetingTimeZone: TimeZone?
  3287. let host: String
  3288. let source: String
  3289. let meetingID: String?
  3290. let typeLabel: String
  3291. let statusLabel: String
  3292. let webURL: URL?
  3293. }
  3294. @MainActor
  3295. private func applyMeetings(_ meetings: [ScheduledMeeting]) {
  3296. allScheduledMeetings = meetings
  3297. if meetings.isEmpty == false {
  3298. let calendar = Calendar.current
  3299. let selectedStart = calendar.startOfDay(for: selectedMeetingsDayStart)
  3300. let selectedEnd = calendar.date(byAdding: .day, value: 1, to: selectedStart) ?? selectedStart.addingTimeInterval(60 * 60 * 24)
  3301. let selectedDayHasMeetings = meetings.contains { meeting in
  3302. meeting.start >= selectedStart && meeting.start < selectedEnd
  3303. }
  3304. if selectedDayHasMeetings == false,
  3305. let firstUpcoming = meetings
  3306. .filter({ $0.start >= Date() })
  3307. .sorted(by: { $0.start < $1.start })
  3308. .first {
  3309. selectedMeetingsDayStart = calendar.startOfDay(for: firstUpcoming.start)
  3310. } else if selectedDayHasMeetings == false,
  3311. let latest = meetings.max(by: { $0.start < $1.start }) {
  3312. // All instances are in the past: jump to the most recent meeting day so the list is not stuck empty.
  3313. selectedMeetingsDayStart = calendar.startOfDay(for: latest.start)
  3314. }
  3315. }
  3316. applyFilteredMeetings()
  3317. applyMeetingsPageFilters(resetVisible: true)
  3318. updateWidgetMeetingStoreFromCurrentMeetings()
  3319. rescheduleMeetingRemindersIfNeeded()
  3320. }
  3321. @MainActor
  3322. private func applyFilteredMeetings() {
  3323. guard let stack = meetingsListStack else { return }
  3324. let showingHomeSection = selectedHomeSidebarItem == HomeSection.home.rawValue
  3325. stack.arrangedSubviews.forEach { view in
  3326. stack.removeArrangedSubview(view)
  3327. view.removeFromSuperview()
  3328. }
  3329. guard isUserLoggedIn() else {
  3330. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  3331. emptyMeetingLabel?.isHidden = showingHomeSection == false
  3332. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  3333. applySchedulerMeetingsForSelectedDate()
  3334. renderSchedulerCalendarGrid()
  3335. return
  3336. }
  3337. guard storeKitCoordinator.hasPremiumAccess else {
  3338. meetingsStatusLabel?.stringValue = ""
  3339. emptyMeetingLabel?.isHidden = true
  3340. applySchedulerMeetingsForSelectedDate()
  3341. renderSchedulerCalendarGrid()
  3342. return
  3343. }
  3344. if let until = meetingsRateLimitedUntil, until > Date() {
  3345. applyMeetingsRateLimitStatusLabels()
  3346. }
  3347. let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  3348. let calendar = Calendar.current
  3349. let dayStart = selectedMeetingsDayStart
  3350. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
  3351. let source = allScheduledMeetings.filter { meeting in
  3352. meeting.start >= dayStart && meeting.start < dayEnd
  3353. }
  3354. let filtered: [ScheduledMeeting]
  3355. if query.isEmpty {
  3356. filtered = source
  3357. } else {
  3358. filtered = source.filter { meeting in
  3359. meeting.title.lowercased().contains(query)
  3360. || meeting.host.lowercased().contains(query)
  3361. || meeting.source.lowercased().contains(query)
  3362. }
  3363. }
  3364. let ordered = filtered.sorted(by: { $0.start < $1.start })
  3365. if ordered.isEmpty {
  3366. emptyMeetingLabel?.isHidden = showingHomeSection == false
  3367. let isRateLimited = meetingsRateLimitedUntil.map { $0 > Date() } ?? false
  3368. if isRateLimited == false {
  3369. if source.isEmpty {
  3370. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  3371. emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
  3372. } else {
  3373. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  3374. emptyMeetingLabel?.stringValue = "No meetings match your search."
  3375. }
  3376. }
  3377. applySchedulerMeetingsForSelectedDate()
  3378. renderSchedulerCalendarGrid()
  3379. return
  3380. }
  3381. emptyMeetingLabel?.isHidden = true
  3382. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  3383. for meeting in ordered {
  3384. let card = makeMeetingRowCard(meeting)
  3385. stack.addArrangedSubview(card)
  3386. // Make each meeting card span the full list width (like Zoom).
  3387. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  3388. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3389. }
  3390. }
  3391. applySchedulerMeetingsForSelectedDate()
  3392. renderSchedulerCalendarGrid()
  3393. }
  3394. @MainActor
  3395. private func applyMeetingsPageFilters(resetVisible: Bool) {
  3396. guard isUserLoggedIn() else {
  3397. meetingsPageFilteredMeetings = []
  3398. meetingsPageVisibleCount = 0
  3399. renderMeetingsPageList()
  3400. return
  3401. }
  3402. guard storeKitCoordinator.hasPremiumAccess else {
  3403. meetingsPageFilteredMeetings = []
  3404. meetingsPageVisibleCount = 0
  3405. renderMeetingsPageList()
  3406. return
  3407. }
  3408. let calendar = Calendar.current
  3409. let now = Date()
  3410. let activePreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? meetingsPageSelectedRangePreset
  3411. meetingsPageSelectedRangePreset = activePreset
  3412. let activeFromDate: Date?
  3413. let activeToDate: Date?
  3414. if activePreset == "Custom range",
  3415. let fromPicker = meetingsPageFromDatePicker,
  3416. let toPicker = meetingsPageToDatePicker {
  3417. let fromDay = calendar.startOfDay(for: fromPicker.dateValue)
  3418. let toDay = calendar.startOfDay(for: toPicker.dateValue)
  3419. if fromDay <= toDay {
  3420. activeFromDate = fromDay
  3421. activeToDate = toDay
  3422. } else {
  3423. activeFromDate = toDay
  3424. activeToDate = fromDay
  3425. }
  3426. meetingsPageFromDateFilter = activeFromDate
  3427. meetingsPageToDateFilter = activeToDate
  3428. } else {
  3429. activeFromDate = meetingsPageFromDateFilter
  3430. activeToDate = meetingsPageToDateFilter
  3431. }
  3432. var filtered = allScheduledMeetings.filter { meeting in
  3433. guard meeting.start >= now else { return false }
  3434. switch activePreset {
  3435. case "Today":
  3436. let dayStart = calendar.startOfDay(for: now)
  3437. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now
  3438. return meeting.start >= now && meeting.start < dayEnd
  3439. case "This week":
  3440. let weekInterval = calendar.dateInterval(of: .weekOfYear, for: now)
  3441. let weekEnd = weekInterval?.end ?? now
  3442. return meeting.start >= now && meeting.start < weekEnd
  3443. case "This month":
  3444. let monthInterval = calendar.dateInterval(of: .month, for: now)
  3445. let monthEnd = monthInterval?.end ?? now
  3446. return meeting.start >= now && meeting.start < monthEnd
  3447. case "Custom range":
  3448. if let fromDate = activeFromDate {
  3449. let startOfFrom = calendar.startOfDay(for: fromDate)
  3450. if meeting.start < startOfFrom { return false }
  3451. }
  3452. if let toDate = activeToDate {
  3453. let startOfTo = calendar.startOfDay(for: toDate)
  3454. let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
  3455. if meeting.start >= endOfTo { return false }
  3456. }
  3457. return true
  3458. default:
  3459. return true
  3460. }
  3461. }
  3462. filtered.sort { $0.start < $1.start }
  3463. meetingsPageFilteredMeetings = filtered
  3464. if resetVisible {
  3465. meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
  3466. } else if filtered.count < meetingsPageVisibleCount {
  3467. meetingsPageVisibleCount = filtered.count
  3468. }
  3469. renderMeetingsPageList()
  3470. }
  3471. @MainActor
  3472. private func renderMeetingsPageList() {
  3473. guard let stack = meetingsPageListStack else { return }
  3474. stack.arrangedSubviews.forEach { view in
  3475. stack.removeArrangedSubview(view)
  3476. view.removeFromSuperview()
  3477. }
  3478. guard isUserLoggedIn() else {
  3479. meetingsPageCountLabel?.stringValue = "0 scheduled"
  3480. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  3481. meetingsPageEmptyLabel?.isHidden = false
  3482. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  3483. meetingsPageLoadMoreButton?.isHidden = true
  3484. return
  3485. }
  3486. guard storeKitCoordinator.hasPremiumAccess else {
  3487. meetingsPageCountLabel?.stringValue = ""
  3488. meetingsPageStatusLabel?.stringValue = ""
  3489. meetingsPageEmptyLabel?.isHidden = true
  3490. meetingsPageLoadMoreButton?.isHidden = true
  3491. return
  3492. }
  3493. let totalCount = meetingsPageFilteredMeetings.count
  3494. let visibleCount = min(meetingsPageVisibleCount, totalCount)
  3495. meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
  3496. if totalCount == 0 {
  3497. meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
  3498. meetingsPageEmptyLabel?.isHidden = false
  3499. meetingsPageLoadMoreButton?.isHidden = true
  3500. return
  3501. }
  3502. meetingsPageEmptyLabel?.isHidden = true
  3503. meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
  3504. for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
  3505. let card = makeMeetingRowCard(meeting)
  3506. stack.addArrangedSubview(card)
  3507. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  3508. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3509. }
  3510. }
  3511. meetingsPageLoadMoreButton?.isHidden = true
  3512. }
  3513. @objc private func meetingsPageApplyFiltersTapped() {
  3514. let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
  3515. meetingsPageSelectedRangePreset = selectedPreset
  3516. updateMeetingsDatePickersEnabledState()
  3517. if selectedPreset == "Custom range",
  3518. let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
  3519. let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
  3520. let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
  3521. if fromDay <= toDay {
  3522. meetingsPageFromDateFilter = fromDay
  3523. meetingsPageToDateFilter = toDay
  3524. } else {
  3525. meetingsPageFromDateFilter = toDay
  3526. meetingsPageToDateFilter = fromDay
  3527. }
  3528. } else {
  3529. meetingsPageFromDateFilter = nil
  3530. meetingsPageToDateFilter = nil
  3531. }
  3532. Task { @MainActor in
  3533. self.applyMeetingsPageFilters(resetVisible: true)
  3534. }
  3535. }
  3536. @objc private func meetingsPageResetFiltersTapped() {
  3537. meetingsPageSelectedRangePreset = "All"
  3538. meetingsPageFromDateFilter = nil
  3539. meetingsPageToDateFilter = nil
  3540. meetingsPageCategoryFilter?.selectItem(withTitle: "All")
  3541. meetingsPageFromDatePicker?.dateValue = Date()
  3542. meetingsPageToDatePicker?.dateValue = Date()
  3543. updateMeetingsDatePickersEnabledState()
  3544. Task { @MainActor in
  3545. self.applyMeetingsPageFilters(resetVisible: true)
  3546. }
  3547. }
  3548. @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
  3549. meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
  3550. updateMeetingsDatePickersEnabledState()
  3551. Task { @MainActor in
  3552. self.applyMeetingsPageFilters(resetVisible: true)
  3553. }
  3554. }
  3555. private func updateMeetingsDatePickersEnabledState() {
  3556. let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
  3557. meetingsPageFromDatePicker?.isEnabled = isCustomRange
  3558. meetingsPageToDatePicker?.isEnabled = isCustomRange
  3559. meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  3560. meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  3561. }
  3562. private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
  3563. let calendar = Calendar.current
  3564. let safeDayStart = calendar.startOfDay(for: dayStart)
  3565. let dayEnd = calendar.date(byAdding: .day, value: 1, to: safeDayStart) ?? safeDayStart.addingTimeInterval(60 * 60 * 24)
  3566. return allScheduledMeetings
  3567. .filter { meeting in
  3568. meeting.start >= safeDayStart && meeting.start < dayEnd
  3569. }
  3570. .sorted(by: { $0.start < $1.start })
  3571. }
  3572. @MainActor
  3573. private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
  3574. let calendar = Calendar.current
  3575. selectedMeetingsDayStart = calendar.startOfDay(for: newDayStart)
  3576. let monthComponents = calendar.dateComponents([.year, .month], from: selectedMeetingsDayStart)
  3577. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? selectedMeetingsDayStart
  3578. updateMeetingsDayUI()
  3579. applyFilteredMeetings()
  3580. }
  3581. @MainActor
  3582. private func updateMeetingsDayUI() {
  3583. let dayStart = selectedMeetingsDayStart
  3584. let formatter = DateFormatter()
  3585. formatter.dateFormat = "EEEE, MMM d"
  3586. meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
  3587. let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
  3588. meetingsTodayButton?.isEnabled = isToday == false
  3589. meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  3590. schedulerTodayButton?.isEnabled = isToday == false
  3591. schedulerTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  3592. updateSchedulerDateHeader()
  3593. applySchedulerMeetingsForSelectedDate()
  3594. renderSchedulerCalendarGrid()
  3595. }
  3596. private func meetingsDayDisplayName(for dayStart: Date) -> String {
  3597. let calendar = Calendar.current
  3598. if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
  3599. if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
  3600. calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
  3601. if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
  3602. calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
  3603. let formatter = DateFormatter()
  3604. formatter.dateFormat = "EEEE, MMM d"
  3605. return formatter.string(from: dayStart)
  3606. }
  3607. @objc private func meetingsPrevDayTapped() {
  3608. let calendar = Calendar.current
  3609. let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
  3610. Task { @MainActor in
  3611. self.setSelectedMeetingsDayStart(prev)
  3612. }
  3613. }
  3614. @objc private func meetingsNextDayTapped() {
  3615. let calendar = Calendar.current
  3616. let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
  3617. Task { @MainActor in
  3618. self.setSelectedMeetingsDayStart(next)
  3619. }
  3620. }
  3621. @objc private func meetingsTodayTapped() {
  3622. Task { @MainActor in
  3623. self.setSelectedMeetingsDayStart(Date())
  3624. }
  3625. }
  3626. private func updateSchedulerDateHeader() {
  3627. let formatter = DateFormatter()
  3628. formatter.dateFormat = "MMMM yyyy"
  3629. schedulerMonthLabel?.stringValue = formatter.string(from: schedulerVisibleMonthStart)
  3630. let selectedFormatter = DateFormatter()
  3631. selectedFormatter.dateFormat = "EEEE, MMM d"
  3632. schedulerSelectedDateLabel?.stringValue = selectedFormatter.string(from: selectedMeetingsDayStart)
  3633. }
  3634. @MainActor
  3635. private func applySchedulerMeetingsForSelectedDate() {
  3636. guard let stack = schedulerMeetingsListStack else { return }
  3637. stack.arrangedSubviews.forEach { view in
  3638. stack.removeArrangedSubview(view)
  3639. view.removeFromSuperview()
  3640. }
  3641. guard isUserLoggedIn() else {
  3642. schedulerMeetingsEmptyLabel?.isHidden = false
  3643. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  3644. return
  3645. }
  3646. guard storeKitCoordinator.hasPremiumAccess else {
  3647. schedulerMeetingsEmptyLabel?.isHidden = true
  3648. return
  3649. }
  3650. let meetings = meetingsForDay(selectedMeetingsDayStart)
  3651. if meetings.isEmpty {
  3652. schedulerMeetingsEmptyLabel?.isHidden = false
  3653. schedulerMeetingsEmptyLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: selectedMeetingsDayStart))."
  3654. return
  3655. }
  3656. schedulerMeetingsEmptyLabel?.isHidden = true
  3657. for meeting in meetings {
  3658. let card = makeMeetingRowCard(meeting)
  3659. stack.addArrangedSubview(card)
  3660. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  3661. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3662. }
  3663. }
  3664. }
  3665. @MainActor
  3666. private func renderSchedulerCalendarGrid() {
  3667. guard let gridStack = schedulerCalendarGridStack else { return }
  3668. schedulerDayButtons = [:]
  3669. schedulerDateByButtonTag = [:]
  3670. nextSchedulerButtonTag = 1
  3671. gridStack.arrangedSubviews.forEach { row in
  3672. gridStack.removeArrangedSubview(row)
  3673. row.removeFromSuperview()
  3674. }
  3675. let calendar = Calendar.current
  3676. let monthStart = schedulerVisibleMonthStart
  3677. let monthRange = calendar.range(of: .day, in: .month, for: monthStart) ?? 1..<32
  3678. let monthWeekday = calendar.component(.weekday, from: monthStart)
  3679. let leadingSlots = monthWeekday - 1
  3680. let totalSlots = 42
  3681. for week in 0..<6 {
  3682. let row = NSStackView()
  3683. row.orientation = .horizontal
  3684. row.distribution = .fillEqually
  3685. row.spacing = 6
  3686. row.translatesAutoresizingMaskIntoConstraints = false
  3687. gridStack.addArrangedSubview(row)
  3688. row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
  3689. for weekday in 0..<7 {
  3690. let cellIndex = (week * 7) + weekday
  3691. let dayOffset = cellIndex - leadingSlots
  3692. let dayDate = calendar.date(byAdding: .day, value: dayOffset, to: monthStart) ?? monthStart
  3693. let dayNumber = calendar.component(.day, from: dayDate)
  3694. let inCurrentMonth = monthRange.contains(dayNumber) && calendar.isDate(dayDate, equalTo: monthStart, toGranularity: .month)
  3695. let hasMeetings = isUserLoggedIn() && meetingsForDay(dayDate).isEmpty == false
  3696. let button = makeSchedulerDayCell(
  3697. day: dayNumber,
  3698. date: dayDate,
  3699. inCurrentMonth: inCurrentMonth,
  3700. selected: calendar.isDate(dayDate, inSameDayAs: selectedMeetingsDayStart),
  3701. isToday: calendar.isDateInToday(dayDate),
  3702. hasMeetings: hasMeetings
  3703. )
  3704. row.addArrangedSubview(button)
  3705. schedulerDayButtons[calendar.startOfDay(for: dayDate)] = button
  3706. }
  3707. }
  3708. // Keep a consistent 6x7 grid footprint.
  3709. if totalSlots == 42 {
  3710. gridStack.needsLayout = true
  3711. }
  3712. }
  3713. private func makeSchedulerDayCell(
  3714. day: Int,
  3715. date: Date,
  3716. inCurrentMonth: Bool,
  3717. selected: Bool,
  3718. isToday: Bool,
  3719. hasMeetings: Bool
  3720. ) -> NSButton {
  3721. let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
  3722. button.isBordered = false
  3723. button.bezelStyle = .shadowlessSquare
  3724. button.focusRingType = .none
  3725. button.font = .systemFont(ofSize: 16, weight: selected ? .semibold : .regular)
  3726. button.alignment = .center
  3727. button.wantsLayer = true
  3728. button.layer?.cornerRadius = 16
  3729. button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
  3730. let normalBackground = selected
  3731. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
  3732. : NSColor.clear
  3733. let hoverBackground = selected
  3734. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
  3735. : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
  3736. button.normalColor = normalBackground
  3737. button.hoverColor = hoverBackground
  3738. button.onHoverChanged = { [weak button] hovering in
  3739. guard let button, selected == false else { return }
  3740. if hovering {
  3741. button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
  3742. button.layer?.borderColor = (hasMeetings
  3743. ? self.accentBlue.withAlphaComponent(0.65)
  3744. : self.accentBlue.withAlphaComponent(0.45)
  3745. ).cgColor
  3746. } else if isToday {
  3747. button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
  3748. button.layer?.borderWidth = 1
  3749. } else if hasMeetings {
  3750. button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
  3751. button.layer?.borderWidth = 1
  3752. } else {
  3753. button.layer?.borderColor = NSColor.clear.cgColor
  3754. button.layer?.borderWidth = 0
  3755. }
  3756. }
  3757. if selected {
  3758. button.layer?.borderColor = accentBlue.cgColor
  3759. } else if isToday {
  3760. button.layer?.borderColor = accentOrange.withAlphaComponent(0.8).cgColor
  3761. button.layer?.borderWidth = 1
  3762. } else if hasMeetings {
  3763. button.layer?.borderColor = accentBlue.withAlphaComponent(0.45).cgColor
  3764. } else {
  3765. button.layer?.borderColor = NSColor.clear.cgColor
  3766. }
  3767. button.contentTintColor = selected
  3768. ? primaryText
  3769. : (inCurrentMonth ? primaryText : mutedText.withAlphaComponent(0.75))
  3770. let tag = nextSchedulerButtonTag
  3771. nextSchedulerButtonTag += 1
  3772. button.tag = tag
  3773. schedulerDateByButtonTag[tag] = Calendar.current.startOfDay(for: date)
  3774. button.translatesAutoresizingMaskIntoConstraints = false
  3775. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  3776. return button
  3777. }
  3778. @objc private func schedulerDayTapped(_ sender: NSButton) {
  3779. guard let selectedDate = schedulerDateByButtonTag[sender.tag] else { return }
  3780. Task { @MainActor in
  3781. self.setSelectedMeetingsDayStart(selectedDate)
  3782. }
  3783. }
  3784. @objc private func schedulerPrevMonthTapped() {
  3785. let calendar = Calendar.current
  3786. let prev = calendar.date(byAdding: .month, value: -1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  3787. let monthComponents = calendar.dateComponents([.year, .month], from: prev)
  3788. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? prev
  3789. updateSchedulerDateHeader()
  3790. renderSchedulerCalendarGrid()
  3791. }
  3792. @objc private func schedulerNextMonthTapped() {
  3793. let calendar = Calendar.current
  3794. let next = calendar.date(byAdding: .month, value: 1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  3795. let monthComponents = calendar.dateComponents([.year, .month], from: next)
  3796. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? next
  3797. updateSchedulerDateHeader()
  3798. renderSchedulerCalendarGrid()
  3799. }
  3800. @objc private func schedulerTodayTapped() {
  3801. Task { @MainActor in
  3802. self.setSelectedMeetingsDayStart(Date())
  3803. }
  3804. }
  3805. private func loadScheduledMeetings() async {
  3806. guard isUserLoggedIn() else {
  3807. await MainActor.run {
  3808. self.isLoadingMeetings = false
  3809. self.setMeetingsLoadingUI(false)
  3810. self.applyMeetings([])
  3811. self.applyGuestLoginMessages()
  3812. }
  3813. return
  3814. }
  3815. let hasPremium = await MainActor.run { self.storeKitCoordinator.hasPremiumAccess }
  3816. guard hasPremium else {
  3817. await MainActor.run {
  3818. self.isLoadingMeetings = false
  3819. self.setMeetingsLoadingUI(false)
  3820. self.applyMeetings([])
  3821. self.applyPremiumLockMessages()
  3822. }
  3823. return
  3824. }
  3825. let shouldSkipForRateLimit = await MainActor.run { () -> Bool in
  3826. if let until = self.meetingsRateLimitedUntil, until > Date() {
  3827. self.applyMeetingsRateLimitStatusLabels()
  3828. return true
  3829. }
  3830. self.setMeetingsLoadingUI(true)
  3831. return false
  3832. }
  3833. if shouldSkipForRateLimit {
  3834. await MainActor.run {
  3835. self.isLoadingMeetings = false
  3836. self.setMeetingsLoadingUI(false)
  3837. }
  3838. return
  3839. }
  3840. defer {
  3841. Task { @MainActor in
  3842. self.isLoadingMeetings = false
  3843. self.setMeetingsLoadingUI(false)
  3844. }
  3845. }
  3846. do {
  3847. let zoomToken = try await zoomOAuth.validAccessToken(
  3848. presentingWindow: view.window,
  3849. requiringAnyOf: ["meeting:read:list_meetings"]
  3850. )
  3851. let needsProfileTimeZone = await MainActor.run { self.zoomAccountTimeZone == nil }
  3852. if needsProfileTimeZone {
  3853. if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
  3854. let profileTZ = parseZoomTimeZone(profile.timezone)
  3855. await MainActor.run { self.persistZoomAccountTimeZone(profileTZ) }
  3856. }
  3857. }
  3858. let fallbackTZ = await MainActor.run { self.zoomAccountTimeZone }
  3859. let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: fallbackTZ)
  3860. await MainActor.run {
  3861. self.meetingsRateLimitedUntil = nil
  3862. self.applyMeetings(zoomMeetings)
  3863. }
  3864. } catch {
  3865. await MainActor.run {
  3866. if case ZoomOAuthError.missingClientId = error {
  3867. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3868. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3869. self.promptForZoomOAuthCredentialsIfNeeded()
  3870. } else if case ZoomOAuthError.missingClientSecret = error {
  3871. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3872. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3873. self.promptForZoomOAuthCredentialsIfNeeded()
  3874. } else if case ZoomOAuthError.missingRequiredScope = error {
  3875. self.zoomOAuth.clearSavedTokens()
  3876. self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  3877. self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  3878. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  3879. let seconds = max(retryAfterSeconds ?? 300, 30)
  3880. self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
  3881. self.applyMeetingsRateLimitStatusLabels()
  3882. } else {
  3883. self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  3884. self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  3885. }
  3886. if case ZoomOAuthError.missingClientId = error {
  3887. self.applyMeetings([])
  3888. } else if case ZoomOAuthError.missingClientSecret = error {
  3889. self.applyMeetings([])
  3890. } else if case ZoomOAuthError.missingRequiredScope = error {
  3891. self.applyMeetings([])
  3892. }
  3893. self.applyFilteredMeetings()
  3894. self.applyMeetingsPageFilters(resetVisible: false)
  3895. }
  3896. }
  3897. }
  3898. @MainActor
  3899. private func presentZoomOAuthCredentialPrompt() -> Bool {
  3900. let alert = NSAlert()
  3901. alert.alertStyle = .informational
  3902. alert.messageText = "Configure Zoom OAuth"
  3903. 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)."
  3904. let wrapper = NSStackView()
  3905. wrapper.orientation = .vertical
  3906. wrapper.spacing = 8
  3907. wrapper.translatesAutoresizingMaskIntoConstraints = false
  3908. let clientIdField = NSTextField()
  3909. clientIdField.placeholderString = "Zoom Client ID"
  3910. clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
  3911. let clientSecretField = NSSecureTextField()
  3912. clientSecretField.placeholderString = "Zoom Client Secret"
  3913. clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
  3914. [clientIdField, clientSecretField].forEach { field in
  3915. field.translatesAutoresizingMaskIntoConstraints = false
  3916. field.widthAnchor.constraint(equalToConstant: 420).isActive = true
  3917. wrapper.addArrangedSubview(field)
  3918. }
  3919. alert.accessoryView = wrapper
  3920. alert.addButton(withTitle: "Save")
  3921. alert.addButton(withTitle: "Cancel")
  3922. let result = alert.runModal()
  3923. if result == .alertFirstButtonReturn {
  3924. var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3925. var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3926. if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
  3927. if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
  3928. if clientId.isEmpty == false, clientSecret.isEmpty == false {
  3929. zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
  3930. return true
  3931. }
  3932. meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
  3933. }
  3934. return false
  3935. }
  3936. @MainActor
  3937. private func promptForZoomOAuthCredentialsIfNeeded() {
  3938. guard isPromptingZoomCredentials == false else { return }
  3939. isPromptingZoomCredentials = true
  3940. defer { isPromptingZoomCredentials = false }
  3941. if presentZoomOAuthCredentialPrompt() {
  3942. meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
  3943. triggerMeetingsRefresh(force: true)
  3944. }
  3945. }
  3946. private struct ZoomUserMeResponse: Decodable {
  3947. let first_name: String?
  3948. let last_name: String?
  3949. let display_name: String?
  3950. let email: String?
  3951. let pic_url: String?
  3952. let timezone: String?
  3953. var displayName: String? {
  3954. if let display_name, display_name.isEmpty == false { return display_name }
  3955. let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
  3956. return parts.isEmpty ? nil : parts.joined(separator: " ")
  3957. }
  3958. var pictureURL: String? {
  3959. guard let pic_url, pic_url.isEmpty == false else { return nil }
  3960. return pic_url
  3961. }
  3962. }
  3963. private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
  3964. let url = URL(string: "https://api.zoom.us/v2/users/me")!
  3965. var request = URLRequest(url: url)
  3966. request.httpMethod = "GET"
  3967. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3968. let (data, response) = try await URLSession.shared.data(for: request)
  3969. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3970. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
  3971. }
  3972. return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
  3973. }
  3974. private struct ZoomZAKTokenResponse: Decodable {
  3975. let token: String
  3976. }
  3977. /// 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)).
  3978. private func fetchZoomUserZAK(accessToken: String) async throws -> String {
  3979. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/token")!
  3980. components.queryItems = [URLQueryItem(name: "type", value: "zak")]
  3981. guard let url = components.url else {
  3982. throw GoogleOAuthError.tokenExchangeFailed("Invalid ZAK request URL")
  3983. }
  3984. var request = URLRequest(url: url)
  3985. request.httpMethod = "GET"
  3986. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3987. let (data, response) = try await URLSession.shared.data(for: request)
  3988. guard let http = response as? HTTPURLResponse else {
  3989. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (ZAK)")
  3990. }
  3991. if http.statusCode == 429 {
  3992. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  3993. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3994. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3995. }
  3996. guard (200..<300).contains(http.statusCode) else {
  3997. let raw = String(data: data, encoding: .utf8) ?? "Failed to load ZAK"
  3998. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3999. throw ZoomOAuthError.missingRequiredScope(raw)
  4000. }
  4001. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4002. }
  4003. let decoded = try JSONDecoder().decode(ZoomZAKTokenResponse.self, from: data)
  4004. let trimmed = decoded.token.trimmingCharacters(in: .whitespacesAndNewlines)
  4005. guard trimmed.isEmpty == false else {
  4006. throw GoogleOAuthError.tokenExchangeFailed("Empty ZAK from Zoom")
  4007. }
  4008. return trimmed
  4009. }
  4010. private struct ZoomCreateMeetingBody: Encodable {
  4011. let topic: String
  4012. let type: Int
  4013. let start_time: String
  4014. let duration: Int
  4015. let timezone: String
  4016. }
  4017. private struct ZoomCreateMeetingAPIResult: Decodable {
  4018. let id: Int?
  4019. let join_url: String?
  4020. let start_url: String?
  4021. }
  4022. private struct ZoomInstantMeetingBody: Encodable {
  4023. let topic: String
  4024. let type: Int
  4025. }
  4026. /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
  4027. private func createZoomMeeting(accessToken: String, topic: String, startTimeUTC: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
  4028. let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
  4029. var request = URLRequest(url: url)
  4030. request.httpMethod = "POST"
  4031. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4032. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  4033. let body = ZoomCreateMeetingBody(
  4034. topic: topic,
  4035. type: 2,
  4036. start_time: startTimeUTC,
  4037. duration: durationMinutes,
  4038. timezone: timeZone.identifier
  4039. )
  4040. request.httpBody = try JSONEncoder().encode(body)
  4041. let (data, response) = try await URLSession.shared.data(for: request)
  4042. guard let http = response as? HTTPURLResponse else {
  4043. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
  4044. }
  4045. if http.statusCode == 429 {
  4046. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4047. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4048. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4049. }
  4050. guard (200..<300).contains(http.statusCode) else {
  4051. let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
  4052. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4053. throw ZoomOAuthError.missingRequiredScope(raw)
  4054. }
  4055. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4056. }
  4057. return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
  4058. }
  4059. /// [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`.
  4060. private func createZoomInstantMeeting(accessToken: String, topic: String = "Instant meeting") async throws -> ZoomCreateMeetingAPIResult {
  4061. let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
  4062. var request = URLRequest(url: url)
  4063. request.httpMethod = "POST"
  4064. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4065. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  4066. request.httpBody = try JSONEncoder().encode(ZoomInstantMeetingBody(topic: topic, type: 1))
  4067. let (data, response) = try await URLSession.shared.data(for: request)
  4068. guard let http = response as? HTTPURLResponse else {
  4069. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
  4070. }
  4071. if http.statusCode == 429 {
  4072. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4073. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4074. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4075. }
  4076. guard (200..<300).contains(http.statusCode) else {
  4077. let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
  4078. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4079. throw ZoomOAuthError.missingRequiredScope(raw)
  4080. }
  4081. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4082. }
  4083. return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
  4084. }
  4085. private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
  4086. struct ZoomMeeting: Decodable {
  4087. let id: Int?
  4088. let topic: String?
  4089. let start_time: String?
  4090. let timezone: String?
  4091. let duration: Int?
  4092. let host_id: String?
  4093. let type: Int?
  4094. let status: String?
  4095. let join_url: String?
  4096. }
  4097. struct ZoomMeetingsPage: Decodable {
  4098. let meetings: [ZoomMeeting]
  4099. let next_page_token: String?
  4100. }
  4101. let iso = ISO8601DateFormatter()
  4102. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  4103. let fallbackISO = ISO8601DateFormatter()
  4104. fallbackISO.formatOptions = [.withInternetDateTime]
  4105. func inferredTimeZone(from startRaw: String, explicitZoomTimezone: String?) -> TimeZone? {
  4106. if let parsedExplicit = parseZoomTimeZone(explicitZoomTimezone) {
  4107. return parsedExplicit
  4108. }
  4109. if let fallbackTimeZone { return fallbackTimeZone }
  4110. if startRaw.hasSuffix("Z") || startRaw.hasSuffix("z") {
  4111. return TimeZone(secondsFromGMT: 0)
  4112. }
  4113. let startPattern = #"([+-]\d{2}:?\d{2}|[+-]\d{2})$"#
  4114. guard let regex = try? NSRegularExpression(pattern: startPattern) else { return nil }
  4115. let nsRange = NSRange(startRaw.startIndex..<startRaw.endIndex, in: startRaw)
  4116. guard let match = regex.firstMatch(in: startRaw, options: [], range: nsRange),
  4117. let offsetRange = Range(match.range(at: 1), in: startRaw) else {
  4118. return nil
  4119. }
  4120. return parseZoomTimeZone(String(startRaw[offsetRange]))
  4121. }
  4122. func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
  4123. raw.compactMap { meeting in
  4124. guard let startRaw = meeting.start_time else { return nil }
  4125. let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
  4126. guard let start else { return nil }
  4127. let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
  4128. let meetingIDString = meeting.id.map(String.init)
  4129. let overrideZone = meetingIDString
  4130. .flatMap { self.meetingTimeZoneOverrides[$0] }
  4131. .flatMap { TimeZone(identifier: $0) }
  4132. let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
  4133. let webURL = embeddedWebClientURLForZoomMeeting(meetingId: meeting.id, joinURLString: meeting.join_url)
  4134. return ScheduledMeeting(
  4135. title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
  4136. start: start,
  4137. end: end,
  4138. meetingTimeZone: displayZone,
  4139. host: meeting.host_id ?? "Zoom Host",
  4140. source: "Zoom",
  4141. meetingID: meetingIDString,
  4142. typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
  4143. statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
  4144. webURL: webURL
  4145. )
  4146. }
  4147. }
  4148. var allMeetings: [ZoomMeeting] = []
  4149. var nextPageToken: String?
  4150. var pageCount = 0
  4151. repeat {
  4152. pageCount += 1
  4153. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  4154. var items: [URLQueryItem] = [
  4155. URLQueryItem(name: "type", value: "scheduled"),
  4156. URLQueryItem(name: "page_size", value: "30")
  4157. ]
  4158. if let nextPageToken, nextPageToken.isEmpty == false {
  4159. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  4160. }
  4161. components.queryItems = items
  4162. var request = URLRequest(url: components.url!)
  4163. request.httpMethod = "GET"
  4164. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4165. let (data, response) = try await URLSession.shared.data(for: request)
  4166. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  4167. let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
  4168. if (response as? HTTPURLResponse)?.statusCode == 429 {
  4169. let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
  4170. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4171. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4172. }
  4173. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4174. throw ZoomOAuthError.missingRequiredScope(raw)
  4175. }
  4176. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4177. }
  4178. let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
  4179. allMeetings.append(contentsOf: decoded.meetings)
  4180. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  4181. nextPageToken = (token?.isEmpty == false) ? token : nil
  4182. } while nextPageToken != nil && pageCount < maxMeetingListPages
  4183. return mapMeetings(allMeetings)
  4184. }
  4185. // MARK: - AI Companion Meeting Summaries (Zoom API)
  4186. /// In-memory model for an AI Companion meeting summary. Mirrors the fields returned by
  4187. /// `GET /v2/meetings/meeting_summaries` and `GET /v2/meetings/{meetingId}/meeting_summary`.
  4188. /// Docs: https://developers.zoom.us/changelog/meetings/november-20-2023/
  4189. struct AICompanionSummary: Equatable {
  4190. struct SummarySection: Equatable {
  4191. let label: String
  4192. let summary: String
  4193. }
  4194. let meetingUUID: String?
  4195. let meetingID: String?
  4196. let meetingTopic: String?
  4197. let meetingHostEmail: String?
  4198. let meetingStartTime: Date?
  4199. let meetingEndTime: Date?
  4200. let summaryTitle: String?
  4201. let summaryOverview: String?
  4202. let summaryDetails: [SummarySection]
  4203. let nextSteps: [String]
  4204. let summaryContent: String?
  4205. let summaryDocURL: URL?
  4206. let summaryCreatedTime: Date?
  4207. let summaryLastModifiedTime: Date?
  4208. }
  4209. private struct ZoomMeetingSummaryAPIItem: Decodable {
  4210. struct SummaryDetail: Decodable {
  4211. let label: String?
  4212. let summary: String?
  4213. }
  4214. let meeting_host_id: String?
  4215. let meeting_host_email: String?
  4216. let meeting_uuid: String?
  4217. let meeting_id: Int64?
  4218. let meeting_topic: String?
  4219. let meeting_start_time: String?
  4220. let meeting_end_time: String?
  4221. let summary_start_time: String?
  4222. let summary_end_time: String?
  4223. let summary_created_time: String?
  4224. let summary_last_modified_time: String?
  4225. let summary_title: String?
  4226. let summary_overview: String?
  4227. let summary_details: [SummaryDetail]?
  4228. let next_steps: [String]?
  4229. let summary_content: String?
  4230. let summary_doc_url: String?
  4231. func toModel(using iso: ISO8601DateFormatter, fallback: ISO8601DateFormatter) -> ViewController.AICompanionSummary {
  4232. func parseDate(_ s: String?) -> Date? {
  4233. guard let s, s.isEmpty == false else { return nil }
  4234. return iso.date(from: s) ?? fallback.date(from: s)
  4235. }
  4236. let details: [AICompanionSummary.SummarySection] = (summary_details ?? []).compactMap { detail in
  4237. let label = (detail.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  4238. let body = (detail.summary ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  4239. if label.isEmpty && body.isEmpty { return nil }
  4240. return AICompanionSummary.SummarySection(label: label, summary: body)
  4241. }
  4242. let steps: [String] = (next_steps ?? [])
  4243. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  4244. .filter { $0.isEmpty == false }
  4245. let docURL: URL? = {
  4246. guard let raw = summary_doc_url?.trimmingCharacters(in: .whitespacesAndNewlines),
  4247. raw.isEmpty == false else { return nil }
  4248. return URL(string: raw)
  4249. }()
  4250. return AICompanionSummary(
  4251. meetingUUID: meeting_uuid,
  4252. meetingID: meeting_id.map(String.init),
  4253. meetingTopic: meeting_topic,
  4254. meetingHostEmail: meeting_host_email,
  4255. meetingStartTime: parseDate(meeting_start_time),
  4256. meetingEndTime: parseDate(meeting_end_time),
  4257. summaryTitle: summary_title,
  4258. summaryOverview: summary_overview,
  4259. summaryDetails: details,
  4260. nextSteps: steps,
  4261. summaryContent: summary_content,
  4262. summaryDocURL: docURL,
  4263. summaryCreatedTime: parseDate(summary_created_time),
  4264. summaryLastModifiedTime: parseDate(summary_last_modified_time)
  4265. )
  4266. }
  4267. }
  4268. /// Fetches AI Companion summaries: prefers `GET /v2/meetings/meeting_summaries` when the token has
  4269. /// `meeting:read:list_summaries`. User-managed apps usually lack that scope; we then list **past**
  4270. /// meetings via `meeting:read:list_meetings` and load each `meeting:read:summary`.
  4271. private func fetchZoomMeetingSummaries(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
  4272. if let bulk = try await fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: accessToken, from: from, to: to) {
  4273. return bulk
  4274. }
  4275. return try await fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: accessToken, from: from, to: to)
  4276. }
  4277. private func zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: Data, statusCode: Int) -> Bool {
  4278. guard (200..<300).contains(statusCode) == false else { return false }
  4279. // Bulk list is often unavailable; a generic 403 still means "try past meetings + per-meeting summary".
  4280. if statusCode == 403 { return true }
  4281. let raw = String(data: data, encoding: .utf8) ?? ""
  4282. if raw.localizedCaseInsensitiveContains("does not contain scopes") { return true }
  4283. if raw.localizedCaseInsensitiveContains("list_summaries") { return true }
  4284. if raw.localizedCaseInsensitiveContains("meeting_summaries") && raw.localizedCaseInsensitiveContains("scope") {
  4285. return true
  4286. }
  4287. return false
  4288. }
  4289. /// Returns `nil` when the token cannot use the bulk list endpoint (typical for user-managed apps).
  4290. private func fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary]? {
  4291. struct Page: Decodable {
  4292. let summaries: [ZoomMeetingSummaryAPIItem]?
  4293. let meeting_summaries: [ZoomMeetingSummaryAPIItem]?
  4294. let next_page_token: String?
  4295. }
  4296. let iso = ISO8601DateFormatter()
  4297. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  4298. let fallbackISO = ISO8601DateFormatter()
  4299. fallbackISO.formatOptions = [.withInternetDateTime]
  4300. let dayFormatter = DateFormatter()
  4301. dayFormatter.dateFormat = "yyyy-MM-dd"
  4302. dayFormatter.timeZone = TimeZone(identifier: "UTC")
  4303. var collected: [AICompanionSummary] = []
  4304. var seenUUIDs = Set<String>()
  4305. var nextPageToken: String?
  4306. var safetyCounter = 0
  4307. repeat {
  4308. var components = URLComponents(string: "https://api.zoom.us/v2/meetings/meeting_summaries")!
  4309. var items: [URLQueryItem] = [
  4310. URLQueryItem(name: "page_size", value: "30")
  4311. ]
  4312. if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
  4313. if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
  4314. if let nextPageToken, nextPageToken.isEmpty == false {
  4315. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  4316. }
  4317. components.queryItems = items
  4318. var request = URLRequest(url: components.url!)
  4319. request.httpMethod = "GET"
  4320. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4321. let (data, response) = try await URLSession.shared.data(for: request)
  4322. guard let http = response as? HTTPURLResponse else {
  4323. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
  4324. }
  4325. if http.statusCode == 429 {
  4326. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4327. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4328. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4329. }
  4330. guard (200..<300).contains(http.statusCode) else {
  4331. if safetyCounter == 0, zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: data, statusCode: http.statusCode) {
  4332. return nil
  4333. }
  4334. let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summaries"
  4335. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4336. throw ZoomOAuthError.missingRequiredScope(raw)
  4337. }
  4338. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4339. }
  4340. let decoded = try JSONDecoder().decode(Page.self, from: data)
  4341. let raw = decoded.summaries ?? decoded.meeting_summaries ?? []
  4342. for item in raw {
  4343. let model = item.toModel(using: iso, fallback: fallbackISO)
  4344. let dedupKey = model.meetingUUID ?? (model.meetingID ?? UUID().uuidString)
  4345. if seenUUIDs.insert(dedupKey).inserted {
  4346. collected.append(model)
  4347. }
  4348. }
  4349. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  4350. nextPageToken = (token?.isEmpty == false) ? token : nil
  4351. safetyCounter += 1
  4352. if safetyCounter >= 10 { break }
  4353. } while nextPageToken != nil
  4354. return collected
  4355. }
  4356. private func fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
  4357. struct PastMeeting: Decodable {
  4358. let id: Int?
  4359. let uuid: String?
  4360. let topic: String?
  4361. let start_time: String?
  4362. }
  4363. struct PastMeetingsPage: Decodable {
  4364. let meetings: [PastMeeting]
  4365. let next_page_token: String?
  4366. }
  4367. let iso = ISO8601DateFormatter()
  4368. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  4369. let fallbackISO = ISO8601DateFormatter()
  4370. fallbackISO.formatOptions = [.withInternetDateTime]
  4371. let dayFormatter = DateFormatter()
  4372. dayFormatter.dateFormat = "yyyy-MM-dd"
  4373. dayFormatter.timeZone = TimeZone(identifier: "UTC")
  4374. func parseStart(_ raw: String?) -> Date? {
  4375. guard let raw, raw.isEmpty == false else { return nil }
  4376. return iso.date(from: raw) ?? fallbackISO.date(from: raw)
  4377. }
  4378. func isInSelectedRange(start: Date?) -> Bool {
  4379. guard let start else { return true }
  4380. if let from, start < from { return false }
  4381. if let to, start > to { return false }
  4382. return true
  4383. }
  4384. var candidates: [(meetingId: String, start: Date?)] = []
  4385. var nextPageToken: String?
  4386. var pageIdx = 0
  4387. repeat {
  4388. // Zoom expects `previous_meetings` (not `past`) for past instances; see List meetings API.
  4389. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  4390. var items: [URLQueryItem] = [
  4391. URLQueryItem(name: "type", value: "previous_meetings"),
  4392. URLQueryItem(name: "page_size", value: "30")
  4393. ]
  4394. if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
  4395. if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
  4396. if let nextPageToken, nextPageToken.isEmpty == false {
  4397. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  4398. }
  4399. components.queryItems = items
  4400. var request = URLRequest(url: components.url!)
  4401. request.httpMethod = "GET"
  4402. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4403. let (data, response) = try await URLSession.shared.data(for: request)
  4404. guard let http = response as? HTTPURLResponse else {
  4405. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (past meetings)")
  4406. }
  4407. if http.statusCode == 429 {
  4408. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4409. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4410. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4411. }
  4412. guard (200..<300).contains(http.statusCode) else {
  4413. let raw = String(data: data, encoding: .utf8) ?? "Failed to load past meetings"
  4414. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4415. throw ZoomOAuthError.missingRequiredScope(raw)
  4416. }
  4417. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4418. }
  4419. let decoded = try JSONDecoder().decode(PastMeetingsPage.self, from: data)
  4420. for m in decoded.meetings {
  4421. guard let numericId = m.id else { continue }
  4422. let start = parseStart(m.start_time)
  4423. guard isInSelectedRange(start: start) else { continue }
  4424. candidates.append((String(numericId), start))
  4425. }
  4426. let t = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  4427. nextPageToken = (t?.isEmpty == false) ? t : nil
  4428. pageIdx += 1
  4429. if pageIdx >= 15 { break }
  4430. if candidates.count >= 80 { break }
  4431. } while nextPageToken != nil
  4432. candidates.sort { ($0.start ?? .distantPast) > ($1.start ?? .distantPast) }
  4433. var summaries: [AICompanionSummary] = []
  4434. let maxDetailFetches = 40
  4435. for c in candidates.prefix(maxDetailFetches) {
  4436. if let detail = try await fetchZoomMeetingSummaryDetail(accessToken: accessToken, meetingIdOrUUID: c.meetingId) {
  4437. summaries.append(detail)
  4438. }
  4439. }
  4440. return summaries
  4441. }
  4442. /// Fetches a single AI Companion summary by meeting ID or UUID via
  4443. /// `GET /v2/meetings/{meetingId}/meeting_summary`. UUIDs containing `/` or `//` must be
  4444. /// double-URL-encoded per Zoom's API conventions.
  4445. private func fetchZoomMeetingSummaryDetail(accessToken: String, meetingIdOrUUID: String) async throws -> AICompanionSummary? {
  4446. let trimmed = meetingIdOrUUID.trimmingCharacters(in: .whitespacesAndNewlines)
  4447. guard trimmed.isEmpty == false else { return nil }
  4448. let isLikelyUUID = trimmed.contains("/") || trimmed.contains("=")
  4449. let pathSegment: String
  4450. if isLikelyUUID {
  4451. // Zoom requires double encoding for UUIDs that begin with `/` or contain `//`.
  4452. let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
  4453. let once = trimmed.addingPercentEncoding(withAllowedCharacters: allowed) ?? trimmed
  4454. pathSegment = once.addingPercentEncoding(withAllowedCharacters: allowed) ?? once
  4455. } else {
  4456. pathSegment = trimmed
  4457. }
  4458. let urlString = "https://api.zoom.us/v2/meetings/\(pathSegment)/meeting_summary"
  4459. guard let url = URL(string: urlString) else {
  4460. throw GoogleOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
  4461. }
  4462. var request = URLRequest(url: url)
  4463. request.httpMethod = "GET"
  4464. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4465. let (data, response) = try await URLSession.shared.data(for: request)
  4466. guard let http = response as? HTTPURLResponse else {
  4467. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
  4468. }
  4469. if http.statusCode == 404 || http.statusCode == 204 { return nil }
  4470. if http.statusCode == 429 {
  4471. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4472. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4473. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4474. }
  4475. guard (200..<300).contains(http.statusCode) else {
  4476. let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summary"
  4477. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4478. throw ZoomOAuthError.missingRequiredScope(raw)
  4479. }
  4480. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4481. }
  4482. let iso = ISO8601DateFormatter()
  4483. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  4484. let fallbackISO = ISO8601DateFormatter()
  4485. fallbackISO.formatOptions = [.withInternetDateTime]
  4486. let item = try JSONDecoder().decode(ZoomMeetingSummaryAPIItem.self, from: data)
  4487. return item.toModel(using: iso, fallback: fallbackISO)
  4488. }
  4489. private func makeSettingsView() -> NSView {
  4490. let panel = NSView()
  4491. panel.translatesAutoresizingMaskIntoConstraints = false
  4492. let scroll = NSScrollView()
  4493. scroll.translatesAutoresizingMaskIntoConstraints = false
  4494. scroll.drawsBackground = false
  4495. scroll.hasHorizontalScroller = false
  4496. scroll.hasVerticalScroller = true
  4497. scroll.autohidesScrollers = true
  4498. scroll.borderType = .noBorder
  4499. scroll.scrollerStyle = .overlay
  4500. scroll.automaticallyAdjustsContentInsets = false
  4501. let clip = TopAlignedClipView()
  4502. clip.drawsBackground = false
  4503. scroll.contentView = clip
  4504. panel.addSubview(scroll)
  4505. let content = NSView()
  4506. content.translatesAutoresizingMaskIntoConstraints = false
  4507. scroll.documentView = content
  4508. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  4509. card.translatesAutoresizingMaskIntoConstraints = false
  4510. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  4511. content.addSubview(card)
  4512. let stack = NSStackView()
  4513. stack.translatesAutoresizingMaskIntoConstraints = false
  4514. stack.orientation = .vertical
  4515. stack.spacing = 18
  4516. stack.alignment = .leading
  4517. card.addSubview(stack)
  4518. let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
  4519. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  4520. stack.addArrangedSubview(pageTitle)
  4521. stack.addArrangedSubview(pageSubtitle)
  4522. stack.setCustomSpacing(24, after: pageSubtitle)
  4523. let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4524. stack.addArrangedSubview(appearanceTitle)
  4525. let darkModeRow = makeSettingsDarkModeRow()
  4526. stack.addArrangedSubview(darkModeRow)
  4527. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4528. stack.setCustomSpacing(24, after: darkModeRow)
  4529. let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4530. stack.addArrangedSubview(accountTitle)
  4531. let googleAccountRow = makeSettingsGoogleAccountRow()
  4532. stack.addArrangedSubview(googleAccountRow)
  4533. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4534. stack.setCustomSpacing(24, after: googleAccountRow)
  4535. let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4536. stack.addArrangedSubview(appTitle)
  4537. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  4538. stack.addArrangedSubview(shareButton)
  4539. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4540. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  4541. stack.addArrangedSubview(upgradeButton)
  4542. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4543. settingsUpgradeButton = upgradeButton
  4544. stack.setCustomSpacing(24, after: upgradeButton)
  4545. let notificationsTitle = textLabel("Notifications", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4546. let remindersSection = makeSettingsRemindersSection()
  4547. let notificationsBlock = NSStackView()
  4548. notificationsBlock.translatesAutoresizingMaskIntoConstraints = false
  4549. notificationsBlock.orientation = .vertical
  4550. notificationsBlock.spacing = 18
  4551. notificationsBlock.alignment = .leading
  4552. notificationsBlock.addArrangedSubview(notificationsTitle)
  4553. notificationsBlock.addArrangedSubview(remindersSection)
  4554. remindersSection.widthAnchor.constraint(equalTo: notificationsBlock.widthAnchor).isActive = true
  4555. stack.addArrangedSubview(notificationsBlock)
  4556. notificationsBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4557. settingsNotificationsBlock = notificationsBlock
  4558. stack.setCustomSpacing(24, after: notificationsBlock)
  4559. let transcriptionTitle = textLabel("AI Companion transcription", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4560. let transcriptionSubtitle = textLabel(
  4561. "Preferred languages are used for Apple on-device speech recognition after each meeting recording.",
  4562. font: .systemFont(ofSize: 12, weight: .regular),
  4563. color: secondaryText
  4564. )
  4565. transcriptionSubtitle.maximumNumberOfLines = 0
  4566. transcriptionSubtitle.lineBreakMode = .byWordWrapping
  4567. let transcriptionBlock = NSStackView()
  4568. transcriptionBlock.translatesAutoresizingMaskIntoConstraints = false
  4569. transcriptionBlock.orientation = .vertical
  4570. transcriptionBlock.spacing = 12
  4571. transcriptionBlock.alignment = .leading
  4572. transcriptionBlock.addArrangedSubview(transcriptionTitle)
  4573. transcriptionBlock.addArrangedSubview(transcriptionSubtitle)
  4574. let lang1Row = makeSettingsSpeechLanguageRow(isPrimary: true)
  4575. let lang2Row = makeSettingsSpeechLanguageRow(isPrimary: false)
  4576. transcriptionBlock.addArrangedSubview(lang1Row)
  4577. transcriptionBlock.addArrangedSubview(lang2Row)
  4578. lang1Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
  4579. lang2Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
  4580. stack.addArrangedSubview(transcriptionBlock)
  4581. transcriptionBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4582. stack.setCustomSpacing(24, after: transcriptionBlock)
  4583. let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4584. stack.addArrangedSubview(legalTitle)
  4585. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  4586. stack.addArrangedSubview(privacyButton)
  4587. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4588. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  4589. stack.addArrangedSubview(supportButton)
  4590. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4591. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  4592. stack.addArrangedSubview(termsButton)
  4593. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4594. NSLayoutConstraint.activate([
  4595. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  4596. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  4597. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  4598. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  4599. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  4600. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  4601. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  4602. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  4603. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  4604. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  4605. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
  4606. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  4607. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  4608. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  4609. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  4610. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  4611. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  4612. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  4613. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  4614. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  4615. ])
  4616. updatePremiumButtons()
  4617. return panel
  4618. }
  4619. @MainActor
  4620. private func rebuildHomeSettingsViewPreservingSelection() {
  4621. guard let column = homeSettingsHostColumn else { return }
  4622. homeSettingsView?.removeFromSuperview()
  4623. let newSettings = makeSettingsView()
  4624. newSettings.translatesAutoresizingMaskIntoConstraints = false
  4625. column.addSubview(newSettings)
  4626. NSLayoutConstraint.activate([
  4627. newSettings.topAnchor.constraint(equalTo: column.topAnchor),
  4628. newSettings.leadingAnchor.constraint(equalTo: column.leadingAnchor),
  4629. newSettings.trailingAnchor.constraint(equalTo: column.trailingAnchor),
  4630. newSettings.bottomAnchor.constraint(equalTo: column.bottomAnchor)
  4631. ])
  4632. homeSettingsView = newSettings
  4633. updateSelectedHomeSectionUI()
  4634. }
  4635. private enum SettingsAction: Int {
  4636. case shareApp = 1
  4637. case upgrade = 2
  4638. case privacyPolicy = 4
  4639. case support = 5
  4640. case termsOfServices = 6
  4641. }
  4642. private func makeSettingsDarkModeRow() -> NSView {
  4643. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4644. row.translatesAutoresizingMaskIntoConstraints = false
  4645. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  4646. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4647. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  4648. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  4649. let toggle = NSSwitch()
  4650. toggle.translatesAutoresizingMaskIntoConstraints = false
  4651. toggle.state = darkModeEnabled ? .on : .off
  4652. toggle.target = self
  4653. toggle.action = #selector(settingsDarkModeToggled(_:))
  4654. settingsDarkModeSwitch = toggle
  4655. row.addSubview(icon)
  4656. row.addSubview(title)
  4657. row.addSubview(toggle)
  4658. NSLayoutConstraint.activate([
  4659. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4660. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4661. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  4662. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4663. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4664. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4665. ])
  4666. return row
  4667. }
  4668. private func makeSettingsSpeechLanguageRow(isPrimary: Bool) -> NSView {
  4669. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4670. row.translatesAutoresizingMaskIntoConstraints = false
  4671. row.heightAnchor.constraint(equalToConstant: 72).isActive = true
  4672. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4673. let titleText = isPrimary ? "Preferred Language 1" : "Preferred Language 2"
  4674. let titleLabel = textLabel(titleText, font: .systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  4675. let popup = NSPopUpButton(frame: .zero, pullsDown: false)
  4676. popup.translatesAutoresizingMaskIntoConstraints = false
  4677. popup.target = self
  4678. popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
  4679. let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
  4680. if isPrimary == false {
  4681. popup.addItem(withTitle: "None")
  4682. popup.lastItem?.representedObject = ""
  4683. }
  4684. for option in options {
  4685. popup.addItem(withTitle: option.displayName)
  4686. popup.lastItem?.representedObject = option.identifier
  4687. }
  4688. if isPrimary {
  4689. let selected = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
  4690. ?? options.first?.identifier
  4691. ?? Locale.current.identifier
  4692. selectTranscriptionLanguage(identifier: selected, in: popup)
  4693. settingsPagePrimaryLanguagePopup = popup
  4694. } else {
  4695. if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
  4696. selectTranscriptionLanguage(identifier: selected, in: popup)
  4697. } else {
  4698. popup.selectItem(at: 0)
  4699. }
  4700. settingsPageSecondaryLanguagePopup = popup
  4701. }
  4702. row.addSubview(titleLabel)
  4703. row.addSubview(popup)
  4704. NSLayoutConstraint.activate([
  4705. titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4706. titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  4707. titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4708. popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4709. popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4710. popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
  4711. popup.heightAnchor.constraint(equalToConstant: 28)
  4712. ])
  4713. return row
  4714. }
  4715. @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
  4716. guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
  4717. primary.isEmpty == false else { return }
  4718. var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
  4719. if secondary?.isEmpty == true {
  4720. secondary = nil
  4721. }
  4722. if let secondaryValue = secondary,
  4723. secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
  4724. secondary = nil
  4725. settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
  4726. }
  4727. TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
  4728. }
  4729. private func makeSettingsRemindersSection() -> NSView {
  4730. let container = NSStackView()
  4731. container.translatesAutoresizingMaskIntoConstraints = false
  4732. container.orientation = .vertical
  4733. container.spacing = 1
  4734. container.alignment = .leading
  4735. let masterRow = makeReminderToggleRow(
  4736. icon: "🔔",
  4737. title: "Reminders",
  4738. state: remindersEnabled,
  4739. action: #selector(settingsReminderMasterToggled(_:)),
  4740. isSubRow: false
  4741. )
  4742. container.addArrangedSubview(masterRow)
  4743. masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4744. let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
  4745. let day1Row = makeReminderToggleRow(
  4746. icon: "📅",
  4747. title: "1 Day Before",
  4748. state: remind1Day,
  4749. action: #selector(settingsReminder1DayToggled(_:)),
  4750. isSubRow: true
  4751. )
  4752. day1Row.alphaValue = subAlpha
  4753. container.addArrangedSubview(day1Row)
  4754. day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4755. let hours12Row = makeReminderToggleRow(
  4756. icon: "🕛",
  4757. title: "12 Hours Before",
  4758. state: remind12Hours,
  4759. action: #selector(settingsReminder12HoursToggled(_:)),
  4760. isSubRow: true
  4761. )
  4762. hours12Row.alphaValue = subAlpha
  4763. container.addArrangedSubview(hours12Row)
  4764. hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4765. let hour1Row = makeReminderToggleRow(
  4766. icon: "⏰",
  4767. title: "1 Hour Before",
  4768. state: remind1Hour,
  4769. action: #selector(settingsReminder1HourToggled(_:)),
  4770. isSubRow: true
  4771. )
  4772. hour1Row.alphaValue = subAlpha
  4773. container.addArrangedSubview(hour1Row)
  4774. hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4775. // Keep references so toggles can update UI without rebuilding the whole settings view.
  4776. if let masterSwitch = (masterRow.subviews.compactMap { $0 as? NSSwitch }.first) {
  4777. settingsRemindersMasterSwitch = masterSwitch
  4778. }
  4779. if let day1Switch = (day1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  4780. settingsReminders1DaySwitch = day1Switch
  4781. }
  4782. if let hours12Switch = (hours12Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  4783. settingsReminders12HoursSwitch = hours12Switch
  4784. }
  4785. if let hour1Switch = (hour1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  4786. settingsReminders1HourSwitch = hour1Switch
  4787. }
  4788. settingsReminders1DayRow = day1Row
  4789. settingsReminders12HoursRow = hours12Row
  4790. settingsReminders1HourRow = hour1Row
  4791. return container
  4792. }
  4793. private func makeReminderToggleRow(
  4794. icon: String,
  4795. title: String,
  4796. state: Bool,
  4797. action: Selector,
  4798. isSubRow: Bool
  4799. ) -> NSView {
  4800. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4801. row.translatesAutoresizingMaskIntoConstraints = false
  4802. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  4803. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4804. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  4805. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: isSubRow ? .medium : .semibold), color: primaryText)
  4806. let toggle = NSSwitch()
  4807. toggle.translatesAutoresizingMaskIntoConstraints = false
  4808. toggle.state = state ? .on : .off
  4809. toggle.target = self
  4810. toggle.action = action
  4811. row.addSubview(iconLabel)
  4812. row.addSubview(titleLabel)
  4813. row.addSubview(toggle)
  4814. let leading: CGFloat = isSubRow ? 26 : 14
  4815. NSLayoutConstraint.activate([
  4816. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: leading),
  4817. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4818. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  4819. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4820. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4821. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4822. ])
  4823. return row
  4824. }
  4825. private func makeSettingsGoogleAccountRow() -> NSView {
  4826. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4827. row.translatesAutoresizingMaskIntoConstraints = false
  4828. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4829. let signedIn = isUserLoggedIn()
  4830. let titleText = signedIn ? "Zoom account connected" : "Zoom account not connected"
  4831. let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
  4832. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  4833. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  4834. subtitle.maximumNumberOfLines = 2
  4835. subtitle.lineBreakMode = .byTruncatingTail
  4836. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  4837. actionButton.translatesAutoresizingMaskIntoConstraints = false
  4838. actionButton.bezelStyle = .rounded
  4839. actionButton.controlSize = .regular
  4840. settingsGoogleActionButton = actionButton
  4841. row.addSubview(title)
  4842. row.addSubview(subtitle)
  4843. row.addSubview(actionButton)
  4844. NSLayoutConstraint.activate([
  4845. row.heightAnchor.constraint(equalToConstant: 78),
  4846. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4847. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  4848. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  4849. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  4850. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  4851. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4852. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4853. ])
  4854. return row
  4855. }
  4856. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  4857. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  4858. button.translatesAutoresizingMaskIntoConstraints = false
  4859. button.isBordered = false
  4860. button.wantsLayer = true
  4861. button.layer?.cornerRadius = 10
  4862. button.normalColor = palette.inputBackground
  4863. button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
  4864. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4865. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  4866. button.tag = action.rawValue
  4867. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
  4868. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  4869. if action == .upgrade {
  4870. settingsUpgradeTitleLabel = titleLabel
  4871. }
  4872. button.addSubview(iconLabel)
  4873. button.addSubview(titleLabel)
  4874. NSLayoutConstraint.activate([
  4875. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  4876. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  4877. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  4878. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  4879. ])
  4880. return button
  4881. }
  4882. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  4883. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  4884. switch action {
  4885. case .shareApp:
  4886. showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
  4887. case .upgrade:
  4888. settingsUpgradePremiumTapped()
  4889. case .privacyPolicy:
  4890. openURLInDefaultBrowser(AppExternalURLs.privacyPolicy)
  4891. case .support:
  4892. openURLInDefaultBrowser(AppExternalURLs.support)
  4893. case .termsOfServices:
  4894. openURLInDefaultBrowser(AppExternalURLs.termsOfServices)
  4895. }
  4896. }
  4897. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  4898. if isUserLoggedIn() {
  4899. logoutTapped()
  4900. return
  4901. }
  4902. zoomPrimarySignInTapped()
  4903. }
  4904. private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  4905. let v = NSView()
  4906. v.wantsLayer = true
  4907. v.layer?.backgroundColor = color.cgColor
  4908. v.layer?.cornerRadius = cornerRadius
  4909. return v
  4910. }
  4911. private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  4912. view.wantsLayer = true
  4913. view.layer?.borderColor = borderColor.cgColor
  4914. view.layer?.borderWidth = borderWidth
  4915. if shadow {
  4916. view.layer?.shadowColor = NSColor.black.cgColor
  4917. view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
  4918. view.layer?.shadowRadius = 18
  4919. view.layer?.shadowOffset = NSSize(width: 0, height: -2)
  4920. } else {
  4921. view.layer?.shadowOpacity = 0
  4922. }
  4923. }
  4924. private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  4925. let label = NSTextField(labelWithString: text)
  4926. label.translatesAutoresizingMaskIntoConstraints = false
  4927. label.font = font
  4928. label.textColor = color
  4929. label.backgroundColor = .clear
  4930. label.isBezeled = false
  4931. label.isEditable = false
  4932. label.isSelectable = false
  4933. return label
  4934. }
  4935. private func styledLabel(
  4936. _ text: String,
  4937. font: NSFont,
  4938. color: NSColor,
  4939. kern: CGFloat? = nil,
  4940. lineSpacing: CGFloat? = nil
  4941. ) -> NSTextField {
  4942. let label = textLabel(text, font: font, color: color)
  4943. if kern == nil, lineSpacing == nil {
  4944. return label
  4945. }
  4946. let paragraph = NSMutableParagraphStyle()
  4947. paragraph.alignment = label.alignment
  4948. paragraph.lineBreakMode = .byWordWrapping
  4949. if let lineSpacing { paragraph.lineSpacing = lineSpacing }
  4950. var attrs: [NSAttributedString.Key: Any] = [
  4951. .font: font,
  4952. .foregroundColor: color,
  4953. .paragraphStyle: paragraph
  4954. ]
  4955. if let kern { attrs[.kern] = kern }
  4956. label.attributedStringValue = NSAttributedString(string: text, attributes: attrs)
  4957. return label
  4958. }
  4959. private func startStoreKit() {
  4960. storeKitStartupTask?.cancel()
  4961. storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
  4962. DispatchQueue.main.async {
  4963. guard let self else { return }
  4964. let newIsPremium = self.storeKitCoordinator.hasPremiumAccess
  4965. let wasPremium = self.lastKnownPremiumAccess
  4966. self.lastKnownPremiumAccess = newIsPremium
  4967. self.updatePremiumButtons()
  4968. if wasPremium != newIsPremium {
  4969. self.rebuildHomeSettingsViewPreservingSelection()
  4970. }
  4971. self.reconcileDesktopWidgetsForPremiumAccess()
  4972. if (wasPremium == false) && newIsPremium {
  4973. self.startMeetingsAutoRefresh()
  4974. self.triggerMeetingsRefresh(force: false)
  4975. self.schedulePremiumRatingPromptIfNeeded()
  4976. }
  4977. if wasPremium && !newIsPremium {
  4978. self.cancelAllMeetingReminders()
  4979. self.premiumRatingPromptWorkItem?.cancel()
  4980. self.premiumRatingPromptWorkItem = nil
  4981. self.showPaywall()
  4982. }
  4983. if newIsPremium {
  4984. self.launchPaywallWorkItem?.cancel()
  4985. self.launchPaywallWorkItem = nil
  4986. self.hasPresentedLaunchPaywall = true
  4987. } else {
  4988. self.scheduleLaunchPaywallIfNeeded()
  4989. }
  4990. }
  4991. }
  4992. storeKitStartupTask = Task { [weak self] in
  4993. await self?.storeKitCoordinator.start()
  4994. await MainActor.run {
  4995. self?.updatePremiumButtons()
  4996. self?.reconcileDesktopWidgetsForPremiumAccess()
  4997. self?.scheduleLaunchPaywallIfNeeded()
  4998. }
  4999. }
  5000. }
  5001. private func scheduleLaunchPaywallIfNeeded() {
  5002. guard hasPresentedLaunchPaywall == false else { return }
  5003. guard storeKitCoordinator.hasPremiumAccess == false else {
  5004. hasPresentedLaunchPaywall = true
  5005. return
  5006. }
  5007. guard launchPaywallWorkItem == nil else { return }
  5008. let workItem = DispatchWorkItem { [weak self] in
  5009. guard let self else { return }
  5010. self.launchPaywallWorkItem = nil
  5011. guard self.hasPresentedLaunchPaywall == false else { return }
  5012. guard self.storeKitCoordinator.hasPremiumAccess == false else {
  5013. self.hasPresentedLaunchPaywall = true
  5014. return
  5015. }
  5016. self.hasPresentedLaunchPaywall = true
  5017. self.showPaywall()
  5018. }
  5019. launchPaywallWorkItem = workItem
  5020. DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
  5021. }
  5022. @MainActor
  5023. private func updatePremiumButtons() {
  5024. let isPremium = storeKitCoordinator.hasPremiumAccess
  5025. let hasLifetimePremium = storeKitCoordinator.hasLifetimePremiumAccess
  5026. settingsUpgradeButton?.title = ""
  5027. settingsUpgradeTitleLabel?.stringValue = hasLifetimePremium ? "Premium Lifetime" : "Upgrade"
  5028. settingsUpgradeButton?.isHidden = hasLifetimePremium
  5029. settingsUpgradeButton?.isEnabled = hasLifetimePremium == false
  5030. settingsUpgradeButton?.alphaValue = hasLifetimePremium ? 0.6 : 1.0
  5031. settingsNotificationsBlock?.isHidden = isPremium == false
  5032. updatePremiumLockedSections()
  5033. if isPremium == false, isUserLoggedIn() {
  5034. applyMeetings([])
  5035. applyPremiumLockMessages()
  5036. }
  5037. if isPremium {
  5038. schedulePremiumRatingPromptIfNeeded()
  5039. }
  5040. updateTopBarPremiumButton()
  5041. }
  5042. @MainActor
  5043. private func updateTopBarPremiumButton() {
  5044. guard let button = topBarPremiumButton else { return }
  5045. let isPremium = storeKitCoordinator.hasPremiumAccess
  5046. if isPremium {
  5047. let title = "Manage Subscription"
  5048. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  5049. button.attributedTitle = NSAttributedString(string: title, attributes: [
  5050. .foregroundColor: NSColor.white,
  5051. .font: font
  5052. ])
  5053. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  5054. if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
  5055. let img = base.withSymbolConfiguration(symbolConfig) {
  5056. img.isTemplate = true
  5057. button.image = img
  5058. button.imagePosition = .imageLeading
  5059. button.imageHugsTitle = true
  5060. }
  5061. button.contentTintColor = NSColor.white
  5062. button.toolTip = title
  5063. button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
  5064. } else {
  5065. let title = "Upgrade to Pro"
  5066. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  5067. button.attributedTitle = NSAttributedString(string: title, attributes: [
  5068. .foregroundColor: NSColor.white,
  5069. .font: font
  5070. ])
  5071. button.image = nil
  5072. button.toolTip = title
  5073. button.layer?.backgroundColor = accentBlue.cgColor
  5074. button.contentTintColor = .white
  5075. }
  5076. }
  5077. @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
  5078. setDarkMode(sender.state == .on)
  5079. }
  5080. @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
  5081. let enabled = sender.state == .on
  5082. remindersEnabled = enabled
  5083. updateRemindersSubRowState()
  5084. if enabled {
  5085. requestNotificationPermissionIfNeeded { [weak self] granted in
  5086. guard let self else { return }
  5087. if granted {
  5088. self.rescheduleMeetingRemindersIfNeeded()
  5089. } else {
  5090. self.remindersEnabled = false
  5091. self.settingsRemindersMasterSwitch?.state = .off
  5092. self.updateRemindersSubRowState()
  5093. self.cancelAllMeetingReminders()
  5094. self.showSimpleAlert(
  5095. title: "Notifications disabled",
  5096. message: "To enable reminders, allow notifications for this app in System Settings → Notifications."
  5097. )
  5098. }
  5099. }
  5100. } else {
  5101. cancelAllMeetingReminders()
  5102. }
  5103. }
  5104. @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
  5105. remind1Day = sender.state == .on
  5106. rescheduleMeetingRemindersIfNeeded()
  5107. }
  5108. @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
  5109. remind12Hours = sender.state == .on
  5110. rescheduleMeetingRemindersIfNeeded()
  5111. }
  5112. @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
  5113. remind1Hour = sender.state == .on
  5114. rescheduleMeetingRemindersIfNeeded()
  5115. }
  5116. private func updateRemindersSubRowState() {
  5117. let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
  5118. settingsReminders1DayRow?.alphaValue = subAlpha
  5119. settingsReminders12HoursRow?.alphaValue = subAlpha
  5120. settingsReminders1HourRow?.alphaValue = subAlpha
  5121. let enabled = remindersEnabled
  5122. settingsReminders1DaySwitch?.isEnabled = enabled
  5123. settingsReminders12HoursSwitch?.isEnabled = enabled
  5124. settingsReminders1HourSwitch?.isEnabled = enabled
  5125. }
  5126. private func requestNotificationPermissionIfNeeded(completion: @escaping (Bool) -> Void) {
  5127. let center = UNUserNotificationCenter.current()
  5128. center.getNotificationSettings { settings in
  5129. switch settings.authorizationStatus {
  5130. case .notDetermined:
  5131. center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
  5132. DispatchQueue.main.async { completion(granted) }
  5133. }
  5134. case .authorized, .provisional, .ephemeral:
  5135. DispatchQueue.main.async { completion(true) }
  5136. default:
  5137. DispatchQueue.main.async { completion(false) }
  5138. }
  5139. }
  5140. }
  5141. private func rescheduleMeetingRemindersIfNeeded() {
  5142. guard storeKitCoordinator.hasPremiumAccess else {
  5143. cancelAllMeetingReminders()
  5144. return
  5145. }
  5146. guard remindersEnabled else {
  5147. cancelAllMeetingReminders()
  5148. return
  5149. }
  5150. // If nothing is selected, don't schedule anything (but keep master toggle on).
  5151. if remind1Day == false, remind12Hours == false, remind1Hour == false {
  5152. cancelAllMeetingReminders()
  5153. return
  5154. }
  5155. scheduleMeetingReminders(for: allScheduledMeetings)
  5156. }
  5157. private func cancelAllMeetingReminders() {
  5158. UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
  5159. }
  5160. private func scheduleMeetingReminders(for meetings: [ScheduledMeeting]) {
  5161. let center = UNUserNotificationCenter.current()
  5162. let now = Date()
  5163. struct Interval {
  5164. let offset: TimeInterval
  5165. let label: String
  5166. let suffix: String
  5167. let isEnabled: () -> Bool
  5168. }
  5169. let intervals: [Interval] = [
  5170. Interval(offset: 60 * 60 * 24, label: "Starts in 1 day", suffix: "1d", isEnabled: { self.remind1Day }),
  5171. Interval(offset: 60 * 60 * 12, label: "Starts in 12 hours", suffix: "12h", isEnabled: { self.remind12Hours }),
  5172. Interval(offset: 60 * 60, label: "Starts in 1 hour", suffix: "1h", isEnabled: { self.remind1Hour })
  5173. ]
  5174. var requests: [UNNotificationRequest] = []
  5175. for meeting in meetings {
  5176. let title = meeting.title.trimmingCharacters(in: .whitespacesAndNewlines)
  5177. guard title.isEmpty == false else { continue }
  5178. for interval in intervals where interval.isEnabled() {
  5179. let triggerDate = meeting.start.addingTimeInterval(-interval.offset)
  5180. guard triggerDate > now else { continue }
  5181. let content = UNMutableNotificationContent()
  5182. content.title = "📅 \(title)"
  5183. let subtitle = meeting.host.trimmingCharacters(in: .whitespacesAndNewlines)
  5184. content.subtitle = subtitle.isEmpty ? meeting.source : subtitle
  5185. content.body = interval.label
  5186. content.sound = .default
  5187. let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate)
  5188. let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
  5189. let id = "zoom.meeting.reminder.\(stableReminderKey(for: meeting)).\(interval.suffix)"
  5190. requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
  5191. }
  5192. }
  5193. center.removeAllPendingNotificationRequests()
  5194. for request in requests {
  5195. center.add(request, withCompletionHandler: nil)
  5196. }
  5197. }
  5198. private func stableReminderKey(for meeting: ScheduledMeeting) -> String {
  5199. if let id = meeting.meetingID?.trimmingCharacters(in: .whitespacesAndNewlines), id.isEmpty == false {
  5200. return id
  5201. }
  5202. if let url = meeting.webURL?.absoluteString, url.isEmpty == false {
  5203. return sha256Prefix(url)
  5204. }
  5205. return sha256Prefix("\(meeting.title)|\(meeting.start.timeIntervalSince1970)")
  5206. }
  5207. private func sha256Prefix(_ input: String) -> String {
  5208. let digest = SHA256.hash(data: Data(input.utf8))
  5209. let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
  5210. return String(hex.prefix(24))
  5211. }
  5212. private func setDarkMode(_ enabled: Bool) {
  5213. darkModeEnabled = enabled
  5214. palette = Palette(isDarkMode: enabled)
  5215. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  5216. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  5217. if let window = view.window {
  5218. applyWindowBackgroundForCurrentTheme(window)
  5219. }
  5220. let keepSelected = selectedHomeSidebarItem
  5221. selectedHomeSidebarItem = keepSelected
  5222. showHomeView(profile: nil, refreshMeetings: false)
  5223. updatePremiumLockedSections()
  5224. }
  5225. private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
  5226. // Keep the window's backing in sync with the app's current theme, otherwise rounded
  5227. // shell corners can briefly show the previous mode's lighter/darker color.
  5228. window.backgroundColor = appBackground
  5229. window.isOpaque = true
  5230. if let contentView = window.contentView {
  5231. contentView.wantsLayer = true
  5232. contentView.layer?.backgroundColor = appBackground.cgColor
  5233. }
  5234. }
  5235. private func systemPrefersDarkMode() -> Bool {
  5236. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  5237. let style = global?["AppleInterfaceStyle"] as? String
  5238. return style?.lowercased() == "dark"
  5239. }
  5240. @objc private func settingsUpgradePremiumTapped() {
  5241. guard storeKitCoordinator.hasLifetimePremiumAccess == false else { return }
  5242. showPaywall()
  5243. }
  5244. // MARK: - Paywall (ported from meetings_app)
  5245. private func showPaywall() {
  5246. if storeKitCoordinator.hasPremiumAccess && storeKitCoordinator.hasLifetimePremiumAccess == false {
  5247. selectedPremiumPlan = .lifetime
  5248. }
  5249. if let existing = paywallOverlayView {
  5250. refreshPaywallStoreUI()
  5251. if let superview = existing.superview {
  5252. superview.addSubview(existing)
  5253. }
  5254. updatePaywallResponsiveLayout()
  5255. NSApp.activate(ignoringOtherApps: true)
  5256. return
  5257. }
  5258. let overlay = NSView()
  5259. overlay.translatesAutoresizingMaskIntoConstraints = false
  5260. overlay.wantsLayer = true
  5261. overlay.alphaValue = 0
  5262. overlay.layer?.backgroundColor = NSColor.clear.cgColor
  5263. let dim = NSView()
  5264. dim.translatesAutoresizingMaskIntoConstraints = false
  5265. dim.wantsLayer = true
  5266. dim.layer?.backgroundColor = palette.isDarkMode
  5267. ? NSColor.black.withAlphaComponent(0.62).cgColor
  5268. : NSColor.black.withAlphaComponent(0.28).cgColor
  5269. overlay.addSubview(dim)
  5270. // Clicking outside the paywall dismisses it.
  5271. let outsideButton = NSButton(title: "", target: self, action: #selector(closePaywallClicked(_:)))
  5272. outsideButton.translatesAutoresizingMaskIntoConstraints = false
  5273. outsideButton.isBordered = false
  5274. outsideButton.bezelStyle = .regularSquare
  5275. outsideButton.contentTintColor = .clear
  5276. outsideButton.wantsLayer = true
  5277. outsideButton.layer?.backgroundColor = NSColor.clear.cgColor
  5278. overlay.addSubview(outsideButton)
  5279. let content = makePaywallContent()
  5280. overlay.addSubview(content)
  5281. view.addSubview(overlay)
  5282. NSLayoutConstraint.activate([
  5283. overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  5284. overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  5285. overlay.topAnchor.constraint(equalTo: view.topAnchor),
  5286. overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  5287. dim.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  5288. dim.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  5289. dim.topAnchor.constraint(equalTo: overlay.topAnchor),
  5290. dim.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  5291. outsideButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  5292. outsideButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  5293. outsideButton.topAnchor.constraint(equalTo: overlay.topAnchor),
  5294. outsideButton.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  5295. content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  5296. content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  5297. content.topAnchor.constraint(equalTo: overlay.topAnchor),
  5298. content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
  5299. ])
  5300. paywallPanelWidthConstraint = nil
  5301. paywallPanelHeightConstraint = nil
  5302. paywallOverlayView = overlay
  5303. NSAnimationContext.runAnimationGroup { context in
  5304. context.duration = 0.18
  5305. overlay.animator().alphaValue = 1
  5306. }
  5307. Task { [weak self] in
  5308. guard let self else { return }
  5309. await self.storeKitCoordinator.refreshProducts()
  5310. await MainActor.run {
  5311. self.refreshPaywallStoreUI()
  5312. self.updatePaywallResponsiveLayout()
  5313. }
  5314. }
  5315. }
  5316. @objc private func closePaywallClicked(_ sender: Any?) {
  5317. hidePaywall()
  5318. }
  5319. private func hidePaywall() {
  5320. guard let overlay = paywallOverlayView else { return }
  5321. NSAnimationContext.runAnimationGroup { context in
  5322. context.duration = 0.16
  5323. overlay.animator().alphaValue = 0
  5324. } completionHandler: { [weak self] in
  5325. overlay.removeFromSuperview()
  5326. self?.paywallPanelWidthConstraint = nil
  5327. self?.paywallPanelHeightConstraint = nil
  5328. self?.paywallPanelView = nil
  5329. self?.paywallBodyStack = nil
  5330. self?.paywallIsDesktopLayout = false
  5331. }
  5332. }
  5333. private func updatePaywallPanelSize() {
  5334. guard paywallPanelWidthConstraint != nil, paywallPanelHeightConstraint != nil else { return }
  5335. let safeInsetX: CGFloat = 80
  5336. let safeInsetY: CGFloat = 110
  5337. let maxWidth = max(640, view.bounds.width - safeInsetX)
  5338. let maxHeight = max(560, view.bounds.height - safeInsetY)
  5339. let panelWidth = min(1040, maxWidth)
  5340. let panelHeight = min(860, maxHeight)
  5341. if abs(paywallPanelWidthConstraint?.constant ?? 0 - panelWidth) > 1 {
  5342. paywallPanelWidthConstraint?.constant = panelWidth
  5343. }
  5344. if abs(paywallPanelHeightConstraint?.constant ?? 0 - panelHeight) > 1 {
  5345. paywallPanelHeightConstraint?.constant = panelHeight
  5346. }
  5347. }
  5348. private func updatePaywallResponsiveLayout() {
  5349. guard let bodyStack = paywallBodyStack, let panel = paywallPanelView else { return }
  5350. let width = panel.bounds.width
  5351. // Two-column desktop layout needs room for the plan column (520+) + a slimmer benefits column.
  5352. let shouldUseDesktop = width >= 900
  5353. guard shouldUseDesktop != paywallIsDesktopLayout else { return }
  5354. paywallIsDesktopLayout = shouldUseDesktop
  5355. if shouldUseDesktop {
  5356. bodyStack.orientation = .horizontal
  5357. bodyStack.alignment = .top
  5358. bodyStack.spacing = 26
  5359. } else {
  5360. bodyStack.orientation = .vertical
  5361. bodyStack.alignment = .leading
  5362. bodyStack.spacing = 18
  5363. }
  5364. let useWidePlans = width >= 880
  5365. paywallPlansRowWide?.isHidden = useWidePlans == false
  5366. paywallPlansGridNarrow?.isHidden = useWidePlans
  5367. bodyStack.layoutSubtreeIfNeeded()
  5368. }
  5369. private func makePaywallContent() -> NSView {
  5370. paywallPlanViews.removeAll()
  5371. paywallPlanPriceLabels.removeAll()
  5372. paywallPlanSubtitleLabels.removeAll()
  5373. paywallPlanStrikeLabels.removeAll()
  5374. premiumPlanByView.removeAll()
  5375. paywallOfferLabel = nil
  5376. paywallContinueLabel = nil
  5377. paywallContinueButton = nil
  5378. paywallContinueEnabled = true
  5379. paywallPlansRowWide = nil
  5380. paywallPlansGridNarrow = nil
  5381. let root = NSView()
  5382. root.translatesAutoresizingMaskIntoConstraints = false
  5383. root.wantsLayer = true
  5384. root.layer?.backgroundColor = NSColor.clear.cgColor
  5385. let panel = NSView()
  5386. panel.translatesAutoresizingMaskIntoConstraints = false
  5387. panel.wantsLayer = true
  5388. // Theme-aware surface so Light Mode doesn't render on a black backdrop.
  5389. panel.layer?.backgroundColor = palette.appBackground.cgColor
  5390. panel.layer?.cornerRadius = 0
  5391. styleSurface(panel, borderColor: NSColor.clear, borderWidth: 0, shadow: false)
  5392. root.addSubview(panel)
  5393. // Full-screen paywall: fill the entire app content area.
  5394. NSLayoutConstraint.activate([
  5395. panel.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  5396. panel.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  5397. panel.topAnchor.constraint(equalTo: root.topAnchor),
  5398. panel.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  5399. ])
  5400. paywallPanelWidthConstraint = nil
  5401. paywallPanelHeightConstraint = nil
  5402. // Responsive logic should measure the panel size (not the full-screen root).
  5403. self.paywallPanelView = panel
  5404. let contentStack = NSStackView()
  5405. contentStack.translatesAutoresizingMaskIntoConstraints = false
  5406. contentStack.orientation = .vertical
  5407. contentStack.spacing = 14
  5408. contentStack.alignment = .leading
  5409. panel.addSubview(contentStack)
  5410. let topRow = NSStackView()
  5411. topRow.translatesAutoresizingMaskIntoConstraints = false
  5412. topRow.orientation = .horizontal
  5413. topRow.alignment = .centerY
  5414. topRow.distribution = .fill
  5415. topRow.spacing = 10
  5416. topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 26, weight: .semibold), color: primaryText))
  5417. let topSpacer = NSView()
  5418. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  5419. topRow.addArrangedSubview(topSpacer)
  5420. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  5421. closeButton.translatesAutoresizingMaskIntoConstraints = false
  5422. closeButton.isBordered = false
  5423. closeButton.bezelStyle = .regularSquare
  5424. closeButton.wantsLayer = true
  5425. closeButton.layer?.cornerRadius = 14
  5426. closeButton.normalColor = palette.inputBackground
  5427. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  5428. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  5429. closeButton.layer?.borderWidth = 1
  5430. closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
  5431. closeButton.contentTintColor = secondaryText
  5432. closeButton.toolTip = "Continue with free plan"
  5433. panel.addSubview(closeButton)
  5434. NSLayoutConstraint.activate([
  5435. closeButton.widthAnchor.constraint(equalToConstant: 28),
  5436. closeButton.heightAnchor.constraint(equalToConstant: 28),
  5437. closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  5438. closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48)
  5439. ])
  5440. contentStack.addArrangedSubview(topRow)
  5441. topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5442. let hero = paywallHeroCard(
  5443. eyebrow: "PREMIUM EXPERIENCE",
  5444. title: "Elevate your meetings workflow",
  5445. subtitle: "Unlock automation, premium meeting tools, and priority support for every session."
  5446. )
  5447. contentStack.addArrangedSubview(hero)
  5448. hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5449. let features = paywallFeaturesRow()
  5450. contentStack.addArrangedSubview(features)
  5451. features.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5452. let bodyStack = NSStackView()
  5453. bodyStack.translatesAutoresizingMaskIntoConstraints = false
  5454. bodyStack.orientation = .vertical
  5455. bodyStack.spacing = 16
  5456. bodyStack.alignment = .leading
  5457. bodyStack.distribution = .fill
  5458. contentStack.addArrangedSubview(bodyStack)
  5459. paywallBodyStack = bodyStack
  5460. bodyStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5461. let planColumn = NSStackView()
  5462. planColumn.translatesAutoresizingMaskIntoConstraints = false
  5463. planColumn.orientation = .vertical
  5464. planColumn.spacing = 12
  5465. planColumn.alignment = .leading
  5466. planColumn.distribution = .fill
  5467. bodyStack.addArrangedSubview(planColumn)
  5468. planColumn.widthAnchor.constraint(equalTo: bodyStack.widthAnchor).isActive = true
  5469. let weeklyCard = paywallPlanCard(
  5470. title: "Weekly",
  5471. price: paywallDisplayPrice(for: .weekly),
  5472. badge: "Basic",
  5473. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5474. subtitle: paywallPlanSubtitle(for: .weekly),
  5475. plan: .weekly,
  5476. strikePrice: nil
  5477. )
  5478. let monthlyCard = paywallPlanCard(
  5479. title: "Monthly",
  5480. price: paywallDisplayPrice(for: .monthly),
  5481. badge: "Popular",
  5482. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  5483. subtitle: paywallPlanSubtitle(for: .monthly),
  5484. plan: .monthly,
  5485. strikePrice: nil
  5486. )
  5487. let yearlyCard = paywallPlanCard(
  5488. title: "Yearly",
  5489. price: paywallDisplayPrice(for: .yearly),
  5490. badge: "Save 67%",
  5491. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5492. subtitle: paywallPlanSubtitle(for: .yearly),
  5493. plan: .yearly,
  5494. strikePrice: nil
  5495. )
  5496. let lifetimeCard = paywallPlanCard(
  5497. title: "Lifetime",
  5498. price: paywallDisplayPrice(for: .lifetime),
  5499. badge: "Best Value",
  5500. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5501. subtitle: paywallPlanSubtitle(for: .lifetime),
  5502. plan: .lifetime,
  5503. strikePrice: nil
  5504. )
  5505. let plansRowWide = NSStackView()
  5506. plansRowWide.translatesAutoresizingMaskIntoConstraints = false
  5507. plansRowWide.orientation = .horizontal
  5508. plansRowWide.spacing = 12
  5509. plansRowWide.alignment = .centerY
  5510. plansRowWide.distribution = .fillEqually
  5511. plansRowWide.addArrangedSubview(weeklyCard)
  5512. plansRowWide.addArrangedSubview(monthlyCard)
  5513. plansRowWide.addArrangedSubview(yearlyCard)
  5514. plansRowWide.addArrangedSubview(lifetimeCard)
  5515. paywallPlansRowWide = plansRowWide
  5516. planColumn.addArrangedSubview(plansRowWide)
  5517. plansRowWide.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5518. let plansGridNarrow = NSStackView()
  5519. plansGridNarrow.translatesAutoresizingMaskIntoConstraints = false
  5520. plansGridNarrow.orientation = .vertical
  5521. plansGridNarrow.spacing = 12
  5522. plansGridNarrow.alignment = .leading
  5523. plansGridNarrow.distribution = .fillEqually
  5524. let plansRow1 = NSStackView()
  5525. plansRow1.translatesAutoresizingMaskIntoConstraints = false
  5526. plansRow1.orientation = .horizontal
  5527. plansRow1.spacing = 12
  5528. plansRow1.alignment = .centerY
  5529. plansRow1.distribution = .fillEqually
  5530. plansRow1.addArrangedSubview(paywallPlanCard(
  5531. title: "Weekly",
  5532. price: paywallDisplayPrice(for: .weekly),
  5533. badge: "Basic Deal",
  5534. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5535. subtitle: paywallPlanSubtitle(for: .weekly),
  5536. plan: .weekly,
  5537. strikePrice: nil
  5538. ))
  5539. plansRow1.addArrangedSubview(paywallPlanCard(
  5540. title: "Monthly",
  5541. price: paywallDisplayPrice(for: .monthly),
  5542. badge: "Popular",
  5543. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  5544. subtitle: paywallPlanSubtitle(for: .monthly),
  5545. plan: .monthly,
  5546. strikePrice: nil
  5547. ))
  5548. let plansRow2 = NSStackView()
  5549. plansRow2.translatesAutoresizingMaskIntoConstraints = false
  5550. plansRow2.orientation = .horizontal
  5551. plansRow2.spacing = 12
  5552. plansRow2.alignment = .centerY
  5553. plansRow2.distribution = .fillEqually
  5554. plansRow2.addArrangedSubview(paywallPlanCard(
  5555. title: "Yearly",
  5556. price: paywallDisplayPrice(for: .yearly),
  5557. badge: "Best Deal",
  5558. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5559. subtitle: paywallPlanSubtitle(for: .yearly),
  5560. plan: .yearly,
  5561. strikePrice: nil
  5562. ))
  5563. plansRow2.addArrangedSubview(paywallPlanCard(
  5564. title: "Lifetime",
  5565. price: paywallDisplayPrice(for: .lifetime),
  5566. badge: "Save 50%",
  5567. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5568. subtitle: paywallPlanSubtitle(for: .lifetime),
  5569. plan: .lifetime,
  5570. strikePrice: nil
  5571. ))
  5572. plansGridNarrow.addArrangedSubview(plansRow1)
  5573. plansGridNarrow.addArrangedSubview(plansRow2)
  5574. plansGridNarrow.isHidden = true
  5575. paywallPlansGridNarrow = plansGridNarrow
  5576. planColumn.addArrangedSubview(plansGridNarrow)
  5577. plansGridNarrow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5578. plansRow1.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  5579. plansRow2.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  5580. updatePaywallPlanSelection()
  5581. planColumn.setCustomSpacing(16, after: plansGridNarrow)
  5582. let trustRow = paywallTrustRow()
  5583. planColumn.addArrangedSubview(trustRow)
  5584. trustRow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5585. planColumn.setCustomSpacing(14, after: trustRow)
  5586. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  5587. offer.alignment = .center
  5588. paywallOfferLabel = offer
  5589. let offerWrap = NSView()
  5590. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  5591. offerWrap.addSubview(offer)
  5592. NSLayoutConstraint.activate([
  5593. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  5594. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  5595. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  5596. ])
  5597. planColumn.addArrangedSubview(offerWrap)
  5598. offerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5599. planColumn.setCustomSpacing(14, after: offerWrap)
  5600. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  5601. continueButton.translatesAutoresizingMaskIntoConstraints = false
  5602. continueButton.isBordered = false
  5603. continueButton.bezelStyle = .regularSquare
  5604. continueButton.wantsLayer = true
  5605. continueButton.layer?.cornerRadius = 14
  5606. continueButton.normalColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 1)
  5607. continueButton.hoverColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 0.92)
  5608. continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
  5609. styleSurface(continueButton, borderColor: NSColor.black.withAlphaComponent(0.12), borderWidth: 1, shadow: true)
  5610. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  5611. continueButton.addSubview(continueLabel)
  5612. NSLayoutConstraint.activate([
  5613. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  5614. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  5615. ])
  5616. paywallContinueButton = continueButton
  5617. paywallContinueLabel = continueLabel
  5618. let continueButtonWrap = NSView()
  5619. continueButtonWrap.translatesAutoresizingMaskIntoConstraints = false
  5620. continueButtonWrap.addSubview(continueButton)
  5621. planColumn.addArrangedSubview(continueButtonWrap)
  5622. NSLayoutConstraint.activate([
  5623. continueButtonWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor),
  5624. continueButton.topAnchor.constraint(equalTo: continueButtonWrap.topAnchor),
  5625. continueButton.bottomAnchor.constraint(equalTo: continueButtonWrap.bottomAnchor),
  5626. continueButton.centerXAnchor.constraint(equalTo: continueButtonWrap.centerXAnchor),
  5627. continueButton.widthAnchor.constraint(equalToConstant: 400),
  5628. continueButton.widthAnchor.constraint(lessThanOrEqualTo: continueButtonWrap.widthAnchor, constant: -40)
  5629. ])
  5630. planColumn.setCustomSpacing(16, after: continueButtonWrap)
  5631. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
  5632. secure.alignment = .center
  5633. let secureWrap = NSView()
  5634. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  5635. secureWrap.addSubview(secure)
  5636. NSLayoutConstraint.activate([
  5637. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  5638. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  5639. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  5640. ])
  5641. planColumn.addArrangedSubview(secureWrap)
  5642. secureWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5643. planColumn.setCustomSpacing(8, after: secureWrap)
  5644. let primaryFooterButton = paywallFooterButton(
  5645. title: storeKitCoordinator.hasPremiumAccess ? "Manage Subscription" : "Continue with free plan",
  5646. action: storeKitCoordinator.hasPremiumAccess
  5647. ? #selector(paywallManageSubscriptionClicked(_:))
  5648. : #selector(closePaywallClicked(_:))
  5649. )
  5650. let restoreButton = paywallFooterButton(
  5651. title: "Restore Purchase",
  5652. action: #selector(paywallRestorePurchaseClicked(_:))
  5653. )
  5654. let privacyButton = paywallFooterButton(
  5655. title: "Privacy Policy",
  5656. action: #selector(paywallPrivacyPolicyClicked(_:))
  5657. )
  5658. let supportButton = paywallFooterButton(
  5659. title: "Support",
  5660. action: #selector(paywallSupportClicked(_:))
  5661. )
  5662. let termsButton = paywallFooterButton(
  5663. title: "Terms of Services",
  5664. action: #selector(paywallTermsOfServicesClicked(_:))
  5665. )
  5666. // Footer: evenly spaced links across the paywall width.
  5667. let footerContent = NSStackView()
  5668. footerContent.translatesAutoresizingMaskIntoConstraints = false
  5669. footerContent.orientation = .horizontal
  5670. footerContent.alignment = .centerY
  5671. footerContent.distribution = .equalSpacing
  5672. footerContent.spacing = 36
  5673. footerContent.addArrangedSubview(primaryFooterButton)
  5674. footerContent.addArrangedSubview(restoreButton)
  5675. footerContent.addArrangedSubview(privacyButton)
  5676. footerContent.addArrangedSubview(supportButton)
  5677. footerContent.addArrangedSubview(termsButton)
  5678. let footerWrap = NSView()
  5679. footerWrap.translatesAutoresizingMaskIntoConstraints = false
  5680. footerWrap.addSubview(footerContent)
  5681. NSLayoutConstraint.activate([
  5682. footerContent.leadingAnchor.constraint(equalTo: footerWrap.leadingAnchor, constant: 44),
  5683. footerContent.trailingAnchor.constraint(equalTo: footerWrap.trailingAnchor, constant: -44),
  5684. footerContent.topAnchor.constraint(equalTo: footerWrap.topAnchor),
  5685. footerContent.bottomAnchor.constraint(equalTo: footerWrap.bottomAnchor)
  5686. ])
  5687. // Push footer to the bottom of the paywall.
  5688. let footerPushSpacer = NSView()
  5689. footerPushSpacer.translatesAutoresizingMaskIntoConstraints = false
  5690. footerPushSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  5691. footerPushSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  5692. planColumn.addArrangedSubview(footerPushSpacer)
  5693. planColumn.addArrangedSubview(footerWrap)
  5694. footerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5695. // Keep a comfortable top margin, but center the paywall content vertically when there's room.
  5696. let contentTopMin = contentStack.topAnchor.constraint(greaterThanOrEqualTo: panel.topAnchor, constant: 84)
  5697. let contentBottomMax = contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -18)
  5698. let contentCenterY = contentStack.centerYAnchor.constraint(equalTo: panel.centerYAnchor)
  5699. contentCenterY.priority = .defaultLow
  5700. let contentBottomEq = contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -18)
  5701. contentBottomEq.priority = .defaultLow
  5702. NSLayoutConstraint.activate([
  5703. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 70),
  5704. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  5705. contentTopMin,
  5706. contentBottomMax,
  5707. contentCenterY,
  5708. contentBottomEq
  5709. ])
  5710. refreshPaywallStoreUI()
  5711. updatePaywallResponsiveLayout()
  5712. return root
  5713. }
  5714. private func paywallBenefitsSection() -> NSView {
  5715. let stack = NSStackView()
  5716. stack.translatesAutoresizingMaskIntoConstraints = false
  5717. stack.orientation = .vertical
  5718. stack.spacing = 8
  5719. stack.alignment = .leading
  5720. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
  5721. let rowOne = NSStackView()
  5722. rowOne.translatesAutoresizingMaskIntoConstraints = false
  5723. rowOne.orientation = .horizontal
  5724. rowOne.spacing = 10
  5725. rowOne.distribution = .fillEqually
  5726. rowOne.alignment = .centerY
  5727. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  5728. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  5729. let rowTwo = NSStackView()
  5730. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  5731. rowTwo.orientation = .horizontal
  5732. rowTwo.spacing = 10
  5733. rowTwo.distribution = .fillEqually
  5734. rowTwo.alignment = .centerY
  5735. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  5736. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  5737. stack.addArrangedSubview(rowOne)
  5738. stack.addArrangedSubview(rowTwo)
  5739. return stack
  5740. }
  5741. private func paywallHeroCard(eyebrow: String, title: String, subtitle: String) -> NSView {
  5742. let card = NSView()
  5743. card.translatesAutoresizingMaskIntoConstraints = false
  5744. card.wantsLayer = true
  5745. card.layer?.cornerRadius = 14
  5746. card.layer?.backgroundColor = palette.secondaryCardBackground.cgColor
  5747. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5748. let eyebrowLabel = styledLabel(
  5749. eyebrow,
  5750. font: NSFont.systemFont(ofSize: 11, weight: .semibold),
  5751. color: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 0.92),
  5752. kern: 0.8
  5753. )
  5754. eyebrowLabel.alignment = .left
  5755. let titleLabel = styledLabel(
  5756. title,
  5757. font: NSFont.systemFont(ofSize: 18, weight: .semibold),
  5758. color: primaryText,
  5759. lineSpacing: 1
  5760. )
  5761. titleLabel.alignment = .left
  5762. let subtitleLabel = styledLabel(
  5763. subtitle,
  5764. font: NSFont.systemFont(ofSize: 13, weight: .regular),
  5765. color: secondaryText,
  5766. lineSpacing: 2
  5767. )
  5768. subtitleLabel.maximumNumberOfLines = 2
  5769. subtitleLabel.lineBreakMode = .byTruncatingTail
  5770. subtitleLabel.alignment = .left
  5771. let stack = NSStackView()
  5772. stack.translatesAutoresizingMaskIntoConstraints = false
  5773. stack.orientation = .vertical
  5774. stack.alignment = .leading
  5775. stack.spacing = 6
  5776. stack.addArrangedSubview(eyebrowLabel)
  5777. stack.addArrangedSubview(titleLabel)
  5778. stack.addArrangedSubview(subtitleLabel)
  5779. card.addSubview(stack)
  5780. NSLayoutConstraint.activate([
  5781. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  5782. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  5783. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  5784. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
  5785. ])
  5786. return card
  5787. }
  5788. private func paywallFeaturesRow() -> NSView {
  5789. let row = NSStackView()
  5790. row.translatesAutoresizingMaskIntoConstraints = false
  5791. row.orientation = .horizontal
  5792. row.spacing = 10
  5793. row.alignment = .centerY
  5794. row.distribution = .fillEqually
  5795. row.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  5796. row.addArrangedSubview(paywallBenefitItem(icon: "💬", text: "Smart scheduling"))
  5797. row.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
  5798. row.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
  5799. return row
  5800. }
  5801. private func paywallTrustRow() -> NSView {
  5802. let row = NSStackView()
  5803. row.translatesAutoresizingMaskIntoConstraints = false
  5804. row.orientation = .horizontal
  5805. row.spacing = 10
  5806. row.alignment = .centerY
  5807. row.distribution = .fillEqually
  5808. row.addArrangedSubview(paywallTrustItem(title: "Cancel anytime", subtitle: "No lock-in"))
  5809. row.addArrangedSubview(paywallTrustItem(title: "Instant access", subtitle: "Unlock all tools"))
  5810. row.addArrangedSubview(paywallTrustItem(title: "Secure billing", subtitle: "Handled by Apple"))
  5811. return row
  5812. }
  5813. private func paywallTrustItem(title: String, subtitle: String) -> NSView {
  5814. let card = NSView()
  5815. card.translatesAutoresizingMaskIntoConstraints = false
  5816. card.wantsLayer = true
  5817. card.layer?.cornerRadius = 12
  5818. card.layer?.backgroundColor = palette.inputBackground.cgColor
  5819. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5820. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: primaryText)
  5821. let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  5822. let stack = NSStackView()
  5823. stack.translatesAutoresizingMaskIntoConstraints = false
  5824. stack.orientation = .vertical
  5825. stack.alignment = .centerX
  5826. stack.spacing = 2
  5827. stack.addArrangedSubview(titleLabel)
  5828. stack.addArrangedSubview(subtitleLabel)
  5829. card.addSubview(stack)
  5830. NSLayoutConstraint.activate([
  5831. card.heightAnchor.constraint(equalToConstant: 44),
  5832. stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  5833. stack.centerYAnchor.constraint(equalTo: card.centerYAnchor)
  5834. ])
  5835. return card
  5836. }
  5837. private func paywallBenefitItem(icon: String, text: String) -> NSView {
  5838. let card = NSView()
  5839. card.translatesAutoresizingMaskIntoConstraints = false
  5840. card.wantsLayer = true
  5841. card.layer?.cornerRadius = 10
  5842. card.layer?.backgroundColor = palette.inputBackground.cgColor
  5843. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  5844. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5845. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  5846. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  5847. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  5848. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  5849. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5850. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 13, weight: .medium), color: accentBlue)
  5851. iconWrap.addSubview(iconLabel)
  5852. NSLayoutConstraint.activate([
  5853. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  5854. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  5855. ])
  5856. let title = textLabel(text, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: primaryText)
  5857. card.addSubview(iconWrap)
  5858. card.addSubview(title)
  5859. NSLayoutConstraint.activate([
  5860. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  5861. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  5862. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  5863. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  5864. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  5865. ])
  5866. return card
  5867. }
  5868. private func paywallPlanCard(
  5869. title: String,
  5870. price: String,
  5871. badge: String,
  5872. badgeColor: NSColor,
  5873. subtitle: String?,
  5874. plan: PremiumPlan,
  5875. strikePrice: String?
  5876. ) -> NSView {
  5877. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  5878. wrapper.translatesAutoresizingMaskIntoConstraints = false
  5879. wrapper.isBordered = false
  5880. wrapper.bezelStyle = .regularSquare
  5881. wrapper.wantsLayer = true
  5882. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  5883. wrapper.heightAnchor.constraint(equalToConstant: 138).isActive = true
  5884. wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
  5885. let card = NSView()
  5886. card.translatesAutoresizingMaskIntoConstraints = false
  5887. card.wantsLayer = true
  5888. card.layer?.cornerRadius = 16
  5889. card.layer?.backgroundColor = palette.sectionCard.cgColor
  5890. wrapper.addSubview(card)
  5891. NSLayoutConstraint.activate([
  5892. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5893. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5894. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 14),
  5895. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  5896. ])
  5897. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5898. let badgeLabel = styledLabel(
  5899. badge.uppercased(),
  5900. font: NSFont.systemFont(ofSize: 10.5, weight: .semibold),
  5901. color: .white,
  5902. kern: 0.6
  5903. )
  5904. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  5905. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  5906. badgeWrap.wantsLayer = true
  5907. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  5908. badgeWrap.layer?.borderWidth = 1
  5909. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  5910. badgeWrap.layer?.shadowOpacity = 0.20
  5911. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  5912. badgeWrap.layer?.shadowRadius = 3
  5913. badgeWrap.addSubview(badgeLabel)
  5914. NSLayoutConstraint.activate([
  5915. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  5916. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  5917. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  5918. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  5919. ])
  5920. wrapper.addSubview(badgeWrap)
  5921. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: accentBlue)
  5922. card.addSubview(titleLabel)
  5923. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: primaryText)
  5924. card.addSubview(priceLabel)
  5925. paywallPlanPriceLabels[plan, default: []].append(priceLabel)
  5926. NSLayoutConstraint.activate([
  5927. badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  5928. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  5929. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  5930. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
  5931. priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  5932. priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  5933. ])
  5934. if let subtitle {
  5935. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  5936. card.addSubview(sub)
  5937. paywallPlanSubtitleLabels[plan, default: []].append(sub)
  5938. NSLayoutConstraint.activate([
  5939. sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  5940. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
  5941. sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  5942. ])
  5943. }
  5944. if let strikePrice {
  5945. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  5946. card.addSubview(strike)
  5947. paywallPlanStrikeLabels[plan, default: []].append(strike)
  5948. NSLayoutConstraint.activate([
  5949. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  5950. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  5951. ])
  5952. }
  5953. paywallPlanViews[plan, default: []].append(card)
  5954. premiumPlanByView[ObjectIdentifier(card)] = plan
  5955. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  5956. guard let self, let card else { return }
  5957. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  5958. }
  5959. wrapper.onHoverChanged?(false)
  5960. return wrapper
  5961. }
  5962. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  5963. guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
  5964. selectedPremiumPlan = plan
  5965. updatePaywallPlanSelection()
  5966. }
  5967. private func updatePaywallPlanSelection() {
  5968. for (plan, views) in paywallPlanViews {
  5969. for view in views {
  5970. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  5971. }
  5972. }
  5973. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  5974. updatePaywallContinueState(isLoading: false)
  5975. }
  5976. private func paywallOfferText(for plan: PremiumPlan) -> String {
  5977. if storeKitCoordinator.hasLifetimePremiumAccess {
  5978. return "Lifetime premium is active on this Apple ID."
  5979. }
  5980. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  5981. let price = paywallDisplayPrice(for: plan)
  5982. switch plan {
  5983. case .weekly: return "\(price)/week"
  5984. case .monthly: return "\(price)/month"
  5985. case .yearly: return "\(price)/year"
  5986. case .lifetime: return "\(price) one-time purchase"
  5987. }
  5988. }
  5989. if let offer = product.subscription?.introductoryOffer,
  5990. offer.paymentMode == .freeTrial,
  5991. let trial = paywallSubscriptionPeriodDescription(offer.period) {
  5992. let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
  5993. if let renewal {
  5994. return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
  5995. }
  5996. return "Free for \(trial), then \(product.displayPrice)"
  5997. }
  5998. if plan == .lifetime {
  5999. return "\(product.displayPrice) one-time purchase"
  6000. }
  6001. if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
  6002. return "\(product.displayPrice)/\(renewal)"
  6003. }
  6004. let fallbackPrice = paywallDisplayPrice(for: plan)
  6005. switch plan {
  6006. case .weekly: return "\(fallbackPrice)/week"
  6007. case .monthly: return "\(fallbackPrice)/month"
  6008. case .yearly: return "\(fallbackPrice)/year"
  6009. case .lifetime: return "\(fallbackPrice) one-time purchase"
  6010. }
  6011. }
  6012. private func paywallFreeTrialText(for plan: PremiumPlan) -> String? {
  6013. guard let product = storeKitCoordinator.productsByID[plan.rawValue],
  6014. let offer = product.subscription?.introductoryOffer,
  6015. offer.paymentMode == .freeTrial,
  6016. let trial = paywallSubscriptionPeriodDescription(offer.period) else {
  6017. return nil
  6018. }
  6019. return trial
  6020. }
  6021. private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
  6022. if plan == .lifetime { return "One-time purchase" }
  6023. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  6024. switch plan {
  6025. case .weekly: return "Billed weekly"
  6026. case .monthly: return "Billed monthly"
  6027. case .yearly: return "Billed yearly"
  6028. case .lifetime: return "One-time purchase"
  6029. }
  6030. }
  6031. if let period = product.subscription?.subscriptionPeriod,
  6032. let unit = paywallSubscriptionPeriodDescription(period),
  6033. let trial = paywallFreeTrialText(for: plan) {
  6034. return "\(product.displayPrice)/\(unit) • \(trial) free trial"
  6035. }
  6036. if let period = product.subscription?.subscriptionPeriod,
  6037. let unit = paywallSubscriptionPeriodDescription(period) {
  6038. return "Billed \(unit)"
  6039. }
  6040. switch plan {
  6041. case .weekly: return "Billed weekly"
  6042. case .monthly: return "Billed monthly"
  6043. case .yearly: return "Billed yearly"
  6044. case .lifetime: return "One-time purchase"
  6045. }
  6046. }
  6047. private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
  6048. guard let period else { return nil }
  6049. let value = period.value
  6050. switch period.unit {
  6051. case .day:
  6052. return value == 1 ? "1 day" : "\(value) days"
  6053. case .week:
  6054. return value == 1 ? "1 week" : "\(value) weeks"
  6055. case .month:
  6056. return value == 1 ? "1 month" : "\(value) months"
  6057. case .year:
  6058. return value == 1 ? "1 year" : "\(value) years"
  6059. @unknown default:
  6060. return nil
  6061. }
  6062. }
  6063. private func refreshPaywallStoreUI() {
  6064. refreshPaywallPlanPrices()
  6065. updatePaywallPlanSelection()
  6066. updatePaywallContinueState(isLoading: false)
  6067. }
  6068. private func refreshPaywallPlanPrices() {
  6069. for plan in PremiumPlan.allCases {
  6070. let price = paywallDisplayPrice(for: plan)
  6071. paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
  6072. let subtitle = paywallPlanSubtitle(for: plan)
  6073. paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
  6074. }
  6075. }
  6076. private func paywallDisplayPrice(for plan: PremiumPlan) -> String {
  6077. if let product = storeKitCoordinator.productsByID[plan.rawValue] {
  6078. return product.displayPrice
  6079. }
  6080. switch plan {
  6081. case .weekly: return "3.99"
  6082. case .monthly: return "7.99"
  6083. case .yearly: return "24.99"
  6084. case .lifetime: return "44.99"
  6085. }
  6086. }
  6087. @objc private func paywallContinueClicked(_ sender: Any?) {
  6088. startSelectedPlanPurchase()
  6089. }
  6090. private func paywallFooterButton(title: String, action: Selector) -> NSButton {
  6091. let button = NSButton(title: title, target: self, action: action)
  6092. button.translatesAutoresizingMaskIntoConstraints = false
  6093. button.isBordered = false
  6094. button.bezelStyle = .shadowlessSquare
  6095. button.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  6096. button.contentTintColor = secondaryText
  6097. button.alignment = .center
  6098. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  6099. return button
  6100. }
  6101. @objc private func paywallPrivacyPolicyClicked(_ sender: Any?) {
  6102. openURLInDefaultBrowser(AppExternalURLs.privacyPolicy)
  6103. }
  6104. @objc private func paywallSupportClicked(_ sender: Any?) {
  6105. openURLInDefaultBrowser(AppExternalURLs.support)
  6106. }
  6107. @objc private func paywallTermsOfServicesClicked(_ sender: Any?) {
  6108. openURLInDefaultBrowser(AppExternalURLs.termsOfServices)
  6109. }
  6110. @objc private func paywallManageSubscriptionClicked(_ sender: Any?) {
  6111. openManageSubscriptions()
  6112. }
  6113. @objc private func paywallRestorePurchaseClicked(_ sender: Any?) {
  6114. Task { [weak self] in
  6115. guard let self else { return }
  6116. let message = await self.storeKitCoordinator.restorePurchases()
  6117. await MainActor.run {
  6118. self.refreshPaywallStoreUI()
  6119. self.updatePremiumButtons()
  6120. self.showSimpleAlert(title: "Restore Purchase", message: message)
  6121. }
  6122. }
  6123. }
  6124. private func startSelectedPlanPurchase() {
  6125. guard paywallContinueEnabled else { return }
  6126. paywallPurchaseTask?.cancel()
  6127. updatePaywallContinueState(isLoading: true)
  6128. let selectedPlan = selectedPremiumPlan
  6129. let hadPremiumBeforePurchase = storeKitCoordinator.hasPremiumAccess
  6130. let hadLifetimeBeforePurchase = storeKitCoordinator.hasLifetimePremiumAccess
  6131. paywallPurchaseTask = Task { [weak self] in
  6132. guard let self else { return }
  6133. if self.storeKitCoordinator.productsByID[selectedPlan.rawValue] == nil {
  6134. await self.storeKitCoordinator.refreshProducts()
  6135. }
  6136. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  6137. await MainActor.run {
  6138. self.updatePaywallContinueState(isLoading: false)
  6139. self.refreshPaywallStoreUI()
  6140. self.updatePremiumButtons()
  6141. switch result {
  6142. case .success:
  6143. if self.storeKitCoordinator.hasPremiumAccess {
  6144. // Ensure QA can repeatedly verify rating flow right after upgrading.
  6145. UserDefaults.standard.set(false, forKey: self.ratingCompletedKey)
  6146. UserDefaults.standard.set(false, forKey: self.ratingPromptShownKey)
  6147. self.schedulePremiumRatingPromptIfNeeded()
  6148. }
  6149. if selectedPlan == .lifetime, hadLifetimeBeforePurchase == false, hadPremiumBeforePurchase {
  6150. self.showSimpleAlert(
  6151. title: "Lifetime Activated",
  6152. 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."
  6153. )
  6154. } else {
  6155. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  6156. }
  6157. self.hidePaywall()
  6158. if self.storeKitCoordinator.hasPremiumAccess {
  6159. DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
  6160. self?.presentRatingPrompt(source: "premium")
  6161. }
  6162. }
  6163. case .pending:
  6164. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  6165. case .cancelled:
  6166. break
  6167. case .failed(let message):
  6168. let fallbackMessage = "StoreKit did not return products for this plan yet. Wait a few seconds and try again, or reopen the app."
  6169. if message.contains("Product not available") {
  6170. self.showSimpleAlert(title: "Purchase Failed", message: fallbackMessage)
  6171. } else {
  6172. self.showSimpleAlert(title: "Purchase Failed", message: message)
  6173. }
  6174. }
  6175. }
  6176. }
  6177. }
  6178. private func updatePaywallContinueState(isLoading: Bool) {
  6179. if isLoading {
  6180. paywallContinueEnabled = false
  6181. paywallContinueLabel?.stringValue = "Processing..."
  6182. paywallContinueButton?.alphaValue = 0.75
  6183. return
  6184. }
  6185. if storeKitCoordinator.hasLifetimePremiumAccess {
  6186. paywallContinueEnabled = false
  6187. paywallContinueLabel?.stringValue = "Lifetime Premium Active"
  6188. paywallContinueButton?.alphaValue = 0.75
  6189. } else if storeKitCoordinator.productsByID[selectedPremiumPlan.rawValue] == nil {
  6190. paywallContinueEnabled = false
  6191. paywallContinueLabel?.stringValue = "Store Unavailable"
  6192. paywallContinueButton?.alphaValue = 0.75
  6193. } else {
  6194. paywallContinueEnabled = true
  6195. if let trial = paywallFreeTrialText(for: selectedPremiumPlan) {
  6196. paywallContinueLabel?.stringValue = "Start with \(trial) trial"
  6197. } else {
  6198. paywallContinueLabel?.stringValue = "Continue"
  6199. }
  6200. paywallContinueButton?.alphaValue = 1.0
  6201. }
  6202. }
  6203. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  6204. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  6205. let idleBorder = palette.inputBorder
  6206. let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
  6207. let hoverIdleBackground =
  6208. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  6209. let selectedBackground = palette.isDarkMode
  6210. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  6211. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  6212. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  6213. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  6214. card.layer?.borderWidth = isSelected ? 2 : 1
  6215. card.layer?.shadowColor = NSColor.black.cgColor
  6216. card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  6217. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  6218. card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
  6219. }
  6220. // MARK: - Home UI
  6221. private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
  6222. let root = NSView()
  6223. let shell = NSView()
  6224. shell.wantsLayer = true
  6225. shell.layer?.backgroundColor = appShellBackground.cgColor
  6226. shell.layer?.cornerRadius = appShellCornerRadius
  6227. shell.layer?.borderWidth = 1
  6228. shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  6229. let chromeColumn = NSView()
  6230. chromeColumn.wantsLayer = true
  6231. chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  6232. chromeColumn.layer?.cornerRadius = appShellCornerRadius
  6233. chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
  6234. chromeColumn.layer?.masksToBounds = true
  6235. let chromeDivider = NSView()
  6236. chromeDivider.wantsLayer = true
  6237. chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
  6238. let chromeHeader = NSView()
  6239. chromeHeader.wantsLayer = true
  6240. chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  6241. homeSidebarRowViews = [:]
  6242. homeSidebarIconViews = [:]
  6243. homeSidebarLabels = [:]
  6244. let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Widgets", "AI Companion", "Settings"], selected: selectedHomeSidebarItem, style: .home)
  6245. let content = NSView()
  6246. content.wantsLayer = true
  6247. content.layer?.backgroundColor = NSColor.clear.cgColor
  6248. root.addSubview(shell)
  6249. shell.addSubview(chromeColumn)
  6250. shell.addSubview(content)
  6251. chromeColumn.addSubview(chromeDivider)
  6252. chromeColumn.addSubview(chromeHeader)
  6253. chromeColumn.addSubview(sidebar)
  6254. shell.translatesAutoresizingMaskIntoConstraints = false
  6255. chromeColumn.translatesAutoresizingMaskIntoConstraints = false
  6256. chromeDivider.translatesAutoresizingMaskIntoConstraints = false
  6257. chromeHeader.translatesAutoresizingMaskIntoConstraints = false
  6258. sidebar.translatesAutoresizingMaskIntoConstraints = false
  6259. content.translatesAutoresizingMaskIntoConstraints = false
  6260. NSLayoutConstraint.activate([
  6261. shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6262. shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6263. shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
  6264. shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  6265. chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
  6266. chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
  6267. chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
  6268. chromeColumn.widthAnchor.constraint(equalToConstant: 82),
  6269. chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  6270. chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  6271. chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6272. chromeDivider.widthAnchor.constraint(equalToConstant: 1),
  6273. chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  6274. chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  6275. chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6276. chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
  6277. sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  6278. sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6279. sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
  6280. sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  6281. content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6282. content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
  6283. content.topAnchor.constraint(equalTo: shell.topAnchor),
  6284. content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
  6285. ])
  6286. let brandStack = NSStackView()
  6287. brandStack.orientation = .vertical
  6288. brandStack.spacing = 0
  6289. brandStack.alignment = .leading
  6290. let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
  6291. let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
  6292. brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
  6293. brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
  6294. [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
  6295. let topBar = NSView()
  6296. topBar.wantsLayer = true
  6297. topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  6298. let topBarDivider = NSView()
  6299. topBarDivider.wantsLayer = true
  6300. topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
  6301. let searchPill = NSView()
  6302. searchPill.wantsLayer = true
  6303. searchPill.layer?.backgroundColor = searchPillBackground.cgColor
  6304. searchPill.layer?.cornerRadius = 10
  6305. searchPill.layer?.borderWidth = 0
  6306. let searchIcon = NSImageView()
  6307. searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
  6308. searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
  6309. searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  6310. searchIcon.imageScaling = .scaleProportionallyUpOrDown
  6311. let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
  6312. searchHintLabel.isHidden = false
  6313. let searchField = SearchPillTextField()
  6314. searchField.isBordered = false
  6315. searchField.drawsBackground = false
  6316. searchField.backgroundColor = .clear
  6317. searchField.focusRingType = .none
  6318. searchField.isEditable = false
  6319. searchField.isSelectable = false
  6320. searchField.font = .systemFont(ofSize: 13, weight: .regular)
  6321. searchField.textColor = primaryText
  6322. searchField.alignment = .left
  6323. searchField.placeholderString = nil
  6324. if let cell = searchField.cell as? NSTextFieldCell {
  6325. cell.isBezeled = false
  6326. cell.isBordered = false
  6327. cell.backgroundColor = .clear
  6328. }
  6329. let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
  6330. guard let searchField, let searchHintLabel else { return }
  6331. let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
  6332. searchHintLabel.isHidden = shouldShow == false
  6333. }
  6334. searchField.onFocusChange = { [weak self] focused in
  6335. self?.applySearchPillFocusBorder(focused: focused)
  6336. updateSearchHintVisibility()
  6337. }
  6338. updateSearchHintVisibility()
  6339. let searchTap = NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped))
  6340. searchPill.addGestureRecognizer(searchTap)
  6341. searchField.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  6342. searchIcon.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  6343. searchHintLabel.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  6344. let searchRow = NSStackView()
  6345. searchRow.orientation = .horizontal
  6346. searchRow.spacing = 14
  6347. searchRow.alignment = .centerY
  6348. searchRow.addArrangedSubview(searchPill)
  6349. let rightTopBarCluster = NSStackView()
  6350. rightTopBarCluster.orientation = .horizontal
  6351. rightTopBarCluster.spacing = 10
  6352. rightTopBarCluster.alignment = .centerY
  6353. let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
  6354. topBarPremiumButton = upgradeToProButton
  6355. updateTopBarPremiumButton()
  6356. let authButton = NSButton(title: "", target: self, action: #selector(topBarAuthButtonTapped))
  6357. authButton.isBordered = false
  6358. authButton.wantsLayer = true
  6359. authButton.layer?.cornerRadius = 11
  6360. authButton.contentTintColor = .white
  6361. authButton.font = .systemFont(ofSize: 12, weight: .semibold)
  6362. authButton.bezelStyle = .shadowlessSquare
  6363. self.topBarAuthButton = authButton
  6364. [upgradeToProButton, authButton].forEach { rightTopBarCluster.addArrangedSubview($0) }
  6365. let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
  6366. let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
  6367. let actions = NSStackView(views: [
  6368. makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange, action: #selector(newMeetingTapped)),
  6369. makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
  6370. makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
  6371. ])
  6372. actions.orientation = .horizontal
  6373. actions.spacing = 12
  6374. actions.alignment = .centerY
  6375. actions.distribution = .fillEqually
  6376. let panel = NSView()
  6377. panel.wantsLayer = true
  6378. panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6379. panel.layer?.cornerRadius = 16
  6380. panel.layer?.borderWidth = 1
  6381. panel.layer?.borderColor = palette.inputBorder.cgColor
  6382. let todaysDateFormatter = DateFormatter()
  6383. todaysDateFormatter.dateFormat = "EEEE, MMM d"
  6384. let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
  6385. let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
  6386. let meetingsDayNav = NSStackView()
  6387. meetingsDayNav.orientation = .horizontal
  6388. meetingsDayNav.spacing = 4
  6389. meetingsDayNav.alignment = .centerY
  6390. let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
  6391. let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
  6392. let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
  6393. [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
  6394. let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
  6395. let meetingsScrollView = NSScrollView()
  6396. meetingsScrollView.drawsBackground = false
  6397. meetingsScrollView.hasVerticalScroller = true
  6398. meetingsScrollView.hasHorizontalScroller = false
  6399. meetingsScrollView.autohidesScrollers = true
  6400. let meetingsDocument = FlippedView()
  6401. let meetingsStack = NSStackView()
  6402. meetingsStack.orientation = .vertical
  6403. meetingsStack.spacing = 14
  6404. meetingsStack.alignment = .leading
  6405. // Keep meeting cards pinned to the top of the scroll content.
  6406. meetingsStack.setContentHuggingPriority(.required, for: .vertical)
  6407. meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  6408. let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
  6409. refreshMeetingsButton.isBordered = false
  6410. refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
  6411. refreshMeetingsButton.contentTintColor = primaryText
  6412. let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  6413. if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
  6414. let image = base.withSymbolConfiguration(refreshSymbolConfig) {
  6415. image.isTemplate = true
  6416. refreshMeetingsButton.image = image
  6417. refreshMeetingsButton.imagePosition = .imageLeading
  6418. refreshMeetingsButton.imageHugsTitle = true
  6419. refreshMeetingsButton.imageScaling = .scaleNone
  6420. }
  6421. refreshMeetingsButton.wantsLayer = true
  6422. refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
  6423. ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
  6424. : NSColor.black.withAlphaComponent(0.06)
  6425. ).cgColor
  6426. refreshMeetingsButton.layer?.cornerRadius = 11
  6427. refreshMeetingsButton.layer?.borderWidth = 1
  6428. refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
  6429. self.refreshMeetingsButton = refreshMeetingsButton
  6430. let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
  6431. placeholder.isHidden = true
  6432. let settingsView = makeSettingsView()
  6433. settingsView.isHidden = selectedHomeSidebarItem != "Settings"
  6434. let meetingsPageView = makeMeetingsPageView()
  6435. meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
  6436. let widgetsPageView = makeWidgetsPageView()
  6437. widgetsPageView.isHidden = selectedHomeSidebarItem != "Widgets"
  6438. let aiCompanionPageView = makeAiCompanionPageView()
  6439. aiCompanionPageView.isHidden = selectedHomeSidebarItem != HomeSection.aiCompanion.rawValue
  6440. let searchOverlay = NSView()
  6441. searchOverlay.wantsLayer = true
  6442. searchOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.45).cgColor
  6443. searchOverlay.isHidden = true
  6444. searchOverlay.translatesAutoresizingMaskIntoConstraints = false
  6445. let searchPanel = NSView()
  6446. searchPanel.wantsLayer = true
  6447. searchPanel.layer?.backgroundColor = secondaryCardBackground.cgColor
  6448. searchPanel.layer?.cornerRadius = 14
  6449. searchPanel.layer?.borderWidth = 1
  6450. searchPanel.layer?.borderColor = palette.inputBorder.cgColor
  6451. searchPanel.translatesAutoresizingMaskIntoConstraints = false
  6452. let popupTitle = makeLabel("Search Meetings", size: 18, color: primaryText, weight: .semibold, centered: false)
  6453. popupTitle.translatesAutoresizingMaskIntoConstraints = false
  6454. let popupSubtitle = makeLabel("Find any scheduled meeting instantly", size: 12, color: secondaryText, weight: .regular, centered: false)
  6455. popupSubtitle.translatesAutoresizingMaskIntoConstraints = false
  6456. let popupSearchChrome = NSView()
  6457. popupSearchChrome.translatesAutoresizingMaskIntoConstraints = false
  6458. popupSearchChrome.wantsLayer = true
  6459. popupSearchChrome.layer?.backgroundColor = searchPillBackground.cgColor
  6460. popupSearchChrome.layer?.cornerRadius = 10
  6461. popupSearchChrome.layer?.borderWidth = 1
  6462. popupSearchChrome.layer?.borderColor = palette.inputBorder.cgColor
  6463. let popupSearchIcon = NSImageView()
  6464. popupSearchIcon.translatesAutoresizingMaskIntoConstraints = false
  6465. popupSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search meetings")
  6466. popupSearchIcon.contentTintColor = secondaryText
  6467. popupSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
  6468. let popupSearchField = NSTextField()
  6469. popupSearchField.translatesAutoresizingMaskIntoConstraints = false
  6470. popupSearchField.isBordered = false
  6471. popupSearchField.drawsBackground = false
  6472. popupSearchField.backgroundColor = .clear
  6473. popupSearchField.focusRingType = .none
  6474. popupSearchField.font = .systemFont(ofSize: 15, weight: .regular)
  6475. popupSearchField.textColor = primaryText
  6476. popupSearchField.placeholderString = "Search all meetings by title, host, source, ID..."
  6477. let popupResultsScroll = NSScrollView()
  6478. popupResultsScroll.translatesAutoresizingMaskIntoConstraints = false
  6479. popupResultsScroll.drawsBackground = false
  6480. popupResultsScroll.hasVerticalScroller = true
  6481. popupResultsScroll.hasHorizontalScroller = false
  6482. popupResultsScroll.autohidesScrollers = true
  6483. let popupResultsDocument = FlippedView()
  6484. popupResultsDocument.translatesAutoresizingMaskIntoConstraints = false
  6485. let popupResultsStack = NSStackView()
  6486. popupResultsStack.translatesAutoresizingMaskIntoConstraints = false
  6487. popupResultsStack.orientation = .vertical
  6488. popupResultsStack.spacing = 10
  6489. popupResultsStack.alignment = .leading
  6490. let popupEmpty = makeLabel("No meetings match your search.", size: 13, color: secondaryText, weight: .regular, centered: true)
  6491. popupEmpty.translatesAutoresizingMaskIntoConstraints = false
  6492. popupEmpty.isHidden = true
  6493. root.addSubview(searchOverlay)
  6494. searchOverlay.addSubview(searchPanel)
  6495. searchPanel.addSubview(popupTitle)
  6496. searchPanel.addSubview(popupSubtitle)
  6497. searchPanel.addSubview(popupSearchChrome)
  6498. searchPanel.addSubview(popupResultsScroll)
  6499. searchPanel.addSubview(popupEmpty)
  6500. popupSearchChrome.addSubview(popupSearchIcon)
  6501. popupSearchChrome.addSubview(popupSearchField)
  6502. popupResultsScroll.documentView = popupResultsDocument
  6503. popupResultsDocument.addSubview(popupResultsStack)
  6504. let schedulerRoot = NSView()
  6505. schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
  6506. let schedulerSplit = NSStackView()
  6507. schedulerSplit.orientation = .horizontal
  6508. schedulerSplit.spacing = 14
  6509. schedulerSplit.distribution = .fillEqually
  6510. schedulerSplit.alignment = .top
  6511. let schedulerCalendarPanel = NSView()
  6512. schedulerCalendarPanel.wantsLayer = true
  6513. schedulerCalendarPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6514. schedulerCalendarPanel.layer?.cornerRadius = 16
  6515. schedulerCalendarPanel.layer?.borderWidth = 1
  6516. schedulerCalendarPanel.layer?.borderColor = palette.inputBorder.cgColor
  6517. let schedulerMeetingsPanel = NSView()
  6518. schedulerMeetingsPanel.wantsLayer = true
  6519. schedulerMeetingsPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6520. schedulerMeetingsPanel.layer?.cornerRadius = 16
  6521. schedulerMeetingsPanel.layer?.borderWidth = 1
  6522. schedulerMeetingsPanel.layer?.borderColor = palette.inputBorder.cgColor
  6523. let schedulerCalendarTopRow = NSStackView()
  6524. schedulerCalendarTopRow.orientation = .horizontal
  6525. schedulerCalendarTopRow.alignment = .centerY
  6526. schedulerCalendarTopRow.distribution = .fill
  6527. schedulerCalendarTopRow.spacing = 10
  6528. let schedulerConnectLabel = makeLabel("Zoom calendar", size: 18, color: accentBlue, weight: .semibold, centered: false)
  6529. let schedulerPlusButton = NSButton(title: "", target: self, action: #selector(scheduleMeetingTapped))
  6530. schedulerPlusButton.isBordered = false
  6531. schedulerPlusButton.bezelStyle = .shadowlessSquare
  6532. schedulerPlusButton.focusRingType = .none
  6533. schedulerPlusButton.wantsLayer = true
  6534. schedulerPlusButton.layer?.backgroundColor = accentBlue.cgColor
  6535. schedulerPlusButton.layer?.cornerRadius = 14
  6536. schedulerPlusButton.contentTintColor = .white
  6537. schedulerPlusButton.toolTip = "Connect calendar"
  6538. if let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Connect calendar") {
  6539. schedulerPlusButton.image = image
  6540. schedulerPlusButton.imagePosition = .imageOnly
  6541. }
  6542. schedulerPlusButton.translatesAutoresizingMaskIntoConstraints = false
  6543. schedulerPlusButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  6544. schedulerPlusButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  6545. let schedulerMonthRow = NSStackView()
  6546. schedulerMonthRow.orientation = .horizontal
  6547. schedulerMonthRow.alignment = .centerY
  6548. schedulerMonthRow.spacing = 8
  6549. let schedulerPrevMonthButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(schedulerPrevMonthTapped), dimension: 18, pointSize: 10, toolTip: "Previous month")
  6550. let schedulerMonthLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  6551. let schedulerNextMonthButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(schedulerNextMonthTapped), dimension: 18, pointSize: 10, toolTip: "Next month")
  6552. [schedulerPrevMonthButton, schedulerMonthLabel, schedulerNextMonthButton].forEach { schedulerMonthRow.addArrangedSubview($0) }
  6553. let schedulerWeekdaysRow = NSStackView()
  6554. schedulerWeekdaysRow.orientation = .horizontal
  6555. schedulerWeekdaysRow.distribution = .fillEqually
  6556. // Match date-row spacing so each date column aligns under weekday labels.
  6557. schedulerWeekdaysRow.spacing = 6
  6558. let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
  6559. for symbol in weekdaySymbols {
  6560. let weekdayLabel = makeLabel(symbol, size: 12, color: mutedText, weight: .medium, centered: true)
  6561. schedulerWeekdaysRow.addArrangedSubview(weekdayLabel)
  6562. }
  6563. let schedulerCalendarGrid = NSStackView()
  6564. schedulerCalendarGrid.orientation = .vertical
  6565. schedulerCalendarGrid.distribution = .fillEqually
  6566. schedulerCalendarGrid.alignment = .leading
  6567. schedulerCalendarGrid.spacing = 6
  6568. let schedulerRightTopRow = NSStackView()
  6569. schedulerRightTopRow.orientation = .horizontal
  6570. schedulerRightTopRow.alignment = .centerY
  6571. schedulerRightTopRow.spacing = 10
  6572. let schedulerSelectedDateLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  6573. let schedulerTodayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(schedulerTodayTapped))
  6574. schedulerTodayButton.toolTip = "Jump to today"
  6575. [schedulerTodayButton].forEach { schedulerRightTopRow.addArrangedSubview($0) }
  6576. let schedulerMeetingsHeader = makeLabel("Scheduled meetings", size: 13, color: secondaryText, weight: .medium, centered: false)
  6577. let schedulerMeetingsScroll = NSScrollView()
  6578. schedulerMeetingsScroll.drawsBackground = false
  6579. schedulerMeetingsScroll.hasVerticalScroller = true
  6580. schedulerMeetingsScroll.hasHorizontalScroller = false
  6581. schedulerMeetingsScroll.autohidesScrollers = true
  6582. let schedulerMeetingsDocument = FlippedView()
  6583. let schedulerMeetingsStack = NSStackView()
  6584. schedulerMeetingsStack.orientation = .vertical
  6585. schedulerMeetingsStack.spacing = 12
  6586. schedulerMeetingsStack.alignment = .leading
  6587. schedulerMeetingsStack.setContentHuggingPriority(.required, for: .vertical)
  6588. schedulerMeetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  6589. schedulerMeetingsScroll.documentView = schedulerMeetingsDocument
  6590. schedulerMeetingsDocument.addSubview(schedulerMeetingsStack)
  6591. let schedulerEmptyLabel = makeLabel("No meetings scheduled for this date.", size: 15, color: secondaryText, weight: .regular, centered: true)
  6592. schedulerEmptyLabel.isHidden = true
  6593. let bodyRow = NSView()
  6594. bodyRow.translatesAutoresizingMaskIntoConstraints = false
  6595. let mainContentColumn = NSView()
  6596. mainContentColumn.translatesAutoresizingMaskIntoConstraints = false
  6597. mainContentColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  6598. let rightWebPanel = NSView()
  6599. rightWebPanel.translatesAutoresizingMaskIntoConstraints = false
  6600. rightWebPanel.wantsLayer = true
  6601. rightWebPanel.layer?.cornerRadius = 16
  6602. rightWebPanel.layer?.masksToBounds = true
  6603. rightWebPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6604. rightWebPanel.layer?.borderWidth = 1
  6605. rightWebPanel.layer?.borderColor = palette.inputBorder.cgColor
  6606. let webToolbar = NSView()
  6607. webToolbar.translatesAutoresizingMaskIntoConstraints = false
  6608. webToolbar.wantsLayer = true
  6609. webToolbar.layer?.backgroundColor = chromeUnifiedBackground.withAlphaComponent(0.55).cgColor
  6610. func prepareEmbeddedToolButton(_ button: NSButton, symbol: String) {
  6611. button.translatesAutoresizingMaskIntoConstraints = false
  6612. button.bezelStyle = .toolbar
  6613. if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) {
  6614. button.image = image
  6615. }
  6616. }
  6617. let browserBack = NSButton()
  6618. let browserForward = NSButton()
  6619. let browserReload = NSButton()
  6620. let browserClose = NSButton()
  6621. prepareEmbeddedToolButton(browserBack, symbol: "chevron.left")
  6622. prepareEmbeddedToolButton(browserForward, symbol: "chevron.right")
  6623. prepareEmbeddedToolButton(browserReload, symbol: "arrow.clockwise")
  6624. prepareEmbeddedToolButton(browserClose, symbol: "xmark.circle.fill")
  6625. browserClose.toolTip = "Close web panel"
  6626. browserBack.target = self
  6627. browserBack.action = #selector(embeddedBrowserBackTapped)
  6628. browserForward.target = self
  6629. browserForward.action = #selector(embeddedBrowserForwardTapped)
  6630. browserReload.target = self
  6631. browserReload.action = #selector(embeddedBrowserReloadTapped)
  6632. browserClose.target = self
  6633. browserClose.action = #selector(embeddedBrowserClosePanelTapped)
  6634. let toolbarSpring = NSView()
  6635. toolbarSpring.translatesAutoresizingMaskIntoConstraints = false
  6636. toolbarSpring.setContentHuggingPriority(.defaultLow, for: .horizontal)
  6637. let webToolBarStack = NSStackView(views: [browserBack, browserForward, browserReload, toolbarSpring, browserClose])
  6638. webToolBarStack.orientation = .horizontal
  6639. webToolBarStack.spacing = 6
  6640. webToolBarStack.alignment = .centerY
  6641. webToolBarStack.translatesAutoresizingMaskIntoConstraints = false
  6642. let embeddedBrowserConfiguration = WKWebViewConfiguration()
  6643. embeddedBrowserConfiguration.websiteDataStore = zoomEmbeddedWebsiteDataStore
  6644. embeddedBrowserConfiguration.processPool = zoomEmbeddedWebProcessPool
  6645. embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
  6646. embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
  6647. let meetingLifecycleController = WKUserContentController()
  6648. meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
  6649. let lifecycleScript = WKUserScript(
  6650. source: Self.embeddedZoomMeetingLifecycleJavaScript,
  6651. injectionTime: .atDocumentEnd,
  6652. forMainFrameOnly: true
  6653. )
  6654. meetingLifecycleController.addUserScript(lifecycleScript)
  6655. embeddedBrowserConfiguration.userContentController = meetingLifecycleController
  6656. let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
  6657. embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
  6658. embeddedWK.translatesAutoresizingMaskIntoConstraints = false
  6659. embeddedWK.setContentCompressionResistancePriority(.init(1), for: .horizontal)
  6660. embeddedWK.setContentHuggingPriority(.init(1), for: .horizontal)
  6661. embeddedWK.navigationDelegate = self
  6662. embeddedWK.uiDelegate = self
  6663. content.addSubview(topBar)
  6664. content.addSubview(topBarDivider)
  6665. content.addSubview(bodyRow)
  6666. bodyRow.addSubview(mainContentColumn)
  6667. bodyRow.addSubview(rightWebPanel)
  6668. rightWebPanel.addSubview(webToolbar)
  6669. webToolbar.addSubview(webToolBarStack)
  6670. rightWebPanel.addSubview(embeddedWK)
  6671. let rightPanelWidthConstraint = rightWebPanel.widthAnchor.constraint(equalToConstant: 0)
  6672. let mainColumnCollapsedWidthConstraint = mainContentColumn.widthAnchor.constraint(equalToConstant: 0)
  6673. mainColumnCollapsedWidthConstraint.isActive = false
  6674. [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
  6675. $0.translatesAutoresizingMaskIntoConstraints = false
  6676. }
  6677. [brandStack].forEach {
  6678. shell.addSubview($0)
  6679. }
  6680. [searchRow, rightTopBarCluster].forEach {
  6681. topBar.addSubview($0)
  6682. }
  6683. [searchIcon, searchField, searchHintLabel].forEach {
  6684. searchPill.addSubview($0)
  6685. }
  6686. [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, widgetsPageView, aiCompanionPageView, schedulerRoot].forEach {
  6687. $0.translatesAutoresizingMaskIntoConstraints = false
  6688. mainContentColumn.addSubview($0)
  6689. }
  6690. [schedulerSplit, schedulerCalendarPanel, schedulerMeetingsPanel, schedulerCalendarTopRow, schedulerConnectLabel, schedulerPlusButton, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid, schedulerRightTopRow, schedulerSelectedDateLabel, schedulerTodayButton, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel, schedulerMeetingsDocument, schedulerMeetingsStack].forEach {
  6691. $0.translatesAutoresizingMaskIntoConstraints = false
  6692. }
  6693. schedulerRoot.addSubview(schedulerSplit)
  6694. [schedulerCalendarPanel, schedulerMeetingsPanel].forEach { schedulerSplit.addArrangedSubview($0) }
  6695. [schedulerCalendarTopRow, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid].forEach { schedulerCalendarPanel.addSubview($0) }
  6696. schedulerCalendarTopRow.addArrangedSubview(schedulerConnectLabel)
  6697. schedulerCalendarTopRow.addArrangedSubview(NSView())
  6698. schedulerCalendarTopRow.addArrangedSubview(schedulerPlusButton)
  6699. [schedulerSelectedDateLabel, schedulerRightTopRow, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel].forEach { schedulerMeetingsPanel.addSubview($0) }
  6700. topBar.translatesAutoresizingMaskIntoConstraints = false
  6701. topBarDivider.translatesAutoresizingMaskIntoConstraints = false
  6702. meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
  6703. meetingsStack.translatesAutoresizingMaskIntoConstraints = false
  6704. meetingsScrollView.documentView = meetingsDocument
  6705. meetingsDocument.addSubview(meetingsStack)
  6706. let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
  6707. searchRowCenterX.priority = .defaultHigh
  6708. let brandLeading = brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset)
  6709. brandLeadingConstraint = brandLeading
  6710. NSLayoutConstraint.activate([
  6711. brandLeading,
  6712. brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
  6713. brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
  6714. topBar.topAnchor.constraint(equalTo: content.topAnchor),
  6715. topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  6716. topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  6717. topBar.heightAnchor.constraint(equalToConstant: 56),
  6718. topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
  6719. topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  6720. topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  6721. topBarDivider.heightAnchor.constraint(equalToConstant: 1),
  6722. bodyRow.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
  6723. bodyRow.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
  6724. bodyRow.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
  6725. bodyRow.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
  6726. mainContentColumn.leadingAnchor.constraint(equalTo: bodyRow.leadingAnchor),
  6727. mainContentColumn.topAnchor.constraint(equalTo: bodyRow.topAnchor),
  6728. mainContentColumn.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
  6729. mainContentColumn.trailingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor, constant: -homeEmbeddedBrowserColumnGap),
  6730. rightWebPanel.topAnchor.constraint(equalTo: bodyRow.topAnchor),
  6731. rightWebPanel.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
  6732. rightWebPanel.trailingAnchor.constraint(equalTo: bodyRow.trailingAnchor),
  6733. rightPanelWidthConstraint,
  6734. webToolbar.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
  6735. webToolbar.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
  6736. webToolbar.topAnchor.constraint(equalTo: rightWebPanel.topAnchor),
  6737. webToolbar.heightAnchor.constraint(equalToConstant: 40),
  6738. webToolBarStack.leadingAnchor.constraint(equalTo: webToolbar.leadingAnchor, constant: 8),
  6739. webToolBarStack.trailingAnchor.constraint(equalTo: webToolbar.trailingAnchor, constant: -8),
  6740. webToolBarStack.centerYAnchor.constraint(equalTo: webToolbar.centerYAnchor),
  6741. embeddedWK.topAnchor.constraint(equalTo: webToolbar.bottomAnchor),
  6742. embeddedWK.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
  6743. embeddedWK.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
  6744. embeddedWK.bottomAnchor.constraint(equalTo: rightWebPanel.bottomAnchor),
  6745. searchRowCenterX,
  6746. searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  6747. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
  6748. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
  6749. searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
  6750. rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  6751. rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  6752. searchPill.heightAnchor.constraint(equalToConstant: 32),
  6753. searchPill.widthAnchor.constraint(equalToConstant: 320),
  6754. searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
  6755. searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  6756. searchIcon.widthAnchor.constraint(equalToConstant: 16),
  6757. searchIcon.heightAnchor.constraint(equalToConstant: 16),
  6758. searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
  6759. searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  6760. searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
  6761. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  6762. searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
  6763. searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  6764. authButton.heightAnchor.constraint(equalToConstant: 30),
  6765. timeTitle.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 20),
  6766. timeTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6767. dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
  6768. dateTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6769. actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
  6770. actions.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6771. actions.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 12),
  6772. actions.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -12),
  6773. actions.heightAnchor.constraint(equalToConstant: 100),
  6774. panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
  6775. panel.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6776. panel.widthAnchor.constraint(equalToConstant: 610),
  6777. panel.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 6),
  6778. panel.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -6),
  6779. panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  6780. panel.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -14),
  6781. panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
  6782. panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
  6783. meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  6784. meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  6785. meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
  6786. meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
  6787. noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  6788. noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  6789. noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
  6790. meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
  6791. meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  6792. meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  6793. meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
  6794. meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
  6795. meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
  6796. meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
  6797. meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
  6798. meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
  6799. refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  6800. refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  6801. refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
  6802. refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
  6803. placeholder.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6804. placeholder.centerYAnchor.constraint(equalTo: mainContentColumn.centerYAnchor),
  6805. settingsView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6806. settingsView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6807. settingsView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6808. settingsView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6809. meetingsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6810. meetingsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6811. meetingsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6812. meetingsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6813. widgetsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6814. widgetsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6815. widgetsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6816. widgetsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6817. aiCompanionPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6818. aiCompanionPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6819. aiCompanionPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6820. aiCompanionPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6821. schedulerRoot.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 8),
  6822. schedulerRoot.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor, constant: 8),
  6823. schedulerRoot.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor, constant: -8),
  6824. schedulerRoot.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -8),
  6825. schedulerSplit.topAnchor.constraint(equalTo: schedulerRoot.topAnchor),
  6826. schedulerSplit.leadingAnchor.constraint(equalTo: schedulerRoot.leadingAnchor),
  6827. schedulerSplit.trailingAnchor.constraint(equalTo: schedulerRoot.trailingAnchor),
  6828. schedulerSplit.bottomAnchor.constraint(equalTo: schedulerRoot.bottomAnchor),
  6829. schedulerCalendarTopRow.topAnchor.constraint(equalTo: schedulerCalendarPanel.topAnchor, constant: 18),
  6830. schedulerCalendarTopRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6831. schedulerCalendarTopRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6832. schedulerMonthRow.topAnchor.constraint(equalTo: schedulerCalendarTopRow.bottomAnchor, constant: 18),
  6833. schedulerMonthRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6834. schedulerMonthRow.trailingAnchor.constraint(lessThanOrEqualTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6835. schedulerWeekdaysRow.topAnchor.constraint(equalTo: schedulerMonthRow.bottomAnchor, constant: 14),
  6836. schedulerWeekdaysRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6837. schedulerWeekdaysRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6838. schedulerCalendarGrid.topAnchor.constraint(equalTo: schedulerWeekdaysRow.bottomAnchor, constant: 10),
  6839. schedulerCalendarGrid.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6840. schedulerCalendarGrid.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6841. schedulerCalendarGrid.bottomAnchor.constraint(equalTo: schedulerCalendarPanel.bottomAnchor, constant: -16),
  6842. schedulerCalendarGrid.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  6843. schedulerSelectedDateLabel.topAnchor.constraint(equalTo: schedulerMeetingsPanel.topAnchor, constant: 18),
  6844. schedulerSelectedDateLabel.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  6845. schedulerSelectedDateLabel.trailingAnchor.constraint(lessThanOrEqualTo: schedulerRightTopRow.leadingAnchor, constant: -10),
  6846. schedulerRightTopRow.centerYAnchor.constraint(equalTo: schedulerSelectedDateLabel.centerYAnchor),
  6847. schedulerRightTopRow.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  6848. schedulerMeetingsHeader.topAnchor.constraint(equalTo: schedulerSelectedDateLabel.bottomAnchor, constant: 12),
  6849. schedulerMeetingsHeader.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  6850. schedulerMeetingsHeader.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  6851. schedulerMeetingsScroll.topAnchor.constraint(equalTo: schedulerMeetingsHeader.bottomAnchor, constant: 10),
  6852. schedulerMeetingsScroll.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 14),
  6853. schedulerMeetingsScroll.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -14),
  6854. schedulerMeetingsScroll.bottomAnchor.constraint(equalTo: schedulerMeetingsPanel.bottomAnchor, constant: -14),
  6855. schedulerMeetingsDocument.widthAnchor.constraint(equalTo: schedulerMeetingsScroll.contentView.widthAnchor),
  6856. schedulerMeetingsStack.topAnchor.constraint(equalTo: schedulerMeetingsDocument.topAnchor),
  6857. schedulerMeetingsStack.leadingAnchor.constraint(equalTo: schedulerMeetingsDocument.leadingAnchor),
  6858. schedulerMeetingsStack.trailingAnchor.constraint(equalTo: schedulerMeetingsDocument.trailingAnchor),
  6859. schedulerMeetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: schedulerMeetingsDocument.bottomAnchor),
  6860. schedulerEmptyLabel.centerXAnchor.constraint(equalTo: schedulerMeetingsPanel.centerXAnchor),
  6861. schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor),
  6862. searchOverlay.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6863. searchOverlay.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6864. searchOverlay.topAnchor.constraint(equalTo: root.topAnchor),
  6865. searchOverlay.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  6866. searchPanel.centerXAnchor.constraint(equalTo: searchOverlay.centerXAnchor),
  6867. searchPanel.centerYAnchor.constraint(equalTo: searchOverlay.centerYAnchor),
  6868. searchPanel.widthAnchor.constraint(equalToConstant: 640),
  6869. searchPanel.heightAnchor.constraint(equalToConstant: 460),
  6870. popupTitle.topAnchor.constraint(equalTo: searchPanel.topAnchor, constant: 18),
  6871. popupTitle.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  6872. popupTitle.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  6873. popupSubtitle.topAnchor.constraint(equalTo: popupTitle.bottomAnchor, constant: 4),
  6874. popupSubtitle.leadingAnchor.constraint(equalTo: popupTitle.leadingAnchor),
  6875. popupSubtitle.trailingAnchor.constraint(equalTo: popupTitle.trailingAnchor),
  6876. popupSearchChrome.topAnchor.constraint(equalTo: popupSubtitle.bottomAnchor, constant: 12),
  6877. popupSearchChrome.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  6878. popupSearchChrome.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  6879. popupSearchChrome.heightAnchor.constraint(equalToConstant: 40),
  6880. popupSearchIcon.leadingAnchor.constraint(equalTo: popupSearchChrome.leadingAnchor, constant: 12),
  6881. popupSearchIcon.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  6882. popupSearchIcon.widthAnchor.constraint(equalToConstant: 16),
  6883. popupSearchIcon.heightAnchor.constraint(equalToConstant: 16),
  6884. popupSearchField.leadingAnchor.constraint(equalTo: popupSearchIcon.trailingAnchor, constant: 8),
  6885. popupSearchField.trailingAnchor.constraint(equalTo: popupSearchChrome.trailingAnchor, constant: -12),
  6886. popupSearchField.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  6887. popupResultsScroll.topAnchor.constraint(equalTo: popupSearchChrome.bottomAnchor, constant: 12),
  6888. popupResultsScroll.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  6889. popupResultsScroll.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  6890. popupResultsScroll.bottomAnchor.constraint(equalTo: searchPanel.bottomAnchor, constant: -18),
  6891. popupResultsStack.topAnchor.constraint(equalTo: popupResultsDocument.topAnchor),
  6892. popupResultsStack.leadingAnchor.constraint(equalTo: popupResultsDocument.leadingAnchor),
  6893. popupResultsStack.trailingAnchor.constraint(equalTo: popupResultsDocument.trailingAnchor),
  6894. popupResultsStack.bottomAnchor.constraint(lessThanOrEqualTo: popupResultsDocument.bottomAnchor),
  6895. popupResultsStack.widthAnchor.constraint(equalTo: popupResultsScroll.contentView.widthAnchor),
  6896. popupEmpty.centerXAnchor.constraint(equalTo: popupResultsScroll.centerXAnchor),
  6897. popupEmpty.centerYAnchor.constraint(equalTo: popupResultsScroll.centerYAnchor)
  6898. ])
  6899. timeLabel = timeTitle
  6900. dateLabel = dateTitle
  6901. homeWelcomeLabel = nil
  6902. homeTimeLabelView = timeTitle
  6903. homeDateLabelView = dateTitle
  6904. homeActionsRow = actions
  6905. homeMeetingsPanel = panel
  6906. homePlaceholderLabel = placeholder
  6907. homeSettingsView = settingsView
  6908. homeSettingsHostColumn = mainContentColumn
  6909. meetingsPageRootView = meetingsPageView
  6910. widgetsPageRootView = widgetsPageView
  6911. aiCompanionPageRootView = aiCompanionPageView
  6912. schedulerRootView = schedulerRoot
  6913. self.schedulerMonthLabel = schedulerMonthLabel
  6914. schedulerCalendarGridStack = schedulerCalendarGrid
  6915. self.schedulerSelectedDateLabel = schedulerSelectedDateLabel
  6916. schedulerMeetingsListStack = schedulerMeetingsStack
  6917. schedulerMeetingsEmptyLabel = schedulerEmptyLabel
  6918. self.schedulerTodayButton = schedulerTodayButton
  6919. meetingsDayHeaderLabel = panelHeader
  6920. meetingsListStack = meetingsStack
  6921. meetingsStatusLabel = meetingsStatus
  6922. emptyMeetingLabel = noMeeting
  6923. meetingsPrevDayButton = prevDayButton
  6924. meetingsTodayButton = todayButton
  6925. meetingsNextDayButton = nextDayButton
  6926. observeMeetingsScrollEdges(in: meetingsScrollView)
  6927. updateClock()
  6928. updateMeetingsDayUI()
  6929. applyFilteredMeetings()
  6930. applyMeetingsPageFilters(resetVisible: true)
  6931. renderSchedulerCalendarGrid()
  6932. updateSchedulerDateHeader()
  6933. applySchedulerMeetingsForSelectedDate()
  6934. homeSearchField = searchField
  6935. homeSearchPill = searchPill
  6936. meetingSearchOverlayView = searchOverlay
  6937. meetingSearchPanelView = searchPanel
  6938. meetingSearchField = popupSearchField
  6939. meetingSearchResultsStack = popupResultsStack
  6940. meetingSearchEmptyLabel = popupEmpty
  6941. removeMeetingSearchObserver()
  6942. meetingSearchTextObserver = NotificationCenter.default.addObserver(
  6943. forName: NSControl.textDidChangeNotification,
  6944. object: popupSearchField,
  6945. queue: .main
  6946. ) { [weak self] _ in
  6947. guard let self else { return }
  6948. self.applyGlobalMeetingSearch(query: popupSearchField.stringValue)
  6949. }
  6950. applyGlobalMeetingSearch(query: "")
  6951. topBarAuthButtonWidthConstraint = authButton.widthAnchor.constraint(equalToConstant: 120)
  6952. topBarAuthButtonWidthConstraint?.isActive = true
  6953. updateTopBarAuthButton()
  6954. applyAuthenticationUIState(triggerInitialRefresh: false)
  6955. updatePremiumLockedSections()
  6956. embeddedBrowserWebView = embeddedWK
  6957. embeddedBrowserPanel = rightWebPanel
  6958. homeBodyRowView = bodyRow
  6959. homeMainContentColumnView = mainContentColumn
  6960. embeddedBrowserPanelWidthConstraint = rightPanelWidthConstraint
  6961. embeddedBrowserMainColumnCollapsedWidthConstraint = mainColumnCollapsedWidthConstraint
  6962. embeddedBrowserBackButton = browserBack
  6963. embeddedBrowserForwardButton = browserForward
  6964. refreshEmbeddedBrowserNavButtons()
  6965. return root
  6966. }
  6967. @objc private func premiumLockedAreaTapped(_ sender: NSButton) {
  6968. showPaywall()
  6969. }
  6970. @MainActor
  6971. private func updatePremiumLockedSections() {
  6972. let shouldLock = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  6973. refreshWidgetsPagePremiumState()
  6974. homePremiumLockOverlay = updatePremiumLockOverlay(
  6975. existing: homePremiumLockOverlay,
  6976. in: homeMeetingsPanel,
  6977. locked: shouldLock,
  6978. message: "Upgrade to Premium to access meetings"
  6979. )
  6980. meetingsPremiumLockOverlay = updatePremiumLockOverlay(
  6981. existing: meetingsPremiumLockOverlay,
  6982. in: meetingsPageRootView,
  6983. locked: shouldLock,
  6984. message: "Upgrade to Premium to unlock Meetings"
  6985. )
  6986. schedulerPremiumLockOverlay = updatePremiumLockOverlay(
  6987. existing: schedulerPremiumLockOverlay,
  6988. in: schedulerRootView,
  6989. locked: shouldLock,
  6990. message: "Upgrade to Premium to unlock Scheduler"
  6991. )
  6992. }
  6993. @MainActor
  6994. private func updatePremiumLockOverlay(
  6995. existing: NSButton?,
  6996. in target: NSView?,
  6997. locked: Bool,
  6998. message: String
  6999. ) -> NSButton? {
  7000. guard let target else { return existing }
  7001. let button: NSButton
  7002. if let existing, existing.superview === target {
  7003. button = existing
  7004. } else {
  7005. let created = NSButton(title: message, target: self, action: #selector(premiumLockedAreaTapped(_:)))
  7006. created.translatesAutoresizingMaskIntoConstraints = false
  7007. created.isBordered = false
  7008. created.bezelStyle = .shadowlessSquare
  7009. created.alignment = .center
  7010. created.wantsLayer = true
  7011. created.layer?.cornerRadius = 12
  7012. created.layer?.masksToBounds = true
  7013. created.layer?.backgroundColor = NSColor.clear.cgColor
  7014. created.font = .systemFont(ofSize: 15, weight: .semibold)
  7015. created.contentTintColor = .white
  7016. let blur = NSVisualEffectView()
  7017. blur.translatesAutoresizingMaskIntoConstraints = false
  7018. blur.material = .hudWindow
  7019. blur.blendingMode = .withinWindow
  7020. blur.state = .active
  7021. created.addSubview(blur, positioned: .below, relativeTo: nil)
  7022. let dim = NSView()
  7023. dim.translatesAutoresizingMaskIntoConstraints = false
  7024. dim.wantsLayer = true
  7025. dim.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  7026. created.addSubview(dim, positioned: .above, relativeTo: blur)
  7027. target.addSubview(created)
  7028. NSLayoutConstraint.activate([
  7029. created.leadingAnchor.constraint(equalTo: target.leadingAnchor),
  7030. created.trailingAnchor.constraint(equalTo: target.trailingAnchor),
  7031. created.topAnchor.constraint(equalTo: target.topAnchor),
  7032. created.bottomAnchor.constraint(equalTo: target.bottomAnchor),
  7033. blur.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  7034. blur.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  7035. blur.topAnchor.constraint(equalTo: created.topAnchor),
  7036. blur.bottomAnchor.constraint(equalTo: created.bottomAnchor),
  7037. dim.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  7038. dim.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  7039. dim.topAnchor.constraint(equalTo: created.topAnchor),
  7040. dim.bottomAnchor.constraint(equalTo: created.bottomAnchor)
  7041. ])
  7042. button = created
  7043. }
  7044. let blurView = button.subviews.compactMap { $0 as? NSVisualEffectView }.first
  7045. let dimView = button.subviews.first { $0 is NSVisualEffectView == false }
  7046. if palette.isDarkMode {
  7047. blurView?.material = .hudWindow
  7048. button.contentTintColor = .white
  7049. dimView?.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  7050. } else {
  7051. // Lighter, cleaner lock treatment in light mode.
  7052. blurView?.material = .popover
  7053. button.contentTintColor = NSColor(calibratedWhite: 0.09, alpha: 1.0)
  7054. dimView?.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.38).cgColor
  7055. }
  7056. button.title = message
  7057. button.isHidden = locked == false
  7058. return button
  7059. }
  7060. private func makeWidgetsPageView() -> NSView {
  7061. let canAdd = storeKitCoordinator.hasPremiumAccess
  7062. let host = NSHostingView(
  7063. rootView: WidgetsRootView(
  7064. canAddWidgets: canAdd,
  7065. onAddBlocked: { [weak self] in
  7066. self?.showPaywall()
  7067. }
  7068. )
  7069. )
  7070. host.wantsLayer = true
  7071. host.layer?.backgroundColor = NSColor.clear.cgColor
  7072. return host
  7073. }
  7074. private func makeAiCompanionPageView() -> NSView {
  7075. let root = NSView()
  7076. root.wantsLayer = true
  7077. root.layer?.backgroundColor = NSColor.clear.cgColor
  7078. // Let the window dictate width; long status copy must not inflate the layout minimum.
  7079. root.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  7080. let container = NSView()
  7081. container.translatesAutoresizingMaskIntoConstraints = false
  7082. container.wantsLayer = true
  7083. container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  7084. container.layer?.cornerRadius = 16
  7085. container.layer?.borderWidth = 1
  7086. container.layer?.borderColor = palette.inputBorder.cgColor
  7087. root.addSubview(container)
  7088. // Header row: recordings list (AI Companion mode).
  7089. let title = makeLabel("AI Companion", size: 28, color: primaryText, weight: .bold, centered: false)
  7090. let subtitle = makeLabel("Get notes from your previous meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
  7091. let count = makeLabel("0 recordings", size: 12, color: secondaryText, weight: .medium, centered: false)
  7092. let titleStack = NSStackView(views: [title, subtitle, count])
  7093. titleStack.orientation = .vertical
  7094. titleStack.spacing = 4
  7095. titleStack.alignment = .leading
  7096. titleStack.translatesAutoresizingMaskIntoConstraints = false
  7097. // Old Zoom AI summaries controls are intentionally hidden/unused.
  7098. let loadingIndicator = NSProgressIndicator()
  7099. loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
  7100. loadingIndicator.style = .spinning
  7101. loadingIndicator.controlSize = .small
  7102. loadingIndicator.isDisplayedWhenStopped = false
  7103. loadingIndicator.isIndeterminate = true
  7104. loadingIndicator.isHidden = true
  7105. let stopRecording = NSButton(title: "Stop & save", target: self, action: #selector(aiCompanionStopRecordingTapped))
  7106. stopRecording.translatesAutoresizingMaskIntoConstraints = false
  7107. stopRecording.isBordered = false
  7108. stopRecording.wantsLayer = true
  7109. stopRecording.layer?.backgroundColor = accentBlue.cgColor
  7110. stopRecording.layer?.cornerRadius = 10
  7111. stopRecording.contentTintColor = .white
  7112. stopRecording.font = .systemFont(ofSize: 13, weight: .semibold)
  7113. stopRecording.isHidden = true
  7114. let toolbarRight = NSStackView(views: [stopRecording, loadingIndicator])
  7115. toolbarRight.orientation = .horizontal
  7116. toolbarRight.alignment = .centerY
  7117. toolbarRight.spacing = 10
  7118. toolbarRight.translatesAutoresizingMaskIntoConstraints = false
  7119. container.addSubview(titleStack)
  7120. container.addSubview(toolbarRight)
  7121. // Filter row matches `makeMeetingsPageView` styling; preset list is Ai Companion–specific.
  7122. let categoryFilter = NSPopUpButton()
  7123. categoryFilter.translatesAutoresizingMaskIntoConstraints = false
  7124. categoryFilter.addItems(withTitles: AiCompanionRecordingFilter.allCases.map(\.rawValue))
  7125. categoryFilter.selectItem(withTitle: AiCompanionRecordingFilter.today.rawValue)
  7126. categoryFilter.target = self
  7127. categoryFilter.action = #selector(aiCompanionFilterPresetChanged(_:))
  7128. let fromDatePicker = NSDatePicker()
  7129. fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
  7130. fromDatePicker.datePickerStyle = .textFieldAndStepper
  7131. fromDatePicker.datePickerMode = .single
  7132. fromDatePicker.datePickerElements = [.yearMonthDay]
  7133. fromDatePicker.dateValue = Date()
  7134. fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  7135. let toDatePicker = NSDatePicker()
  7136. toDatePicker.translatesAutoresizingMaskIntoConstraints = false
  7137. toDatePicker.datePickerStyle = .textFieldAndStepper
  7138. toDatePicker.datePickerMode = .single
  7139. toDatePicker.datePickerElements = [.yearMonthDay]
  7140. toDatePicker.dateValue = Date()
  7141. toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  7142. let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(aiCompanionFilterApplyTapped))
  7143. applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  7144. applyFiltersButton.isBordered = false
  7145. applyFiltersButton.wantsLayer = true
  7146. applyFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  7147. applyFiltersButton.layer?.cornerRadius = 10
  7148. applyFiltersButton.layer?.borderWidth = 1
  7149. applyFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  7150. applyFiltersButton.contentTintColor = primaryText
  7151. applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  7152. let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(aiCompanionFilterResetTapped))
  7153. resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  7154. resetFiltersButton.isBordered = false
  7155. resetFiltersButton.wantsLayer = true
  7156. resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  7157. resetFiltersButton.layer?.cornerRadius = 10
  7158. resetFiltersButton.layer?.borderWidth = 1
  7159. resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  7160. resetFiltersButton.contentTintColor = primaryText
  7161. resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  7162. let refreshButton = NSButton(title: "", target: self, action: #selector(aiCompanionFilterRefreshTapped))
  7163. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  7164. refreshButton.isBordered = false
  7165. refreshButton.wantsLayer = true
  7166. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  7167. refreshButton.layer?.cornerRadius = 10
  7168. refreshButton.layer?.borderWidth = 1
  7169. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  7170. refreshButton.contentTintColor = primaryText
  7171. refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
  7172. if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
  7173. refreshButton.image = image
  7174. refreshButton.imagePosition = .imageOnly
  7175. }
  7176. let filterBar = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
  7177. filterBar.orientation = .horizontal
  7178. filterBar.spacing = 10
  7179. filterBar.alignment = .centerY
  7180. filterBar.translatesAutoresizingMaskIntoConstraints = false
  7181. configureResponsiveMeetingsStyleFilterRow(
  7182. filterRow: filterBar,
  7183. categoryFilter: categoryFilter,
  7184. fromDatePicker: fromDatePicker,
  7185. toDatePicker: toDatePicker,
  7186. applyFiltersButton: applyFiltersButton,
  7187. resetFiltersButton: resetFiltersButton,
  7188. refreshButton: refreshButton
  7189. )
  7190. container.addSubview(filterBar)
  7191. // Hint text below filters (recording help — not Zoom API status).
  7192. let status = makeLabel("", size: 12, color: secondaryText, weight: .regular, centered: false)
  7193. status.translatesAutoresizingMaskIntoConstraints = false
  7194. status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  7195. status.lineBreakMode = .byWordWrapping
  7196. status.maximumNumberOfLines = 0
  7197. if let cell = status.cell as? NSTextFieldCell {
  7198. cell.wraps = true
  7199. }
  7200. container.addSubview(status)
  7201. // List scroll view (left column).
  7202. let listScroll = NSScrollView()
  7203. listScroll.translatesAutoresizingMaskIntoConstraints = false
  7204. listScroll.drawsBackground = false
  7205. listScroll.hasHorizontalScroller = false
  7206. listScroll.hasVerticalScroller = true
  7207. listScroll.autohidesScrollers = true
  7208. listScroll.borderType = .noBorder
  7209. listScroll.scrollerStyle = .overlay
  7210. listScroll.automaticallyAdjustsContentInsets = false
  7211. let listClip = TopAlignedClipView()
  7212. listClip.drawsBackground = false
  7213. listScroll.contentView = listClip
  7214. // Wrapper document view (same pattern as Settings scroll): without a bottom
  7215. // `>= clip` chain, a vertical stack as documentView can stretch to the clip height
  7216. // and leave rows pinned to the bottom.
  7217. let listDocument = NSView()
  7218. listDocument.translatesAutoresizingMaskIntoConstraints = false
  7219. let listStack = NSStackView()
  7220. listStack.translatesAutoresizingMaskIntoConstraints = false
  7221. listStack.orientation = .vertical
  7222. listStack.alignment = .leading
  7223. listStack.spacing = 10
  7224. listScroll.documentView = listDocument
  7225. listDocument.addSubview(listStack)
  7226. container.addSubview(listScroll)
  7227. let listEmpty = makeLabel("No recordings yet.", size: 12, color: secondaryText, weight: .regular, centered: true)
  7228. listEmpty.translatesAutoresizingMaskIntoConstraints = false
  7229. listEmpty.maximumNumberOfLines = 3
  7230. listEmpty.lineBreakMode = .byWordWrapping
  7231. listEmpty.isHidden = true
  7232. container.addSubview(listEmpty)
  7233. // Old detail pane (AI notes) removed.
  7234. let divider = NSView()
  7235. divider.translatesAutoresizingMaskIntoConstraints = false
  7236. divider.wantsLayer = true
  7237. divider.layer?.backgroundColor = palette.inputBorder.cgColor
  7238. divider.isHidden = true
  7239. container.addSubview(divider)
  7240. NSLayoutConstraint.activate([
  7241. container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  7242. container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  7243. container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  7244. container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  7245. titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
  7246. titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
  7247. titleStack.trailingAnchor.constraint(lessThanOrEqualTo: toolbarRight.leadingAnchor, constant: -16),
  7248. toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
  7249. toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
  7250. stopRecording.heightAnchor.constraint(equalToConstant: 30),
  7251. stopRecording.widthAnchor.constraint(greaterThanOrEqualToConstant: 110),
  7252. filterBar.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
  7253. filterBar.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  7254. filterBar.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
  7255. status.topAnchor.constraint(equalTo: filterBar.bottomAnchor, constant: 12),
  7256. status.leadingAnchor.constraint(equalTo: filterBar.leadingAnchor),
  7257. status.trailingAnchor.constraint(equalTo: filterBar.trailingAnchor),
  7258. listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
  7259. listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  7260. listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
  7261. listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  7262. listDocument.topAnchor.constraint(equalTo: listClip.topAnchor),
  7263. listDocument.leadingAnchor.constraint(equalTo: listClip.leadingAnchor),
  7264. listDocument.trailingAnchor.constraint(equalTo: listClip.trailingAnchor),
  7265. listDocument.widthAnchor.constraint(equalTo: listClip.widthAnchor),
  7266. listDocument.bottomAnchor.constraint(greaterThanOrEqualTo: listClip.bottomAnchor),
  7267. listStack.topAnchor.constraint(equalTo: listDocument.topAnchor, constant: 8),
  7268. listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
  7269. listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
  7270. listDocument.bottomAnchor.constraint(greaterThanOrEqualTo: listStack.bottomAnchor, constant: 8),
  7271. listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
  7272. listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
  7273. listEmpty.widthAnchor.constraint(lessThanOrEqualTo: listScroll.widthAnchor, constant: -24),
  7274. divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
  7275. divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
  7276. divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
  7277. divider.widthAnchor.constraint(equalToConstant: 1)
  7278. ])
  7279. aiCompanionListScroll = listScroll
  7280. aiCompanionListStack = listStack
  7281. aiCompanionStatusLabel = status
  7282. aiCompanionCountLabel = count
  7283. aiCompanionEmptyLabel = listEmpty
  7284. aiCompanionLoadingIndicator = loadingIndicator
  7285. aiCompanionDetailEmptyLabel = nil
  7286. aiCompanionDetailScroll = nil
  7287. aiCompanionDetailStack = nil
  7288. aiCompanionStopRecordingButton = stopRecording
  7289. aiCompanionFilterPopup = categoryFilter
  7290. aiCompanionFilterFromPicker = fromDatePicker
  7291. aiCompanionFilterToPicker = toDatePicker
  7292. aiCompanionFilterApplyButton = applyFiltersButton
  7293. aiCompanionFilterResetButton = resetFiltersButton
  7294. aiCompanionFilterRefreshButton = refreshButton
  7295. updateAiCompanionDatePickersEnabledState()
  7296. renderAiCompanionList()
  7297. updateAiCompanionRecordingUI()
  7298. return root
  7299. }
  7300. /// Mirrors `updateMeetingsDatePickersEnabledState` for the Ai Companion recordings filter row.
  7301. @MainActor
  7302. private func updateAiCompanionDatePickersEnabledState() {
  7303. let isCustomRange = aiCompanionRecordingFilter == .custom
  7304. aiCompanionFilterFromPicker?.isEnabled = isCustomRange
  7305. aiCompanionFilterToPicker?.isEnabled = isCustomRange
  7306. aiCompanionFilterFromPicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  7307. aiCompanionFilterToPicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  7308. }
  7309. @objc private func aiCompanionFilterPresetChanged(_ sender: NSPopUpButton) {
  7310. guard let title = sender.titleOfSelectedItem,
  7311. let preset = AiCompanionRecordingFilter(rawValue: title) else { return }
  7312. aiCompanionRecordingFilter = preset
  7313. updateAiCompanionDatePickersEnabledState()
  7314. renderAiCompanionList()
  7315. }
  7316. @objc private func aiCompanionFilterApplyTapped() {
  7317. renderAiCompanionList()
  7318. }
  7319. @objc private func aiCompanionFilterResetTapped() {
  7320. aiCompanionRecordingFilter = .today
  7321. aiCompanionFilterPopup?.selectItem(withTitle: AiCompanionRecordingFilter.today.rawValue)
  7322. aiCompanionFilterFromPicker?.dateValue = Date()
  7323. aiCompanionFilterToPicker?.dateValue = Date()
  7324. updateAiCompanionDatePickersEnabledState()
  7325. renderAiCompanionList()
  7326. }
  7327. @objc private func aiCompanionFilterRefreshTapped() {
  7328. recordedMeetings = MeetingRecordingManager.shared.recordings
  7329. renderAiCompanionList()
  7330. updateAiCompanionRecordingUI()
  7331. }
  7332. private func aiCompanionDateFilterHalfOpenInterval() -> (start: Date, endExclusive: Date)? {
  7333. let cal = Calendar.current
  7334. let now = Date()
  7335. switch aiCompanionRecordingFilter {
  7336. case .all:
  7337. return nil
  7338. case .today:
  7339. let d = cal.startOfDay(for: now)
  7340. guard let end = cal.date(byAdding: .day, value: 1, to: d) else { return nil }
  7341. return (d, end)
  7342. case .thisWeek:
  7343. guard let week = cal.dateInterval(of: .weekOfYear, for: now) else { return nil }
  7344. return (week.start, week.end)
  7345. case .previousWeek:
  7346. guard let thisWeek = cal.dateInterval(of: .weekOfYear, for: now) else { return nil }
  7347. let prevStart = cal.date(byAdding: .day, value: -7, to: thisWeek.start) ?? thisWeek.start
  7348. return (prevStart, thisWeek.start)
  7349. case .previousMonth:
  7350. let comps = cal.dateComponents([.year, .month], from: now)
  7351. guard let thisMonthStart = cal.date(from: comps) else { return nil }
  7352. guard let prevMonthStart = cal.date(byAdding: .month, value: -1, to: thisMonthStart) else { return nil }
  7353. return (prevMonthStart, thisMonthStart)
  7354. case .custom:
  7355. guard let fromPicker = aiCompanionFilterFromPicker, let toPicker = aiCompanionFilterToPicker else { return nil }
  7356. let a = cal.startOfDay(for: fromPicker.dateValue)
  7357. let b = cal.startOfDay(for: toPicker.dateValue)
  7358. let lo = min(a, b)
  7359. let hi = max(a, b)
  7360. guard let endExclusive = cal.date(byAdding: .day, value: 1, to: hi) else { return nil }
  7361. return (lo, endExclusive)
  7362. }
  7363. }
  7364. private func aiCompanionFilteredRecordings() -> [MeetingRecordingManager.RecordedMeeting] {
  7365. guard let interval = aiCompanionDateFilterHalfOpenInterval() else {
  7366. return recordedMeetings
  7367. }
  7368. return recordedMeetings.filter { $0.startedAt >= interval.start && $0.startedAt < interval.endExclusive }
  7369. }
  7370. @MainActor
  7371. private func updateAiCompanionRecordingUI() {
  7372. let mgr = MeetingRecordingManager.shared
  7373. aiCompanionStopRecordingButton?.isHidden = !mgr.isRecording
  7374. if mgr.isRecording {
  7375. aiCompanionStatusLabel?.stringValue =
  7376. "Recording… Stops automatically when you leave or the meeting ends."
  7377. aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
  7378. aiCompanionStatusLabel?.textColor = secondaryText
  7379. } else if pendingZoomMeetingRecording != nil {
  7380. aiCompanionStatusLabel?.stringValue =
  7381. "Waiting to join… Recording starts when you enter the meeting."
  7382. aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
  7383. aiCompanionStatusLabel?.textColor = secondaryText
  7384. } else {
  7385. aiCompanionStatusLabel?.stringValue = "Previous meetings"
  7386. aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
  7387. aiCompanionStatusLabel?.textColor = primaryText
  7388. }
  7389. refreshAiCompanionStatusPreferredMaxLayoutWidth()
  7390. }
  7391. /// Keeps the status field wrapping inside the card so a long path or error string cannot widen the window.
  7392. @MainActor
  7393. private func refreshAiCompanionStatusPreferredMaxLayoutWidth() {
  7394. guard let status = aiCompanionStatusLabel, let container = status.superview else { return }
  7395. let w = container.bounds.width
  7396. guard w > 1 else { return }
  7397. let margin: CGFloat = 48
  7398. status.preferredMaxLayoutWidth = max(160, w - margin)
  7399. }
  7400. @objc private func aiCompanionStopRecordingTapped() {
  7401. stopEmbeddedMeetingRecordingMonitor()
  7402. Task { @MainActor in
  7403. do {
  7404. try await MeetingRecordingManager.shared.stopRecording()
  7405. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  7406. self.renderAiCompanionList()
  7407. self.updateAiCompanionRecordingUI()
  7408. } catch {
  7409. self.showSimpleAlert(title: "Could not save recording", message: error.localizedDescription)
  7410. }
  7411. }
  7412. }
  7413. // MARK: - AI Companion list / detail rendering
  7414. @MainActor
  7415. private func renderAiCompanionList() {
  7416. guard let stack = aiCompanionListStack else { return }
  7417. aiCompanionRowButtons.removeAll()
  7418. stack.arrangedSubviews.forEach { view in
  7419. stack.removeArrangedSubview(view)
  7420. view.removeFromSuperview()
  7421. }
  7422. let displayed = aiCompanionFilteredRecordings()
  7423. let totalCount = recordedMeetings.count
  7424. let displayedCount = displayed.count
  7425. if aiCompanionRecordingFilter == .all {
  7426. aiCompanionCountLabel?.stringValue = totalCount == 1 ? "1 recording" : "\(totalCount) recordings"
  7427. } else {
  7428. aiCompanionCountLabel?.stringValue = "\(displayedCount) of \(totalCount) recordings"
  7429. }
  7430. let showEmpty = displayed.isEmpty
  7431. if showEmpty {
  7432. if totalCount == 0 {
  7433. aiCompanionEmptyLabel?.stringValue = "No previous meetings yet."
  7434. } else {
  7435. aiCompanionEmptyLabel?.stringValue = "No previous meetings matched the selected filter."
  7436. }
  7437. }
  7438. aiCompanionEmptyLabel?.isHidden = !showEmpty
  7439. for recording in displayed {
  7440. guard let idx = recordedMeetings.firstIndex(where: { $0.id == recording.id }) else { continue }
  7441. let row = makeRecordedMeetingCard(recording: recording, index: idx)
  7442. stack.addArrangedSubview(row)
  7443. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  7444. }
  7445. refreshLocalRecordingTranscriptPanelContentIfNeeded()
  7446. refreshLocalRecordingNotesPanelContentIfNeeded()
  7447. }
  7448. private func localRecordingNotesStatus(for recording: MeetingRecordingManager.RecordedMeeting) -> LocalRecordingNotesStatus {
  7449. if let raw = recording.notesStatusRaw, let s = LocalRecordingNotesStatus(rawValue: raw) {
  7450. if s == .notRequested,
  7451. let t = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
  7452. t.isEmpty == false {
  7453. return .ready
  7454. }
  7455. return s
  7456. }
  7457. if let t = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false {
  7458. return .ready
  7459. }
  7460. return .notRequested
  7461. }
  7462. private func aiCompanionCardDateLabel(for date: Date) -> String {
  7463. let formatter = DateFormatter()
  7464. formatter.locale = Locale(identifier: "en_GB")
  7465. formatter.dateFormat = "d-MMMM-yyyy"
  7466. return "Date: \(formatter.string(from: date))"
  7467. }
  7468. private func aiCompanionCardSavedTimeLabel(for date: Date) -> String {
  7469. let formatter = DateFormatter()
  7470. formatter.locale = Locale(identifier: "en_US_POSIX")
  7471. formatter.dateStyle = .none
  7472. formatter.timeStyle = .short
  7473. return "Saved time: \(formatter.string(from: date))"
  7474. }
  7475. private let aiCompanionGetNotesButtonHeight: CGFloat = 28
  7476. private func styleAiCompanionGetNotesButton(_ button: NSButton, enabled: Bool) {
  7477. let pillHeight = aiCompanionGetNotesButtonHeight
  7478. button.isBordered = false
  7479. button.wantsLayer = true
  7480. button.layer?.cornerRadius = pillHeight / 2
  7481. button.layer?.borderWidth = 1
  7482. button.font = .systemFont(ofSize: 12, weight: .semibold)
  7483. button.imagePosition = .imageLeading
  7484. button.imageHugsTitle = true
  7485. let enabledFill = palette.isDarkMode
  7486. ? NSColor(calibratedRed: 22 / 255, green: 34 / 255, blue: 54 / 255, alpha: 1)
  7487. : NSColor(calibratedRed: 236 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  7488. let disabledFill = palette.isDarkMode
  7489. ? NSColor(calibratedRed: 18 / 255, green: 26 / 255, blue: 40 / 255, alpha: 1)
  7490. : NSColor(calibratedRed: 245 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
  7491. if enabled {
  7492. button.layer?.backgroundColor = enabledFill.cgColor
  7493. button.layer?.borderColor = accentBlue.cgColor
  7494. button.contentTintColor = accentBlue
  7495. if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
  7496. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  7497. .applying(NSImage.SymbolConfiguration(hierarchicalColor: accentBlue))
  7498. button.image = icon.withSymbolConfiguration(config)
  7499. }
  7500. } else {
  7501. let mutedBlue = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.38 : 0.45)
  7502. button.layer?.backgroundColor = disabledFill.cgColor
  7503. button.layer?.borderColor = mutedBlue.cgColor
  7504. button.contentTintColor = mutedBlue
  7505. if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
  7506. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  7507. .applying(NSImage.SymbolConfiguration(hierarchicalColor: mutedBlue))
  7508. button.image = icon.withSymbolConfiguration(config)
  7509. }
  7510. }
  7511. }
  7512. private func localRecordingNotesStatusLabel(
  7513. for recording: MeetingRecordingManager.RecordedMeeting,
  7514. notesStatus: LocalRecordingNotesStatus
  7515. ) -> (text: String, color: NSColor) {
  7516. if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
  7517. return ("Generating transcript…", secondaryText)
  7518. }
  7519. if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
  7520. return ("Transcript unavailable", NSColor.systemRed)
  7521. }
  7522. switch notesStatus {
  7523. case .notRequested:
  7524. let hasTranscript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
  7525. if hasTranscript {
  7526. return ("Notes not requested", secondaryText)
  7527. }
  7528. return ("Waiting for transcript…", secondaryText)
  7529. case .processing:
  7530. let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
  7531. return (msg, secondaryText)
  7532. case .ready:
  7533. return ("Notes ready", secondaryText)
  7534. case .failed:
  7535. let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7536. if err.isEmpty {
  7537. return ("Notes could not be generated", NSColor.systemRed)
  7538. }
  7539. return (err, NSColor.systemRed)
  7540. }
  7541. }
  7542. @MainActor
  7543. private func makeRecordedMeetingCard(recording: MeetingRecordingManager.RecordedMeeting, index: Int) -> NSView {
  7544. let card = NSView()
  7545. card.translatesAutoresizingMaskIntoConstraints = false
  7546. card.wantsLayer = true
  7547. card.layer?.cornerRadius = 12
  7548. card.layer?.borderWidth = 1
  7549. card.layer?.borderColor = palette.inputBorder.cgColor
  7550. card.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.04) : NSColor.black.withAlphaComponent(0.03)).cgColor
  7551. let title = makeLabel(recording.resolvedDisplayTitle, size: 15, color: primaryText, weight: .semibold, centered: false)
  7552. title.translatesAutoresizingMaskIntoConstraints = false
  7553. title.maximumNumberOfLines = 2
  7554. title.lineBreakMode = .byTruncatingTail
  7555. let dateLabel = makeLabel(aiCompanionCardDateLabel(for: recording.startedAt), size: 12, color: secondaryText, weight: .regular, centered: false)
  7556. dateLabel.translatesAutoresizingMaskIntoConstraints = false
  7557. let savedTimeLabel = makeLabel(aiCompanionCardSavedTimeLabel(for: recording.endedAt), size: 12, color: secondaryText, weight: .regular, centered: false)
  7558. savedTimeLabel.translatesAutoresizingMaskIntoConstraints = false
  7559. let notesStatus = localRecordingNotesStatus(for: recording)
  7560. let hasTranscript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
  7561. let transcriptionBusy = aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id)
  7562. let notesReady = notesStatus == .ready
  7563. let getNotesTitle = notesReady ? "View Notes" : "Get Notes"
  7564. let getNotesBtn = NSButton(title: getNotesTitle, target: self, action: #selector(recordingGetNotesTapped(_:)))
  7565. getNotesBtn.translatesAutoresizingMaskIntoConstraints = false
  7566. getNotesBtn.tag = index
  7567. let getNotesEnabled = (notesStatus != .processing)
  7568. && (notesReady || notesStatus == .failed || (hasTranscript && !transcriptionBusy))
  7569. getNotesBtn.isEnabled = getNotesEnabled
  7570. styleAiCompanionGetNotesButton(getNotesBtn, enabled: getNotesEnabled)
  7571. let statusInfo = localRecordingNotesStatusLabel(for: recording, notesStatus: notesStatus)
  7572. let statusLabel = makeLabel(statusInfo.text, size: 12, color: statusInfo.color, weight: .regular, centered: false)
  7573. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  7574. statusLabel.maximumNumberOfLines = 3
  7575. statusLabel.lineBreakMode = .byWordWrapping
  7576. if let cell = statusLabel.cell as? NSTextFieldCell {
  7577. cell.wraps = true
  7578. }
  7579. card.addSubview(title)
  7580. card.addSubview(dateLabel)
  7581. card.addSubview(savedTimeLabel)
  7582. card.addSubview(getNotesBtn)
  7583. card.addSubview(statusLabel)
  7584. NSLayoutConstraint.activate([
  7585. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  7586. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  7587. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7588. dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8),
  7589. dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7590. dateLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7591. savedTimeLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 4),
  7592. savedTimeLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7593. savedTimeLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7594. getNotesBtn.topAnchor.constraint(equalTo: savedTimeLabel.bottomAnchor, constant: 12),
  7595. getNotesBtn.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7596. getNotesBtn.heightAnchor.constraint(equalToConstant: aiCompanionGetNotesButtonHeight),
  7597. getNotesBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 108),
  7598. statusLabel.topAnchor.constraint(equalTo: getNotesBtn.bottomAnchor, constant: 8),
  7599. statusLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7600. statusLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7601. statusLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
  7602. ])
  7603. return card
  7604. }
  7605. private func bodyTextForLocalRecordingTranscript(recording: MeetingRecordingManager.RecordedMeeting) -> String {
  7606. if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
  7607. return "Generating transcript…"
  7608. }
  7609. if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
  7610. return "Transcript could not be generated.\n\n\(err)"
  7611. }
  7612. if let text = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false {
  7613. return text
  7614. }
  7615. 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."
  7616. }
  7617. private func bodyTextForLocalRecordingNotes(recording: MeetingRecordingManager.RecordedMeeting) -> String {
  7618. switch localRecordingNotesStatus(for: recording) {
  7619. case .processing:
  7620. return localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
  7621. case .failed:
  7622. let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7623. return err.isEmpty ? "Notes could not be generated." : "Notes unavailable.\n\n\(err)"
  7624. case .ready:
  7625. return recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7626. case .notRequested:
  7627. return "Tap Get notes on the recording card after the transcript is ready."
  7628. }
  7629. }
  7630. @MainActor
  7631. private func refreshLocalRecordingNotesPanelContentIfNeeded() {
  7632. guard let recordingID = aiCompanionLocalNotesPanelRecordingID,
  7633. let textView = aiCompanionLocalNotesTextView,
  7634. let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
  7635. textView.string = bodyTextForLocalRecordingNotes(recording: recording)
  7636. }
  7637. @MainActor
  7638. private func presentLocalRecordingNotesPanel(recording: MeetingRecordingManager.RecordedMeeting, initialText: String?) {
  7639. guard let hostWindow = view.window else { return }
  7640. let meetingTitle = recording.resolvedDisplayTitle
  7641. let trimmedInitial = initialText?.trimmingCharacters(in: .whitespacesAndNewlines)
  7642. let body: String = {
  7643. if let trimmedInitial, trimmedInitial.isEmpty == false { return trimmedInitial }
  7644. return bodyTextForLocalRecordingNotes(recording: recording)
  7645. }()
  7646. let hostIsDarkMode = darkModeEnabled
  7647. let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
  7648. let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
  7649. let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
  7650. if let panel = aiCompanionLocalNotesPanel, let textView = aiCompanionLocalNotesTextView {
  7651. panel.title = "Notes - \(meetingTitle)"
  7652. panel.appearance = NSAppearance(named: panelAppearanceName)
  7653. panel.backgroundColor = outerBackgroundColor
  7654. textView.string = body
  7655. aiCompanionLocalNotesPanelRecordingID = recording.id
  7656. aiCompanionLocalNotesRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
  7657. aiCompanionLocalNotesHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
  7658. aiCompanionLocalNotesSubtitleLabel?.stringValue = meetingTitle
  7659. if hostWindow.attachedSheet !== panel {
  7660. hostWindow.beginSheet(panel, completionHandler: nil)
  7661. }
  7662. panel.makeKeyAndOrderFront(nil)
  7663. NSApp.activate(ignoringOtherApps: true)
  7664. return
  7665. }
  7666. let panelWidth: CGFloat = 720
  7667. let panelHeight: CGFloat = 540
  7668. let panel = NSPanel(
  7669. contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
  7670. styleMask: [.titled, .closable],
  7671. backing: .buffered,
  7672. defer: false
  7673. )
  7674. panel.isReleasedWhenClosed = false
  7675. panel.title = "Notes - \(meetingTitle)"
  7676. panel.isFloatingPanel = false
  7677. panel.hidesOnDeactivate = false
  7678. panel.delegate = self
  7679. panel.appearance = NSAppearance(named: panelAppearanceName)
  7680. panel.backgroundColor = outerBackgroundColor
  7681. panel.standardWindowButton(.zoomButton)?.isHidden = true
  7682. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  7683. let root = NSView()
  7684. root.translatesAutoresizingMaskIntoConstraints = false
  7685. root.wantsLayer = true
  7686. root.layer?.backgroundColor = outerBackgroundColor.cgColor
  7687. let header = NSView()
  7688. header.translatesAutoresizingMaskIntoConstraints = false
  7689. header.wantsLayer = true
  7690. header.layer?.backgroundColor = headerBackgroundColor.cgColor
  7691. header.layer?.cornerRadius = 10
  7692. let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
  7693. let titleLabel = textLabel("Meeting notes", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
  7694. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  7695. let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
  7696. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  7697. subtitleLabel.lineBreakMode = .byTruncatingTail
  7698. let scroll = NSScrollView()
  7699. scroll.translatesAutoresizingMaskIntoConstraints = false
  7700. scroll.drawsBackground = true
  7701. scroll.backgroundColor = NSColor.textBackgroundColor
  7702. scroll.hasVerticalScroller = true
  7703. scroll.hasHorizontalScroller = false
  7704. scroll.borderType = .bezelBorder
  7705. let textView = NSTextView()
  7706. textView.isEditable = false
  7707. textView.isSelectable = true
  7708. textView.backgroundColor = NSColor.textBackgroundColor
  7709. textView.textColor = NSColor.labelColor
  7710. textView.font = .systemFont(ofSize: 13, weight: .regular)
  7711. textView.string = body
  7712. textView.textContainer?.widthTracksTextView = true
  7713. textView.textContainerInset = NSSize(width: 10, height: 10)
  7714. let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalNotesCopyTapped(_:)))
  7715. copyButton.translatesAutoresizingMaskIntoConstraints = false
  7716. copyButton.bezelStyle = .rounded
  7717. let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalNotesCloseTapped(_:)))
  7718. closeButton.translatesAutoresizingMaskIntoConstraints = false
  7719. closeButton.bezelStyle = .rounded
  7720. scroll.documentView = textView
  7721. header.addSubview(titleLabel)
  7722. header.addSubview(subtitleLabel)
  7723. header.addSubview(copyButton)
  7724. header.addSubview(closeButton)
  7725. root.addSubview(header)
  7726. root.addSubview(scroll)
  7727. NSLayoutConstraint.activate([
  7728. header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
  7729. header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7730. header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7731. titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
  7732. titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7733. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7734. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  7735. subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7736. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7737. subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
  7738. copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7739. copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
  7740. closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7741. closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
  7742. scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
  7743. scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7744. scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7745. scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
  7746. ])
  7747. panel.contentView = root
  7748. aiCompanionLocalNotesPanel = panel
  7749. aiCompanionLocalNotesTextView = textView
  7750. aiCompanionLocalNotesRootView = root
  7751. aiCompanionLocalNotesHeaderView = header
  7752. aiCompanionLocalNotesSubtitleLabel = subtitleLabel
  7753. aiCompanionLocalNotesPanelRecordingID = recording.id
  7754. hostWindow.beginSheet(panel, completionHandler: nil)
  7755. panel.makeKeyAndOrderFront(nil)
  7756. NSApp.activate(ignoringOtherApps: true)
  7757. }
  7758. @MainActor
  7759. private func startLocalRecordingNotesProcessing(recordingId: String, transcript: String, requestId: UUID, autoPresentOnReady: Bool) {
  7760. localRecordingNotesTaskByRecordingId[recordingId]?.cancel()
  7761. localRecordingNotesRequestIdByRecordingId[recordingId] = requestId
  7762. localRecordingNotesProgressByRecordingId[recordingId] = "Generating notes…"
  7763. MeetingRecordingManager.shared.setMeetingNotes(
  7764. recordingId: recordingId,
  7765. notesStatusRaw: LocalRecordingNotesStatus.processing.rawValue,
  7766. notesText: nil,
  7767. notesError: nil
  7768. )
  7769. recordedMeetings = MeetingRecordingManager.shared.recordings
  7770. renderAiCompanionList()
  7771. let task = Task { [weak self] in
  7772. guard let self else { return }
  7773. do {
  7774. // Notes language must follow the transcript text, not Apple Speech locale prefs
  7775. // (e.g. Urdu selected for recognition must not force Urdu notes for an English transcript).
  7776. let notes = try await self.meetingNotesService.generateNotes(from: transcript)
  7777. if Task.isCancelled {
  7778. await MainActor.run { [weak self] in
  7779. guard let self else { return }
  7780. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7781. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7782. }
  7783. }
  7784. return
  7785. }
  7786. await MainActor.run { [weak self] in
  7787. guard let self else { return }
  7788. guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
  7789. self.localRecordingNotesProgressByRecordingId[recordingId] = nil
  7790. MeetingRecordingManager.shared.setMeetingNotes(
  7791. recordingId: recordingId,
  7792. notesStatusRaw: LocalRecordingNotesStatus.ready.rawValue,
  7793. notesText: notes,
  7794. notesError: nil
  7795. )
  7796. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  7797. self.refreshLocalRecordingNotesPanelContentIfNeeded()
  7798. self.renderAiCompanionList()
  7799. if autoPresentOnReady, let rec = self.recordedMeetings.first(where: { $0.id == recordingId }) {
  7800. self.presentLocalRecordingNotesPanel(recording: rec, initialText: notes)
  7801. }
  7802. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7803. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7804. }
  7805. }
  7806. } catch {
  7807. if error is CancellationError {
  7808. await MainActor.run { [weak self] in
  7809. guard let self else { return }
  7810. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7811. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7812. }
  7813. }
  7814. return
  7815. }
  7816. await MainActor.run { [weak self] in
  7817. guard let self else { return }
  7818. guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
  7819. self.localRecordingNotesProgressByRecordingId[recordingId] = nil
  7820. let msg = error.localizedDescription.isEmpty ? "Failed to generate notes." : error.localizedDescription
  7821. MeetingRecordingManager.shared.setMeetingNotes(
  7822. recordingId: recordingId,
  7823. notesStatusRaw: LocalRecordingNotesStatus.failed.rawValue,
  7824. notesText: nil,
  7825. notesError: msg
  7826. )
  7827. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  7828. self.refreshLocalRecordingNotesPanelContentIfNeeded()
  7829. self.renderAiCompanionList()
  7830. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7831. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7832. }
  7833. }
  7834. }
  7835. }
  7836. localRecordingNotesTaskByRecordingId[recordingId] = task
  7837. }
  7838. @objc private func recordingGetNotesTapped(_ sender: NSButton) {
  7839. let idx = sender.tag
  7840. guard recordedMeetings.indices.contains(idx) else { return }
  7841. let recording = recordedMeetings[idx]
  7842. let status = localRecordingNotesStatus(for: recording)
  7843. if status == .ready,
  7844. let text = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
  7845. text.isEmpty == false {
  7846. presentLocalRecordingNotesPanel(recording: recording, initialText: text)
  7847. return
  7848. }
  7849. if status == .processing { return }
  7850. if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
  7851. showSimpleAlert(title: "Transcript not ready", message: "Wait until the transcript finishes generating, then try Get notes again.")
  7852. return
  7853. }
  7854. let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7855. if transcript.isEmpty {
  7856. if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
  7857. showSimpleAlert(title: "No transcript", message: "Transcription failed for this recording, so notes cannot be generated.")
  7858. } else {
  7859. showSimpleAlert(title: "No transcript yet", message: "Wait until the transcript is available (or open Transcript to check progress), then tap Get notes.")
  7860. }
  7861. return
  7862. }
  7863. let requestId = UUID()
  7864. startLocalRecordingNotesProcessing(recordingId: recording.id, transcript: transcript, requestId: requestId, autoPresentOnReady: true)
  7865. }
  7866. @objc private func aiCompanionLocalNotesCopyTapped(_ sender: NSButton) {
  7867. let text = aiCompanionLocalNotesTextView?.string ?? ""
  7868. NSPasteboard.general.clearContents()
  7869. NSPasteboard.general.setString(text, forType: .string)
  7870. }
  7871. @objc private func aiCompanionLocalNotesCloseTapped(_ sender: NSButton) {
  7872. guard let panel = aiCompanionLocalNotesPanel, let host = view.window else { return }
  7873. host.endSheet(panel)
  7874. }
  7875. @MainActor
  7876. private func refreshLocalRecordingTranscriptPanelContentIfNeeded() {
  7877. guard let recordingID = aiCompanionLocalTranscriptPanelRecordingID,
  7878. let textView = aiCompanionLocalTranscriptTextView,
  7879. let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
  7880. textView.string = bodyTextForLocalRecordingTranscript(recording: recording)
  7881. }
  7882. @MainActor
  7883. private func presentLocalRecordingTranscriptPanel(recording: MeetingRecordingManager.RecordedMeeting) {
  7884. guard let hostWindow = view.window else { return }
  7885. let meetingTitle = recording.resolvedDisplayTitle
  7886. let body = bodyTextForLocalRecordingTranscript(recording: recording)
  7887. let hostIsDarkMode = darkModeEnabled
  7888. let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
  7889. let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
  7890. let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
  7891. if let panel = aiCompanionLocalTranscriptPanel, let textView = aiCompanionLocalTranscriptTextView {
  7892. panel.title = "Transcript - \(meetingTitle)"
  7893. panel.appearance = NSAppearance(named: panelAppearanceName)
  7894. panel.backgroundColor = outerBackgroundColor
  7895. textView.string = body
  7896. aiCompanionLocalTranscriptPanelRecordingID = recording.id
  7897. aiCompanionLocalTranscriptRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
  7898. aiCompanionLocalTranscriptHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
  7899. aiCompanionLocalTranscriptSubtitleLabel?.stringValue = meetingTitle
  7900. if hostWindow.attachedSheet !== panel {
  7901. hostWindow.beginSheet(panel, completionHandler: nil)
  7902. }
  7903. panel.makeKeyAndOrderFront(nil)
  7904. NSApp.activate(ignoringOtherApps: true)
  7905. return
  7906. }
  7907. let panelWidth: CGFloat = 720
  7908. let panelHeight: CGFloat = 540
  7909. let panel = NSPanel(
  7910. contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
  7911. styleMask: [.titled, .closable],
  7912. backing: .buffered,
  7913. defer: false
  7914. )
  7915. panel.isReleasedWhenClosed = false
  7916. panel.title = "Transcript - \(meetingTitle)"
  7917. panel.isFloatingPanel = false
  7918. panel.hidesOnDeactivate = false
  7919. panel.delegate = self
  7920. panel.appearance = NSAppearance(named: panelAppearanceName)
  7921. panel.backgroundColor = outerBackgroundColor
  7922. panel.standardWindowButton(.zoomButton)?.isHidden = true
  7923. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  7924. let root = NSView()
  7925. root.translatesAutoresizingMaskIntoConstraints = false
  7926. root.wantsLayer = true
  7927. root.layer?.backgroundColor = outerBackgroundColor.cgColor
  7928. let header = NSView()
  7929. header.translatesAutoresizingMaskIntoConstraints = false
  7930. header.wantsLayer = true
  7931. header.layer?.backgroundColor = headerBackgroundColor.cgColor
  7932. header.layer?.cornerRadius = 10
  7933. let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
  7934. let titleLabel = textLabel("Transcript", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
  7935. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  7936. let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
  7937. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  7938. subtitleLabel.lineBreakMode = .byTruncatingTail
  7939. let scroll = NSScrollView()
  7940. scroll.translatesAutoresizingMaskIntoConstraints = false
  7941. scroll.drawsBackground = true
  7942. scroll.backgroundColor = NSColor.textBackgroundColor
  7943. scroll.hasVerticalScroller = true
  7944. scroll.hasHorizontalScroller = false
  7945. scroll.borderType = .bezelBorder
  7946. let textView = NSTextView()
  7947. textView.isEditable = false
  7948. textView.isSelectable = true
  7949. textView.backgroundColor = NSColor.textBackgroundColor
  7950. textView.textColor = NSColor.labelColor
  7951. textView.font = .systemFont(ofSize: 13, weight: .regular)
  7952. textView.string = body
  7953. textView.textContainer?.widthTracksTextView = true
  7954. textView.textContainerInset = NSSize(width: 10, height: 10)
  7955. let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalTranscriptCopyTapped(_:)))
  7956. copyButton.translatesAutoresizingMaskIntoConstraints = false
  7957. copyButton.bezelStyle = .rounded
  7958. let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalTranscriptCloseTapped(_:)))
  7959. closeButton.translatesAutoresizingMaskIntoConstraints = false
  7960. closeButton.bezelStyle = .rounded
  7961. scroll.documentView = textView
  7962. header.addSubview(titleLabel)
  7963. header.addSubview(subtitleLabel)
  7964. header.addSubview(copyButton)
  7965. header.addSubview(closeButton)
  7966. root.addSubview(header)
  7967. root.addSubview(scroll)
  7968. NSLayoutConstraint.activate([
  7969. header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
  7970. header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7971. header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7972. titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
  7973. titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7974. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7975. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  7976. subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7977. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7978. subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
  7979. copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7980. copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
  7981. closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7982. closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
  7983. scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
  7984. scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7985. scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7986. scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
  7987. ])
  7988. panel.contentView = root
  7989. aiCompanionLocalTranscriptPanel = panel
  7990. aiCompanionLocalTranscriptTextView = textView
  7991. aiCompanionLocalTranscriptRootView = root
  7992. aiCompanionLocalTranscriptHeaderView = header
  7993. aiCompanionLocalTranscriptSubtitleLabel = subtitleLabel
  7994. aiCompanionLocalTranscriptPanelRecordingID = recording.id
  7995. hostWindow.beginSheet(panel, completionHandler: nil)
  7996. panel.makeKeyAndOrderFront(nil)
  7997. NSApp.activate(ignoringOtherApps: true)
  7998. }
  7999. @objc private func recordingTranscriptTapped(_ sender: NSButton) {
  8000. let idx = sender.tag
  8001. guard recordedMeetings.indices.contains(idx) else { return }
  8002. presentLocalRecordingTranscriptPanel(recording: recordedMeetings[idx])
  8003. }
  8004. @objc private func aiCompanionLocalTranscriptCopyTapped(_ sender: NSButton) {
  8005. let text = aiCompanionLocalTranscriptTextView?.string ?? ""
  8006. NSPasteboard.general.clearContents()
  8007. NSPasteboard.general.setString(text, forType: .string)
  8008. }
  8009. @objc private func aiCompanionLocalTranscriptCloseTapped(_ sender: NSButton) {
  8010. guard let panel = aiCompanionLocalTranscriptPanel, let host = view.window else { return }
  8011. host.endSheet(panel)
  8012. }
  8013. @objc
  8014. private func recordingPlayTapped(_ sender: NSButton) {
  8015. let idx = sender.tag
  8016. guard recordedMeetings.indices.contains(idx) else { return }
  8017. let recording = recordedMeetings[idx]
  8018. if recordedMeetingsPlayingID == recording.id {
  8019. recordedMeetingsPlayer?.stop()
  8020. recordedMeetingsPlayer = nil
  8021. recordedMeetingsPlayingID = nil
  8022. renderAiCompanionList()
  8023. return
  8024. }
  8025. do {
  8026. let url = try MeetingRecordingManager.shared.audioURL(for: recording)
  8027. recordedMeetingsPlayer?.stop()
  8028. recordedMeetingsPlayer = try AVAudioPlayer(contentsOf: url)
  8029. recordedMeetingsPlayer?.prepareToPlay()
  8030. recordedMeetingsPlayer?.play()
  8031. recordedMeetingsPlayingID = recording.id
  8032. renderAiCompanionList()
  8033. } catch {
  8034. showSimpleAlert(title: "Unable to play", message: "Could not open the audio recording.")
  8035. }
  8036. }
  8037. @MainActor
  8038. private func renderAiCompanionDetail() {
  8039. guard let stack = aiCompanionDetailStack else { return }
  8040. stack.arrangedSubviews.forEach { view in
  8041. stack.removeArrangedSubview(view)
  8042. view.removeFromSuperview()
  8043. }
  8044. guard isUserLoggedIn() else {
  8045. aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
  8046. aiCompanionDetailEmptyLabel?.isHidden = false
  8047. return
  8048. }
  8049. guard let index = aiCompanionSelectedIndex,
  8050. aiCompanionSummaries.indices.contains(index) else {
  8051. aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
  8052. ? "Run an AI Companion-enabled meeting to see notes here."
  8053. : "Select a meeting on the left to view its AI notes."
  8054. aiCompanionDetailEmptyLabel?.isHidden = false
  8055. return
  8056. }
  8057. aiCompanionDetailEmptyLabel?.isHidden = true
  8058. let summary = aiCompanionSummaries[index]
  8059. let topic = summary.summaryTitle?.isEmpty == false
  8060. ? summary.summaryTitle!
  8061. : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
  8062. let titleLabel = makeLabel(topic, size: 22, color: primaryText, weight: .bold, centered: false)
  8063. titleLabel.maximumNumberOfLines = 0
  8064. titleLabel.lineBreakMode = .byWordWrapping
  8065. stack.addArrangedSubview(titleLabel)
  8066. let metaText = aiCompanionDateText(for: summary)
  8067. let metaLabel = makeLabel(metaText, size: 12, color: secondaryText, weight: .regular, centered: false)
  8068. metaLabel.maximumNumberOfLines = 0
  8069. stack.addArrangedSubview(metaLabel)
  8070. let actionsRow = NSStackView()
  8071. actionsRow.orientation = .horizontal
  8072. actionsRow.spacing = 10
  8073. actionsRow.alignment = .centerY
  8074. actionsRow.translatesAutoresizingMaskIntoConstraints = false
  8075. let openDocButton = NSButton(title: "Open in Zoom Docs", target: self, action: #selector(aiCompanionOpenDocTapped))
  8076. openDocButton.translatesAutoresizingMaskIntoConstraints = false
  8077. openDocButton.isBordered = false
  8078. openDocButton.wantsLayer = true
  8079. openDocButton.layer?.backgroundColor = accentBlue.cgColor
  8080. openDocButton.layer?.cornerRadius = 10
  8081. openDocButton.contentTintColor = .white
  8082. openDocButton.font = .systemFont(ofSize: 12, weight: .semibold)
  8083. openDocButton.isEnabled = summary.summaryDocURL != nil
  8084. openDocButton.alphaValue = summary.summaryDocURL != nil ? 1.0 : 0.45
  8085. let copyButton = NSButton(title: "Copy as Markdown", target: self, action: #selector(aiCompanionCopyMarkdownTapped))
  8086. copyButton.translatesAutoresizingMaskIntoConstraints = false
  8087. copyButton.isBordered = false
  8088. copyButton.wantsLayer = true
  8089. copyButton.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
  8090. copyButton.layer?.cornerRadius = 10
  8091. copyButton.layer?.borderWidth = 1
  8092. copyButton.layer?.borderColor = palette.inputBorder.cgColor
  8093. copyButton.contentTintColor = primaryText
  8094. copyButton.font = .systemFont(ofSize: 12, weight: .semibold)
  8095. [openDocButton, copyButton].forEach { btn in
  8096. btn.heightAnchor.constraint(equalToConstant: 30).isActive = true
  8097. btn.widthAnchor.constraint(greaterThanOrEqualToConstant: 130).isActive = true
  8098. }
  8099. actionsRow.addArrangedSubview(openDocButton)
  8100. actionsRow.addArrangedSubview(copyButton)
  8101. stack.addArrangedSubview(actionsRow)
  8102. aiCompanionOpenDocButton = openDocButton
  8103. aiCompanionCopyMarkdownButton = copyButton
  8104. if let overview = summary.summaryOverview?.trimmingCharacters(in: .whitespacesAndNewlines), overview.isEmpty == false {
  8105. stack.addArrangedSubview(makeAiCompanionSectionHeader("Overview"))
  8106. stack.addArrangedSubview(makeAiCompanionBodyLabel(overview))
  8107. }
  8108. for section in summary.summaryDetails {
  8109. let labelText = section.label.isEmpty ? "Details" : section.label
  8110. stack.addArrangedSubview(makeAiCompanionSectionHeader(labelText))
  8111. if section.summary.isEmpty == false {
  8112. stack.addArrangedSubview(makeAiCompanionBodyLabel(section.summary))
  8113. }
  8114. }
  8115. if summary.nextSteps.isEmpty == false {
  8116. stack.addArrangedSubview(makeAiCompanionSectionHeader("Next steps"))
  8117. let bullets = summary.nextSteps.map { "• \($0)" }.joined(separator: "\n")
  8118. stack.addArrangedSubview(makeAiCompanionBodyLabel(bullets))
  8119. }
  8120. if summary.summaryOverview == nil && summary.summaryDetails.isEmpty && summary.nextSteps.isEmpty,
  8121. let fullContent = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
  8122. fullContent.isEmpty == false {
  8123. stack.addArrangedSubview(makeAiCompanionSectionHeader("Summary"))
  8124. stack.addArrangedSubview(makeAiCompanionBodyLabel(fullContent))
  8125. }
  8126. }
  8127. @MainActor
  8128. private func makeAiCompanionSectionHeader(_ text: String) -> NSTextField {
  8129. let label = makeLabel(text.uppercased(), size: 11, color: accentBlue, weight: .bold, centered: false)
  8130. label.maximumNumberOfLines = 1
  8131. return label
  8132. }
  8133. @MainActor
  8134. private func makeAiCompanionBodyLabel(_ text: String) -> NSTextField {
  8135. let label = makeLabel(text, size: 13, color: primaryText, weight: .regular, centered: false)
  8136. label.maximumNumberOfLines = 0
  8137. label.lineBreakMode = .byWordWrapping
  8138. label.isSelectable = true
  8139. return label
  8140. }
  8141. private func aiCompanionDateText(for summary: AICompanionSummary) -> String {
  8142. let formatter = DateFormatter()
  8143. formatter.dateStyle = .medium
  8144. formatter.timeStyle = .short
  8145. formatter.timeZone = TimeZone.current
  8146. let startText = summary.meetingStartTime.map { formatter.string(from: $0) }
  8147. let endText = summary.meetingEndTime.map { formatter.string(from: $0) }
  8148. switch (startText, endText) {
  8149. case let (start?, end?): return "\(start) → \(end)"
  8150. case let (start?, nil): return start
  8151. case let (nil, end?): return "Ended \(end)"
  8152. case (nil, nil): return "Date unavailable"
  8153. }
  8154. }
  8155. // MARK: - AI Companion data loading & actions
  8156. private func aiCompanionDateRange() -> (from: Date?, to: Date?) {
  8157. let now = Date()
  8158. let calendar = Calendar.current
  8159. let days: Int
  8160. switch aiCompanionSelectedRange {
  8161. case "Last 7 days": days = 7
  8162. case "Last 30 days": days = 30
  8163. case "Last 90 days": days = 90
  8164. case "All available": return (nil, nil)
  8165. default: days = 30
  8166. }
  8167. let from = calendar.date(byAdding: .day, value: -days, to: now) ?? now
  8168. return (from, now)
  8169. }
  8170. @MainActor
  8171. private func setAiCompanionLoadingUI(_ loading: Bool) {
  8172. if loading {
  8173. aiCompanionLoadingIndicator?.startAnimation(nil)
  8174. } else {
  8175. aiCompanionLoadingIndicator?.stopAnimation(nil)
  8176. }
  8177. aiCompanionRefreshButton?.isEnabled = !loading
  8178. aiCompanionRefreshButton?.alphaValue = loading ? 0.55 : 1.0
  8179. aiCompanionRangePopup?.isEnabled = !loading
  8180. }
  8181. private func loadAICompanionSummaries(force: Bool) async {
  8182. guard isUserLoggedIn() else {
  8183. await MainActor.run {
  8184. self.aiCompanionSummaries = []
  8185. self.aiCompanionSelectedIndex = nil
  8186. self.renderAiCompanionList()
  8187. self.renderAiCompanionDetail()
  8188. self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
  8189. }
  8190. return
  8191. }
  8192. if aiCompanionIsLoading && force == false { return }
  8193. aiCompanionIsLoading = true
  8194. await MainActor.run {
  8195. self.setAiCompanionLoadingUI(true)
  8196. self.aiCompanionStatusLabel?.stringValue = "Loading AI notes…"
  8197. }
  8198. defer {
  8199. aiCompanionIsLoading = false
  8200. Task { @MainActor in self.setAiCompanionLoadingUI(false) }
  8201. }
  8202. do {
  8203. let token = try await zoomOAuth.validAccessTokenForMeetingSummaries(presentingWindow: view.window)
  8204. let (from, to) = aiCompanionDateRange()
  8205. let summaries = try await fetchZoomMeetingSummaries(accessToken: token, from: from, to: to)
  8206. await MainActor.run {
  8207. self.aiCompanionSummaries = summaries.sorted(by: { lhs, rhs in
  8208. (lhs.meetingStartTime ?? .distantPast) > (rhs.meetingStartTime ?? .distantPast)
  8209. })
  8210. self.aiCompanionLastLoadedAt = Date()
  8211. if self.aiCompanionSummaries.isEmpty {
  8212. self.aiCompanionSelectedIndex = nil
  8213. } else {
  8214. if let idx = self.aiCompanionSelectedIndex, idx >= self.aiCompanionSummaries.count {
  8215. self.aiCompanionSelectedIndex = 0
  8216. } else if self.aiCompanionSelectedIndex == nil {
  8217. self.aiCompanionSelectedIndex = 0
  8218. }
  8219. }
  8220. self.renderAiCompanionList()
  8221. self.renderAiCompanionDetail()
  8222. if self.aiCompanionSummaries.isEmpty {
  8223. self.aiCompanionStatusLabel?.stringValue = "No AI notes found for this range yet."
  8224. } else {
  8225. let formatter = DateFormatter()
  8226. formatter.dateStyle = .none
  8227. formatter.timeStyle = .short
  8228. self.aiCompanionStatusLabel?.stringValue = "Updated \(formatter.string(from: Date()))"
  8229. }
  8230. }
  8231. } catch {
  8232. await MainActor.run {
  8233. self.aiCompanionSummaries = []
  8234. self.aiCompanionSelectedIndex = nil
  8235. self.renderAiCompanionList()
  8236. self.renderAiCompanionDetail()
  8237. if case ZoomOAuthError.missingRequiredScope = error {
  8238. self.zoomOAuth.clearSavedTokens()
  8239. 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."
  8240. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  8241. let minutes = max(1, Int(ceil(Double(retryAfterSeconds ?? 300) / 60.0)))
  8242. self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  8243. } else if case ZoomOAuthError.missingClientId = error {
  8244. self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
  8245. } else if case ZoomOAuthError.missingClientSecret = error {
  8246. self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
  8247. } else if case ZoomOAuthError.authenticationCancelled = error {
  8248. self.aiCompanionStatusLabel?.stringValue = "Sign-in cancelled. Please sign in with Zoom to load AI notes."
  8249. } else {
  8250. self.aiCompanionStatusLabel?.stringValue = "Unable to load AI notes right now. Please try again shortly."
  8251. }
  8252. }
  8253. }
  8254. }
  8255. private func aiCompanionRefreshIfStale() {
  8256. let stale: Bool
  8257. if let lastLoaded = aiCompanionLastLoadedAt {
  8258. stale = Date().timeIntervalSince(lastLoaded) > 60
  8259. } else {
  8260. stale = true
  8261. }
  8262. if stale {
  8263. Task { await self.loadAICompanionSummaries(force: false) }
  8264. }
  8265. }
  8266. @objc private func aiCompanionRefreshTapped() {
  8267. // Removed: Zoom AI summaries fetch (AI Companion now shows local recordings only).
  8268. }
  8269. @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
  8270. // Removed: Zoom AI summaries range filter (AI Companion now shows local recordings only).
  8271. }
  8272. @objc private func aiCompanionRowTapped(_ sender: NSButton) {
  8273. // Removed: Zoom AI summaries row selection (AI Companion now shows local recordings only).
  8274. }
  8275. @objc private func aiCompanionOpenDocTapped() {
  8276. // Removed: Zoom Docs deep link (AI Companion now shows local recordings only).
  8277. }
  8278. @objc private func aiCompanionCopyMarkdownTapped() {
  8279. // Removed: Zoom AI summary markdown export (AI Companion now shows local recordings only).
  8280. }
  8281. private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
  8282. if let content = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
  8283. content.isEmpty == false {
  8284. return content
  8285. }
  8286. var lines: [String] = []
  8287. let title = summary.summaryTitle?.isEmpty == false ? summary.summaryTitle! : (summary.meetingTopic ?? "Meeting summary")
  8288. lines.append("# \(title)")
  8289. lines.append("")
  8290. lines.append("_\(aiCompanionDateText(for: summary))_")
  8291. lines.append("")
  8292. if let overview = summary.summaryOverview, overview.isEmpty == false {
  8293. lines.append("## Overview")
  8294. lines.append(overview)
  8295. lines.append("")
  8296. }
  8297. for section in summary.summaryDetails {
  8298. let header = section.label.isEmpty ? "Details" : section.label
  8299. lines.append("## \(header)")
  8300. if section.summary.isEmpty == false {
  8301. lines.append(section.summary)
  8302. }
  8303. lines.append("")
  8304. }
  8305. if summary.nextSteps.isEmpty == false {
  8306. lines.append("## Next steps")
  8307. for step in summary.nextSteps {
  8308. lines.append("- \(step)")
  8309. }
  8310. lines.append("")
  8311. }
  8312. if let url = summary.summaryDocURL {
  8313. lines.append("Full document: \(url.absoluteString)")
  8314. }
  8315. return lines.joined(separator: "\n")
  8316. }
  8317. @MainActor
  8318. private func refreshWidgetsPagePremiumState() {
  8319. guard let host = widgetsPageRootView as? NSHostingView<WidgetsRootView> else { return }
  8320. host.rootView = WidgetsRootView(
  8321. canAddWidgets: storeKitCoordinator.hasPremiumAccess,
  8322. onAddBlocked: { [weak self] in
  8323. self?.showPaywall()
  8324. }
  8325. )
  8326. }
  8327. @MainActor
  8328. private func reconcileDesktopWidgetsForPremiumAccess() {
  8329. refreshWidgetsPagePremiumState()
  8330. if storeKitCoordinator.hasPremiumAccess {
  8331. DesktopWidgetWindowManager.shared.restore()
  8332. } else {
  8333. DesktopWidgetWindowManager.shared.removeAllWidgets()
  8334. }
  8335. }
  8336. @MainActor
  8337. private func updateWidgetMeetingStoreFromCurrentMeetings() {
  8338. let upcoming = allScheduledMeetings
  8339. .filter { $0.start >= Date() }
  8340. .sorted(by: { $0.start < $1.start })
  8341. .prefix(3)
  8342. .map { meeting in
  8343. WidgetMeetingSnapshot(
  8344. id: meeting.meetingID ?? meeting.title,
  8345. title: meeting.title,
  8346. timeText: widgetTimeText(for: meeting),
  8347. joinLink: meeting.webURL?.absoluteString,
  8348. startTime: meeting.start.timeIntervalSince1970,
  8349. endTime: meeting.end?.timeIntervalSince1970,
  8350. timeZoneIdentifier: meeting.meetingTimeZone?.identifier
  8351. )
  8352. }
  8353. WidgetMeetingStore.save(Array(upcoming))
  8354. DesktopWidgetWindowManager.shared.refreshForMeetingsChange()
  8355. }
  8356. private func widgetTimeText(for meeting: ScheduledMeeting) -> String {
  8357. let formatter = DateFormatter()
  8358. formatter.dateFormat = "h:mm a"
  8359. formatter.timeZone = meeting.meetingTimeZone ?? TimeZone.current
  8360. return formatter.string(from: meeting.start)
  8361. }
  8362. private func setupWidgetBridge() {
  8363. let center = NotificationCenter.default
  8364. center.addObserver(forName: .widgetOpenHomePage, object: nil, queue: .main) { [weak self] _ in
  8365. self?.menuBarShowSection(.home)
  8366. }
  8367. center.addObserver(forName: .widgetOpenMeetingsPage, object: nil, queue: .main) { [weak self] _ in
  8368. self?.menuBarShowSection(.meetings)
  8369. }
  8370. center.addObserver(forName: .widgetOpenSchedulerPage, object: nil, queue: .main) { [weak self] _ in
  8371. self?.menuBarShowSection(.scheduler)
  8372. }
  8373. center.addObserver(forName: .widgetOpenWidgetsPage, object: nil, queue: .main) { [weak self] _ in
  8374. self?.menuBarShowSection(.widgets)
  8375. }
  8376. center.addObserver(forName: .widgetOpenSettingsPage, object: nil, queue: .main) { [weak self] _ in
  8377. self?.menuBarShowSection(.settings)
  8378. }
  8379. center.addObserver(forName: .widgetStartNewMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  8380. self?.menuBarTriggerNewMeeting()
  8381. }
  8382. center.addObserver(forName: .widgetJoinMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  8383. self?.menuBarTriggerJoinMeeting()
  8384. }
  8385. center.addObserver(forName: .widgetScheduleMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  8386. self?.menuBarTriggerScheduleMeeting()
  8387. }
  8388. center.addObserver(forName: .widgetRefreshRequested, object: nil, queue: .main) { [weak self] _ in
  8389. self?.triggerMeetingsRefresh(force: true)
  8390. }
  8391. center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
  8392. guard let self else { return }
  8393. guard let link = notification.userInfo?["link"] as? String else { return }
  8394. self.joinZoomMeetingFromLink(link, displayName: nil)
  8395. }
  8396. }
  8397. /// Meetings + Ai Companion filter rows: preset + dates stay near intrinsic width; flexible gap before Apply/Reset/Refresh.
  8398. private func configureResponsiveMeetingsStyleFilterRow(
  8399. filterRow: NSStackView,
  8400. categoryFilter: NSPopUpButton,
  8401. fromDatePicker: NSDatePicker,
  8402. toDatePicker: NSDatePicker,
  8403. applyFiltersButton: NSButton,
  8404. resetFiltersButton: NSButton,
  8405. refreshButton: NSButton
  8406. ) {
  8407. filterRow.distribution = .fill
  8408. filterRow.setContentHuggingPriority(.defaultLow, for: .horizontal)
  8409. let spacer = NSView()
  8410. spacer.translatesAutoresizingMaskIntoConstraints = false
  8411. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  8412. spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  8413. filterRow.insertArrangedSubview(spacer, at: 3)
  8414. categoryFilter.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8415. categoryFilter.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
  8416. fromDatePicker.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8417. fromDatePicker.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
  8418. toDatePicker.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8419. toDatePicker.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
  8420. applyFiltersButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8421. applyFiltersButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  8422. resetFiltersButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8423. resetFiltersButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  8424. refreshButton.setContentHuggingPriority(.required, for: .horizontal)
  8425. refreshButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  8426. NSLayoutConstraint.activate([
  8427. categoryFilter.widthAnchor.constraint(greaterThanOrEqualToConstant: 120),
  8428. fromDatePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 170),
  8429. toDatePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 170),
  8430. fromDatePicker.widthAnchor.constraint(equalTo: toDatePicker.widthAnchor),
  8431. applyFiltersButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
  8432. resetFiltersButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
  8433. refreshButton.widthAnchor.constraint(equalToConstant: 44),
  8434. applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  8435. resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  8436. refreshButton.heightAnchor.constraint(equalToConstant: 34)
  8437. ])
  8438. }
  8439. private func makeMeetingsPageView() -> NSView {
  8440. let root = NSView()
  8441. root.wantsLayer = true
  8442. root.layer?.backgroundColor = NSColor.clear.cgColor
  8443. let container = NSView()
  8444. container.translatesAutoresizingMaskIntoConstraints = false
  8445. container.wantsLayer = true
  8446. container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  8447. container.layer?.cornerRadius = 16
  8448. container.layer?.borderWidth = 1
  8449. container.layer?.borderColor = palette.inputBorder.cgColor
  8450. root.addSubview(container)
  8451. let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
  8452. let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
  8453. let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
  8454. let titleStack = NSStackView(views: [title, subtitle, count])
  8455. titleStack.orientation = .vertical
  8456. titleStack.spacing = 4
  8457. titleStack.alignment = .leading
  8458. titleStack.translatesAutoresizingMaskIntoConstraints = false
  8459. let categoryFilter = NSPopUpButton()
  8460. categoryFilter.translatesAutoresizingMaskIntoConstraints = false
  8461. categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
  8462. categoryFilter.selectItem(withTitle: "All")
  8463. categoryFilter.target = self
  8464. categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
  8465. let fromDatePicker = NSDatePicker()
  8466. fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
  8467. fromDatePicker.datePickerStyle = .textFieldAndStepper
  8468. fromDatePicker.datePickerMode = .single
  8469. fromDatePicker.datePickerElements = [.yearMonthDay]
  8470. fromDatePicker.dateValue = Date()
  8471. fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  8472. let toDatePicker = NSDatePicker()
  8473. toDatePicker.translatesAutoresizingMaskIntoConstraints = false
  8474. toDatePicker.datePickerStyle = .textFieldAndStepper
  8475. toDatePicker.datePickerMode = .single
  8476. toDatePicker.datePickerElements = [.yearMonthDay]
  8477. toDatePicker.dateValue = Date()
  8478. toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  8479. let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
  8480. applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  8481. applyFiltersButton.isBordered = false
  8482. applyFiltersButton.wantsLayer = true
  8483. applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
  8484. applyFiltersButton.layer?.cornerRadius = 10
  8485. applyFiltersButton.contentTintColor = .white
  8486. applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8487. let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
  8488. resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  8489. resetFiltersButton.isBordered = false
  8490. resetFiltersButton.wantsLayer = true
  8491. resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  8492. resetFiltersButton.layer?.cornerRadius = 10
  8493. resetFiltersButton.layer?.borderWidth = 1
  8494. resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  8495. resetFiltersButton.contentTintColor = primaryText
  8496. resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8497. let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
  8498. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  8499. refreshButton.isBordered = false
  8500. refreshButton.wantsLayer = true
  8501. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  8502. refreshButton.layer?.cornerRadius = 10
  8503. refreshButton.layer?.borderWidth = 1
  8504. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  8505. refreshButton.contentTintColor = primaryText
  8506. refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8507. if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
  8508. refreshButton.image = image
  8509. refreshButton.imagePosition = .imageOnly
  8510. }
  8511. let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
  8512. filterRow.orientation = .horizontal
  8513. filterRow.spacing = 10
  8514. filterRow.alignment = .centerY
  8515. filterRow.translatesAutoresizingMaskIntoConstraints = false
  8516. configureResponsiveMeetingsStyleFilterRow(
  8517. filterRow: filterRow,
  8518. categoryFilter: categoryFilter,
  8519. fromDatePicker: fromDatePicker,
  8520. toDatePicker: toDatePicker,
  8521. applyFiltersButton: applyFiltersButton,
  8522. resetFiltersButton: resetFiltersButton,
  8523. refreshButton: refreshButton
  8524. )
  8525. let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
  8526. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  8527. let listScroll = NSScrollView()
  8528. listScroll.translatesAutoresizingMaskIntoConstraints = false
  8529. listScroll.drawsBackground = false
  8530. listScroll.hasVerticalScroller = true
  8531. listScroll.hasHorizontalScroller = false
  8532. listScroll.autohidesScrollers = true
  8533. let listDocument = FlippedView()
  8534. listDocument.translatesAutoresizingMaskIntoConstraints = false
  8535. let listStack = NSStackView()
  8536. listStack.translatesAutoresizingMaskIntoConstraints = false
  8537. listStack.orientation = .vertical
  8538. listStack.spacing = 12
  8539. listStack.alignment = .leading
  8540. listStack.setContentHuggingPriority(.required, for: .vertical)
  8541. listStack.setContentCompressionResistancePriority(.required, for: .vertical)
  8542. listScroll.documentView = listDocument
  8543. listDocument.addSubview(listStack)
  8544. let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
  8545. empty.translatesAutoresizingMaskIntoConstraints = false
  8546. empty.isHidden = true
  8547. let loadMoreButton = NSButton(title: "", target: nil, action: nil)
  8548. loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
  8549. loadMoreButton.isBordered = false
  8550. loadMoreButton.wantsLayer = true
  8551. loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
  8552. loadMoreButton.layer?.cornerRadius = 10
  8553. loadMoreButton.layer?.borderWidth = 1
  8554. loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
  8555. loadMoreButton.contentTintColor = primaryText
  8556. loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8557. loadMoreButton.isHidden = true
  8558. [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
  8559. NSLayoutConstraint.activate([
  8560. container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  8561. container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  8562. container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  8563. container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  8564. titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
  8565. titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  8566. titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
  8567. filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
  8568. filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  8569. filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
  8570. statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
  8571. statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
  8572. statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
  8573. listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
  8574. listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  8575. listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  8576. listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  8577. listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
  8578. listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
  8579. listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
  8580. listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
  8581. // Pin bottom edge so document height grows with arranged subviews.
  8582. listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
  8583. empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
  8584. empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
  8585. loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  8586. loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  8587. loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  8588. loadMoreButton.heightAnchor.constraint(equalToConstant: 1)
  8589. ])
  8590. meetingsPageCategoryFilter = categoryFilter
  8591. meetingsPageFromDatePicker = fromDatePicker
  8592. meetingsPageToDatePicker = toDatePicker
  8593. meetingsPageRefreshButton = refreshButton
  8594. meetingsPageCountLabel = count
  8595. meetingsPageStatusLabel = statusLabel
  8596. meetingsPageListStack = listStack
  8597. meetingsPageEmptyLabel = empty
  8598. meetingsPageScrollView = listScroll
  8599. meetingsPageLoadMoreButton = loadMoreButton
  8600. observeMeetingsPageInfiniteScroll(in: listScroll)
  8601. updateMeetingsDatePickersEnabledState()
  8602. return root
  8603. }
  8604. deinit {
  8605. premiumRatingPromptWorkItem?.cancel()
  8606. usageRatingPromptWorkItem?.cancel()
  8607. removeSearchFieldObserver()
  8608. removeMeetingSearchObserver()
  8609. clearMeetingsPageScrollObserver()
  8610. removeSearchShortcutMonitor()
  8611. for observer in windowFullScreenObservers {
  8612. NotificationCenter.default.removeObserver(observer)
  8613. }
  8614. windowFullScreenObservers.removeAll()
  8615. for observer in calendarDayChangeObservers {
  8616. NotificationCenter.default.removeObserver(observer)
  8617. }
  8618. calendarDayChangeObservers.removeAll()
  8619. if let workspaceWakeObserver {
  8620. NSWorkspace.shared.notificationCenter.removeObserver(workspaceWakeObserver)
  8621. self.workspaceWakeObserver = nil
  8622. }
  8623. clockTimer?.invalidate()
  8624. }
  8625. private func ensureUsageSessionStartDate() {
  8626. if UserDefaults.standard.object(forKey: usageSessionStartedAtKey) == nil {
  8627. UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  8628. }
  8629. }
  8630. private func usageSessionStartedAt() -> Date {
  8631. let storedValue = UserDefaults.standard.double(forKey: usageSessionStartedAtKey)
  8632. if storedValue > 0 {
  8633. return Date(timeIntervalSince1970: storedValue)
  8634. }
  8635. let now = Date()
  8636. UserDefaults.standard.set(now.timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  8637. return now
  8638. }
  8639. private func hasCompletedOrSeenRatingFlow() -> Bool {
  8640. UserDefaults.standard.bool(forKey: ratingCompletedKey)
  8641. }
  8642. private func scheduleUsageBasedRatingPromptIfNeeded() {
  8643. usageRatingPromptWorkItem?.cancel()
  8644. guard hasCompletedOrSeenRatingFlow() == false else { return }
  8645. let elapsed = Date().timeIntervalSince(usageSessionStartedAt())
  8646. let delay = max(0, usageRatingPromptDelay - elapsed)
  8647. let workItem = DispatchWorkItem { [weak self] in
  8648. self?.presentRatingPrompt(source: "usage")
  8649. }
  8650. usageRatingPromptWorkItem = workItem
  8651. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  8652. }
  8653. private func schedulePremiumRatingPromptIfNeeded() {
  8654. guard hasCompletedOrSeenRatingFlow() == false else { return }
  8655. guard premiumRatingPromptWorkItem == nil else { return }
  8656. let workItem = DispatchWorkItem { [weak self] in
  8657. self?.premiumRatingPromptWorkItem = nil
  8658. self?.presentRatingPrompt(source: "premium")
  8659. }
  8660. premiumRatingPromptWorkItem = workItem
  8661. DispatchQueue.main.asyncAfter(deadline: .now() + premiumRatingPromptDelay, execute: workItem)
  8662. }
  8663. private func presentRatingPrompt(source: String) {
  8664. guard hasCompletedOrSeenRatingFlow() == false else { return }
  8665. UserDefaults.standard.set(true, forKey: ratingPromptShownKey)
  8666. NSApp.activate(ignoringOtherApps: true)
  8667. let alert = NSAlert()
  8668. alert.alertStyle = .informational
  8669. alert.messageText = "Enjoying Companion AI for Zoom?"
  8670. if source == "premium" {
  8671. alert.informativeText = "You unlocked Premium. If you're enjoying the app, please rate us."
  8672. } else {
  8673. alert.informativeText = "You've been using the app for a while. If it's helping you, please rate us."
  8674. }
  8675. alert.addButton(withTitle: "Rate Now")
  8676. alert.addButton(withTitle: "Later")
  8677. let response = alert.runModal()
  8678. if response == .alertFirstButtonReturn {
  8679. UserDefaults.standard.set(true, forKey: ratingCompletedKey)
  8680. SKStoreReviewController.requestReview()
  8681. }
  8682. }
  8683. private func installCalendarDayChangeObservers() {
  8684. guard calendarDayChangeObservers.isEmpty else { return }
  8685. let center = NotificationCenter.default
  8686. let dayChangeNames: [Notification.Name] = [.NSCalendarDayChanged, .NSSystemClockDidChange]
  8687. for name in dayChangeNames {
  8688. let observer = center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
  8689. Task { @MainActor [weak self] in
  8690. self?.refreshHomeForCurrentDayIfNeeded(force: true)
  8691. }
  8692. }
  8693. calendarDayChangeObservers.append(observer)
  8694. }
  8695. workspaceWakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
  8696. forName: NSWorkspace.didWakeNotification,
  8697. object: nil,
  8698. queue: .main
  8699. ) { [weak self] _ in
  8700. Task { @MainActor [weak self] in
  8701. self?.refreshHomeForCurrentDayIfNeeded(force: true)
  8702. }
  8703. }
  8704. }
  8705. /// When the calendar day changes (midnight, wake from sleep, or return to the app), reset the home UI to today and reload meetings.
  8706. @MainActor
  8707. private func refreshHomeForCurrentDayIfNeeded(force: Bool = false) {
  8708. let calendar = Calendar.current
  8709. let todayStart = calendar.startOfDay(for: Date())
  8710. let dayChanged = calendar.isDate(todayStart, inSameDayAs: lastObservedCalendarDayStart) == false
  8711. guard dayChanged || force else { return }
  8712. if dayChanged {
  8713. lastObservedCalendarDayStart = todayStart
  8714. applyCalendarDayRolloverRefresh()
  8715. return
  8716. }
  8717. updateClock()
  8718. guard hasBuiltInitialHomeView else { return }
  8719. updateMeetingsDayUI()
  8720. applyFilteredMeetings()
  8721. applyMeetingsPageFilters(resetVisible: false)
  8722. renderSchedulerCalendarGrid()
  8723. if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
  8724. triggerMeetingsRefresh(force: false)
  8725. }
  8726. }
  8727. @MainActor
  8728. private func applyCalendarDayRolloverRefresh() {
  8729. updateClock()
  8730. guard hasBuiltInitialHomeView else { return }
  8731. setSelectedMeetingsDayStart(Date())
  8732. applyMeetingsPageFilters(resetVisible: true)
  8733. renderAiCompanionList()
  8734. if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
  8735. triggerMeetingsRefresh(force: true)
  8736. }
  8737. }
  8738. private func startClock() {
  8739. clockTimer?.invalidate()
  8740. clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  8741. Task { @MainActor [weak self] in
  8742. self?.updateClock()
  8743. }
  8744. }
  8745. Task { @MainActor in
  8746. updateClock()
  8747. }
  8748. }
  8749. @MainActor
  8750. private func updateClock() {
  8751. refreshHomeForCurrentDayIfNeeded()
  8752. let now = Date()
  8753. let timeFormatter = DateFormatter()
  8754. timeFormatter.dateFormat = "h:mm a"
  8755. let dateFormatter = DateFormatter()
  8756. dateFormatter.dateFormat = "EEEE, d MMM"
  8757. timeLabel?.stringValue = timeFormatter.string(from: now)
  8758. dateLabel?.stringValue = dateFormatter.string(from: now)
  8759. }
  8760. // MARK: - Shared UI
  8761. private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .home) -> NSView {
  8762. let sidebar = NSView()
  8763. sidebar.wantsLayer = true
  8764. sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
  8765. let stack = NSStackView()
  8766. stack.orientation = .vertical
  8767. stack.spacing = style == .home ? 12 : 16
  8768. stack.alignment = .centerX
  8769. stack.distribution = .fill
  8770. // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
  8771. stack.setContentHuggingPriority(.required, for: .vertical)
  8772. stack.setContentCompressionResistancePriority(.required, for: .vertical)
  8773. stack.translatesAutoresizingMaskIntoConstraints = false
  8774. sidebar.addSubview(stack)
  8775. for item in items {
  8776. let row = NSView()
  8777. row.translatesAutoresizingMaskIntoConstraints = false
  8778. row.wantsLayer = true
  8779. let selectedRow = item == selected
  8780. row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  8781. row.layer?.cornerRadius = style == .home ? 12 : 10
  8782. row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
  8783. // Prevent rows from stretching/collapsing when the window resizes.
  8784. row.setContentHuggingPriority(.required, for: .vertical)
  8785. row.setContentCompressionResistancePriority(.required, for: .vertical)
  8786. if style == .home {
  8787. // Must be tall enough for icon (26) + paddings + label without clipping.
  8788. row.heightAnchor.constraint(equalToConstant: 66).isActive = true
  8789. }
  8790. if style == .home {
  8791. let iconContainer = NSView()
  8792. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  8793. row.addSubview(iconContainer)
  8794. let iconView = NSImageView()
  8795. iconView.translatesAutoresizingMaskIntoConstraints = false
  8796. iconView.contentTintColor = selectedRow ? primaryText : sidebarInactiveText
  8797. iconView.imageScaling = .scaleProportionallyUpOrDown
  8798. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  8799. let preferredSymbol = sidebarSymbolName(for: item, filled: selectedRow)
  8800. let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
  8801. iconView.image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
  8802. ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item)
  8803. iconContainer.addSubview(iconView)
  8804. let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
  8805. label.translatesAutoresizingMaskIntoConstraints = false
  8806. row.addSubview(label)
  8807. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  8808. hit.identifier = NSUserInterfaceItemIdentifier(item)
  8809. hit.isBordered = false
  8810. hit.bezelStyle = .shadowlessSquare
  8811. hit.focusRingType = .none
  8812. hit.translatesAutoresizingMaskIntoConstraints = false
  8813. row.addSubview(hit, positioned: .above, relativeTo: nil)
  8814. NSLayoutConstraint.activate([
  8815. iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
  8816. iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8817. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  8818. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  8819. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  8820. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  8821. iconView.widthAnchor.constraint(equalToConstant: 22),
  8822. iconView.heightAnchor.constraint(equalToConstant: 22),
  8823. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  8824. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8825. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
  8826. hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  8827. hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
  8828. hit.topAnchor.constraint(equalTo: row.topAnchor),
  8829. hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  8830. ])
  8831. homeSidebarRowViews[item] = row
  8832. homeSidebarIconViews[item] = iconView
  8833. homeSidebarLabels[item] = label
  8834. if item == "Hub" {
  8835. let badge = makeSidebarBadge(text: "1")
  8836. badge.translatesAutoresizingMaskIntoConstraints = false
  8837. iconContainer.addSubview(badge)
  8838. NSLayoutConstraint.activate([
  8839. badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
  8840. badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
  8841. ])
  8842. } else if item == "More" {
  8843. let dot = NSView()
  8844. dot.translatesAutoresizingMaskIntoConstraints = false
  8845. dot.wantsLayer = true
  8846. dot.layer?.backgroundColor = NSColor.systemRed.cgColor
  8847. dot.layer?.cornerRadius = 4
  8848. row.addSubview(dot)
  8849. NSLayoutConstraint.activate([
  8850. dot.widthAnchor.constraint(equalToConstant: 8),
  8851. dot.heightAnchor.constraint(equalToConstant: 8),
  8852. dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  8853. dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
  8854. ])
  8855. }
  8856. } else {
  8857. let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
  8858. let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
  8859. [icon, label].forEach {
  8860. $0.translatesAutoresizingMaskIntoConstraints = false
  8861. row.addSubview($0)
  8862. }
  8863. NSLayoutConstraint.activate([
  8864. icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  8865. icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8866. label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
  8867. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8868. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  8869. ])
  8870. }
  8871. stack.addArrangedSubview(row)
  8872. }
  8873. NSLayoutConstraint.activate([
  8874. stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
  8875. stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
  8876. ])
  8877. if style == .home {
  8878. NSLayoutConstraint.activate([
  8879. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
  8880. ])
  8881. }
  8882. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  8883. return sidebar
  8884. }
  8885. @objc private func homeSidebarItemTapped(_ sender: NSButton) {
  8886. guard let item = sender.identifier?.rawValue else { return }
  8887. closeEmbeddedBrowserPanel()
  8888. selectedHomeSidebarItem = item
  8889. updateHomeSidebarHighlight()
  8890. updateSelectedHomeSectionUI()
  8891. }
  8892. @objc private func newMeetingTapped() {
  8893. menuBarTriggerNewMeeting()
  8894. }
  8895. func menuBarIsUserLoggedIn() -> Bool {
  8896. isUserLoggedIn()
  8897. }
  8898. func menuBarShowSection(_ section: HomeSection) {
  8899. closeEmbeddedBrowserPanel()
  8900. selectedHomeSidebarItem = section.rawValue
  8901. updateHomeSidebarHighlight()
  8902. updateSelectedHomeSectionUI()
  8903. }
  8904. func menuBarOpenTopMeeting(joinLink: String?) {
  8905. if let link = joinLink?.trimmingCharacters(in: .whitespacesAndNewlines),
  8906. link.isEmpty == false {
  8907. joinZoomMeetingFromLink(link, displayName: nil)
  8908. return
  8909. }
  8910. menuBarShowSection(.meetings)
  8911. }
  8912. func menuBarSyncTopMeetingsForStatusMenu() {
  8913. updateWidgetMeetingStoreFromCurrentMeetings()
  8914. }
  8915. func menuBarTriggerNewMeeting() {
  8916. guard isUserLoggedIn() else {
  8917. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
  8918. return
  8919. }
  8920. Task {
  8921. do {
  8922. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  8923. guard configured else { return }
  8924. let parentWindow = await MainActor.run { self.view.window }
  8925. let token = try await zoomOAuth.validAccessToken(
  8926. presentingWindow: parentWindow,
  8927. requiringAnyOf: ["meeting:write:meeting"]
  8928. )
  8929. let result = try await createZoomInstantMeeting(accessToken: token)
  8930. guard let joinURL = self.embeddedWebClientURLForZoomMeeting(
  8931. meetingId: result.id,
  8932. joinURLString: result.join_url,
  8933. startURLString: result.start_url
  8934. ) else {
  8935. await MainActor.run {
  8936. self.showSimpleAlert(
  8937. title: "Unable to start",
  8938. 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."
  8939. )
  8940. }
  8941. return
  8942. }
  8943. await MainActor.run {
  8944. self.openWebURLPreferringInApp(
  8945. joinURL,
  8946. zoomMeetingDisplayName: "Instant meeting",
  8947. recordingConsentWindowTitle: "New Meeting"
  8948. )
  8949. }
  8950. } catch {
  8951. await MainActor.run {
  8952. if case ZoomOAuthError.missingRequiredScope(_) = error {
  8953. self.zoomOAuth.clearSavedTokens()
  8954. self.showSimpleAlert(
  8955. title: "Zoom permissions",
  8956. 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."
  8957. )
  8958. } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
  8959. let seconds = max(retryAfter ?? 300, 30)
  8960. let minutes = Int(ceil(Double(seconds) / 60.0))
  8961. self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before starting another meeting. Try again in about \(minutes) min.")
  8962. } else if case ZoomOAuthError.authenticationCancelled = error {
  8963. return
  8964. } else {
  8965. self.showSimpleError("Could not start meeting", error: error)
  8966. }
  8967. }
  8968. }
  8969. }
  8970. }
  8971. func menuBarTriggerJoinMeeting() {
  8972. joinMeetingTapped()
  8973. }
  8974. func menuBarTriggerScheduleMeeting() {
  8975. scheduleMeetingTapped()
  8976. }
  8977. func menuBarTriggerAuthAction() {
  8978. if isUserLoggedIn() {
  8979. logoutTapped()
  8980. } else {
  8981. zoomPrimarySignInTapped()
  8982. }
  8983. }
  8984. @MainActor
  8985. private func updateHomeSidebarHighlight() {
  8986. for (item, row) in homeSidebarRowViews {
  8987. let selected = item == selectedHomeSidebarItem
  8988. row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  8989. homeSidebarLabels[item]?.textColor = selected ? primaryText : sidebarInactiveText
  8990. homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : sidebarInactiveText
  8991. let preferredSymbol = sidebarSymbolName(for: item, filled: selected)
  8992. let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
  8993. if let image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
  8994. ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item) {
  8995. homeSidebarIconViews[item]?.image = image
  8996. }
  8997. }
  8998. }
  8999. @MainActor
  9000. private func updateSelectedHomeSectionUI() {
  9001. let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
  9002. let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
  9003. let isSettings = selectedHomeSidebarItem == HomeSection.settings.rawValue
  9004. let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
  9005. let isWidgets = selectedHomeSidebarItem == HomeSection.widgets.rawValue
  9006. let isAiCompanion = selectedHomeSidebarItem == HomeSection.aiCompanion.rawValue
  9007. let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  9008. homeWelcomeLabel?.stringValue = "Home"
  9009. homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
  9010. let dashboardViews: [NSView?] = [
  9011. homeTimeLabelView,
  9012. homeDateLabelView,
  9013. homeActionsRow,
  9014. homeMeetingsPanel,
  9015. meetingsDayHeaderLabel,
  9016. meetingsStatusLabel,
  9017. meetingsPrevDayButton,
  9018. meetingsTodayButton,
  9019. meetingsNextDayButton,
  9020. meetingsScrollView,
  9021. refreshMeetingsButton
  9022. ]
  9023. let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler || isWidgets || isAiCompanion
  9024. dashboardViews.forEach { $0?.isHidden = hideDashboard }
  9025. // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
  9026. // and showed "No meetings…" on top of meeting cards when returning to Home.
  9027. if hideDashboard {
  9028. emptyMeetingLabel?.isHidden = true
  9029. } else {
  9030. let hasMeetingCards = (meetingsListStack?.arrangedSubviews.isEmpty == false)
  9031. emptyMeetingLabel?.isHidden = premiumLocked || hasMeetingCards
  9032. }
  9033. homeSettingsView?.isHidden = isSettings == false
  9034. meetingsPageRootView?.isHidden = isMeetings == false
  9035. schedulerRootView?.isHidden = isScheduler == false
  9036. widgetsPageRootView?.isHidden = isWidgets == false
  9037. aiCompanionPageRootView?.isHidden = isAiCompanion == false
  9038. if isHome {
  9039. homePlaceholderLabel?.isHidden = true
  9040. } else {
  9041. // Keep non-Home pages empty for now.
  9042. homePlaceholderLabel?.isHidden = true
  9043. }
  9044. if isScheduler {
  9045. updateSchedulerDateHeader()
  9046. applySchedulerMeetingsForSelectedDate()
  9047. renderSchedulerCalendarGrid()
  9048. }
  9049. if isMeetings {
  9050. applyMeetingsPageFilters(resetVisible: false)
  9051. }
  9052. if isAiCompanion {
  9053. recordedMeetings = MeetingRecordingManager.shared.recordings
  9054. renderAiCompanionList()
  9055. updateAiCompanionRecordingUI()
  9056. }
  9057. }
  9058. private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
  9059. switch item {
  9060. case "Home":
  9061. return filled ? "house.fill" : "house"
  9062. case "Meetings":
  9063. return filled ? "video.fill" : "video"
  9064. case "Chat":
  9065. return filled ? "message.fill" : "message"
  9066. case "Scheduler":
  9067. // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
  9068. return "calendar.badge.clock"
  9069. case "Widgets":
  9070. return filled ? "rectangle.grid.2x2.fill" : "rectangle.grid.2x2"
  9071. case "AI Companion":
  9072. return filled ? "sparkles" : "sparkles"
  9073. case "Settings":
  9074. // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
  9075. return filled ? "gearshape.fill" : "gearshape"
  9076. case "Hub":
  9077. return "square.grid.3x3"
  9078. case "More":
  9079. return "ellipsis"
  9080. default:
  9081. return "circle"
  9082. }
  9083. }
  9084. private func makeSidebarBadge(text: String) -> NSView {
  9085. let badge = NSView()
  9086. badge.wantsLayer = true
  9087. badge.layer?.backgroundColor = NSColor.systemRed.cgColor
  9088. badge.layer?.cornerRadius = 12 / 2
  9089. badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
  9090. badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
  9091. let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
  9092. label.translatesAutoresizingMaskIntoConstraints = false
  9093. badge.addSubview(label)
  9094. NSLayoutConstraint.activate([
  9095. label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
  9096. label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
  9097. ])
  9098. return badge
  9099. }
  9100. @MainActor
  9101. private func alignNativeTrafficLights() {
  9102. guard let window = view.window else { return }
  9103. guard let closeButton = window.standardWindowButton(.closeButton),
  9104. let miniButton = window.standardWindowButton(.miniaturizeButton),
  9105. let zoomButton = window.standardWindowButton(.zoomButton) else { return }
  9106. guard let buttonContainer = closeButton.superview else { return }
  9107. let buttons = [closeButton, miniButton, zoomButton]
  9108. // Compute from top inset so moving "down" is stable in titlebar coordinates.
  9109. let containerHeight = buttonContainer.bounds.height
  9110. let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
  9111. var nextX = nativeTrafficLightsLeading
  9112. for button in buttons {
  9113. button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
  9114. nextX += button.frame.width + 8
  9115. }
  9116. }
  9117. private func makeUpgradeToProButton(action: Selector?) -> NSButton {
  9118. let title = "Upgrade to Pro"
  9119. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  9120. button.isBordered = false
  9121. button.focusRingType = .none
  9122. button.wantsLayer = true
  9123. button.layer?.backgroundColor = accentBlue.cgColor
  9124. button.layer?.cornerRadius = 14
  9125. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  9126. button.attributedTitle = NSAttributedString(string: title, attributes: [
  9127. .foregroundColor: NSColor.white,
  9128. .font: font
  9129. ])
  9130. button.toolTip = title
  9131. button.translatesAutoresizingMaskIntoConstraints = false
  9132. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  9133. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 148).isActive = true
  9134. return button
  9135. }
  9136. /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
  9137. private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
  9138. let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
  9139. button.isBordered = false
  9140. button.bezelStyle = .shadowlessSquare
  9141. button.focusRingType = .none
  9142. button.contentTintColor = palette.isDarkMode
  9143. ? NSColor(calibratedWhite: 0.84, alpha: 1)
  9144. : NSColor(calibratedWhite: 0.22, alpha: 1)
  9145. if let toolTip {
  9146. button.toolTip = toolTip
  9147. }
  9148. let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
  9149. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
  9150. let image = base.withSymbolConfiguration(symbolConfig) {
  9151. image.isTemplate = true
  9152. button.image = image
  9153. }
  9154. button.imageScaling = .scaleProportionallyUpOrDown
  9155. button.imagePosition = .imageOnly
  9156. button.translatesAutoresizingMaskIntoConstraints = false
  9157. button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
  9158. button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
  9159. return button
  9160. }
  9161. private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
  9162. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  9163. button.isBordered = false
  9164. button.bezelStyle = .shadowlessSquare
  9165. button.focusRingType = .none
  9166. button.wantsLayer = true
  9167. button.layer?.backgroundColor = (palette.isDarkMode
  9168. ? NSColor.white.withAlphaComponent(0.06)
  9169. : NSColor.black.withAlphaComponent(0.06)
  9170. ).cgColor
  9171. button.layer?.cornerRadius = 10
  9172. button.layer?.borderWidth = 1
  9173. button.layer?.borderColor = (palette.isDarkMode
  9174. ? NSColor.white.withAlphaComponent(0.08)
  9175. : NSColor.black.withAlphaComponent(0.12)
  9176. ).cgColor
  9177. let tint = palette.isDarkMode
  9178. ? NSColor(calibratedWhite: 0.9, alpha: 1)
  9179. : NSColor(calibratedWhite: 0.18, alpha: 1)
  9180. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  9181. button.contentTintColor = tint
  9182. button.font = font
  9183. button.attributedTitle = NSAttributedString(string: title, attributes: [
  9184. .foregroundColor: tint,
  9185. .font: font
  9186. ])
  9187. if let symbol {
  9188. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  9189. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  9190. let image = base.withSymbolConfiguration(symbolConfig) {
  9191. image.isTemplate = true
  9192. button.image = image
  9193. button.imagePosition = .imageLeading
  9194. }
  9195. button.imageHugsTitle = true
  9196. button.imageScaling = .scaleNone
  9197. }
  9198. button.translatesAutoresizingMaskIntoConstraints = false
  9199. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  9200. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
  9201. return button
  9202. }
  9203. private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
  9204. let root = HoverTileView()
  9205. root.translatesAutoresizingMaskIntoConstraints = false
  9206. root.widthAnchor.constraint(equalToConstant: 88).isActive = true
  9207. // Give caption text a little more vertical room so descenders (e.g. "g") are never clipped.
  9208. root.heightAnchor.constraint(equalToConstant: 76).isActive = true
  9209. let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
  9210. iconButton.isBordered = false
  9211. iconButton.wantsLayer = true
  9212. iconButton.layer?.backgroundColor = color.cgColor
  9213. iconButton.layer?.cornerRadius = 15
  9214. iconButton.layer?.shadowOpacity = 0.2
  9215. iconButton.layer?.shadowRadius = 7
  9216. iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
  9217. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
  9218. if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  9219. let configured = baseImage.withSymbolConfiguration(symbolConfig) {
  9220. iconButton.image = configured
  9221. }
  9222. iconButton.contentTintColor = .white
  9223. iconButton.imageScaling = .scaleNone
  9224. let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
  9225. [iconButton, label].forEach {
  9226. $0.translatesAutoresizingMaskIntoConstraints = false
  9227. root.addSubview($0)
  9228. }
  9229. let iconTopConstraint = iconButton.topAnchor.constraint(equalTo: root.topAnchor)
  9230. NSLayoutConstraint.activate([
  9231. iconTopConstraint,
  9232. iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  9233. iconButton.widthAnchor.constraint(equalToConstant: 50),
  9234. iconButton.heightAnchor.constraint(equalToConstant: 50),
  9235. label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
  9236. label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  9237. label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  9238. ])
  9239. root.onHoverChanged = { [weak iconButton, weak label] hovering in
  9240. NSAnimationContext.runAnimationGroup { context in
  9241. context.duration = hovering ? 0.16 : 0.14
  9242. context.timingFunction = CAMediaTimingFunction(name: hovering ? .easeOut : .easeInEaseOut)
  9243. iconTopConstraint.animator().constant = hovering ? -3 : 0
  9244. label?.animator().alphaValue = hovering ? 1.0 : 0.96
  9245. }
  9246. guard let iconButton else { return }
  9247. iconButton.layer?.shadowOpacity = hovering ? 0.28 : 0.2
  9248. iconButton.layer?.shadowRadius = hovering ? 10 : 7
  9249. iconButton.layer?.shadowOffset = hovering ? NSSize(width: 0, height: -3) : NSSize(width: 0, height: -1)
  9250. }
  9251. return root
  9252. }
  9253. private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
  9254. let hoverBackground = palette.isDarkMode
  9255. ? NSColor.white.withAlphaComponent(0.075)
  9256. : NSColor.black.withAlphaComponent(0.10)
  9257. let hoverBorder = palette.isDarkMode
  9258. ? NSColor.white.withAlphaComponent(0.85)
  9259. : NSColor.black.withAlphaComponent(0.22)
  9260. let card = MeetingCardView(
  9261. url: meeting.webURL,
  9262. hoverBackground: hoverBackground,
  9263. hoverBorder: hoverBorder,
  9264. onTapURL: { [weak self] _ in
  9265. self?.joinScheduledMeeting(meeting)
  9266. }
  9267. )
  9268. card.wantsLayer = true
  9269. card.layer?.backgroundColor = meetingCardBackground.cgColor
  9270. card.layer?.cornerRadius = 13
  9271. card.layer?.borderWidth = 1
  9272. card.layer?.borderColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.10)).cgColor
  9273. card.translatesAutoresizingMaskIntoConstraints = false
  9274. card.heightAnchor.constraint(equalToConstant: 116).isActive = true
  9275. let dateFormatter = DateFormatter()
  9276. dateFormatter.dateFormat = "EEE, MMM d"
  9277. let timeFormatter = DateFormatter()
  9278. timeFormatter.dateFormat = "h:mm a"
  9279. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  9280. dateFormatter.timeZone = displayTimeZone
  9281. timeFormatter.timeZone = displayTimeZone
  9282. let startText = timeFormatter.string(from: meeting.start)
  9283. let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
  9284. let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
  9285. let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
  9286. let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
  9287. detail.maximumNumberOfLines = 2
  9288. let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
  9289. [title, detail, host].forEach {
  9290. $0.translatesAutoresizingMaskIntoConstraints = false
  9291. card.addSubview($0)
  9292. }
  9293. NSLayoutConstraint.activate([
  9294. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
  9295. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  9296. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  9297. detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
  9298. detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  9299. detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  9300. host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
  9301. host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  9302. host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  9303. host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
  9304. ])
  9305. return card
  9306. }
  9307. private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
  9308. let label = NSTextField(labelWithString: text)
  9309. label.font = .systemFont(ofSize: size, weight: weight)
  9310. label.textColor = color
  9311. label.alignment = centered ? .center : .left
  9312. return label
  9313. }
  9314. private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
  9315. let wrapper = NSView()
  9316. let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
  9317. button.font = .systemFont(ofSize: 20, weight: .medium)
  9318. button.isBordered = false
  9319. button.wantsLayer = true
  9320. button.layer?.cornerRadius = 12
  9321. button.layer?.backgroundColor = cardBackground.cgColor
  9322. button.contentTintColor = primaryText
  9323. button.translatesAutoresizingMaskIntoConstraints = false
  9324. let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
  9325. label.translatesAutoresizingMaskIntoConstraints = false
  9326. wrapper.addSubview(button)
  9327. wrapper.addSubview(label)
  9328. NSLayoutConstraint.activate([
  9329. button.topAnchor.constraint(equalTo: wrapper.topAnchor),
  9330. button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  9331. button.widthAnchor.constraint(equalToConstant: 52),
  9332. button.heightAnchor.constraint(equalToConstant: 52),
  9333. label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
  9334. label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  9335. label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  9336. ])
  9337. return (wrapper, action == nil ? nil : button)
  9338. }
  9339. }
  9340. extension ViewController: EmbeddedOAuthPresenting {}
  9341. extension ViewController: WKScriptMessageHandler {
  9342. func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
  9343. guard message.name == Self.embeddedZoomMeetingLifecycleHandlerName else { return }
  9344. guard let href = message.body as? String, let url = URL(string: href) else { return }
  9345. Task { @MainActor in
  9346. let title = self.embeddedBrowserWebView?.title
  9347. self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
  9348. }
  9349. }
  9350. }
  9351. extension ViewController: WKNavigationDelegate, WKUIDelegate {
  9352. func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  9353. guard webView === embeddedBrowserWebView else {
  9354. decisionHandler(.allow)
  9355. return
  9356. }
  9357. if let prefix = oauthSessionCallbackPrefix,
  9358. let targetURL = navigationAction.request.url,
  9359. targetURL.absoluteString.hasPrefix(prefix) {
  9360. oauthSessionContinuation?.resume(returning: targetURL)
  9361. oauthSessionContinuation = nil
  9362. oauthSessionCallbackPrefix = nil
  9363. decisionHandler(.cancel)
  9364. return
  9365. }
  9366. if let targetURL = navigationAction.request.url {
  9367. Task { @MainActor in
  9368. self.updateEmbeddedMeetingRecordingPresence(for: targetURL, webViewTitle: webView.title)
  9369. }
  9370. }
  9371. decisionHandler(.allow)
  9372. }
  9373. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
  9374. guard webView === embeddedBrowserWebView else { return }
  9375. updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
  9376. applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
  9377. refreshEmbeddedBrowserNavButtons()
  9378. }
  9379. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  9380. guard webView === embeddedBrowserWebView else { return }
  9381. updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
  9382. applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
  9383. if let pageURL = webView.url, isZoomEmbeddedWebClientInCallURL(pageURL) {
  9384. embeddedZoomPendingWebAuth = nil
  9385. embeddedZoomPendingJoinURL = nil
  9386. }
  9387. refreshEmbeddedBrowserNavButtons()
  9388. }
  9389. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
  9390. guard webView === embeddedBrowserWebView else { return nil }
  9391. if navigationAction.targetFrame == nil {
  9392. webView.load(navigationAction.request)
  9393. }
  9394. return nil
  9395. }
  9396. func webView(
  9397. _ webView: WKWebView,
  9398. requestMediaCapturePermissionFor origin: WKSecurityOrigin,
  9399. initiatedByFrame frame: WKFrameInfo,
  9400. type: WKMediaCaptureType,
  9401. decisionHandler: @escaping (WKPermissionDecision) -> Void
  9402. ) {
  9403. guard webView === embeddedBrowserWebView else {
  9404. decisionHandler(.prompt)
  9405. return
  9406. }
  9407. Task {
  9408. let grant: Bool
  9409. switch type {
  9410. case .camera:
  9411. grant = await cameraAccessGrantedForWebCapture()
  9412. case .microphone:
  9413. grant = await microphoneAccessGrantedForWebCapture()
  9414. case .cameraAndMicrophone:
  9415. let camera = await cameraAccessGrantedForWebCapture()
  9416. let microphone = await microphoneAccessGrantedForWebCapture()
  9417. grant = camera && microphone
  9418. @unknown default:
  9419. grant = false
  9420. }
  9421. await MainActor.run {
  9422. decisionHandler(grant ? .grant : .deny)
  9423. }
  9424. }
  9425. }
  9426. }
  9427. extension ViewController: NSWindowDelegate {
  9428. func windowShouldClose(_ sender: NSWindow) -> Bool {
  9429. if let panel = joinMeetingWindow, sender === panel, let host = view.window {
  9430. host.endSheet(panel)
  9431. return false
  9432. }
  9433. if let panel = aiCompanionLocalTranscriptPanel, sender === panel, let host = view.window {
  9434. host.endSheet(panel)
  9435. return false
  9436. }
  9437. if let panel = aiCompanionLocalNotesPanel, sender === panel, let host = view.window {
  9438. host.endSheet(panel)
  9439. return false
  9440. }
  9441. return true
  9442. }
  9443. func windowWillClose(_ notification: Notification) {
  9444. guard let window = notification.object as? NSWindow else { return }
  9445. if window === joinMeetingWindow {
  9446. resetJoinMeetingPanelReferences()
  9447. }
  9448. if window === scheduleMeetingWindow {
  9449. resetScheduleMeetingPanelReferences()
  9450. }
  9451. }
  9452. }
  9453. private extension Array {
  9454. subscript(safe index: Int) -> Element? {
  9455. guard index >= 0, index < count else { return nil }
  9456. return self[index]
  9457. }
  9458. }
  9459. /// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
  9460. private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
  9461. private func lineHeight() -> CGFloat {
  9462. guard let font = font else { return 0 }
  9463. return ceil(font.ascender - font.descender + font.leading)
  9464. }
  9465. private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
  9466. let lh = lineHeight()
  9467. guard lh > 0, h > lh else { return 0 }
  9468. return max(0, floor((h - lh) / 2))
  9469. }
  9470. private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
  9471. let lh = lineHeight()
  9472. guard lh > 0 else { return cellFrame }
  9473. let m = verticalMargin(forBoundsHeight: cellFrame.height)
  9474. var r = cellFrame
  9475. r.origin.y += m
  9476. r.size.height = lh
  9477. return r
  9478. }
  9479. override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
  9480. super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
  9481. }
  9482. override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
  9483. let text = super.setUpFieldEditorAttributes(textObj)
  9484. guard let tv = text as? NSTextView else { return text }
  9485. // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
  9486. let h = max(controlView?.bounds.height ?? 0, 42)
  9487. let margin = verticalMargin(forBoundsHeight: h)
  9488. if margin > 0 {
  9489. // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
  9490. tv.textContainerInset = NSSize(width: 0, height: margin)
  9491. }
  9492. return text
  9493. }
  9494. }
  9495. /// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
  9496. private final class JoinPanelFieldContainer: NSView {
  9497. let textField: NSTextField
  9498. private let normalBorder: NSColor
  9499. private let focusBorder: NSColor
  9500. private let fill: NSColor
  9501. private var beginObserver: NSObjectProtocol?
  9502. private var endObserver: NSObjectProtocol?
  9503. init(
  9504. placeholder: String,
  9505. normalBorder: NSColor,
  9506. focusBorder: NSColor,
  9507. fill: NSColor,
  9508. primaryText: NSColor,
  9509. mutedText: NSColor
  9510. ) {
  9511. self.normalBorder = normalBorder
  9512. self.focusBorder = focusBorder
  9513. self.fill = fill
  9514. textField = NSTextField()
  9515. textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
  9516. super.init(frame: .zero)
  9517. translatesAutoresizingMaskIntoConstraints = false
  9518. wantsLayer = true
  9519. // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
  9520. layer?.masksToBounds = false
  9521. layer?.cornerRadius = 23
  9522. layer?.backgroundColor = fill.cgColor
  9523. applyBorder(focused: false)
  9524. textField.font = .systemFont(ofSize: 14, weight: .regular)
  9525. textField.textColor = primaryText
  9526. textField.alignment = .center
  9527. textField.isEditable = true
  9528. textField.isSelectable = true
  9529. textField.refusesFirstResponder = false
  9530. textField.focusRingType = .none
  9531. textField.isBordered = false
  9532. textField.drawsBackground = false
  9533. textField.translatesAutoresizingMaskIntoConstraints = false
  9534. if let cell = textField.cell as? NSTextFieldCell {
  9535. cell.isBezeled = false
  9536. cell.drawsBackground = false
  9537. cell.alignment = .center
  9538. cell.usesSingleLineMode = true
  9539. cell.lineBreakMode = .byTruncatingTail
  9540. let placeholderParagraph = NSMutableParagraphStyle()
  9541. placeholderParagraph.alignment = .center
  9542. cell.placeholderAttributedString = NSAttributedString(
  9543. string: placeholder,
  9544. attributes: [
  9545. .foregroundColor: mutedText.withAlphaComponent(0.88),
  9546. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  9547. .paragraphStyle: placeholderParagraph
  9548. ]
  9549. )
  9550. }
  9551. addSubview(textField)
  9552. NSLayoutConstraint.activate([
  9553. textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  9554. textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  9555. textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
  9556. textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
  9557. heightAnchor.constraint(equalToConstant: 46)
  9558. ])
  9559. beginObserver = NotificationCenter.default.addObserver(
  9560. forName: NSControl.textDidBeginEditingNotification,
  9561. object: textField,
  9562. queue: .main
  9563. ) { [weak self] _ in
  9564. self?.applyBorder(focused: true)
  9565. }
  9566. endObserver = NotificationCenter.default.addObserver(
  9567. forName: NSControl.textDidEndEditingNotification,
  9568. object: textField,
  9569. queue: .main
  9570. ) { [weak self] _ in
  9571. self?.applyBorder(focused: false)
  9572. }
  9573. }
  9574. required init?(coder: NSCoder) {
  9575. fatalError("init(coder:) has not been implemented")
  9576. }
  9577. deinit {
  9578. if let beginObserver {
  9579. NotificationCenter.default.removeObserver(beginObserver)
  9580. }
  9581. if let endObserver {
  9582. NotificationCenter.default.removeObserver(endObserver)
  9583. }
  9584. }
  9585. private func applyBorder(focused: Bool) {
  9586. layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
  9587. layer?.borderWidth = focused ? 1.5 : 1
  9588. }
  9589. override func viewDidMoveToWindow() {
  9590. super.viewDidMoveToWindow()
  9591. if window == nil {
  9592. applyBorder(focused: false)
  9593. }
  9594. }
  9595. }
  9596. private final class SearchPillTextField: NSTextField {
  9597. var onFocusChange: ((Bool) -> Void)?
  9598. private(set) var isSearchFocused = false
  9599. func forceClearFocusState() {
  9600. isSearchFocused = false
  9601. onFocusChange?(false)
  9602. }
  9603. override func becomeFirstResponder() -> Bool {
  9604. let ok = super.becomeFirstResponder()
  9605. if ok {
  9606. isSearchFocused = true
  9607. onFocusChange?(true)
  9608. }
  9609. return ok
  9610. }
  9611. override func resignFirstResponder() -> Bool {
  9612. let ok = super.resignFirstResponder()
  9613. if ok {
  9614. isSearchFocused = false
  9615. onFocusChange?(false)
  9616. }
  9617. return ok
  9618. }
  9619. }
  9620. private final class FlippedView: NSView {
  9621. override var isFlipped: Bool { true }
  9622. }
  9623. private final class MeetingCardView: NSView {
  9624. private let url: URL?
  9625. private let hoverBackground: NSColor
  9626. private let hoverBorder: NSColor
  9627. private let onTapURL: ((URL) -> Void)?
  9628. private var tracking: NSTrackingArea?
  9629. private var isHovering = false
  9630. private var normalBackgroundColor: CGColor?
  9631. private var normalBorderColor: CGColor?
  9632. private var normalBorderWidth: CGFloat?
  9633. init(url: URL?, hoverBackground: NSColor, hoverBorder: NSColor, onTapURL: ((URL) -> Void)? = nil) {
  9634. self.url = url
  9635. self.hoverBackground = hoverBackground
  9636. self.hoverBorder = hoverBorder
  9637. self.onTapURL = onTapURL
  9638. super.init(frame: .zero)
  9639. }
  9640. required init?(coder: NSCoder) {
  9641. self.url = nil
  9642. self.hoverBackground = NSColor.white.withAlphaComponent(0.075)
  9643. self.hoverBorder = NSColor.white.withAlphaComponent(0.85)
  9644. self.onTapURL = nil
  9645. super.init(coder: coder)
  9646. }
  9647. override func updateTrackingAreas() {
  9648. super.updateTrackingAreas()
  9649. if let tracking { removeTrackingArea(tracking) }
  9650. let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
  9651. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  9652. addTrackingArea(area)
  9653. tracking = area
  9654. }
  9655. override func resetCursorRects() {
  9656. super.resetCursorRects()
  9657. if url != nil {
  9658. addCursorRect(bounds, cursor: .pointingHand)
  9659. }
  9660. }
  9661. override func mouseEntered(with event: NSEvent) {
  9662. super.mouseEntered(with: event)
  9663. guard url != nil else { return }
  9664. isHovering = true
  9665. applyHoverState()
  9666. }
  9667. override func mouseExited(with event: NSEvent) {
  9668. super.mouseExited(with: event)
  9669. guard url != nil else { return }
  9670. isHovering = false
  9671. applyHoverState()
  9672. }
  9673. override func mouseUp(with event: NSEvent) {
  9674. super.mouseUp(with: event)
  9675. guard let url else { return }
  9676. if let onTapURL {
  9677. onTapURL(url)
  9678. } else {
  9679. NSWorkspace.shared.open(url)
  9680. }
  9681. }
  9682. private func applyHoverState() {
  9683. guard let layer else { return }
  9684. if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
  9685. if normalBorderColor == nil { normalBorderColor = layer.borderColor }
  9686. if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
  9687. if isHovering {
  9688. layer.backgroundColor = hoverBackground.cgColor
  9689. layer.borderColor = hoverBorder.cgColor
  9690. layer.borderWidth = max(1.5, layer.borderWidth)
  9691. } else {
  9692. layer.backgroundColor = normalBackgroundColor
  9693. layer.borderColor = normalBorderColor
  9694. if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
  9695. }
  9696. }
  9697. }
  9698. struct GoogleOAuthTokens: Codable, Equatable {
  9699. var accessToken: String
  9700. var refreshToken: String?
  9701. var expiresAt: Date
  9702. var scope: String?
  9703. var tokenType: String?
  9704. }
  9705. struct GoogleUserProfile: Codable, Equatable {
  9706. var name: String?
  9707. var email: String?
  9708. var picture: String?
  9709. }
  9710. struct ZoomOAuthTokens: Codable, Equatable {
  9711. var accessToken: String
  9712. var refreshToken: String?
  9713. var expiresAt: Date
  9714. var scope: String?
  9715. var tokenType: String?
  9716. }
  9717. enum ZoomOAuthError: Error {
  9718. case missingClientId
  9719. case missingClientSecret
  9720. case invalidCallbackURL
  9721. case missingAuthorizationCode
  9722. case tokenExchangeFailed(String)
  9723. case missingRequiredScope(String)
  9724. case rateLimited(retryAfterSeconds: Int?)
  9725. case unableToOpenBrowser
  9726. case authenticationTimedOut
  9727. case authenticationCancelled
  9728. }
  9729. final class ZoomOAuthTokenStore {
  9730. private let defaultsKey: String
  9731. private let defaults: UserDefaults
  9732. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  9733. account: String = "zoomOAuthTokens",
  9734. defaults: UserDefaults = .standard) {
  9735. self.defaultsKey = "\(service).\(account)"
  9736. self.defaults = defaults
  9737. }
  9738. func readTokens() throws -> ZoomOAuthTokens? {
  9739. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  9740. return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
  9741. }
  9742. func writeTokens(_ tokens: ZoomOAuthTokens) throws {
  9743. let data = try JSONEncoder().encode(tokens)
  9744. defaults.set(data, forKey: defaultsKey)
  9745. }
  9746. func clearTokens() {
  9747. defaults.removeObject(forKey: defaultsKey)
  9748. }
  9749. }
  9750. final class ZoomOAuthService: NSObject {
  9751. static let shared = ZoomOAuthService()
  9752. private var inAppOAuthWindowController: InAppOAuthWindowController?
  9753. /// When set, Zoom OAuth runs in this session instead of a separate window (unused; sign-in uses `InAppOAuthWindowController`).
  9754. @MainActor
  9755. var embeddedOAuthSession: (@MainActor (URL, String, Double) async throws -> URL)?
  9756. /// Called after a successful interactive OAuth flow (separate sign-in window).
  9757. @MainActor
  9758. var onInteractiveSignInCompleted: (() -> Void)?
  9759. private let tokenStore = ZoomOAuthTokenStore()
  9760. private let clientIdDefaultsKey = "zoom.oauth.clientId"
  9761. private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
  9762. private let infoPlistClientIdKey = "ZoomOAuthClientId"
  9763. private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
  9764. // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
  9765. /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
  9766. private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
  9767. /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
  9768. private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
  9769. func setClientCredentials(clientId: String, clientSecret: String) {
  9770. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  9771. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  9772. }
  9773. func configuredClientId() -> String? {
  9774. if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
  9775. let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
  9776. if trimmed.isEmpty == false { return trimmed }
  9777. }
  9778. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
  9779. .trimmingCharacters(in: .whitespacesAndNewlines)
  9780. if let value, value.isEmpty == false { return value }
  9781. return bundledClientId.isEmpty ? nil : bundledClientId
  9782. }
  9783. func configuredClientSecret() -> String? {
  9784. if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
  9785. let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
  9786. if trimmed.isEmpty == false { return trimmed }
  9787. }
  9788. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
  9789. .trimmingCharacters(in: .whitespacesAndNewlines)
  9790. if let value, value.isEmpty == false { return value }
  9791. return bundledClientSecret.isEmpty ? nil : bundledClientSecret
  9792. }
  9793. func clearSavedTokens() {
  9794. tokenStore.clearTokens()
  9795. }
  9796. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  9797. // Default: baseline meeting access used throughout the app.
  9798. try await validAccessToken(presentingWindow: presentingWindow, requiringAnyOf: [
  9799. // Zoom's newer granular scopes for listing and scheduling meetings.
  9800. "meeting:read:list_meetings",
  9801. "meeting:write:meeting"
  9802. ])
  9803. }
  9804. func validAccessToken(presentingWindow: NSWindow?, requiringAnyOf requiredScopes: [String]) async throws -> String {
  9805. if let tokens = try tokenStore.readTokens(),
  9806. tokens.expiresAt.timeIntervalSinceNow > 60,
  9807. tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
  9808. return tokens.accessToken
  9809. } else if var tokens = try tokenStore.readTokens(),
  9810. let refreshed = try await refreshTokens(tokens) {
  9811. tokens = refreshed
  9812. try tokenStore.writeTokens(tokens)
  9813. if tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
  9814. return tokens.accessToken
  9815. }
  9816. }
  9817. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  9818. try tokenStore.writeTokens(tokens)
  9819. guard tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) else {
  9820. throw ZoomOAuthError.missingRequiredScope(tokens.scope ?? "Missing required scope(s).")
  9821. }
  9822. return tokens.accessToken
  9823. }
  9824. /// User-managed OAuth apps often **cannot** add `meeting:read:list_summaries` (admin/master only).
  9825. /// In that case we load AI summaries by listing past meetings (`meeting:read:list_meetings`) and
  9826. /// calling `meeting:read:summary` per meeting.
  9827. func validAccessTokenForMeetingSummaries(presentingWindow: NSWindow?) async throws -> String {
  9828. func canLoadSummaries(_ scope: String?) -> Bool {
  9829. tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_summaries"])
  9830. || (tokenHasScopes(scope, requiringAnyOf: ["meeting:read:summary"])
  9831. && tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_meetings"]))
  9832. }
  9833. if let tokens = try tokenStore.readTokens(),
  9834. tokens.expiresAt.timeIntervalSinceNow > 60,
  9835. canLoadSummaries(tokens.scope) {
  9836. return tokens.accessToken
  9837. } else if var tokens = try tokenStore.readTokens(),
  9838. let refreshed = try await refreshTokens(tokens) {
  9839. tokens = refreshed
  9840. try tokenStore.writeTokens(tokens)
  9841. if canLoadSummaries(tokens.scope) {
  9842. return tokens.accessToken
  9843. }
  9844. }
  9845. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  9846. try tokenStore.writeTokens(tokens)
  9847. guard canLoadSummaries(tokens.scope) else {
  9848. let hint = " Grant meeting:read:list_summaries (if available), or both meeting:read:list_meetings and meeting:read:summary."
  9849. throw ZoomOAuthError.missingRequiredScope((tokens.scope ?? "Missing required scope(s).") + hint)
  9850. }
  9851. return tokens.accessToken
  9852. }
  9853. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
  9854. guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
  9855. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  9856. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  9857. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  9858. let state = UUID().uuidString
  9859. // Explicitly request the scopes we rely on.
  9860. // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
  9861. // Note: `meeting:read:list_summaries` is often unavailable on user-managed General apps; we
  9862. // fall back to per-meeting summary fetches using `meeting:read:summary` + past meeting list.
  9863. let requestedScopes = [
  9864. "meeting:read:list_meetings",
  9865. "meeting:write:meeting",
  9866. "meeting:read:summary",
  9867. "zoomapp:inmeeting",
  9868. "user_zak:read"
  9869. ]
  9870. var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
  9871. components.queryItems = [
  9872. URLQueryItem(name: "response_type", value: "code"),
  9873. URLQueryItem(name: "client_id", value: clientId),
  9874. URLQueryItem(name: "redirect_uri", value: redirectURI),
  9875. URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
  9876. // Force consent so updated scopes are actually granted.
  9877. URLQueryItem(name: "prompt", value: "consent"),
  9878. // Zoom-specific: forces interactive verification even if already signed in.
  9879. URLQueryItem(name: "force_verify", value: "true"),
  9880. URLQueryItem(name: "state", value: state)
  9881. ]
  9882. guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
  9883. do {
  9884. let callbackURL: URL
  9885. if let runEmbeddedOAuth = await MainActor.run(body: { embeddedOAuthSession }) {
  9886. callbackURL = try await runEmbeddedOAuth(authURL, redirectURI, 180)
  9887. } else {
  9888. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  9889. callbackURL = try await oauthWindow.authenticate(
  9890. startURL: authURL,
  9891. callbackURLPrefix: redirectURI,
  9892. presentingWindow: presentingWindow,
  9893. timeoutSeconds: 180
  9894. )
  9895. }
  9896. let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  9897. guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
  9898. guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  9899. throw ZoomOAuthError.missingAuthorizationCode
  9900. }
  9901. let tokens = try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
  9902. await MainActor.run { self.onInteractiveSignInCompleted?() }
  9903. return tokens
  9904. } catch GoogleOAuthError.authenticationTimedOut {
  9905. throw ZoomOAuthError.authenticationTimedOut
  9906. } catch GoogleOAuthError.authenticationCancelled {
  9907. throw ZoomOAuthError.authenticationCancelled
  9908. } catch {
  9909. throw error
  9910. }
  9911. }
  9912. @MainActor
  9913. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  9914. if let existing = inAppOAuthWindowController { return existing }
  9915. let controller = InAppOAuthWindowController(
  9916. title: "Zoom Sign-In",
  9917. websiteDataStore: zoomEmbeddedWebsiteDataStore,
  9918. processPool: zoomEmbeddedWebProcessPool
  9919. )
  9920. inAppOAuthWindowController = controller
  9921. return controller
  9922. }
  9923. private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
  9924. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  9925. request.httpMethod = "POST"
  9926. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  9927. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  9928. request.httpBody = Self.formURLEncoded([
  9929. "grant_type": "authorization_code",
  9930. "code": code,
  9931. "redirect_uri": redirectURI
  9932. ])
  9933. let (data, response) = try await URLSession.shared.data(for: request)
  9934. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  9935. throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  9936. }
  9937. struct TokenResponse: Decodable {
  9938. let access_token: String
  9939. let refresh_token: String?
  9940. let expires_in: Double
  9941. let scope: String?
  9942. let token_type: String?
  9943. }
  9944. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  9945. return ZoomOAuthTokens(
  9946. accessToken: decoded.access_token,
  9947. refreshToken: decoded.refresh_token,
  9948. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  9949. scope: decoded.scope,
  9950. tokenType: decoded.token_type
  9951. )
  9952. }
  9953. private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
  9954. guard let refreshToken = tokens.refreshToken else { return nil }
  9955. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  9956. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  9957. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  9958. request.httpMethod = "POST"
  9959. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  9960. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  9961. request.httpBody = Self.formURLEncoded([
  9962. "grant_type": "refresh_token",
  9963. "refresh_token": refreshToken
  9964. ])
  9965. let (data, response) = try await URLSession.shared.data(for: request)
  9966. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  9967. return nil
  9968. }
  9969. struct RefreshResponse: Decodable {
  9970. let access_token: String
  9971. let refresh_token: String?
  9972. let expires_in: Double
  9973. let scope: String?
  9974. let token_type: String?
  9975. }
  9976. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  9977. return ZoomOAuthTokens(
  9978. accessToken: decoded.access_token,
  9979. refreshToken: decoded.refresh_token ?? refreshToken,
  9980. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  9981. scope: decoded.scope ?? tokens.scope,
  9982. tokenType: decoded.token_type ?? tokens.tokenType
  9983. )
  9984. }
  9985. private func normalizedGrantedScopes(_ scopeValue: String?) -> Set<String> {
  9986. guard let scopeValue, scopeValue.isEmpty == false else { return [] }
  9987. let parts = scopeValue.split { $0 == " " || $0 == "," }
  9988. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  9989. .filter { $0.isEmpty == false }
  9990. return Set(parts)
  9991. }
  9992. /// Whether `granted` includes the Zoom scope `req` (granular exact match, admin/master variants,
  9993. /// or legacy broad scopes Zoom still returns on some tokens).
  9994. private func grantedScopeSatisfies(_ granted: Set<String>, requirement req: String) -> Bool {
  9995. if granted.contains(req)
  9996. || granted.contains("\(req):admin")
  9997. || granted.contains("\(req):master") {
  9998. return true
  9999. }
  10000. // Classic scopes: listing / reading meetings and summaries often appear as `meeting:read`
  10001. // instead of `meeting:read:list_meetings` / `meeting:read:summary` on the token string.
  10002. switch req {
  10003. case "meeting:read:list_meetings", "meeting:read:summary":
  10004. return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
  10005. case "meeting:write:meeting":
  10006. return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
  10007. case "user_zak:read":
  10008. return granted.contains("user_zak") || granted.contains("user:read:zak")
  10009. default:
  10010. return false
  10011. }
  10012. }
  10013. private func tokenHasScopes(_ scopeValue: String?, requiringAnyOf requiredScopes: [String]) -> Bool {
  10014. let granted = normalizedGrantedScopes(scopeValue)
  10015. if granted.isEmpty { return false }
  10016. return requiredScopes.contains { grantedScopeSatisfies(granted, requirement: $0) }
  10017. }
  10018. private static func basicAuth(clientId: String, clientSecret: String) -> String {
  10019. let joined = "\(clientId):\(clientSecret)"
  10020. return Data(joined.utf8).base64EncodedString()
  10021. }
  10022. private static func formURLEncoded(_ params: [String: String]) -> Data {
  10023. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  10024. let pairs = params.map { key, value in
  10025. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  10026. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  10027. return "\(k)=\(v)"
  10028. }.joined(separator: "&")
  10029. return Data(pairs.utf8)
  10030. }
  10031. }
  10032. enum GoogleOAuthError: Error {
  10033. case missingClientId
  10034. case missingClientSecret
  10035. case invalidCallbackURL
  10036. case missingAuthorizationCode
  10037. case tokenExchangeFailed(String)
  10038. case unableToOpenBrowser
  10039. case authenticationTimedOut
  10040. case authenticationCancelled
  10041. }
  10042. final class GoogleOAuthService: NSObject {
  10043. static let shared = GoogleOAuthService()
  10044. private var inAppOAuthWindowController: InAppOAuthWindowController?
  10045. private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
  10046. private let clientSecret = "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"
  10047. private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
  10048. private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
  10049. private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
  10050. func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
  10051. func clearSavedTokens() {
  10052. tokenStore.clearTokens()
  10053. }
  10054. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  10055. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  10056. request.httpMethod = "GET"
  10057. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  10058. let (data, response) = try await URLSession.shared.data(for: request)
  10059. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  10060. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  10061. }
  10062. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  10063. }
  10064. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  10065. if let tokens = try tokenStore.readTokens(),
  10066. tokens.expiresAt.timeIntervalSinceNow > 60,
  10067. tokenHasCalendarScope(tokens.scope) {
  10068. return tokens.accessToken
  10069. }
  10070. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  10071. try tokenStore.writeTokens(tokens)
  10072. return tokens.accessToken
  10073. }
  10074. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  10075. guard let presentingWindow else { throw GoogleOAuthError.unableToOpenBrowser }
  10076. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  10077. let codeVerifier = Self.randomURLSafeString(length: 64)
  10078. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  10079. let state = Self.randomURLSafeString(length: 32)
  10080. // Google OAuth app settings often already include this callback from prior loopback setup.
  10081. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  10082. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  10083. components.queryItems = [
  10084. URLQueryItem(name: "client_id", value: clientId),
  10085. URLQueryItem(name: "redirect_uri", value: redirectURI),
  10086. URLQueryItem(name: "response_type", value: "code"),
  10087. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  10088. URLQueryItem(name: "state", value: state),
  10089. URLQueryItem(name: "code_challenge", value: codeChallenge),
  10090. URLQueryItem(name: "code_challenge_method", value: "S256"),
  10091. URLQueryItem(name: "access_type", value: "offline"),
  10092. URLQueryItem(name: "prompt", value: "consent")
  10093. ]
  10094. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  10095. let callbackURL = try await oauthWindow.authenticate(
  10096. startURL: authURL,
  10097. callbackURLPrefix: redirectURI,
  10098. presentingWindow: presentingWindow
  10099. )
  10100. let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  10101. guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
  10102. guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  10103. throw GoogleOAuthError.missingAuthorizationCode
  10104. }
  10105. return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
  10106. }
  10107. @MainActor
  10108. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  10109. if let existing = inAppOAuthWindowController { return existing }
  10110. let controller = InAppOAuthWindowController(title: "Google Sign-In")
  10111. inAppOAuthWindowController = controller
  10112. return controller
  10113. }
  10114. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
  10115. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  10116. request.httpMethod = "POST"
  10117. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  10118. request.httpBody = Self.formURLEncoded([
  10119. "client_id": clientId,
  10120. "client_secret": clientSecret,
  10121. "code": code,
  10122. "code_verifier": codeVerifier,
  10123. "redirect_uri": redirectURI,
  10124. "grant_type": "authorization_code"
  10125. ])
  10126. let (data, response) = try await URLSession.shared.data(for: request)
  10127. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  10128. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  10129. }
  10130. struct TokenResponse: Decodable {
  10131. let access_token: String
  10132. let expires_in: Double
  10133. let refresh_token: String?
  10134. let scope: String?
  10135. let token_type: String?
  10136. }
  10137. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  10138. return GoogleOAuthTokens(
  10139. accessToken: decoded.access_token,
  10140. refreshToken: decoded.refresh_token,
  10141. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  10142. scope: decoded.scope,
  10143. tokenType: decoded.token_type
  10144. )
  10145. }
  10146. private static func pkceChallenge(for verifier: String) -> String {
  10147. let digest = SHA256.hash(data: Data(verifier.utf8))
  10148. return Data(digest).base64URLEncodedString()
  10149. }
  10150. private static func randomURLSafeString(length: Int) -> String {
  10151. var bytes = [UInt8](repeating: 0, count: length)
  10152. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  10153. return Data(bytes).base64URLEncodedString()
  10154. }
  10155. private static func formURLEncoded(_ params: [String: String]) -> Data {
  10156. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  10157. let pairs = params.map { key, value in
  10158. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  10159. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  10160. return "\(k)=\(v)"
  10161. }.joined(separator: "&")
  10162. return Data(pairs.utf8)
  10163. }
  10164. private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
  10165. guard let scopeValue else { return false }
  10166. return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
  10167. }
  10168. }
  10169. private extension Data {
  10170. func base64URLEncodedString() -> String {
  10171. base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
  10172. }
  10173. }
  10174. final class KeychainTokenStore {
  10175. private let defaultsKey: String
  10176. private let defaults: UserDefaults
  10177. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  10178. account: String = "googleOAuthTokens",
  10179. defaults: UserDefaults = .standard) {
  10180. self.defaultsKey = "\(service).\(account)"
  10181. self.defaults = defaults
  10182. }
  10183. func readTokens() throws -> GoogleOAuthTokens? {
  10184. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  10185. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  10186. }
  10187. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  10188. let data = try JSONEncoder().encode(tokens)
  10189. defaults.set(data, forKey: defaultsKey)
  10190. }
  10191. func clearTokens() {
  10192. defaults.removeObject(forKey: defaultsKey)
  10193. }
  10194. }
  10195. // MARK: - OAuth window (previous sign-in behavior)
  10196. @MainActor
  10197. private final class OAuthWebViewContainerView: NSView {
  10198. private let webView: WKWebView
  10199. init(webView: WKWebView) {
  10200. self.webView = webView
  10201. super.init(frame: .zero)
  10202. addSubview(webView)
  10203. }
  10204. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  10205. override func layout() {
  10206. super.layout()
  10207. webView.frame = bounds
  10208. }
  10209. }
  10210. /// 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.
  10211. @MainActor
  10212. private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
  10213. private let webView: WKWebView
  10214. private var callbackPrefix: String?
  10215. private var callbackContinuation: CheckedContinuation<URL, Error>?
  10216. private weak var parentWindowForChild: NSWindow?
  10217. /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
  10218. private var suppressUserCloseCancellation = false
  10219. init(
  10220. title: String,
  10221. websiteDataStore: WKWebsiteDataStore = .nonPersistent(),
  10222. processPool: WKProcessPool? = nil
  10223. ) {
  10224. let configuration = WKWebViewConfiguration()
  10225. configuration.websiteDataStore = websiteDataStore
  10226. if let processPool {
  10227. configuration.processPool = processPool
  10228. }
  10229. self.webView = WKWebView(frame: .zero, configuration: configuration)
  10230. let container = OAuthWebViewContainerView(webView: webView)
  10231. let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  10232. let window = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
  10233. window.title = title
  10234. window.contentView = container
  10235. window.contentMinSize = contentSize
  10236. window.contentMaxSize = contentSize
  10237. window.isReleasedWhenClosed = false
  10238. super.init(window: window)
  10239. window.delegate = self
  10240. webView.navigationDelegate = self
  10241. }
  10242. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  10243. func windowWillClose(_ notification: Notification) {
  10244. guard suppressUserCloseCancellation == false else { return }
  10245. removeFromParentIfNeeded()
  10246. webView.stopLoading()
  10247. if let continuation = callbackContinuation {
  10248. callbackContinuation = nil
  10249. continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
  10250. }
  10251. }
  10252. func authenticate(startURL: URL, callbackURLPrefix: String, presentingWindow: NSWindow?, timeoutSeconds: Double = 120) async throws -> URL {
  10253. self.callbackPrefix = callbackURLPrefix
  10254. parentWindowForChild = presentingWindow
  10255. applyFixedOAuthFrame(presentingWindow: presentingWindow)
  10256. if let parent = presentingWindow, let w = window {
  10257. parent.addChildWindow(w, ordered: .above)
  10258. }
  10259. showWindow(nil)
  10260. window?.makeKeyAndOrderFront(nil)
  10261. webView.load(URLRequest(url: startURL))
  10262. do {
  10263. return try await withThrowingTaskGroup(of: URL.self) { group in
  10264. group.addTask { [weak self] in
  10265. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  10266. return try await self.awaitCallback()
  10267. }
  10268. group.addTask {
  10269. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  10270. throw GoogleOAuthError.authenticationTimedOut
  10271. }
  10272. let url = try await group.next()!
  10273. group.cancelAll()
  10274. self.closeOAuthWindowAfterFlow()
  10275. return url
  10276. }
  10277. } catch {
  10278. self.closeOAuthWindowAfterFlow()
  10279. throw error
  10280. }
  10281. }
  10282. private func closeOAuthWindowAfterFlow() {
  10283. suppressUserCloseCancellation = true
  10284. webView.stopLoading()
  10285. removeFromParentIfNeeded()
  10286. close()
  10287. suppressUserCloseCancellation = false
  10288. }
  10289. private func awaitCallback() async throws -> URL {
  10290. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  10291. callbackContinuation = continuation
  10292. }
  10293. }
  10294. private func removeFromParentIfNeeded() {
  10295. if let parent = parentWindowForChild, let w = window {
  10296. parent.removeChildWindow(w)
  10297. }
  10298. parentWindowForChild = nil
  10299. }
  10300. private func applyFixedOAuthFrame(presentingWindow: NSWindow?) {
  10301. guard let window else { return }
  10302. if let parent = presentingWindow {
  10303. let parentContent = parent.contentRect(forFrameRect: parent.frame)
  10304. let oauthFrame = window.frameRect(forContentRect: parentContent)
  10305. window.setFrame(oauthFrame, display: true)
  10306. } else {
  10307. let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  10308. window.setContentSize(contentSize)
  10309. window.center()
  10310. }
  10311. let lockedContentSize = window.contentRect(forFrameRect: window.frame).size
  10312. window.contentMinSize = lockedContentSize
  10313. window.contentMaxSize = lockedContentSize
  10314. }
  10315. func webView(_ webView: WKWebView,
  10316. decidePolicyFor navigationAction: WKNavigationAction,
  10317. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  10318. guard let targetURL = navigationAction.request.url,
  10319. let callbackPrefix,
  10320. targetURL.absoluteString.hasPrefix(callbackPrefix) else {
  10321. decisionHandler(.allow)
  10322. return
  10323. }
  10324. callbackContinuation?.resume(returning: targetURL)
  10325. callbackContinuation = nil
  10326. decisionHandler(.cancel)
  10327. }
  10328. }
  10329. extension GoogleOAuthError: LocalizedError {
  10330. var errorDescription: String? {
  10331. switch self {
  10332. case .missingClientId: return "Missing Google OAuth Client ID."
  10333. case .missingClientSecret: return "Missing Google OAuth Client Secret."
  10334. case .invalidCallbackURL: return "Invalid OAuth callback URL."
  10335. case .missingAuthorizationCode: return "Google did not return an authorization code."
  10336. case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
  10337. case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
  10338. case .authenticationTimedOut: return "Google sign-in timed out."
  10339. case .authenticationCancelled: return "Sign-in was cancelled."
  10340. }
  10341. }
  10342. }
  10343. extension ZoomOAuthError: LocalizedError {
  10344. var errorDescription: String? {
  10345. switch self {
  10346. case .missingClientId:
  10347. return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
  10348. case .missingClientSecret:
  10349. return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
  10350. case .invalidCallbackURL:
  10351. return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
  10352. case .missingAuthorizationCode:
  10353. return "Zoom did not return an authorization code."
  10354. case .tokenExchangeFailed(let details):
  10355. return details
  10356. case .missingRequiredScope(let details):
  10357. return "The Zoom access token is missing required scopes. \(details)"
  10358. case .rateLimited(let retryAfterSeconds):
  10359. if let retryAfterSeconds {
  10360. return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
  10361. }
  10362. return "Zoom rate limit reached. Try again later."
  10363. case .unableToOpenBrowser:
  10364. return "Could not open Zoom sign-in (no window to present from)."
  10365. case .authenticationTimedOut:
  10366. return "Zoom sign-in timed out waiting for the browser redirect."
  10367. case .authenticationCancelled:
  10368. return "Zoom sign-in was cancelled."
  10369. }
  10370. }
  10371. }
  10372. // MARK: - Desktop Widgets (Zoom)
  10373. private struct WidgetQuickAction: Identifiable, Hashable {
  10374. enum Destination: String, Hashable {
  10375. case home
  10376. case meetings
  10377. case scheduler
  10378. case widgets
  10379. case settings
  10380. case newMeeting
  10381. case joinMeeting
  10382. case scheduleMeeting
  10383. case refreshMeetings
  10384. }
  10385. let id: String
  10386. let title: String
  10387. let systemImage: String
  10388. let destination: Destination
  10389. }
  10390. private struct WidgetVariant: Identifiable, Hashable {
  10391. let id: String
  10392. let title: String
  10393. let subtitle: String
  10394. let size: WidgetSize
  10395. let quickActions: [WidgetQuickAction]
  10396. }
  10397. private enum WidgetTemplates {
  10398. static let zoomVariants: [WidgetVariant] = [
  10399. WidgetVariant(
  10400. id: "zoom_small",
  10401. title: "Zoom",
  10402. subtitle: "Quick actions",
  10403. size: .small,
  10404. quickActions: [
  10405. WidgetQuickAction(id: "new", title: "New meeting", systemImage: "video.fill", destination: .newMeeting),
  10406. WidgetQuickAction(id: "join", title: "Join", systemImage: "plus", destination: .joinMeeting),
  10407. WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "calendar", destination: .scheduleMeeting),
  10408. WidgetQuickAction(id: "open", title: "Meetings", systemImage: "list.bullet.rectangle", destination: .meetings)
  10409. ]
  10410. ),
  10411. WidgetVariant(
  10412. id: "zoom_medium",
  10413. title: "Zoom",
  10414. subtitle: "Upcoming meetings",
  10415. size: .medium,
  10416. quickActions: [
  10417. WidgetQuickAction(id: "meetings", title: "Meetings", systemImage: "video", destination: .meetings),
  10418. WidgetQuickAction(id: "scheduler", title: "Scheduler", systemImage: "calendar.badge.clock", destination: .scheduler),
  10419. WidgetQuickAction(id: "widgets", title: "Widgets", systemImage: "rectangle.grid.2x2", destination: .widgets),
  10420. WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
  10421. ]
  10422. )
  10423. ]
  10424. static func variant(for variantID: String) -> WidgetVariant {
  10425. zoomVariants.first(where: { $0.id == variantID }) ?? zoomVariants[0]
  10426. }
  10427. }
  10428. private extension WidgetVariant {
  10429. var previewCardSize: CGSize {
  10430. switch size {
  10431. case .small:
  10432. return CGSize(width: 196, height: 196)
  10433. case .medium:
  10434. return CGSize(width: 340, height: 288)
  10435. }
  10436. }
  10437. // Simple Zoom-blue palette shared by all widget sizes.
  10438. var gradientStops: [Color] {
  10439. [
  10440. Color(red: 0.04, green: 0.32, blue: 0.86),
  10441. Color(red: 0.10, green: 0.50, blue: 0.96)
  10442. ]
  10443. }
  10444. // Neutral highlight keeps the blue clean without washing out the logo.
  10445. var highlightTint: Color {
  10446. Color.white.opacity(0.16)
  10447. }
  10448. }
  10449. private enum WidgetSize: String, CaseIterable, Codable, Hashable {
  10450. case small
  10451. case medium
  10452. var title: String {
  10453. switch self {
  10454. case .small: return "Small"
  10455. case .medium: return "Medium"
  10456. }
  10457. }
  10458. }
  10459. private struct WidgetInstance: Identifiable, Codable, Hashable {
  10460. struct Origin: Codable, Hashable {
  10461. var x: Double
  10462. var y: Double
  10463. }
  10464. let id: UUID
  10465. let variantID: String
  10466. var size: WidgetSize
  10467. var origin: Origin?
  10468. init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
  10469. self.id = id
  10470. self.variantID = variantID
  10471. self.size = size
  10472. self.origin = origin
  10473. }
  10474. }
  10475. private enum WidgetAppNavigator {
  10476. static func open(target: WidgetQuickAction.Destination) {
  10477. DispatchQueue.main.async {
  10478. bringAppToFront()
  10479. DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
  10480. NotificationCenter.default.post(name: notificationName(for: target), object: nil)
  10481. }
  10482. }
  10483. }
  10484. static func openMeetingLink(_ link: String) {
  10485. DispatchQueue.main.async {
  10486. bringAppToFront()
  10487. DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
  10488. NotificationCenter.default.post(
  10489. name: .widgetOpenMeetingLinkRequested,
  10490. object: nil,
  10491. userInfo: ["link": link]
  10492. )
  10493. }
  10494. }
  10495. }
  10496. private static func notificationName(for target: WidgetQuickAction.Destination) -> Notification.Name {
  10497. switch target {
  10498. case .home: return .widgetOpenHomePage
  10499. case .meetings: return .widgetOpenMeetingsPage
  10500. case .scheduler: return .widgetOpenSchedulerPage
  10501. case .widgets: return .widgetOpenWidgetsPage
  10502. case .settings: return .widgetOpenSettingsPage
  10503. case .newMeeting: return .widgetStartNewMeetingRequested
  10504. case .joinMeeting: return .widgetJoinMeetingRequested
  10505. case .scheduleMeeting: return .widgetScheduleMeetingRequested
  10506. case .refreshMeetings: return .widgetRefreshRequested
  10507. }
  10508. }
  10509. private static func bringAppToFront() {
  10510. NSApp.unhide(nil)
  10511. NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
  10512. NSApp.activate(ignoringOtherApps: true)
  10513. NSApp.arrangeInFront(nil)
  10514. for window in NSApp.windows where window.contentViewController is ViewController {
  10515. if window.isMiniaturized {
  10516. window.deminiaturize(nil)
  10517. }
  10518. window.orderFrontRegardless()
  10519. window.makeKeyAndOrderFront(nil)
  10520. }
  10521. }
  10522. }
  10523. private extension Notification.Name {
  10524. static let widgetOpenHomePage = Notification.Name("widgetOpenHomePage")
  10525. static let widgetOpenMeetingsPage = Notification.Name("widgetOpenMeetingsPage")
  10526. static let widgetOpenSchedulerPage = Notification.Name("widgetOpenSchedulerPage")
  10527. static let widgetOpenWidgetsPage = Notification.Name("widgetOpenWidgetsPage")
  10528. static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
  10529. static let widgetStartNewMeetingRequested = Notification.Name("widgetStartNewMeetingRequested")
  10530. static let widgetJoinMeetingRequested = Notification.Name("widgetJoinMeetingRequested")
  10531. static let widgetScheduleMeetingRequested = Notification.Name("widgetScheduleMeetingRequested")
  10532. static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
  10533. static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
  10534. }
  10535. private final class DesktopWidgetWindowManager: ObservableObject {
  10536. static let shared = DesktopWidgetWindowManager()
  10537. private let storageKey = "zoom_app.widget.instancesData"
  10538. private var windowsByInstanceID: [UUID: NSPanel] = [:]
  10539. @Published private(set) var instancesChangeToken: Int = 0
  10540. @Published private(set) var meetingsRefreshToken: Int = 0
  10541. private init() {}
  10542. func hasInstance(variantID: String) -> Bool {
  10543. loadInstances().contains { $0.variantID == variantID }
  10544. }
  10545. func show(instance: WidgetInstance) {
  10546. let variant = WidgetTemplates.variant(for: instance.variantID)
  10547. var resolved = instance
  10548. if resolved.origin == nil {
  10549. resolved.origin = nextAutoPlacement(for: variant)
  10550. }
  10551. let panel = makePanel(for: resolved, variant: variant)
  10552. windowsByInstanceID[resolved.id] = panel
  10553. persistUpsert(instance: resolved)
  10554. if let origin = resolved.origin {
  10555. panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
  10556. persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
  10557. } else {
  10558. panel.center()
  10559. persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
  10560. }
  10561. panel.orderFront(nil)
  10562. panel.orderFrontRegardless()
  10563. instancesChangeToken += 1
  10564. }
  10565. @discardableResult
  10566. func removeInstances(variantID: String) -> Int {
  10567. let instances = loadInstances()
  10568. let removed = instances.filter { $0.variantID == variantID }
  10569. let remaining = instances.filter { $0.variantID != variantID }
  10570. for instance in removed {
  10571. if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
  10572. panel.close()
  10573. }
  10574. }
  10575. saveInstances(remaining)
  10576. instancesChangeToken += 1
  10577. return removed.count
  10578. }
  10579. @discardableResult
  10580. func removeAllWidgets() -> Int {
  10581. let removedCount = loadInstances().count
  10582. for panel in windowsByInstanceID.values {
  10583. panel.close()
  10584. }
  10585. windowsByInstanceID.removeAll()
  10586. saveInstances([])
  10587. instancesChangeToken += 1
  10588. return removedCount
  10589. }
  10590. func restore() {
  10591. for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
  10592. show(instance: instance)
  10593. }
  10594. }
  10595. func refreshForMeetingsChange() {
  10596. resizeVisiblePanelsForCurrentContent()
  10597. meetingsRefreshToken += 1
  10598. }
  10599. private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
  10600. let size = panelSize(for: variant.size)
  10601. let panel = DesktopWidgetPanel(
  10602. contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
  10603. styleMask: [.nonactivatingPanel, .borderless],
  10604. backing: .buffered,
  10605. defer: false
  10606. )
  10607. panel.becomesKeyOnlyIfNeeded = false
  10608. panel.isReleasedWhenClosed = false
  10609. panel.isFloatingPanel = false
  10610. panel.isMovable = true
  10611. panel.hasShadow = false
  10612. panel.isOpaque = false
  10613. panel.backgroundColor = .clear
  10614. panel.isMovableByWindowBackground = true
  10615. panel.hidesOnDeactivate = false
  10616. panel.ignoresMouseEvents = false
  10617. let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
  10618. panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
  10619. panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
  10620. let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
  10621. host.translatesAutoresizingMaskIntoConstraints = false
  10622. let container = NSView()
  10623. container.wantsLayer = true
  10624. container.layer?.backgroundColor = NSColor.clear.cgColor
  10625. container.layer?.cornerRadius = 22
  10626. container.layer?.masksToBounds = true
  10627. container.addSubview(host)
  10628. NSLayoutConstraint.activate([
  10629. host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  10630. host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  10631. host.topAnchor.constraint(equalTo: container.topAnchor),
  10632. host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  10633. ])
  10634. panel.contentView = container
  10635. NotificationCenter.default.addObserver(
  10636. forName: NSWindow.didMoveNotification,
  10637. object: panel,
  10638. queue: .main
  10639. ) { [weak self, weak panel] _ in
  10640. guard let self, let panel else { return }
  10641. self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
  10642. }
  10643. return panel
  10644. }
  10645. private func panelSize(for size: WidgetSize) -> CGSize {
  10646. switch size {
  10647. case .small:
  10648. return CGSize(width: 220, height: 204)
  10649. case .medium:
  10650. return CGSize(width: 390, height: mediumPanelHeight())
  10651. }
  10652. }
  10653. private func mediumPanelHeight() -> CGFloat {
  10654. let visibleMeetingCount = min(WidgetMeetingStore.load().count, 2)
  10655. switch visibleMeetingCount {
  10656. case 0:
  10657. return 252
  10658. case 1:
  10659. return 270
  10660. default:
  10661. return 288
  10662. }
  10663. }
  10664. private func resizeVisiblePanelsForCurrentContent() {
  10665. let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
  10666. for (instanceID, panel) in windowsByInstanceID {
  10667. guard let instance = instancesByID[instanceID] else { continue }
  10668. let variant = WidgetTemplates.variant(for: instance.variantID)
  10669. let targetSize = panelSize(for: variant.size)
  10670. let currentSize = panel.frame.size
  10671. guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
  10672. continue
  10673. }
  10674. var frame = panel.frame
  10675. let oldMaxY = frame.maxY
  10676. frame.size = targetSize
  10677. frame.origin.y = oldMaxY - targetSize.height
  10678. panel.setFrame(frame, display: true)
  10679. persistOrigin(instanceID: instanceID, origin: frame.origin)
  10680. }
  10681. }
  10682. private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
  10683. let size = panelSize(for: variant.size)
  10684. let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
  10685. let margin: CGFloat = 18
  10686. let gapX: CGFloat = 22
  10687. let gapY: CGFloat = 18
  10688. let cellW = size.width + gapX
  10689. let cellH = size.height + gapY
  10690. let existingRects = loadInstances().compactMap { instance -> NSRect? in
  10691. guard let origin = instance.origin else { return nil }
  10692. let instanceSize = panelSize(for: instance.size)
  10693. return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
  10694. }
  10695. let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
  10696. let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
  10697. let maxSlots = max(120, cols * rows)
  10698. for slot in 0..<maxSlots {
  10699. let row = slot / cols
  10700. let col = cols - 1 - (slot % cols)
  10701. let x = frame.minX + margin + CGFloat(col) * cellW
  10702. let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
  10703. let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
  10704. guard candidate.minX >= frame.minX + margin,
  10705. candidate.maxX <= frame.maxX - margin,
  10706. candidate.minY >= frame.minY + margin else {
  10707. continue
  10708. }
  10709. if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
  10710. return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
  10711. }
  10712. }
  10713. return WidgetInstance.Origin(
  10714. x: frame.midX - size.width / 2,
  10715. y: frame.midY - size.height / 2
  10716. )
  10717. }
  10718. private func close(instanceID: UUID) {
  10719. if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
  10720. panel.close()
  10721. }
  10722. let remaining = loadInstances().filter { $0.id != instanceID }
  10723. saveInstances(remaining)
  10724. instancesChangeToken += 1
  10725. }
  10726. private func loadInstances() -> [WidgetInstance] {
  10727. guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
  10728. let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
  10729. let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.zoomVariants.map { ($0.id, $0.size) })
  10730. let sanitized = decoded.compactMap { instance -> WidgetInstance? in
  10731. guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
  10732. var normalized = instance
  10733. normalized.size = expectedSize
  10734. return normalized
  10735. }
  10736. if sanitized != decoded {
  10737. saveInstances(sanitized)
  10738. }
  10739. return sanitized
  10740. }
  10741. private func saveInstances(_ instances: [WidgetInstance]) {
  10742. guard let data = try? JSONEncoder().encode(instances) else { return }
  10743. UserDefaults.standard.set(data, forKey: storageKey)
  10744. }
  10745. private func persistUpsert(instance: WidgetInstance) {
  10746. var instances = loadInstances()
  10747. if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
  10748. instances[idx] = instance
  10749. } else {
  10750. instances.append(instance)
  10751. }
  10752. saveInstances(instances)
  10753. }
  10754. private func persistOrigin(instanceID: UUID, origin: NSPoint) {
  10755. var instances = loadInstances()
  10756. guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
  10757. instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
  10758. saveInstances(instances)
  10759. }
  10760. private struct DesktopWidgetHostView: View {
  10761. let instanceID: UUID
  10762. let variant: WidgetVariant
  10763. @ObservedObject private var manager = DesktopWidgetWindowManager.shared
  10764. var body: some View {
  10765. DesktopWidgetView(variant: variant, isPreview: false)
  10766. .id(manager.meetingsRefreshToken)
  10767. .contextMenu {
  10768. Button("Remove Widget", role: .destructive) {
  10769. DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
  10770. }
  10771. }
  10772. }
  10773. }
  10774. }
  10775. private final class DesktopWidgetPanel: NSPanel {
  10776. override var canBecomeKey: Bool { true }
  10777. override var canBecomeMain: Bool { false }
  10778. override func mouseDown(with event: NSEvent) {
  10779. NSApp.activate(ignoringOtherApps: true)
  10780. makeKeyAndOrderFront(nil)
  10781. super.mouseDown(with: event)
  10782. }
  10783. }
  10784. private struct WidgetsRootView: View {
  10785. @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
  10786. @Environment(\.colorScheme) private var colorScheme
  10787. @State private var toastMessage: String?
  10788. @State private var toastTask: Task<Void, Never>?
  10789. let canAddWidgets: Bool
  10790. let onAddBlocked: (() -> Void)?
  10791. private let variants = WidgetTemplates.zoomVariants
  10792. init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
  10793. self.canAddWidgets = canAddWidgets
  10794. self.onAddBlocked = onAddBlocked
  10795. }
  10796. var body: some View {
  10797. VStack(alignment: .leading, spacing: 18) {
  10798. HStack {
  10799. VStack(alignment: .leading, spacing: 3) {
  10800. Text("Add Widget to Desktop")
  10801. .font(.system(size: 20, weight: .bold))
  10802. Text("Choose a size and add your Zoom desktop widget.")
  10803. .font(.system(size: 13, weight: .medium))
  10804. .foregroundStyle(secondaryTextColor)
  10805. }
  10806. Spacer()
  10807. }
  10808. GeometryReader { geo in
  10809. let count = max(variants.count, 1)
  10810. let spacing: CGFloat = 24
  10811. let baseRowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width }
  10812. let usableWidth = max(1, geo.size.width)
  10813. let usableHeight = max(1, geo.size.height)
  10814. let maxPreviewHeight = variants.map(\.previewCardSize.height).max() ?? 320
  10815. let widthScale = (usableWidth - CGFloat(count - 1) * spacing) / max(1, baseRowWidth)
  10816. let heightScale = usableHeight / max(1, maxPreviewHeight)
  10817. let scale = max(0.45, min(1.0, min(widthScale, heightScale)))
  10818. HStack(alignment: .top, spacing: spacing) {
  10819. ForEach(variants) { variant in
  10820. WidgetPreviewAddCard(
  10821. variant: variant,
  10822. layoutScale: scale,
  10823. isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
  10824. isLocked: canAddWidgets == false,
  10825. onToggle: { toggleWidget(variant: variant) }
  10826. )
  10827. }
  10828. Spacer(minLength: 0)
  10829. }
  10830. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  10831. }
  10832. .frame(maxWidth: .infinity, maxHeight: .infinity)
  10833. Text("Tip: Right-click a desktop widget to remove it.")
  10834. .font(.system(size: 12, weight: .medium))
  10835. .foregroundStyle(secondaryTextColor)
  10836. }
  10837. .padding(22)
  10838. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  10839. .background(
  10840. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10841. .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
  10842. )
  10843. .overlay(
  10844. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10845. .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
  10846. )
  10847. .padding(.horizontal, 12)
  10848. .padding(.vertical, 12)
  10849. .overlay(alignment: .top) {
  10850. if let toastMessage {
  10851. Text(toastMessage)
  10852. .font(.system(size: 13, weight: .semibold))
  10853. .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
  10854. .padding(.horizontal, 14)
  10855. .padding(.vertical, 9)
  10856. .background(
  10857. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10858. .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
  10859. )
  10860. .overlay(
  10861. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10862. .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
  10863. )
  10864. .padding(.top, 8)
  10865. }
  10866. }
  10867. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  10868. }
  10869. private var secondaryTextColor: Color {
  10870. colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
  10871. }
  10872. private func toggleWidget(variant: WidgetVariant) {
  10873. guard canAddWidgets else {
  10874. showToast("Widgets are Premium only. Upgrade to add widgets.")
  10875. onAddBlocked?()
  10876. return
  10877. }
  10878. if desktopWidgetManager.hasInstance(variantID: variant.id) {
  10879. let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
  10880. guard removed > 0 else { return }
  10881. showToast("Removed \(variant.size.title) widget from desktop.")
  10882. } else {
  10883. desktopWidgetManager.show(
  10884. instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
  10885. )
  10886. showToast("Added \(variant.size.title) widget to desktop.")
  10887. }
  10888. }
  10889. private func showToast(_ message: String) {
  10890. toastTask?.cancel()
  10891. toastMessage = message
  10892. toastTask = Task { @MainActor in
  10893. try? await Task.sleep(nanoseconds: 1_800_000_000)
  10894. guard !Task.isCancelled else { return }
  10895. toastMessage = nil
  10896. }
  10897. }
  10898. }
  10899. private struct WidgetPreviewAddCard: View {
  10900. let variant: WidgetVariant
  10901. var layoutScale: CGFloat = 1
  10902. let isAdded: Bool
  10903. let isLocked: Bool
  10904. let onToggle: () -> Void
  10905. @State private var hovering = false
  10906. var body: some View {
  10907. ZStack(alignment: .topTrailing) {
  10908. Button(action: onToggle) {
  10909. WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
  10910. .opacity(isLocked ? 0.52 : 1)
  10911. .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
  10912. .overlay {
  10913. if isLocked {
  10914. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10915. .fill(Color.black.opacity(0.28))
  10916. .frame(
  10917. width: variant.previewCardSize.width * max(0.01, layoutScale),
  10918. height: variant.previewCardSize.height * max(0.01, layoutScale)
  10919. )
  10920. }
  10921. }
  10922. .overlay {
  10923. if isLocked {
  10924. VStack(spacing: 6) {
  10925. Image(systemName: "lock.fill")
  10926. .font(.system(size: 18, weight: .bold))
  10927. Text("Premium")
  10928. .font(.system(size: 13, weight: .bold))
  10929. }
  10930. .foregroundStyle(.white.opacity(0.96))
  10931. .padding(.horizontal, 14)
  10932. .padding(.vertical, 10)
  10933. .background(
  10934. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10935. .fill(Color.black.opacity(0.46))
  10936. )
  10937. .frame(
  10938. width: variant.previewCardSize.width * max(0.01, layoutScale),
  10939. height: variant.previewCardSize.height * max(0.01, layoutScale)
  10940. )
  10941. }
  10942. }
  10943. }
  10944. .buttonStyle(.plain)
  10945. Button(action: onToggle) {
  10946. Image(systemName: isLocked ? "lock.fill" : (isAdded ? "minus" : "plus"))
  10947. .font(.system(size: 12, weight: .bold))
  10948. .foregroundStyle(.white.opacity(0.95))
  10949. .frame(width: 26, height: 26)
  10950. .background(
  10951. Circle().fill(
  10952. isLocked
  10953. ? Color.orange.opacity(0.95)
  10954. : (isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95))
  10955. )
  10956. )
  10957. .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
  10958. }
  10959. .buttonStyle(.plain)
  10960. .offset(x: 10 * layoutScale, y: -10 * layoutScale)
  10961. .opacity((isAdded || isLocked) ? 1 : (hovering ? 1 : 0))
  10962. .animation(.easeOut(duration: 0.12), value: hovering)
  10963. }
  10964. .onHover { hovering = $0 }
  10965. }
  10966. }
  10967. private struct WidgetPreviewCard: View {
  10968. let variant: WidgetVariant
  10969. var layoutScale: CGFloat = 1
  10970. var body: some View {
  10971. let size = variant.previewCardSize
  10972. let scale = max(0.01, layoutScale)
  10973. DesktopWidgetView(variant: variant, isPreview: true)
  10974. .frame(width: size.width, height: size.height)
  10975. .scaleEffect(scale)
  10976. .frame(width: size.width * scale, height: size.height * scale)
  10977. .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
  10978. .overlay(
  10979. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10980. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  10981. )
  10982. }
  10983. }
  10984. private struct DesktopWidgetView: View {
  10985. let variant: WidgetVariant
  10986. var isPreview: Bool
  10987. private var topMeetings: [WidgetMeetingSnapshot] {
  10988. WidgetMeetingStore.load().prefix(3).map { $0 }
  10989. }
  10990. var body: some View {
  10991. ZStack {
  10992. RoundedRectangle(cornerRadius: 22, style: .continuous)
  10993. .fill(
  10994. LinearGradient(
  10995. colors: variant.gradientStops,
  10996. startPoint: .topLeading,
  10997. endPoint: .bottomTrailing
  10998. )
  10999. )
  11000. .overlay(
  11001. RoundedRectangle(cornerRadius: 22, style: .continuous)
  11002. .fill(
  11003. RadialGradient(
  11004. colors: [variant.highlightTint, Color.clear],
  11005. center: .topLeading,
  11006. startRadius: 4,
  11007. endRadius: 200
  11008. )
  11009. )
  11010. .blendMode(.plusLighter)
  11011. .allowsHitTesting(false)
  11012. )
  11013. .overlay(
  11014. RoundedRectangle(cornerRadius: 22, style: .continuous)
  11015. .fill(
  11016. LinearGradient(
  11017. colors: [Color.clear, Color.black.opacity(0.18)],
  11018. startPoint: .top,
  11019. endPoint: .bottom
  11020. )
  11021. )
  11022. .allowsHitTesting(false)
  11023. )
  11024. .overlay(
  11025. RoundedRectangle(cornerRadius: 22, style: .continuous)
  11026. .stroke(Color.white.opacity(0.16), lineWidth: 1)
  11027. )
  11028. VStack(alignment: .leading, spacing: 12) {
  11029. widgetHeader
  11030. contentBody
  11031. if variant.size != .small {
  11032. Spacer(minLength: 0)
  11033. }
  11034. }
  11035. .padding(.horizontal, 14)
  11036. .padding(.bottom, variant.size == .medium ? 16 : 14)
  11037. .padding(.top, variant.size == .medium ? 18 : 10)
  11038. }
  11039. }
  11040. @ViewBuilder
  11041. private var contentBody: some View {
  11042. switch variant.size {
  11043. case .small:
  11044. VStack(alignment: .leading, spacing: 10) {
  11045. Text("Quick actions")
  11046. .font(.system(size: 11.5, weight: .semibold))
  11047. .foregroundStyle(.white.opacity(0.78))
  11048. compactActionButton(title: "New meeting", icon: "video.fill", destination: .newMeeting)
  11049. HStack(spacing: 8) {
  11050. compactActionButton(title: "Join", icon: "plus", destination: .joinMeeting)
  11051. compactActionButton(title: "Schedule", icon: "calendar", destination: .scheduleMeeting)
  11052. }
  11053. }
  11054. case .medium:
  11055. meetingBlock(maxRows: 2)
  11056. LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
  11057. ForEach(variant.quickActions.prefix(4)) { action in
  11058. actionTile(action)
  11059. }
  11060. }
  11061. }
  11062. }
  11063. private var widgetHeader: some View {
  11064. HStack(spacing: 10) {
  11065. Image(systemName: "video.fill")
  11066. .font(.system(size: 16, weight: .bold))
  11067. .foregroundStyle(.white.opacity(0.95))
  11068. .frame(width: 30, height: 30)
  11069. .background(
  11070. RoundedRectangle(cornerRadius: 8, style: .continuous)
  11071. .fill(
  11072. LinearGradient(
  11073. colors: [Color.white.opacity(0.22), Color.black.opacity(0.18)],
  11074. startPoint: .top,
  11075. endPoint: .bottom
  11076. )
  11077. )
  11078. )
  11079. .overlay(
  11080. RoundedRectangle(cornerRadius: 8, style: .continuous)
  11081. .stroke(Color.white.opacity(0.18), lineWidth: 1)
  11082. )
  11083. VStack(alignment: .leading, spacing: 2) {
  11084. Text(variant.title)
  11085. .font(.system(size: 16, weight: .bold))
  11086. .foregroundStyle(.white.opacity(0.95))
  11087. Text(variant.subtitle)
  11088. .font(.system(size: 12, weight: .medium))
  11089. .foregroundStyle(.white.opacity(0.8))
  11090. }
  11091. Spacer(minLength: 0)
  11092. }
  11093. }
  11094. private func meetingBlock(maxRows: Int) -> some View {
  11095. VStack(alignment: .leading, spacing: 9) {
  11096. Text("Upcoming meetings")
  11097. .font(.system(size: 11.5, weight: .semibold))
  11098. .foregroundStyle(.white.opacity(0.80))
  11099. if topMeetings.isEmpty {
  11100. Text("No upcoming meetings")
  11101. .font(.system(size: 12, weight: .medium))
  11102. .foregroundStyle(.white.opacity(0.82))
  11103. } else {
  11104. VStack(alignment: .leading, spacing: 8) {
  11105. ForEach(topMeetings.prefix(maxRows)) { meeting in
  11106. meetingRow(meeting)
  11107. }
  11108. }
  11109. }
  11110. }
  11111. }
  11112. private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
  11113. Button(action: {
  11114. if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
  11115. WidgetAppNavigator.openMeetingLink(link)
  11116. } else {
  11117. WidgetAppNavigator.open(target: .meetings)
  11118. }
  11119. }) {
  11120. HStack(spacing: 8) {
  11121. Image(systemName: "video.fill")
  11122. .font(.system(size: 11, weight: .semibold))
  11123. .foregroundStyle(.white.opacity(0.88))
  11124. VStack(alignment: .leading, spacing: 1) {
  11125. Text(meeting.title)
  11126. .font(.system(size: 11.5, weight: .semibold))
  11127. .foregroundStyle(.white.opacity(0.93))
  11128. .lineLimit(1)
  11129. Text(meeting.timeText)
  11130. .font(.system(size: 10.5, weight: .medium))
  11131. .foregroundStyle(.white.opacity(0.74))
  11132. .lineLimit(1)
  11133. }
  11134. Spacer(minLength: 0)
  11135. }
  11136. .padding(.horizontal, 10)
  11137. .padding(.vertical, 7)
  11138. .background(
  11139. RoundedRectangle(cornerRadius: 10, style: .continuous)
  11140. .fill(
  11141. LinearGradient(
  11142. colors: [Color.white.opacity(0.14), Color.black.opacity(0.22)],
  11143. startPoint: .top,
  11144. endPoint: .bottom
  11145. )
  11146. )
  11147. )
  11148. .overlay(
  11149. RoundedRectangle(cornerRadius: 10, style: .continuous)
  11150. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  11151. )
  11152. }
  11153. .buttonStyle(.plain)
  11154. .disabled(isPreview)
  11155. .allowsHitTesting(!isPreview)
  11156. }
  11157. private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
  11158. Button(action: { WidgetAppNavigator.open(target: destination) }) {
  11159. HStack(spacing: 5) {
  11160. Image(systemName: icon)
  11161. .font(.system(size: 10.5, weight: .semibold))
  11162. .frame(width: 14)
  11163. Text(title)
  11164. .font(.system(size: 11.5, weight: .bold))
  11165. .lineLimit(1)
  11166. .minimumScaleFactor(0.82)
  11167. .layoutPriority(1)
  11168. }
  11169. .foregroundStyle(.white.opacity(0.97))
  11170. .padding(.horizontal, 10)
  11171. .padding(.vertical, 8)
  11172. .frame(maxWidth: .infinity)
  11173. .background(
  11174. RoundedRectangle(cornerRadius: 12, style: .continuous)
  11175. .fill(
  11176. LinearGradient(
  11177. colors: [Color.white.opacity(0.20), Color.black.opacity(0.28)],
  11178. startPoint: .top,
  11179. endPoint: .bottom
  11180. )
  11181. )
  11182. )
  11183. .overlay(
  11184. RoundedRectangle(cornerRadius: 12, style: .continuous)
  11185. .stroke(Color.white.opacity(0.26), lineWidth: 1)
  11186. )
  11187. }
  11188. .buttonStyle(.plain)
  11189. .disabled(isPreview)
  11190. .allowsHitTesting(!isPreview)
  11191. }
  11192. private func actionTile(_ action: WidgetQuickAction) -> some View {
  11193. Button(action: { open(action: action) }) {
  11194. HStack(spacing: 8) {
  11195. Image(systemName: action.systemImage)
  11196. .font(.system(size: 11, weight: .semibold))
  11197. Text(action.title)
  11198. .font(.system(size: 12, weight: .bold))
  11199. .lineLimit(1)
  11200. Spacer(minLength: 0)
  11201. }
  11202. .foregroundStyle(.white.opacity(0.95))
  11203. .padding(.horizontal, 10)
  11204. .padding(.vertical, 9)
  11205. .background(
  11206. RoundedRectangle(cornerRadius: 12, style: .continuous)
  11207. .fill(
  11208. LinearGradient(
  11209. colors: [Color.white.opacity(0.16), Color.black.opacity(0.22)],
  11210. startPoint: .top,
  11211. endPoint: .bottom
  11212. )
  11213. )
  11214. )
  11215. .overlay(
  11216. RoundedRectangle(cornerRadius: 12, style: .continuous)
  11217. .stroke(Color.white.opacity(0.16), lineWidth: 1)
  11218. )
  11219. }
  11220. .buttonStyle(.plain)
  11221. .disabled(isPreview)
  11222. .allowsHitTesting(!isPreview)
  11223. }
  11224. private func open(action: WidgetQuickAction) {
  11225. guard !isPreview else { return }
  11226. WidgetAppNavigator.open(target: action.destination)
  11227. }
  11228. }