ViewController.swift 558 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080100811008210083100841008510086100871008810089100901009110092100931009410095100961009710098100991010010101101021010310104101051010610107101081010910110101111011210113101141011510116101171011810119101201012110122101231012410125101261012710128101291013010131101321013310134101351013610137101381013910140101411014210143101441014510146101471014810149101501015110152101531015410155101561015710158101591016010161101621016310164101651016610167101681016910170101711017210173101741017510176101771017810179101801018110182101831018410185101861018710188101891019010191101921019310194101951019610197101981019910200102011020210203102041020510206102071020810209102101021110212102131021410215102161021710218102191022010221102221022310224102251022610227102281022910230102311023210233102341023510236102371023810239102401024110242102431024410245102461024710248102491025010251102521025310254102551025610257102581025910260102611026210263102641026510266102671026810269102701027110272102731027410275102761027710278102791028010281102821028310284102851028610287102881028910290102911029210293102941029510296102971029810299103001030110302103031030410305103061030710308103091031010311103121031310314103151031610317103181031910320103211032210323103241032510326103271032810329103301033110332103331033410335103361033710338103391034010341103421034310344103451034610347103481034910350103511035210353103541035510356103571035810359103601036110362103631036410365103661036710368103691037010371103721037310374103751037610377103781037910380103811038210383103841038510386103871038810389103901039110392103931039410395103961039710398103991040010401104021040310404104051040610407104081040910410104111041210413104141041510416104171041810419104201042110422104231042410425104261042710428104291043010431104321043310434104351043610437104381043910440104411044210443104441044510446104471044810449104501045110452104531045410455104561045710458104591046010461104621046310464104651046610467104681046910470104711047210473104741047510476104771047810479104801048110482104831048410485104861048710488104891049010491104921049310494104951049610497104981049910500105011050210503105041050510506105071050810509105101051110512105131051410515105161051710518105191052010521105221052310524105251052610527105281052910530105311053210533105341053510536105371053810539105401054110542105431054410545105461054710548105491055010551105521055310554105551055610557105581055910560105611056210563105641056510566105671056810569105701057110572105731057410575105761057710578105791058010581105821058310584105851058610587105881058910590105911059210593105941059510596105971059810599106001060110602106031060410605106061060710608106091061010611106121061310614106151061610617106181061910620106211062210623106241062510626106271062810629106301063110632106331063410635106361063710638106391064010641106421064310644106451064610647106481064910650106511065210653106541065510656106571065810659106601066110662106631066410665106661066710668106691067010671106721067310674106751067610677106781067910680106811068210683106841068510686106871068810689106901069110692106931069410695106961069710698106991070010701107021070310704107051070610707107081070910710107111071210713107141071510716107171071810719107201072110722107231072410725107261072710728107291073010731107321073310734107351073610737107381073910740107411074210743107441074510746107471074810749107501075110752107531075410755107561075710758107591076010761107621076310764107651076610767107681076910770107711077210773107741077510776107771077810779107801078110782107831078410785107861078710788107891079010791107921079310794107951079610797107981079910800108011080210803108041080510806108071080810809108101081110812108131081410815108161081710818108191082010821108221082310824108251082610827108281082910830108311083210833108341083510836108371083810839108401084110842108431084410845108461084710848108491085010851108521085310854108551085610857108581085910860108611086210863108641086510866108671086810869108701087110872108731087410875108761087710878108791088010881108821088310884108851088610887108881088910890108911089210893108941089510896108971089810899109001090110902109031090410905109061090710908109091091010911109121091310914109151091610917109181091910920109211092210923109241092510926109271092810929109301093110932109331093410935109361093710938109391094010941109421094310944109451094610947109481094910950109511095210953109541095510956109571095810959109601096110962109631096410965109661096710968109691097010971109721097310974109751097610977109781097910980109811098210983109841098510986109871098810989109901099110992109931099410995109961099710998109991100011001110021100311004110051100611007110081100911010110111101211013110141101511016110171101811019110201102111022110231102411025110261102711028110291103011031110321103311034110351103611037110381103911040110411104211043110441104511046110471104811049110501105111052110531105411055110561105711058110591106011061110621106311064110651106611067110681106911070110711107211073110741107511076110771107811079110801108111082110831108411085110861108711088110891109011091110921109311094110951109611097110981109911100111011110211103111041110511106111071110811109111101111111112111131111411115111161111711118111191112011121111221112311124111251112611127111281112911130111311113211133111341113511136111371113811139111401114111142111431114411145111461114711148111491115011151111521115311154111551115611157111581115911160111611116211163111641116511166111671116811169111701117111172111731117411175111761117711178111791118011181111821118311184111851118611187111881118911190111911119211193111941119511196111971119811199112001120111202112031120411205112061120711208112091121011211112121121311214112151121611217112181121911220112211122211223112241122511226112271122811229112301123111232112331123411235112361123711238112391124011241112421124311244112451124611247112481124911250112511125211253112541125511256112571125811259112601126111262112631126411265112661126711268112691127011271112721127311274112751127611277112781127911280112811128211283112841128511286112871128811289112901129111292112931129411295112961129711298112991130011301113021130311304113051130611307113081130911310113111131211313113141131511316113171131811319113201132111322113231132411325113261132711328113291133011331113321133311334113351133611337113381133911340113411134211343113441134511346113471134811349113501135111352113531135411355113561135711358113591136011361113621136311364113651136611367113681136911370113711137211373113741137511376113771137811379113801138111382113831138411385113861138711388113891139011391113921139311394113951139611397113981139911400114011140211403114041140511406114071140811409114101141111412114131141411415114161141711418114191142011421114221142311424114251142611427114281142911430114311143211433114341143511436114371143811439114401144111442114431144411445114461144711448114491145011451114521145311454114551145611457114581145911460114611146211463114641146511466114671146811469114701147111472114731147411475114761147711478114791148011481114821148311484114851148611487114881148911490114911149211493114941149511496114971149811499115001150111502115031150411505115061150711508115091151011511115121151311514115151151611517115181151911520115211152211523115241152511526115271152811529115301153111532115331153411535115361153711538115391154011541115421154311544115451154611547115481154911550115511155211553115541155511556115571155811559115601156111562115631156411565115661156711568115691157011571115721157311574115751157611577115781157911580115811158211583115841158511586115871158811589115901159111592115931159411595115961159711598115991160011601116021160311604116051160611607116081160911610116111161211613116141161511616116171161811619116201162111622116231162411625116261162711628116291163011631116321163311634116351163611637116381163911640116411164211643116441164511646116471164811649116501165111652116531165411655116561165711658116591166011661116621166311664116651166611667116681166911670116711167211673116741167511676116771167811679116801168111682116831168411685116861168711688116891169011691116921169311694116951169611697116981169911700117011170211703117041170511706117071170811709117101171111712117131171411715117161171711718117191172011721117221172311724117251172611727117281172911730117311173211733117341173511736117371173811739117401174111742117431174411745117461174711748117491175011751117521175311754117551175611757117581175911760117611176211763117641176511766117671176811769117701177111772117731177411775117761177711778117791178011781117821178311784117851178611787117881178911790117911179211793117941179511796117971179811799118001180111802118031180411805118061180711808118091181011811118121181311814118151181611817118181181911820118211182211823118241182511826118271182811829118301183111832118331183411835118361183711838118391184011841118421184311844118451184611847118481184911850118511185211853118541185511856118571185811859118601186111862118631186411865118661186711868118691187011871118721187311874118751187611877118781187911880118811188211883118841188511886118871188811889118901189111892118931189411895118961189711898118991190011901119021190311904119051190611907119081190911910119111191211913119141191511916119171191811919119201192111922119231192411925119261192711928119291193011931119321193311934119351193611937119381193911940119411194211943119441194511946119471194811949119501195111952119531195411955119561195711958119591196011961119621196311964119651196611967119681196911970119711197211973119741197511976119771197811979119801198111982119831198411985119861198711988119891199011991119921199311994119951199611997119981199912000120011200212003120041200512006120071200812009120101201112012120131201412015120161201712018120191202012021120221202312024120251202612027120281202912030120311203212033120341203512036120371203812039120401204112042120431204412045120461204712048120491205012051120521205312054120551205612057120581205912060120611206212063120641206512066120671206812069120701207112072120731207412075120761207712078120791208012081120821208312084120851208612087120881208912090120911209212093120941209512096120971209812099121001210112102121031210412105121061210712108121091211012111121121211312114121151211612117121181211912120121211212212123121241212512126121271212812129121301213112132121331213412135121361213712138121391214012141121421214312144121451214612147121481214912150121511215212153121541215512156121571215812159121601216112162121631216412165121661216712168
  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. enum HomeSection: String {
  62. case home = "Home"
  63. case meetings = "Meetings"
  64. case scheduler = "Scheduler"
  65. case widgets = "Widgets"
  66. case aiCompanion = "Ai Assistant"
  67. case settings = "Settings"
  68. }
  69. private let googleOAuth = GoogleOAuthService.shared
  70. private let zoomOAuth = ZoomOAuthService.shared
  71. private let loginStateKey = "zoom_app.isLoggedIn"
  72. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  73. private let legacyNotificationsEnabledDefaultsKey = "settings.notificationsEnabled"
  74. private let remindersEnabledDefaultsKey = "reminders.enabled"
  75. private let remind1DayDefaultsKey = "reminders.1day"
  76. private let remind12HoursDefaultsKey = "reminders.12hours"
  77. private let remind1HourDefaultsKey = "reminders.1hour"
  78. private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
  79. private let ratingCompletedKey = "zoom_app.ratingCompleted"
  80. private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
  81. private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
  82. private struct Palette {
  83. let isDarkMode: Bool
  84. var appBackground: NSColor {
  85. isDarkMode
  86. ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  87. : NSColor(calibratedWhite: 0.96, alpha: 1)
  88. }
  89. var sidebarBackground: NSColor {
  90. isDarkMode
  91. ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
  92. : NSColor(calibratedWhite: 0.94, alpha: 1)
  93. }
  94. var sidebarActiveBackground: NSColor {
  95. isDarkMode
  96. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  97. : NSColor(calibratedWhite: 0.86, alpha: 1)
  98. }
  99. var cardBackground: NSColor {
  100. isDarkMode
  101. ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
  102. : NSColor.white
  103. }
  104. var secondaryCardBackground: NSColor {
  105. isDarkMode
  106. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  107. // Slightly darker than pure white so cards are visible on Light background.
  108. : NSColor(calibratedWhite: 0.93, alpha: 1)
  109. }
  110. var appShellBackground: NSColor { appBackground }
  111. var contentShellBackground: NSColor { appBackground }
  112. var topStripBackground: NSColor { chromeUnifiedBackground }
  113. var chromeUnifiedBackground: NSColor {
  114. isDarkMode
  115. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  116. : NSColor(calibratedWhite: 0.92, alpha: 1)
  117. }
  118. var searchPillBackground: NSColor {
  119. isDarkMode
  120. ? NSColor.white.withAlphaComponent(0.06)
  121. : NSColor.black.withAlphaComponent(0.06)
  122. }
  123. var meetingCardBackground: NSColor {
  124. isDarkMode
  125. ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
  126. : NSColor(calibratedWhite: 0.98, alpha: 1)
  127. }
  128. var sectionCard: NSColor {
  129. isDarkMode
  130. ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
  131. : NSColor.white
  132. }
  133. var inputBackground: NSColor {
  134. isDarkMode
  135. ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
  136. : NSColor(calibratedWhite: 0.97, alpha: 1)
  137. }
  138. var inputBorder: NSColor {
  139. isDarkMode
  140. ? NSColor.white.withAlphaComponent(0.08)
  141. : NSColor.black.withAlphaComponent(0.10)
  142. }
  143. var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
  144. var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
  145. var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
  146. var sidebarInactiveText: NSColor {
  147. isDarkMode ? secondaryText : NSColor(calibratedWhite: 0.12, alpha: 1)
  148. }
  149. }
  150. private final class TopAlignedClipView: NSClipView {
  151. override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
  152. var rect = super.constrainBoundsRect(proposedBounds)
  153. rect.origin.x = 0
  154. return rect
  155. }
  156. }
  157. private final class HoverButton: NSButton {
  158. var normalColor: NSColor = .clear { didSet { applyBackground() } }
  159. var hoverColor: NSColor = .clear
  160. var onHoverChanged: ((Bool) -> Void)?
  161. private var tracking: NSTrackingArea?
  162. private var hovering = false { didSet { applyBackground() } }
  163. override func updateTrackingAreas() {
  164. super.updateTrackingAreas()
  165. if let tracking { removeTrackingArea(tracking) }
  166. let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
  167. addTrackingArea(area)
  168. tracking = area
  169. }
  170. override func mouseEntered(with event: NSEvent) {
  171. hovering = true
  172. onHoverChanged?(true)
  173. }
  174. override func mouseExited(with event: NSEvent) {
  175. hovering = false
  176. onHoverChanged?(false)
  177. }
  178. private func applyBackground() {
  179. wantsLayer = true
  180. layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
  181. }
  182. }
  183. private final class HoverTileView: NSView {
  184. var onHoverChanged: ((Bool) -> Void)?
  185. private var tracking: NSTrackingArea?
  186. override func updateTrackingAreas() {
  187. super.updateTrackingAreas()
  188. if let tracking { removeTrackingArea(tracking) }
  189. let area = NSTrackingArea(
  190. rect: bounds,
  191. options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect],
  192. owner: self,
  193. userInfo: nil
  194. )
  195. addTrackingArea(area)
  196. tracking = area
  197. }
  198. override func mouseEntered(with event: NSEvent) {
  199. super.mouseEntered(with: event)
  200. onHoverChanged?(true)
  201. }
  202. override func mouseExited(with event: NSEvent) {
  203. super.mouseExited(with: event)
  204. onHoverChanged?(false)
  205. }
  206. }
  207. private var palette = Palette(isDarkMode: true)
  208. private var darkModeEnabled: Bool {
  209. get {
  210. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  211. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  212. }
  213. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  214. }
  215. private var remindersEnabled: Bool {
  216. get {
  217. let defaults = UserDefaults.standard
  218. if defaults.object(forKey: remindersEnabledDefaultsKey) != nil {
  219. return defaults.bool(forKey: remindersEnabledDefaultsKey)
  220. }
  221. // Migration: if the old single-toggle key exists, treat it as master reminders toggle.
  222. if defaults.object(forKey: legacyNotificationsEnabledDefaultsKey) != nil {
  223. return defaults.bool(forKey: legacyNotificationsEnabledDefaultsKey)
  224. }
  225. return true
  226. }
  227. set { UserDefaults.standard.set(newValue, forKey: remindersEnabledDefaultsKey) }
  228. }
  229. private var remind1Day: Bool {
  230. get {
  231. let defaults = UserDefaults.standard
  232. let stored = defaults.object(forKey: remind1DayDefaultsKey)
  233. return stored != nil ? defaults.bool(forKey: remind1DayDefaultsKey) : true
  234. }
  235. set { UserDefaults.standard.set(newValue, forKey: remind1DayDefaultsKey) }
  236. }
  237. private var remind12Hours: Bool {
  238. get {
  239. let defaults = UserDefaults.standard
  240. let stored = defaults.object(forKey: remind12HoursDefaultsKey)
  241. return stored != nil ? defaults.bool(forKey: remind12HoursDefaultsKey) : true
  242. }
  243. set { UserDefaults.standard.set(newValue, forKey: remind12HoursDefaultsKey) }
  244. }
  245. private var remind1Hour: Bool {
  246. get {
  247. let defaults = UserDefaults.standard
  248. let stored = defaults.object(forKey: remind1HourDefaultsKey)
  249. return stored != nil ? defaults.bool(forKey: remind1HourDefaultsKey) : true
  250. }
  251. set { UserDefaults.standard.set(newValue, forKey: remind1HourDefaultsKey) }
  252. }
  253. private let sidebarWidth: CGFloat = 78
  254. private var appBackground: NSColor { palette.appBackground }
  255. private var sidebarBackground: NSColor { palette.sidebarBackground }
  256. private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
  257. private var cardBackground: NSColor { palette.cardBackground }
  258. private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
  259. private var appShellBackground: NSColor { palette.appShellBackground }
  260. private var contentShellBackground: NSColor { palette.contentShellBackground }
  261. private var topStripBackground: NSColor { palette.topStripBackground }
  262. private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
  263. private var searchPillBackground: NSColor { palette.searchPillBackground }
  264. private var meetingCardBackground: NSColor { palette.meetingCardBackground }
  265. private let appShellCornerRadius: CGFloat = 20
  266. private let homeChromeHeaderHeight: CGFloat = 56
  267. private let nativeTrafficLightsLeading: CGFloat = 14
  268. private let nativeTrafficLightsTopInset: CGFloat = 20
  269. private let brandLeadingInset: CGFloat = 84
  270. private let brandFullScreenLeadingInset: CGFloat = 14
  271. private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
  272. private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
  273. private var primaryText: NSColor { palette.primaryText }
  274. private var secondaryText: NSColor { palette.secondaryText }
  275. private var mutedText: NSColor { palette.mutedText }
  276. private var sidebarInactiveText: NSColor { palette.sidebarInactiveText }
  277. private let rootContainer = NSView()
  278. private var homeView: NSView?
  279. private weak var googleButton: NSButton?
  280. private weak var timeLabel: NSTextField?
  281. private weak var dateLabel: NSTextField?
  282. private weak var meetingsDayHeaderLabel: NSTextField?
  283. private weak var emptyMeetingLabel: NSTextField?
  284. private weak var meetingsListStack: NSStackView?
  285. private weak var meetingsStatusLabel: NSTextField?
  286. private weak var meetingsScrollView: NSScrollView?
  287. private weak var meetingsPrevDayButton: NSButton?
  288. private weak var meetingsNextDayButton: NSButton?
  289. private weak var meetingsTodayButton: NSButton?
  290. private weak var refreshMeetingsButton: NSButton?
  291. private weak var homeWelcomeLabel: NSTextField?
  292. private weak var homeTimeLabelView: NSTextField?
  293. private weak var homeDateLabelView: NSTextField?
  294. private weak var homeActionsRow: NSView?
  295. private weak var homeMeetingsPanel: NSView?
  296. private weak var homePlaceholderLabel: NSTextField?
  297. private weak var homeSearchField: NSTextField?
  298. private weak var homeSearchPill: NSView?
  299. private weak var meetingSearchOverlayView: NSView?
  300. private weak var meetingSearchPanelView: NSView?
  301. private weak var meetingSearchField: NSTextField?
  302. private weak var meetingSearchResultsStack: NSStackView?
  303. private weak var meetingSearchEmptyLabel: NSTextField?
  304. private weak var homeSettingsView: NSView?
  305. private weak var homeSettingsHostColumn: NSView?
  306. private weak var schedulerRootView: NSView?
  307. private weak var schedulerMonthLabel: NSTextField?
  308. private weak var schedulerCalendarGridStack: NSStackView?
  309. private weak var schedulerSelectedDateLabel: NSTextField?
  310. private weak var schedulerMeetingsListStack: NSStackView?
  311. private weak var schedulerMeetingsEmptyLabel: NSTextField?
  312. private weak var schedulerTodayButton: NSButton?
  313. private weak var meetingsPageRootView: NSView?
  314. private weak var widgetsPageRootView: NSView?
  315. private weak var aiCompanionPageRootView: NSView?
  316. private weak var aiCompanionListStack: NSStackView?
  317. private weak var aiCompanionListScroll: NSScrollView?
  318. private weak var aiCompanionDetailScroll: NSScrollView?
  319. private weak var aiCompanionDetailStack: NSStackView?
  320. private weak var aiCompanionStatusLabel: NSTextField?
  321. private weak var aiCompanionCountLabel: NSTextField?
  322. private weak var aiCompanionRefreshButton: NSButton?
  323. private weak var aiCompanionRangePopup: NSPopUpButton?
  324. private weak var aiCompanionEmptyLabel: NSTextField?
  325. private weak var aiCompanionLoadingIndicator: NSProgressIndicator?
  326. private weak var aiCompanionDetailEmptyLabel: NSTextField?
  327. private weak var aiCompanionOpenDocButton: NSButton?
  328. private weak var aiCompanionCopyMarkdownButton: NSButton?
  329. private var aiCompanionRowButtons: [NSButton] = []
  330. private var aiCompanionSummaries: [AICompanionSummary] = []
  331. private var aiCompanionSelectedIndex: Int?
  332. private var aiCompanionIsLoading: Bool = false
  333. private var aiCompanionLastLoadedAt: Date?
  334. private var aiCompanionSelectedRange: String = "Last 30 days"
  335. // New AI Companion mode: local meeting recordings (audio)
  336. private var recordedMeetings: [MeetingRecordingManager.RecordedMeeting] = []
  337. private var recordedMeetingsPlayer: AVAudioPlayer?
  338. private var recordedMeetingsPlayingID: String?
  339. private weak var aiCompanionStopRecordingButton: NSButton?
  340. private weak var aiCompanionLocalTranscriptPanel: NSPanel?
  341. private weak var aiCompanionLocalTranscriptTextView: NSTextView?
  342. private weak var aiCompanionLocalTranscriptRootView: NSView?
  343. private weak var aiCompanionLocalTranscriptHeaderView: NSView?
  344. private weak var aiCompanionLocalTranscriptSubtitleLabel: NSTextField?
  345. private var aiCompanionLocalTranscriptPanelRecordingID: String?
  346. private weak var aiCompanionLocalNotesPanel: NSPanel?
  347. private weak var aiCompanionLocalNotesTextView: NSTextView?
  348. private weak var aiCompanionLocalNotesRootView: NSView?
  349. private weak var aiCompanionLocalNotesHeaderView: NSView?
  350. private weak var aiCompanionLocalNotesSubtitleLabel: NSTextField?
  351. private var aiCompanionLocalNotesPanelRecordingID: String?
  352. private enum AiCompanionRecordingFilter: String, CaseIterable {
  353. case all = "All"
  354. case today = "Today's meetings"
  355. case previousWeek = "Previous week"
  356. case thisWeek = "This week"
  357. case previousMonth = "Previous month"
  358. case custom = "Custom date"
  359. }
  360. private var aiCompanionRecordingFilter: AiCompanionRecordingFilter = .all
  361. private weak var aiCompanionFilterPopup: NSPopUpButton?
  362. private weak var aiCompanionFilterFromPicker: NSDatePicker?
  363. private weak var aiCompanionFilterToPicker: NSDatePicker?
  364. private weak var aiCompanionFilterApplyButton: NSButton?
  365. private weak var aiCompanionFilterResetButton: NSButton?
  366. private weak var aiCompanionFilterRefreshButton: NSButton?
  367. private let meetingNotesService = MeetingNotesService()
  368. private var localRecordingNotesTaskByRecordingId: [String: Task<Void, Never>] = [:]
  369. private var localRecordingNotesRequestIdByRecordingId: [String: UUID] = [:]
  370. private var localRecordingNotesProgressByRecordingId: [String: String] = [:]
  371. private weak var meetingsPageCategoryFilter: NSPopUpButton?
  372. private weak var meetingsPageFromDatePicker: NSDatePicker?
  373. private weak var meetingsPageToDatePicker: NSDatePicker?
  374. private weak var meetingsPageRefreshButton: NSButton?
  375. private weak var meetingsPageCountLabel: NSTextField?
  376. private weak var meetingsPageStatusLabel: NSTextField?
  377. private weak var meetingsPageListStack: NSStackView?
  378. private weak var meetingsPageEmptyLabel: NSTextField?
  379. private weak var meetingsPageLoadMoreButton: NSButton?
  380. private weak var meetingsPageScrollView: NSScrollView?
  381. private weak var embeddedBrowserWebView: WKWebView?
  382. private weak var embeddedBrowserPanel: NSView?
  383. private weak var homeBodyRowView: NSView?
  384. private weak var homeMainContentColumnView: NSView?
  385. private weak var embeddedBrowserBackButton: NSButton?
  386. private weak var embeddedBrowserForwardButton: NSButton?
  387. private var embeddedBrowserPanelWidthConstraint: NSLayoutConstraint?
  388. private var embeddedBrowserMainColumnCollapsedWidthConstraint: NSLayoutConstraint?
  389. private var oauthSessionContinuation: CheckedContinuation<URL, Error>?
  390. private var oauthSessionCallbackPrefix: String?
  391. /// ZAK + display name for the next embedded join; used to re-auth the web client if the guest name screen appears.
  392. private var embeddedZoomPendingWebAuth: (zak: String, displayName: String?)?
  393. private var zoomSignedInDisplayName: String?
  394. /// Gap between main column and right web panel (must match Auto Layout constant).
  395. private let homeEmbeddedBrowserColumnGap: CGFloat = 10
  396. private weak var homePremiumLockOverlay: NSButton?
  397. private weak var meetingsPremiumLockOverlay: NSButton?
  398. private weak var schedulerPremiumLockOverlay: NSButton?
  399. private var schedulerDayButtons: [Date: NSButton] = [:]
  400. private var schedulerDateByButtonTag: [Int: Date] = [:]
  401. private var nextSchedulerButtonTag: Int = 1
  402. private weak var settingsDarkModeSwitch: NSSwitch?
  403. private weak var settingsRemindersMasterSwitch: NSSwitch?
  404. private weak var settingsReminders1DaySwitch: NSSwitch?
  405. private weak var settingsReminders12HoursSwitch: NSSwitch?
  406. private weak var settingsReminders1HourSwitch: NSSwitch?
  407. private weak var settingsReminders1DayRow: NSView?
  408. private weak var settingsReminders12HoursRow: NSView?
  409. private weak var settingsReminders1HourRow: NSView?
  410. private weak var settingsUpgradeButton: NSButton?
  411. private weak var settingsUpgradeTitleLabel: NSTextField?
  412. private weak var settingsNotificationsBlock: NSStackView?
  413. private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
  414. private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
  415. private weak var settingsGoogleActionButton: NSButton?
  416. private weak var topBarPremiumButton: NSButton?
  417. private weak var topBarAuthButton: NSButton?
  418. private var topBarAuthButtonWidthConstraint: NSLayoutConstraint?
  419. private var profileMenuPopover: NSPopover?
  420. private weak var paywallOverlayView: NSView?
  421. private weak var paywallPanelView: NSView?
  422. private weak var paywallBodyStack: NSStackView?
  423. private weak var paywallPlansRowWide: NSStackView?
  424. private weak var paywallPlansGridNarrow: NSStackView?
  425. private var paywallIsDesktopLayout = false
  426. private var paywallPanelWidthConstraint: NSLayoutConstraint?
  427. private var paywallPanelHeightConstraint: NSLayoutConstraint?
  428. private var joinMeetingWindow: NSPanel?
  429. private var scheduleMeetingWindow: NSWindow?
  430. private weak var scheduleTopicField: NSTextField?
  431. private weak var scheduleDateField: NSTextField?
  432. private weak var scheduleTimeField: NSTextField?
  433. private weak var scheduleTimeZonePopup: NSPopUpButton?
  434. private weak var scheduleDurationField: NSTextField?
  435. private weak var scheduleSubmitButton: NSButton?
  436. private var scheduleTimeZoneDisplayToIdentifier: [String: String] = [:]
  437. private weak var joinURLField: NSTextField?
  438. private weak var joinMeetingIDField: NSTextField?
  439. private weak var joinPasscodeField: NSTextField?
  440. private weak var joinMeetingDisplayNameField: NSTextField?
  441. private weak var joinURLFieldsContainer: NSView?
  442. private weak var joinIDFieldsContainer: NSView?
  443. private weak var joinModeSegment: NSSegmentedControl?
  444. private var didTapInlineMeetingConsentClose = false
  445. private weak var activeMeetingConsentWindow: NSWindow?
  446. private weak var meetingConsentPrimaryLanguagePopup: NSPopUpButton?
  447. private weak var meetingConsentSecondaryLanguagePopup: NSPopUpButton?
  448. private var aiCompanionTranscriptionActiveRecordingIDs: Set<String> = []
  449. /// True after the embedded web client loaded a join/in-call URL during the current recording session.
  450. private var embeddedMeetingReachedInCallPage = false
  451. private var embeddedMeetingRecordingMonitorTimer: Timer?
  452. /// Consecutive polls suggesting the user left the call (avoids stopping on transient SPA redirects).
  453. private var embeddedMeetingAbsentCallConsecutiveMisses = 0
  454. private static let embeddedMeetingAbsentCallMissThreshold = 5
  455. private enum LocalRecordingNotesStatus: String {
  456. case notRequested
  457. case processing
  458. case ready
  459. case failed
  460. }
  461. private let paywallContentWidth: CGFloat = 520
  462. private var selectedPremiumPlan: PremiumPlan = .yearly
  463. private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
  464. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  465. private var paywallPlanPriceLabels: [PremiumPlan: [NSTextField]] = [:]
  466. private var paywallPlanSubtitleLabels: [PremiumPlan: [NSTextField]] = [:]
  467. private var paywallPlanStrikeLabels: [PremiumPlan: [NSTextField]] = [:]
  468. private weak var paywallOfferLabel: NSTextField?
  469. private weak var paywallContinueLabel: NSTextField?
  470. private weak var paywallContinueButton: NSView?
  471. private var paywallPurchaseTask: Task<Void, Never>?
  472. private var paywallContinueEnabled = true
  473. private var lastKnownPremiumAccess = false
  474. private var allScheduledMeetings: [ScheduledMeeting] = []
  475. private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
  476. private var schedulerVisibleMonthStart: Date = {
  477. let calendar = Calendar.current
  478. let today = Date()
  479. let components = calendar.dateComponents([.year, .month], from: today)
  480. return calendar.date(from: components) ?? calendar.startOfDay(for: today)
  481. }()
  482. private var selectedHomeSidebarItem: String = "Home"
  483. private var homeSidebarRowViews: [String: NSView] = [:]
  484. private var homeSidebarIconViews: [String: NSImageView] = [:]
  485. private var homeSidebarLabels: [String: NSTextField] = [:]
  486. private var searchTextObserver: NSObjectProtocol?
  487. private var searchShortcutMonitor: Any?
  488. private var searchOutsideClickMonitor: Any?
  489. private var meetingSearchTextObserver: NSObjectProtocol?
  490. private var globalSearchResults: [ScheduledMeeting] = []
  491. private var topBarProfileInitial = "H"
  492. private var clockTimer: Timer?
  493. private var meetingsRefreshTimer: Timer?
  494. private var brandLeadingConstraint: NSLayoutConstraint?
  495. private var windowFullScreenObservers: [NSObjectProtocol] = []
  496. private var isSigningIn = false
  497. private var isPromptingZoomCredentials = false
  498. private var isLoadingMeetings = false
  499. private var meetingsLoadTask: Task<Void, Never>?
  500. private var hasBuiltInitialHomeView = false
  501. private var meetingsScrollObserver: NSObjectProtocol?
  502. private var meetingsPageScrollObserver: NSObjectProtocol?
  503. private var lastMeetingsRefreshAt = Date.distantPast
  504. private var lastScrollEdgeRefreshAt = Date.distantPast
  505. private var lastMeetingsPageAutoLoadAt = Date.distantPast
  506. // Keep this conservative to avoid Zoom API rate limits.
  507. private let meetingsRefreshInterval: TimeInterval = 120
  508. private let scrollRefreshCooldown: TimeInterval = 30
  509. private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
  510. private let maxMeetingListPages = 5
  511. private var meetingsRateLimitedUntil: Date?
  512. private static let zoomAccountTimeZoneDefaultsKey = "zoomAccountTimeZoneIdentifier"
  513. private var zoomAccountTimeZone: TimeZone?
  514. private var meetingTimeZoneOverrides: [String: String] = [:]
  515. private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
  516. private var meetingsPageSelectedRangePreset: String = "All"
  517. private var meetingsPageFromDateFilter: Date?
  518. private var meetingsPageToDateFilter: Date?
  519. private var meetingsPageVisibleCount: Int = 0
  520. private let meetingsPageSize: Int = 8
  521. private var premiumRatingPromptWorkItem: DispatchWorkItem?
  522. private var usageRatingPromptWorkItem: DispatchWorkItem?
  523. private let usageRatingPromptDelay: TimeInterval = 30 * 60
  524. private let premiumRatingPromptDelay: TimeInterval = 10
  525. private enum SidebarStyle {
  526. case home
  527. }
  528. private enum PremiumPlan: String, CaseIterable {
  529. case weekly = "com.mqldev.zoomapp.premium.weekly"
  530. case monthly = "com.mqldev.zoomapp.premium.monthly"
  531. case yearly = "com.mqldev.zoomapp.premium.yearly"
  532. case lifetime = "com.mqldev.zoomapp.premium.lifetime"
  533. var displayName: String {
  534. switch self {
  535. case .weekly: return "Premium Weekly"
  536. case .monthly: return "Premium Monthly"
  537. case .yearly: return "Premium Yearly"
  538. case .lifetime: return "Premium Lifetime"
  539. }
  540. }
  541. }
  542. private final class StoreKitCoordinator {
  543. enum PurchaseOutcome {
  544. case success
  545. case pending
  546. case cancelled
  547. case failed(String)
  548. }
  549. private(set) var productsByID: [String: Product] = [:]
  550. private(set) var activeProductIDs = Set<String>()
  551. var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
  552. var hasLifetimePremiumAccess: Bool { activeProductIDs.contains(PremiumPlan.lifetime.rawValue) }
  553. private var transactionUpdatesTask: Task<Void, Never>?
  554. var onEntitlementsChanged: ((Bool) -> Void)?
  555. deinit {
  556. transactionUpdatesTask?.cancel()
  557. }
  558. func start() async {
  559. await refreshProducts()
  560. await refreshEntitlements()
  561. observeTransactionUpdatesIfNeeded()
  562. }
  563. func refreshProducts() async {
  564. let productIDs = PremiumPlan.allCases.map(\.rawValue)
  565. var loadedProducts: [Product] = []
  566. var attempts = 0
  567. let maxAttempts = 3
  568. while attempts < maxAttempts {
  569. attempts += 1
  570. do {
  571. loadedProducts = try await Product.products(for: productIDs)
  572. } catch {
  573. loadedProducts = []
  574. }
  575. if loadedProducts.isEmpty == false {
  576. break
  577. }
  578. if attempts < maxAttempts {
  579. // StoreKit can transiently return empty right after launch/network changes.
  580. try? await Task.sleep(nanoseconds: UInt64(attempts) * 500_000_000)
  581. }
  582. }
  583. if loadedProducts.isEmpty == false {
  584. productsByID = Dictionary(uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) })
  585. }
  586. }
  587. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  588. guard let product = productsByID[plan.rawValue] else {
  589. await refreshProducts()
  590. guard let refreshed = productsByID[plan.rawValue] else {
  591. return .failed("Product not available. Check StoreKit configuration and product IDs.")
  592. }
  593. return await purchase(product: refreshed)
  594. }
  595. return await purchase(product: product)
  596. }
  597. func restorePurchases() async -> String {
  598. do {
  599. try await AppStore.sync()
  600. await refreshEntitlements()
  601. return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
  602. } catch {
  603. return "Restore failed. \(error.localizedDescription)"
  604. }
  605. }
  606. private func purchase(product: Product) async -> PurchaseOutcome {
  607. do {
  608. let result = try await product.purchase()
  609. switch result {
  610. case .success(let verification):
  611. guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
  612. await transaction.finish()
  613. await refreshEntitlements()
  614. return .success
  615. case .pending:
  616. return .pending
  617. case .userCancelled:
  618. return .cancelled
  619. @unknown default:
  620. return .failed("Unknown purchase state.")
  621. }
  622. } catch {
  623. return .failed(error.localizedDescription)
  624. }
  625. }
  626. private func observeTransactionUpdatesIfNeeded() {
  627. guard transactionUpdatesTask == nil else { return }
  628. transactionUpdatesTask = Task { [weak self] in
  629. for await update in Transaction.updates {
  630. guard case .verified(let transaction) = update else { continue }
  631. guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
  632. await self?.refreshEntitlements()
  633. await transaction.finish()
  634. }
  635. }
  636. }
  637. @MainActor
  638. private func refreshEntitlements() async {
  639. let previousHasPremiumAccess = hasPremiumAccess
  640. let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
  641. var active = Set<String>()
  642. for await entitlement in Transaction.currentEntitlements {
  643. guard case .verified(let transaction) = entitlement else { continue }
  644. guard allIDs.contains(transaction.productID) else { continue }
  645. if Self.isTransactionActive(transaction) {
  646. active.insert(transaction.productID)
  647. }
  648. }
  649. // StoreKit testing can briefly report empty current entitlements even though a latest
  650. // verified transaction exists for a non-consumable. Merge in latest transactions.
  651. for productID in allIDs {
  652. guard let latest = await Transaction.latest(for: productID),
  653. case .verified(let transaction) = latest else { continue }
  654. if Self.isTransactionActive(transaction) {
  655. active.insert(productID)
  656. }
  657. }
  658. activeProductIDs = active
  659. let newHasPremiumAccess = hasPremiumAccess
  660. if newHasPremiumAccess != previousHasPremiumAccess {
  661. onEntitlementsChanged?(newHasPremiumAccess)
  662. }
  663. }
  664. private static func isTransactionActive(_ transaction: StoreKit.Transaction) -> Bool {
  665. if transaction.revocationDate != nil { return false }
  666. if let expirationDate = transaction.expirationDate {
  667. return expirationDate > Date()
  668. }
  669. return true
  670. }
  671. }
  672. private let storeKitCoordinator = StoreKitCoordinator()
  673. private var storeKitStartupTask: Task<Void, Never>?
  674. private var launchPaywallWorkItem: DispatchWorkItem?
  675. private let launchPaywallDelay: TimeInterval = 2
  676. private var hasPresentedLaunchPaywall = false
  677. override func viewDidLoad() {
  678. super.viewDidLoad()
  679. darkModeEnabled = systemPrefersDarkMode()
  680. palette = Palette(isDarkMode: darkModeEnabled)
  681. NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  682. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  683. meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
  684. ensureUsageSessionStartDate()
  685. scheduleUsageBasedRatingPromptIfNeeded()
  686. recordedMeetings = MeetingRecordingManager.shared.recordings
  687. zoomOAuth.embeddedOAuthSession = { [weak self] startURL, callbackPrefix, timeout in
  688. guard let self else { throw ZoomOAuthError.unableToOpenBrowser }
  689. do {
  690. return try await self.runEmbeddedOAuthSession(
  691. startURL: startURL,
  692. callbackURLPrefix: callbackPrefix,
  693. timeoutSeconds: timeout
  694. )
  695. } catch EmbeddedOAuthFlowError.userCancelled {
  696. throw ZoomOAuthError.authenticationCancelled
  697. } catch EmbeddedOAuthFlowError.timedOut {
  698. throw ZoomOAuthError.authenticationTimedOut
  699. } catch EmbeddedOAuthFlowError.webViewUnavailable {
  700. throw ZoomOAuthError.unableToOpenBrowser
  701. }
  702. }
  703. NotificationCenter.default.addObserver(
  704. forName: MeetingRecordingManager.recordingsDidChangeNotification,
  705. object: nil,
  706. queue: .main
  707. ) { [weak self] _ in
  708. Task { @MainActor [weak self] in
  709. guard let self else { return }
  710. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  711. for rec in self.recordedMeetings where rec.transcriptText != nil || rec.transcriptError != nil {
  712. self.aiCompanionTranscriptionActiveRecordingIDs.remove(rec.id)
  713. }
  714. self.renderAiCompanionList()
  715. self.refreshLocalRecordingTranscriptPanelContentIfNeeded()
  716. self.refreshLocalRecordingNotesPanelContentIfNeeded()
  717. }
  718. }
  719. NotificationCenter.default.addObserver(
  720. forName: MeetingRecordingManager.transcriptionDidBeginNotification,
  721. object: nil,
  722. queue: .main
  723. ) { [weak self] note in
  724. guard let id = note.userInfo?["recordingId"] as? String else { return }
  725. Task { @MainActor [weak self] in
  726. guard let self else { return }
  727. self.aiCompanionTranscriptionActiveRecordingIDs.insert(id)
  728. self.renderAiCompanionList()
  729. }
  730. }
  731. NotificationCenter.default.addObserver(
  732. forName: MeetingRecordingManager.transcriptionDidEndNotification,
  733. object: nil,
  734. queue: .main
  735. ) { [weak self] note in
  736. guard let id = note.userInfo?["recordingId"] as? String else { return }
  737. Task { @MainActor [weak self] in
  738. guard let self else { return }
  739. self.aiCompanionTranscriptionActiveRecordingIDs.remove(id)
  740. self.renderAiCompanionList()
  741. }
  742. }
  743. NotificationCenter.default.addObserver(
  744. forName: MeetingRecordingManager.recordingStateDidChangeNotification,
  745. object: nil,
  746. queue: .main
  747. ) { [weak self] _ in
  748. Task { @MainActor [weak self] in
  749. guard let self else { return }
  750. if MeetingRecordingManager.shared.isRecording == false {
  751. self.stopEmbeddedMeetingRecordingMonitor()
  752. }
  753. self.updateAiCompanionRecordingUI()
  754. }
  755. }
  756. setupUI()
  757. setupWidgetBridge()
  758. if let tzId = UserDefaults.standard.string(forKey: Self.zoomAccountTimeZoneDefaultsKey),
  759. let tz = TimeZone(identifier: tzId) {
  760. zoomAccountTimeZone = tz
  761. }
  762. startStoreKit()
  763. }
  764. override func viewDidAppear() {
  765. super.viewDidAppear()
  766. if let window = view.window {
  767. window.setContentSize(
  768. NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  769. )
  770. applyWindowBackgroundForCurrentTheme(window)
  771. // Use full-size content view so custom top chrome sits in the titlebar region.
  772. window.titleVisibility = .hidden
  773. window.titlebarAppearsTransparent = true
  774. window.isMovableByWindowBackground = true
  775. window.styleMask.insert(.fullSizeContentView)
  776. installWindowFullScreenObservers(for: window)
  777. }
  778. alignNativeTrafficLights()
  779. if hasBuiltInitialHomeView == false {
  780. hasBuiltInitialHomeView = true
  781. showHomeView(profile: nil, refreshMeetings: true)
  782. } else if isUserLoggedIn(), storeKitCoordinator.hasPremiumAccess {
  783. triggerMeetingsRefresh(force: false)
  784. }
  785. syncBrandPositionForCurrentWindowState(animated: false)
  786. scheduleLaunchPaywallIfNeeded()
  787. if storeKitCoordinator.hasPremiumAccess {
  788. DesktopWidgetWindowManager.shared.restore()
  789. }
  790. }
  791. override func viewDidLayout() {
  792. super.viewDidLayout()
  793. alignNativeTrafficLights()
  794. if paywallOverlayView != nil {
  795. updatePaywallPanelSize()
  796. updatePaywallResponsiveLayout()
  797. }
  798. if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
  799. updateEmbeddedRightPanelWidthForOpenState()
  800. }
  801. if aiCompanionPageRootView?.isHidden == false {
  802. refreshAiCompanionStatusPreferredMaxLayoutWidth()
  803. }
  804. }
  805. private func setupUI() {
  806. view.wantsLayer = true
  807. view.layer?.backgroundColor = appBackground.cgColor
  808. rootContainer.translatesAutoresizingMaskIntoConstraints = false
  809. view.addSubview(rootContainer)
  810. NSLayoutConstraint.activate([
  811. rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  812. rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  813. rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
  814. rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  815. ])
  816. }
  817. private func installWindowFullScreenObservers(for window: NSWindow) {
  818. guard windowFullScreenObservers.isEmpty else { return }
  819. let center = NotificationCenter.default
  820. let enterObserver = center.addObserver(
  821. forName: NSWindow.didEnterFullScreenNotification,
  822. object: window,
  823. queue: .main
  824. ) { [weak self] _ in
  825. self?.syncBrandPositionForCurrentWindowState(animated: true)
  826. }
  827. let exitObserver = center.addObserver(
  828. forName: NSWindow.didExitFullScreenNotification,
  829. object: window,
  830. queue: .main
  831. ) { [weak self] _ in
  832. self?.syncBrandPositionForCurrentWindowState(animated: true)
  833. }
  834. windowFullScreenObservers = [enterObserver, exitObserver]
  835. }
  836. private func syncBrandPositionForCurrentWindowState(animated: Bool) {
  837. guard let constraint = brandLeadingConstraint else { return }
  838. let isFullScreen = view.window?.styleMask.contains(.fullScreen) == true
  839. let targetInset = isFullScreen ? brandFullScreenLeadingInset : brandLeadingInset
  840. guard abs(constraint.constant - targetInset) > 0.5 else { return }
  841. if animated {
  842. NSAnimationContext.runAnimationGroup { context in
  843. context.duration = 0.2
  844. constraint.animator().constant = targetInset
  845. view.layoutSubtreeIfNeeded()
  846. }
  847. } else {
  848. constraint.constant = targetInset
  849. }
  850. }
  851. private func showHomeView(profile: GoogleUserProfile?, refreshMeetings: Bool = true) {
  852. clearMeetingsScrollObserver()
  853. clearMeetingsPageScrollObserver()
  854. removeSearchFieldObserver()
  855. removeMeetingSearchObserver()
  856. removeSearchShortcutMonitor()
  857. homeSearchField = nil
  858. homeSearchPill = nil
  859. meetingSearchOverlayView = nil
  860. meetingSearchPanelView = nil
  861. meetingSearchField = nil
  862. meetingSearchResultsStack = nil
  863. meetingSearchEmptyLabel = nil
  864. meetingsPageRootView = nil
  865. widgetsPageRootView = nil
  866. aiCompanionPageRootView = nil
  867. aiCompanionListStack = nil
  868. aiCompanionListScroll = nil
  869. aiCompanionDetailScroll = nil
  870. aiCompanionDetailStack = nil
  871. aiCompanionStatusLabel = nil
  872. aiCompanionCountLabel = nil
  873. aiCompanionRefreshButton = nil
  874. aiCompanionRangePopup = nil
  875. aiCompanionEmptyLabel = nil
  876. aiCompanionLoadingIndicator = nil
  877. aiCompanionDetailEmptyLabel = nil
  878. aiCompanionOpenDocButton = nil
  879. aiCompanionCopyMarkdownButton = nil
  880. aiCompanionStopRecordingButton = nil
  881. aiCompanionFilterPopup = nil
  882. aiCompanionFilterFromPicker = nil
  883. aiCompanionFilterToPicker = nil
  884. aiCompanionFilterApplyButton = nil
  885. aiCompanionFilterResetButton = nil
  886. aiCompanionFilterRefreshButton = nil
  887. aiCompanionRowButtons.removeAll()
  888. aiCompanionLastLoadedAt = nil
  889. homeView?.removeFromSuperview()
  890. if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
  891. topBarProfileInitial = String(profileName.prefix(1)).uppercased()
  892. }
  893. selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
  894. homeView = makeHomeView(profile: profile)
  895. if let homeView { attachToRoot(homeView) }
  896. installSearchShortcutMonitor()
  897. startClock()
  898. applyAuthenticationUIState(triggerInitialRefresh: refreshMeetings)
  899. updateHomeSidebarHighlight()
  900. updateSelectedHomeSectionUI()
  901. }
  902. private func isUserLoggedIn() -> Bool {
  903. UserDefaults.standard.bool(forKey: loginStateKey)
  904. }
  905. private func persistLoggedInState(_ loggedIn: Bool) {
  906. UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
  907. }
  908. private func attachToRoot(_ subview: NSView) {
  909. subview.translatesAutoresizingMaskIntoConstraints = false
  910. if subview.superview != rootContainer {
  911. rootContainer.addSubview(subview)
  912. }
  913. NSLayoutConstraint.activate([
  914. subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
  915. subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
  916. subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
  917. subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
  918. ])
  919. }
  920. @objc private func googleLoginTapped() {
  921. guard isSigningIn == false else { return }
  922. isSigningIn = true
  923. googleButton?.title = "..."
  924. googleButton?.isEnabled = false
  925. Task {
  926. do {
  927. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  928. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  929. await MainActor.run {
  930. self.googleButton?.title = "G"
  931. self.resetLoginSigningInState()
  932. self.completeLoginAndRestart(profile: profile)
  933. }
  934. } catch {
  935. await MainActor.run {
  936. self.resetLoginSigningInState()
  937. if case GoogleOAuthError.authenticationCancelled = error { return }
  938. self.showSimpleError("Google login failed", error: error)
  939. }
  940. }
  941. }
  942. }
  943. /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
  944. @objc private func zoomPrimarySignInTapped() {
  945. guard isSigningIn == false else { return }
  946. isSigningIn = true
  947. topBarAuthButton?.title = "Signing in..."
  948. topBarAuthButton?.isEnabled = false
  949. googleButton?.isEnabled = false
  950. Task {
  951. do {
  952. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  953. guard configured else {
  954. await MainActor.run { self.resetLoginSigningInState() }
  955. return
  956. }
  957. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  958. let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
  959. let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
  960. let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
  961. await MainActor.run {
  962. self.resetLoginSigningInState()
  963. self.zoomAccountTimeZone = profileTimeZone
  964. if let profileTimeZone {
  965. UserDefaults.standard.set(profileTimeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
  966. }
  967. self.completeLoginAndRestart(profile: profile)
  968. self.primeZoomEmbeddedWebSession()
  969. }
  970. } catch {
  971. await MainActor.run {
  972. self.resetLoginSigningInState()
  973. if case ZoomOAuthError.authenticationCancelled = error { return }
  974. self.showSimpleError("Zoom sign-in failed", error: error)
  975. }
  976. }
  977. }
  978. }
  979. @objc private func scheduleMeetingTapped() {
  980. guard isUserLoggedIn() else {
  981. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to schedule a meeting.")
  982. return
  983. }
  984. guard ensurePremiumAccess() else { return }
  985. if let existing = scheduleMeetingWindow {
  986. existing.makeKeyAndOrderFront(nil)
  987. NSApp.activate(ignoringOtherApps: true)
  988. DispatchQueue.main.async { [weak self] in
  989. _ = self?.scheduleTopicField?.becomeFirstResponder()
  990. }
  991. return
  992. }
  993. let content = makeScheduleMeetingPanelContent()
  994. let controller = NSViewController()
  995. controller.view = content
  996. let panel = NSPanel(
  997. contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
  998. styleMask: [.titled, .closable, .fullSizeContentView],
  999. backing: .buffered,
  1000. defer: false
  1001. )
  1002. panel.title = "Schedule a meeting"
  1003. panel.titleVisibility = .hidden
  1004. panel.titlebarAppearsTransparent = true
  1005. panel.hidesOnDeactivate = true
  1006. panel.isReleasedWhenClosed = false
  1007. panel.standardWindowButton(.closeButton)?.isHidden = true
  1008. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  1009. panel.standardWindowButton(.zoomButton)?.isHidden = true
  1010. panel.isMovableByWindowBackground = true
  1011. panel.becomesKeyOnlyIfNeeded = false
  1012. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  1013. panel.center()
  1014. panel.contentViewController = controller
  1015. panel.delegate = self
  1016. applyWindowBackgroundForCurrentTheme(panel)
  1017. panel.makeKeyAndOrderFront(nil)
  1018. NSApp.activate(ignoringOtherApps: true)
  1019. scheduleMeetingWindow = panel
  1020. DispatchQueue.main.async { [weak self] in
  1021. _ = self?.scheduleTopicField?.becomeFirstResponder()
  1022. }
  1023. }
  1024. private func makeScheduleMeetingPanelContent() -> NSView {
  1025. let root = NSView()
  1026. root.translatesAutoresizingMaskIntoConstraints = false
  1027. root.wantsLayer = true
  1028. root.layer?.backgroundColor = appBackground.cgColor
  1029. let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  1030. let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
  1031. let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
  1032. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1033. closeButton.isBordered = false
  1034. closeButton.bezelStyle = .regularSquare
  1035. closeButton.wantsLayer = true
  1036. closeButton.layer?.cornerRadius = 14
  1037. closeButton.normalColor = palette.inputBackground
  1038. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  1039. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1040. closeButton.layer?.borderWidth = 1
  1041. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  1042. closeButton.contentTintColor = secondaryText
  1043. closeButton.toolTip = "Close"
  1044. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1045. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1046. let titleRow = NSStackView()
  1047. titleRow.orientation = .horizontal
  1048. titleRow.alignment = .centerY
  1049. titleRow.spacing = 10
  1050. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1051. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  1052. titleStack.orientation = .vertical
  1053. titleStack.spacing = 2
  1054. titleStack.alignment = .leading
  1055. let titleSpacer = NSView()
  1056. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  1057. titleRow.addArrangedSubview(titleStack)
  1058. titleRow.addArrangedSubview(titleSpacer)
  1059. titleRow.addArrangedSubview(closeButton)
  1060. let headerDivider = NSView()
  1061. headerDivider.wantsLayer = true
  1062. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  1063. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  1064. let topicBox = makeJoinFormField(placeholder: "Meeting topic")
  1065. scheduleTopicField = topicBox.textField
  1066. topicBox.textField.stringValue = "Zoom meeting"
  1067. let defaultStart = Date().addingTimeInterval(3600)
  1068. let dateOnlyFormatter = DateFormatter()
  1069. dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
  1070. dateOnlyFormatter.timeZone = TimeZone.current
  1071. dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
  1072. let time12Formatter = DateFormatter()
  1073. time12Formatter.locale = Locale(identifier: "en_US_POSIX")
  1074. time12Formatter.timeZone = TimeZone.current
  1075. time12Formatter.dateFormat = "h:mm a"
  1076. let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
  1077. scheduleDateField = dateBox.textField
  1078. dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
  1079. let timeBox = makeJoinFormField(placeholder: "2:30 PM")
  1080. scheduleTimeField = timeBox.textField
  1081. timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
  1082. let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
  1083. let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
  1084. let tzCombo = NSPopUpButton()
  1085. tzCombo.translatesAutoresizingMaskIntoConstraints = false
  1086. tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
  1087. tzCombo.wantsLayer = true
  1088. tzCombo.layer?.cornerRadius = 10
  1089. tzCombo.layer?.borderWidth = 1
  1090. tzCombo.layer?.borderColor = palette.inputBorder.cgColor
  1091. tzCombo.layer?.backgroundColor = palette.inputBackground.cgColor
  1092. let tzOptions = makeTimeZoneDisplayOptions(referenceDate: defaultStart)
  1093. scheduleTimeZoneDisplayToIdentifier = Dictionary(uniqueKeysWithValues: tzOptions.map { ($0.display, $0.identifier) })
  1094. tzCombo.removeAllItems()
  1095. tzCombo.addItems(withTitles: tzOptions.map(\.display))
  1096. if let selected = tzOptions.first(where: { $0.identifier == TimeZone.current.identifier }) {
  1097. tzCombo.selectItem(withTitle: selected.display)
  1098. } else {
  1099. tzCombo.selectItem(at: 0)
  1100. }
  1101. tzCombo.toolTip = "Timezone with GMT offset. Meeting start is interpreted in this zone."
  1102. scheduleTimeZonePopup = tzCombo
  1103. let tzHint = makeLabel("Includes GMT offset, e.g. GMT+05:00 - Asia/Karachi", size: 11, color: mutedText, weight: .regular, centered: false)
  1104. let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
  1105. tzStack.orientation = .vertical
  1106. tzStack.spacing = 8
  1107. tzStack.alignment = .leading
  1108. tzStack.translatesAutoresizingMaskIntoConstraints = false
  1109. let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
  1110. durationBox.textField.stringValue = "60"
  1111. scheduleDurationField = durationBox.textField
  1112. let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
  1113. let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
  1114. let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
  1115. let topicStack = NSStackView(views: [topicLabel, topicBox])
  1116. topicStack.orientation = .vertical
  1117. topicStack.spacing = 8
  1118. topicStack.alignment = .leading
  1119. topicStack.translatesAutoresizingMaskIntoConstraints = false
  1120. let dateTimeRow = NSStackView(views: [dateBox, timeBox])
  1121. dateTimeRow.orientation = .horizontal
  1122. dateTimeRow.spacing = 10
  1123. dateTimeRow.alignment = .top
  1124. dateTimeRow.distribution = .fillEqually
  1125. dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
  1126. let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
  1127. startStack.orientation = .vertical
  1128. startStack.spacing = 8
  1129. startStack.alignment = .leading
  1130. startStack.translatesAutoresizingMaskIntoConstraints = false
  1131. let durationStack = NSStackView(views: [durationLabel, durationBox])
  1132. durationStack.orientation = .vertical
  1133. durationStack.spacing = 8
  1134. durationStack.alignment = .leading
  1135. durationStack.translatesAutoresizingMaskIntoConstraints = false
  1136. let formCard = NSView()
  1137. formCard.translatesAutoresizingMaskIntoConstraints = false
  1138. formCard.wantsLayer = true
  1139. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  1140. formCard.layer?.cornerRadius = 14
  1141. formCard.layer?.borderWidth = 1
  1142. formCard.layer?.borderColor = palette.inputBorder.cgColor
  1143. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
  1144. cancelButton.isBordered = false
  1145. cancelButton.wantsLayer = true
  1146. cancelButton.layer?.cornerRadius = 10
  1147. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1148. cancelButton.layer?.borderWidth = 1
  1149. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  1150. cancelButton.contentTintColor = primaryText
  1151. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1152. let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
  1153. submitButton.isBordered = false
  1154. submitButton.wantsLayer = true
  1155. submitButton.layer?.cornerRadius = 10
  1156. submitButton.normalColor = accentBlue
  1157. submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  1158. submitButton.contentTintColor = .white
  1159. submitButton.font = .systemFont(ofSize: 13, weight: .bold)
  1160. submitButton.keyEquivalent = "\r"
  1161. scheduleSubmitButton = submitButton
  1162. let buttons = NSStackView(views: [cancelButton, submitButton])
  1163. buttons.orientation = .horizontal
  1164. buttons.spacing = 12
  1165. buttons.alignment = .centerY
  1166. buttons.distribution = .fillEqually
  1167. let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
  1168. innerStack.orientation = .vertical
  1169. innerStack.spacing = 18
  1170. innerStack.alignment = .leading
  1171. innerStack.translatesAutoresizingMaskIntoConstraints = false
  1172. formCard.addSubview(innerStack)
  1173. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  1174. NSLayoutConstraint.activate([
  1175. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1176. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  1177. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  1178. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  1179. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1180. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1181. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  1182. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  1183. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1184. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1185. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  1186. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1187. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1188. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1189. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1190. topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1191. tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1192. tzCombo.heightAnchor.constraint(equalToConstant: 34),
  1193. dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1194. durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1195. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1196. submitButton.heightAnchor.constraint(equalToConstant: 40),
  1197. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1198. ])
  1199. return root
  1200. }
  1201. @objc private func scheduleMeetingCancelTapped() {
  1202. scheduleMeetingWindow?.performClose(nil)
  1203. }
  1204. @objc private func scheduleMeetingSubmitTapped() {
  1205. let topicRaw = scheduleTopicField?.stringValue ?? ""
  1206. let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
  1207. guard topic.isEmpty == false else {
  1208. showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
  1209. return
  1210. }
  1211. let tzId = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1212. if tzId.isEmpty == false,
  1213. scheduleTimeZoneDisplayToIdentifier[tzId] == nil,
  1214. TimeZone(identifier: tzId) == nil {
  1215. showSimpleAlert(
  1216. title: "Timezone",
  1217. message: "Choose a timezone from the list (with GMT offset), for example GMT+05:00 - Asia/Karachi."
  1218. )
  1219. return
  1220. }
  1221. guard let draft = scheduleMeetingDraftFromFormFields() else {
  1222. showSimpleAlert(
  1223. title: "Date & time",
  1224. 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."
  1225. )
  1226. return
  1227. }
  1228. if draft.startDate < Date().addingTimeInterval(-30) {
  1229. showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
  1230. return
  1231. }
  1232. let durationRaw = scheduleDurationField?.stringValue ?? ""
  1233. let digits = durationRaw.filter(\.isNumber)
  1234. guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
  1235. showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
  1236. return
  1237. }
  1238. scheduleSubmitButton?.isEnabled = false
  1239. let tz = draft.timeZone
  1240. Task {
  1241. do {
  1242. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  1243. guard configured else {
  1244. await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
  1245. return
  1246. }
  1247. let oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
  1248. let token = try await zoomOAuth.validAccessToken(
  1249. presentingWindow: oauthParent,
  1250. requiringAnyOf: ["meeting:write:meeting"]
  1251. )
  1252. let result = try await createZoomMeeting(
  1253. accessToken: token,
  1254. topic: topic,
  1255. startTimeUTC: draft.startTimeUTC,
  1256. durationMinutes: duration,
  1257. timeZone: tz
  1258. )
  1259. await MainActor.run {
  1260. self.saveMeetingTimeZoneOverride(meetingID: result.id.map(String.init), timeZone: tz)
  1261. }
  1262. await MainActor.run {
  1263. self.scheduleSubmitButton?.isEnabled = true
  1264. self.scheduleMeetingWindow?.performClose(nil)
  1265. self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
  1266. self.triggerMeetingsRefresh(force: true)
  1267. if let join = result.join_url, join.isEmpty == false {
  1268. NSPasteboard.general.clearContents()
  1269. NSPasteboard.general.setString(join, forType: .string)
  1270. self.showSimpleAlert(
  1271. title: "Meeting scheduled",
  1272. message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
  1273. )
  1274. } else {
  1275. self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
  1276. }
  1277. }
  1278. } catch {
  1279. await MainActor.run {
  1280. self.scheduleSubmitButton?.isEnabled = true
  1281. if case ZoomOAuthError.missingRequiredScope(_) = error {
  1282. self.zoomOAuth.clearSavedTokens()
  1283. self.showSimpleAlert(
  1284. title: "Zoom permissions",
  1285. message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
  1286. )
  1287. } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
  1288. let seconds = max(retryAfter ?? 300, 30)
  1289. let minutes = Int(ceil(Double(seconds) / 60.0))
  1290. self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
  1291. } else if case ZoomOAuthError.authenticationCancelled = error {
  1292. // User closed the sign-in window; no alert.
  1293. } else {
  1294. self.showSimpleError("Could not schedule", error: error)
  1295. }
  1296. }
  1297. }
  1298. }
  1299. }
  1300. private func resetScheduleMeetingPanelReferences() {
  1301. scheduleMeetingWindow = nil
  1302. scheduleTopicField = nil
  1303. scheduleDateField = nil
  1304. scheduleTimeField = nil
  1305. scheduleTimeZonePopup = nil
  1306. scheduleDurationField = nil
  1307. scheduleSubmitButton = nil
  1308. scheduleTimeZoneDisplayToIdentifier = [:]
  1309. }
  1310. /// Resolves the schedule panel timezone (IANA id from combo, or system default).
  1311. private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
  1312. let raw = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1313. if let identifier = scheduleTimeZoneDisplayToIdentifier[raw],
  1314. let mapped = TimeZone(identifier: identifier) {
  1315. return mapped
  1316. }
  1317. guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
  1318. return tz
  1319. }
  1320. private struct TimeZoneDisplayOption {
  1321. let identifier: String
  1322. let display: String
  1323. }
  1324. private func makeTimeZoneDisplayOptions(referenceDate: Date) -> [TimeZoneDisplayOption] {
  1325. TimeZone.knownTimeZoneIdentifiers.compactMap { identifier in
  1326. guard let tz = TimeZone(identifier: identifier) else { return nil }
  1327. let seconds = tz.secondsFromGMT(for: referenceDate)
  1328. let sign = seconds >= 0 ? "+" : "-"
  1329. let absSeconds = abs(seconds)
  1330. let hours = absSeconds / 3600
  1331. let minutes = (absSeconds % 3600) / 60
  1332. let offset = String(format: "GMT%@%02d:%02d", sign, hours, minutes)
  1333. return TimeZoneDisplayOption(identifier: identifier, display: "\(offset) - \(identifier)")
  1334. }
  1335. .sorted { lhs, rhs in
  1336. if lhs.display == rhs.display { return lhs.identifier < rhs.identifier }
  1337. return lhs.display < rhs.display
  1338. }
  1339. }
  1340. /// Parses zoom timezone values like `Asia/Karachi`, `GMT+05:00`, `UTC+5`, `+0500`, or `Z`.
  1341. private func parseZoomTimeZone(_ raw: String?) -> TimeZone? {
  1342. guard let raw else { return nil }
  1343. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1344. guard trimmed.isEmpty == false else { return nil }
  1345. if let named = TimeZone(identifier: trimmed) { return named }
  1346. if trimmed.caseInsensitiveCompare("Z") == .orderedSame { return TimeZone(secondsFromGMT: 0) }
  1347. let normalized = trimmed.uppercased()
  1348. let pattern = #"^(?:GMT|UTC)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$"#
  1349. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1350. let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
  1351. guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
  1352. match.numberOfRanges == 4,
  1353. let signRange = Range(match.range(at: 1), in: normalized),
  1354. let hourRange = Range(match.range(at: 2), in: normalized) else {
  1355. return nil
  1356. }
  1357. let sign = normalized[signRange] == "-" ? -1 : 1
  1358. let hours = Int(normalized[hourRange]) ?? -1
  1359. let minutes: Int = {
  1360. if let minuteRange = Range(match.range(at: 3), in: normalized) {
  1361. return Int(normalized[minuteRange]) ?? -1
  1362. }
  1363. return 0
  1364. }()
  1365. guard (0...23).contains(hours), (0...59).contains(minutes) else { return nil }
  1366. return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60)))
  1367. }
  1368. private func saveMeetingTimeZoneOverride(meetingID: String?, timeZone: TimeZone) {
  1369. guard let meetingID, meetingID.isEmpty == false else { return }
  1370. meetingTimeZoneOverrides[meetingID] = timeZone.identifier
  1371. UserDefaults.standard.set(meetingTimeZoneOverrides, forKey: meetingTimeZoneOverrideKey)
  1372. }
  1373. private struct ScheduleMeetingDraft {
  1374. let startDate: Date
  1375. let startTimeUTC: String
  1376. let timeZone: TimeZone
  1377. }
  1378. /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
  1379. private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
  1380. let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1381. let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1382. guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
  1383. let tz = resolvedScheduleTimeZoneForMeeting()
  1384. let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
  1385. // Parse date strictly as yyyy-MM-dd.
  1386. let dateComponentsRaw = datePart.split(separator: "-")
  1387. guard dateComponentsRaw.count == 3,
  1388. let year = Int(dateComponentsRaw[0]),
  1389. let month = Int(dateComponentsRaw[1]),
  1390. let day = Int(dateComponentsRaw[2]) else {
  1391. return nil
  1392. }
  1393. // Parse time strictly as h:mm AM/PM (optional :ss).
  1394. // This avoids any locale/date formatter ambiguity that can flip AM/PM.
  1395. let pattern = #"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])\s*$"#
  1396. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1397. let nsRange = NSRange(normalizedTime.startIndex..<normalizedTime.endIndex, in: normalizedTime)
  1398. guard let match = regex.firstMatch(in: normalizedTime, options: [], range: nsRange),
  1399. match.numberOfRanges == 5,
  1400. let hourRange = Range(match.range(at: 1), in: normalizedTime),
  1401. let minuteRange = Range(match.range(at: 2), in: normalizedTime),
  1402. let meridiemRange = Range(match.range(at: 4), in: normalizedTime) else {
  1403. return nil
  1404. }
  1405. let rawHour = Int(normalizedTime[hourRange]) ?? -1
  1406. let minute = Int(normalizedTime[minuteRange]) ?? -1
  1407. let second: Int = {
  1408. if let secondRange = Range(match.range(at: 3), in: normalizedTime) {
  1409. return Int(normalizedTime[secondRange]) ?? -1
  1410. }
  1411. return 0
  1412. }()
  1413. let meridiem = normalizedTime[meridiemRange].uppercased()
  1414. guard (1...12).contains(rawHour),
  1415. (0...59).contains(minute),
  1416. (0...59).contains(second) else {
  1417. return nil
  1418. }
  1419. var hour24 = rawHour % 12
  1420. if meridiem == "PM" { hour24 += 12 }
  1421. var calendar = Calendar(identifier: .gregorian)
  1422. calendar.timeZone = tz
  1423. var components = DateComponents()
  1424. components.year = year
  1425. components.month = month
  1426. components.day = day
  1427. components.hour = hour24
  1428. components.minute = minute
  1429. components.second = second
  1430. components.timeZone = tz
  1431. guard let startDate = calendar.date(from: components) else { return nil }
  1432. let utcFormatter = DateFormatter()
  1433. utcFormatter.locale = Locale(identifier: "en_US_POSIX")
  1434. utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
  1435. utcFormatter.calendar = Calendar(identifier: .gregorian)
  1436. // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
  1437. utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
  1438. return ScheduleMeetingDraft(
  1439. startDate: startDate,
  1440. startTimeUTC: utcFormatter.string(from: startDate),
  1441. timeZone: tz
  1442. )
  1443. }
  1444. private func presentJoinMeetingPanel(_ panel: NSPanel) {
  1445. guard let host = view.window else {
  1446. panel.center()
  1447. panel.makeKeyAndOrderFront(nil)
  1448. return
  1449. }
  1450. if host.attachedSheet !== panel {
  1451. host.beginSheet(panel, completionHandler: nil)
  1452. }
  1453. panel.makeKeyAndOrderFront(nil)
  1454. }
  1455. private func dismissJoinMeetingPanel() {
  1456. guard let panel = joinMeetingWindow else { return }
  1457. if let host = view.window, host.attachedSheet === panel {
  1458. host.endSheet(panel)
  1459. } else {
  1460. panel.performClose(nil)
  1461. }
  1462. }
  1463. @objc private func joinMeetingTapped() {
  1464. if let existing = joinMeetingWindow {
  1465. presentJoinMeetingPanel(existing)
  1466. NSApp.activate(ignoringOtherApps: true)
  1467. DispatchQueue.main.async { [weak self] in
  1468. _ = self?.joinURLField?.becomeFirstResponder()
  1469. }
  1470. return
  1471. }
  1472. let content = makeJoinMeetingPanelContent()
  1473. let controller = NSViewController()
  1474. controller.view = content
  1475. let panel = NSPanel(
  1476. contentRect: NSRect(x: 0, y: 0, width: 520, height: 500),
  1477. styleMask: [.titled, .closable, .fullSizeContentView],
  1478. backing: .buffered,
  1479. defer: false
  1480. )
  1481. panel.title = "Join a meeting"
  1482. panel.titleVisibility = .hidden
  1483. panel.titlebarAppearsTransparent = true
  1484. panel.hidesOnDeactivate = true
  1485. panel.isReleasedWhenClosed = false
  1486. panel.standardWindowButton(.closeButton)?.isHidden = true
  1487. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  1488. panel.standardWindowButton(.zoomButton)?.isHidden = true
  1489. panel.isFloatingPanel = false
  1490. panel.isMovable = false
  1491. panel.isMovableByWindowBackground = false
  1492. // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
  1493. panel.becomesKeyOnlyIfNeeded = false
  1494. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  1495. panel.contentViewController = controller
  1496. panel.delegate = self
  1497. applyWindowBackgroundForCurrentTheme(panel)
  1498. joinMeetingWindow = panel
  1499. presentJoinMeetingPanel(panel)
  1500. NSApp.activate(ignoringOtherApps: true)
  1501. DispatchQueue.main.async { [weak self] in
  1502. guard let self else { return }
  1503. _ = self.joinURLField?.becomeFirstResponder()
  1504. }
  1505. }
  1506. private func makeJoinMeetingPanelContent() -> NSView {
  1507. let root = NSView()
  1508. root.translatesAutoresizingMaskIntoConstraints = false
  1509. root.wantsLayer = true
  1510. root.layer?.backgroundColor = appBackground.cgColor
  1511. let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  1512. let subtitleLabel = makeLabel("Opens in the app web panel", size: 12, color: mutedText, weight: .regular, centered: false)
  1513. let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
  1514. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1515. closeButton.isBordered = false
  1516. closeButton.bezelStyle = .regularSquare
  1517. closeButton.wantsLayer = true
  1518. closeButton.layer?.cornerRadius = 14
  1519. closeButton.normalColor = palette.inputBackground
  1520. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  1521. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1522. closeButton.layer?.borderWidth = 1
  1523. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  1524. closeButton.contentTintColor = secondaryText
  1525. closeButton.toolTip = "Close"
  1526. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1527. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1528. let titleRow = NSStackView()
  1529. titleRow.orientation = .horizontal
  1530. titleRow.alignment = .centerY
  1531. titleRow.spacing = 10
  1532. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1533. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  1534. titleStack.orientation = .vertical
  1535. titleStack.spacing = 2
  1536. titleStack.alignment = .leading
  1537. let titleSpacer = NSView()
  1538. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  1539. titleRow.addArrangedSubview(titleStack)
  1540. titleRow.addArrangedSubview(titleSpacer)
  1541. titleRow.addArrangedSubview(closeButton)
  1542. let headerDivider = NSView()
  1543. headerDivider.wantsLayer = true
  1544. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  1545. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  1546. let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
  1547. mode.segmentStyle = .rounded
  1548. mode.selectedSegment = 0
  1549. mode.translatesAutoresizingMaskIntoConstraints = false
  1550. mode.font = .systemFont(ofSize: 12, weight: .semibold)
  1551. mode.controlSize = .large
  1552. if #available(macOS 11.0, *) {
  1553. mode.selectedSegmentBezelColor = accentBlue
  1554. }
  1555. joinModeSegment = mode
  1556. let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
  1557. joinURLField = urlBox.textField
  1558. let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
  1559. let urlStack = NSStackView(views: [urlLabel, urlBox])
  1560. urlStack.orientation = .vertical
  1561. urlStack.spacing = 8
  1562. urlStack.alignment = .leading
  1563. urlStack.translatesAutoresizingMaskIntoConstraints = false
  1564. joinURLFieldsContainer = urlStack
  1565. let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
  1566. let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
  1567. joinMeetingIDField = idBox.textField
  1568. joinPasscodeField = passBox.textField
  1569. let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
  1570. let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
  1571. let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
  1572. idStack.orientation = .vertical
  1573. idStack.spacing = 8
  1574. idStack.alignment = .leading
  1575. idStack.translatesAutoresizingMaskIntoConstraints = false
  1576. idStack.isHidden = true
  1577. joinIDFieldsContainer = idStack
  1578. let nameBox = makeJoinFormField(placeholder: "e.g. Weekly standup (optional — used for the saved file)")
  1579. joinMeetingDisplayNameField = nameBox.textField
  1580. let nameLabel = makeLabel("Meeting name", size: 12, color: secondaryText, weight: .medium, centered: false)
  1581. let nameStack = NSStackView(views: [nameLabel, nameBox])
  1582. nameStack.orientation = .vertical
  1583. nameStack.spacing = 8
  1584. nameStack.alignment = .leading
  1585. nameStack.translatesAutoresizingMaskIntoConstraints = false
  1586. let formCard = NSView()
  1587. formCard.translatesAutoresizingMaskIntoConstraints = false
  1588. formCard.wantsLayer = true
  1589. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  1590. formCard.layer?.cornerRadius = 14
  1591. formCard.layer?.borderWidth = 1
  1592. formCard.layer?.borderColor = palette.inputBorder.cgColor
  1593. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
  1594. cancelButton.isBordered = false
  1595. cancelButton.wantsLayer = true
  1596. cancelButton.layer?.cornerRadius = 10
  1597. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1598. cancelButton.layer?.borderWidth = 1
  1599. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  1600. cancelButton.contentTintColor = primaryText
  1601. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1602. let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
  1603. joinButton.isBordered = false
  1604. joinButton.wantsLayer = true
  1605. joinButton.layer?.cornerRadius = 10
  1606. joinButton.normalColor = accentBlue
  1607. joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  1608. joinButton.contentTintColor = .white
  1609. joinButton.font = .systemFont(ofSize: 13, weight: .bold)
  1610. joinButton.keyEquivalent = "\r"
  1611. let buttons = NSStackView(views: [cancelButton, joinButton])
  1612. buttons.orientation = .horizontal
  1613. buttons.spacing = 12
  1614. buttons.alignment = .centerY
  1615. buttons.distribution = .fillEqually
  1616. let innerStack = NSStackView(views: [mode, urlStack, idStack, nameStack, buttons])
  1617. innerStack.orientation = .vertical
  1618. innerStack.spacing = 18
  1619. innerStack.alignment = .leading
  1620. innerStack.translatesAutoresizingMaskIntoConstraints = false
  1621. formCard.addSubview(innerStack)
  1622. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  1623. NSLayoutConstraint.activate([
  1624. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1625. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  1626. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  1627. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  1628. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1629. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1630. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  1631. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  1632. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1633. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1634. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  1635. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1636. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1637. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1638. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1639. mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1640. urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1641. idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1642. passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1643. nameBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1644. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1645. joinButton.heightAnchor.constraint(equalToConstant: 40),
  1646. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1647. ])
  1648. return root
  1649. }
  1650. private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
  1651. JoinPanelFieldContainer(
  1652. placeholder: placeholder,
  1653. normalBorder: palette.inputBorder,
  1654. focusBorder: accentBlue.withAlphaComponent(0.9),
  1655. fill: palette.inputBackground,
  1656. primaryText: primaryText,
  1657. mutedText: mutedText
  1658. )
  1659. }
  1660. @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
  1661. let urlMode = sender.selectedSegment == 0
  1662. joinURLFieldsContainer?.isHidden = urlMode == false
  1663. joinIDFieldsContainer?.isHidden = urlMode
  1664. }
  1665. @objc private func joinMeetingCancelTapped() {
  1666. dismissJoinMeetingPanel()
  1667. }
  1668. @objc private func joinMeetingSubmitTapped() {
  1669. guard let segment = joinModeSegment else { return }
  1670. let meetingName = joinMeetingDisplayNameField?.stringValue
  1671. let zoomURL: URL?
  1672. if segment.selectedSegment == 0 {
  1673. let raw = joinURLField?.stringValue ?? ""
  1674. guard let url = parseZoomJoinURLFromUserInput(raw) else {
  1675. showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
  1676. return
  1677. }
  1678. zoomURL = url
  1679. } else {
  1680. let idRaw = joinMeetingIDField?.stringValue ?? ""
  1681. let digits = idRaw.filter(\.isNumber)
  1682. guard digits.isEmpty == false else {
  1683. showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
  1684. return
  1685. }
  1686. let pass = joinPasscodeField?.stringValue ?? ""
  1687. guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
  1688. showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
  1689. return
  1690. }
  1691. zoomURL = url
  1692. }
  1693. guard let url = zoomURL else { return }
  1694. openWebURLPreferringInApp(url, zoomMeetingDisplayName: meetingName, recordingConsentWindowTitle: "Join Meeting")
  1695. }
  1696. private enum MeetingRecordingConsentChoice {
  1697. case allowAndContinue
  1698. case continueWithoutRecording
  1699. case dismissed
  1700. }
  1701. private func requestMeetingRecordingConsent(title: String) -> MeetingRecordingConsentChoice {
  1702. let alert = NSAlert()
  1703. alert.messageText = "Record this meeting locally?"
  1704. alert.informativeText = "With your consent, the app will record meeting audio on this Mac and show it in Ai Assistant after the meeting."
  1705. alert.addButton(withTitle: "Allow and Continue")
  1706. alert.addButton(withTitle: "Continue Without Recording")
  1707. alert.window.title = title
  1708. let speechOptions = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
  1709. let languageContainer = NSView(frame: NSRect(x: 0, y: 0, width: 320, height: 96))
  1710. languageContainer.translatesAutoresizingMaskIntoConstraints = false
  1711. languageContainer.widthAnchor.constraint(equalToConstant: 320).isActive = true
  1712. languageContainer.heightAnchor.constraint(equalToConstant: 96).isActive = true
  1713. let languageTitle = NSTextField(labelWithString: "Transcription Languages")
  1714. languageTitle.translatesAutoresizingMaskIntoConstraints = false
  1715. languageTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1716. languageTitle.textColor = NSColor.secondaryLabelColor
  1717. let language1Popup = NSPopUpButton(frame: .zero, pullsDown: false)
  1718. language1Popup.translatesAutoresizingMaskIntoConstraints = false
  1719. language1Popup.controlSize = .small
  1720. language1Popup.target = self
  1721. language1Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
  1722. for option in speechOptions {
  1723. language1Popup.addItem(withTitle: option.displayName)
  1724. language1Popup.lastItem?.representedObject = option.identifier
  1725. }
  1726. let selectedPrimary = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
  1727. ?? speechOptions.first?.identifier
  1728. ?? Locale.current.identifier
  1729. selectTranscriptionLanguage(identifier: selectedPrimary, in: language1Popup)
  1730. let language2Popup = NSPopUpButton(frame: .zero, pullsDown: false)
  1731. language2Popup.translatesAutoresizingMaskIntoConstraints = false
  1732. language2Popup.controlSize = .small
  1733. language2Popup.target = self
  1734. language2Popup.action = #selector(meetingConsentSpeechLanguageChanged(_:))
  1735. language2Popup.addItem(withTitle: "None")
  1736. language2Popup.lastItem?.representedObject = ""
  1737. for option in speechOptions {
  1738. language2Popup.addItem(withTitle: option.displayName)
  1739. language2Popup.lastItem?.representedObject = option.identifier
  1740. }
  1741. if let selectedSecondary = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
  1742. selectTranscriptionLanguage(identifier: selectedSecondary, in: language2Popup)
  1743. } else {
  1744. language2Popup.selectItem(at: 0)
  1745. }
  1746. let language1Label = NSTextField(labelWithString: "Preferred Language 1")
  1747. language1Label.translatesAutoresizingMaskIntoConstraints = false
  1748. language1Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1749. language1Label.textColor = NSColor.secondaryLabelColor
  1750. language1Label.alignment = .center
  1751. let language2Label = NSTextField(labelWithString: "Preferred Language 2")
  1752. language2Label.translatesAutoresizingMaskIntoConstraints = false
  1753. language2Label.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  1754. language2Label.textColor = NSColor.secondaryLabelColor
  1755. language2Label.alignment = .center
  1756. languageContainer.addSubview(languageTitle)
  1757. languageContainer.addSubview(language1Label)
  1758. languageContainer.addSubview(language1Popup)
  1759. languageContainer.addSubview(language2Label)
  1760. languageContainer.addSubview(language2Popup)
  1761. NSLayoutConstraint.activate([
  1762. languageTitle.topAnchor.constraint(equalTo: languageContainer.topAnchor, constant: 0),
  1763. languageTitle.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1764. language1Label.topAnchor.constraint(equalTo: languageTitle.bottomAnchor, constant: 2),
  1765. language1Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1766. language1Popup.topAnchor.constraint(equalTo: language1Label.bottomAnchor, constant: 2),
  1767. language1Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1768. language1Popup.widthAnchor.constraint(equalToConstant: 240),
  1769. language2Label.topAnchor.constraint(equalTo: language1Popup.bottomAnchor, constant: 3),
  1770. language2Label.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1771. language2Popup.topAnchor.constraint(equalTo: language2Label.bottomAnchor, constant: 2),
  1772. language2Popup.centerXAnchor.constraint(equalTo: languageContainer.centerXAnchor),
  1773. language2Popup.widthAnchor.constraint(equalToConstant: 240),
  1774. language2Popup.bottomAnchor.constraint(equalTo: languageContainer.bottomAnchor, constant: -1)
  1775. ])
  1776. alert.accessoryView = languageContainer
  1777. didTapInlineMeetingConsentClose = false
  1778. activeMeetingConsentWindow = alert.window
  1779. meetingConsentPrimaryLanguagePopup = language1Popup
  1780. meetingConsentSecondaryLanguagePopup = language2Popup
  1781. if let contentView = alert.window.contentView {
  1782. let topCloseButton = NSButton(title: "✕", target: self, action: #selector(meetingConsentInlineCloseTapped(_:)))
  1783. topCloseButton.translatesAutoresizingMaskIntoConstraints = false
  1784. topCloseButton.isBordered = false
  1785. topCloseButton.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
  1786. topCloseButton.contentTintColor = NSColor.tertiaryLabelColor
  1787. topCloseButton.wantsLayer = true
  1788. topCloseButton.layer?.cornerRadius = 20
  1789. topCloseButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.35).cgColor
  1790. contentView.addSubview(topCloseButton)
  1791. NSLayoutConstraint.activate([
  1792. topCloseButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 10),
  1793. topCloseButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -10),
  1794. topCloseButton.widthAnchor.constraint(equalToConstant: 40),
  1795. topCloseButton.heightAnchor.constraint(equalToConstant: 40)
  1796. ])
  1797. }
  1798. let response = alert.runModal()
  1799. activeMeetingConsentWindow = nil
  1800. meetingConsentPrimaryLanguagePopup = nil
  1801. meetingConsentSecondaryLanguagePopup = nil
  1802. if didTapInlineMeetingConsentClose {
  1803. return .dismissed
  1804. }
  1805. if let primary = language1Popup.selectedItem?.representedObject as? String,
  1806. primary.isEmpty == false {
  1807. var secondary = language2Popup.selectedItem?.representedObject as? String
  1808. if secondary?.isEmpty == true {
  1809. secondary = nil
  1810. }
  1811. TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
  1812. syncSettingsSpeechLanguagePopupsFromStoredPreferences()
  1813. }
  1814. switch response {
  1815. case .alertFirstButtonReturn:
  1816. return .allowAndContinue
  1817. case .alertSecondButtonReturn:
  1818. return .continueWithoutRecording
  1819. default:
  1820. return .dismissed
  1821. }
  1822. }
  1823. @objc private func meetingConsentInlineCloseTapped(_ sender: NSButton) {
  1824. didTapInlineMeetingConsentClose = true
  1825. guard let consentWindow = activeMeetingConsentWindow else { return }
  1826. NSApp.stopModal(withCode: .abort)
  1827. consentWindow.orderOut(nil)
  1828. }
  1829. @objc private func meetingConsentSpeechLanguageChanged(_ sender: NSPopUpButton) {
  1830. guard let primary = meetingConsentPrimaryLanguagePopup?.selectedItem?.representedObject as? String,
  1831. primary.isEmpty == false else { return }
  1832. var secondary: String? = meetingConsentSecondaryLanguagePopup?.selectedItem?.representedObject as? String
  1833. if secondary?.isEmpty == true {
  1834. secondary = nil
  1835. }
  1836. if let secondaryValue = secondary,
  1837. secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
  1838. secondary = nil
  1839. meetingConsentSecondaryLanguagePopup?.selectItem(at: 0)
  1840. }
  1841. TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
  1842. syncSettingsSpeechLanguagePopupsFromStoredPreferences()
  1843. }
  1844. /// Keeps Settings language menus aligned when preferences change from the pre-join consent sheet.
  1845. private func syncSettingsSpeechLanguagePopupsFromStoredPreferences() {
  1846. let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
  1847. if let primaryPopup = settingsPagePrimaryLanguagePopup {
  1848. let id = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
  1849. ?? options.first?.identifier
  1850. ?? Locale.current.identifier
  1851. selectTranscriptionLanguage(identifier: id, in: primaryPopup)
  1852. }
  1853. if let secondaryPopup = settingsPageSecondaryLanguagePopup {
  1854. if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
  1855. selectTranscriptionLanguage(identifier: selected, in: secondaryPopup)
  1856. } else {
  1857. secondaryPopup.selectItem(at: 0)
  1858. }
  1859. }
  1860. }
  1861. private func selectTranscriptionLanguage(identifier: String, in popup: NSPopUpButton) {
  1862. for item in popup.itemArray {
  1863. if let value = item.representedObject as? String, value == identifier {
  1864. popup.select(item)
  1865. return
  1866. }
  1867. }
  1868. }
  1869. /// Maps `zoom.us` join links to `app.zoom.us` (Workplace web client host).
  1870. private func zoomEmbeddedWebHostURL(for url: URL) -> URL {
  1871. guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
  1872. let host = components.host?.lowercased() ?? ""
  1873. if host == "zoom.us" || host.hasSuffix(".zoom.us") {
  1874. components.host = "app.zoom.us"
  1875. }
  1876. return components.url ?? url
  1877. }
  1878. private func zakQueryValueFromZoomURL(_ url: URL) -> String? {
  1879. let fromQuery = URLComponents(url: url, resolvingAgainstBaseURL: false)?
  1880. .queryItems?
  1881. .first(where: { $0.name.caseInsensitiveCompare("zak") == .orderedSame })?
  1882. .value?
  1883. .trimmingCharacters(in: .whitespacesAndNewlines)
  1884. if let fromQuery, fromQuery.isEmpty == false { return fromQuery }
  1885. return nil
  1886. }
  1887. /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd` + optional `zak`).
  1888. private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String, zak: String? = nil, displayName: String? = nil) -> URL? {
  1889. guard meetingIdDigits.isEmpty == false else { return nil }
  1890. var components = URLComponents()
  1891. components.scheme = "https"
  1892. components.host = "app.zoom.us"
  1893. components.path = "/wc/join/\(meetingIdDigits)"
  1894. var queryItems: [URLQueryItem] = []
  1895. let trimmedPasscode = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
  1896. if trimmedPasscode.isEmpty == false {
  1897. queryItems.append(URLQueryItem(name: "pwd", value: trimmedPasscode))
  1898. }
  1899. let trimmedZAK = zak?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1900. if trimmedZAK.isEmpty == false {
  1901. queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
  1902. }
  1903. let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1904. if trimmedName.isEmpty == false {
  1905. queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
  1906. }
  1907. if queryItems.isEmpty == false {
  1908. components.queryItems = queryItems
  1909. }
  1910. return components.url
  1911. }
  1912. /// Adds ZAK and display name so the embedded web client joins as the OAuth-signed-in user (not a guest).
  1913. private func zoomMeetingURLByAppendingWebAuth(_ url: URL, zak: String, displayName: String?) -> URL {
  1914. let normalizedHost = zoomEmbeddedWebHostURL(for: url)
  1915. let trimmedZAK = zak.trimmingCharacters(in: .whitespacesAndNewlines)
  1916. guard trimmedZAK.isEmpty == false else { return normalizedHost }
  1917. var components = URLComponents(url: normalizedHost, resolvingAgainstBaseURL: false) ?? URLComponents()
  1918. var queryItems = components.queryItems ?? []
  1919. queryItems.removeAll { $0.name.caseInsensitiveCompare("zak") == .orderedSame }
  1920. queryItems.removeAll { $0.name.caseInsensitiveCompare("uname") == .orderedSame }
  1921. queryItems.append(URLQueryItem(name: "zak", value: trimmedZAK))
  1922. let trimmedName = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1923. if trimmedName.isEmpty == false {
  1924. queryItems.append(URLQueryItem(name: "uname", value: trimmedName))
  1925. }
  1926. components.queryItems = queryItems
  1927. return components.url ?? normalizedHost
  1928. }
  1929. private func javaScriptStringLiteral(_ value: String) -> String {
  1930. let escaped = value
  1931. .replacingOccurrences(of: "\\", with: "\\\\")
  1932. .replacingOccurrences(of: "'", with: "\\'")
  1933. .replacingOccurrences(of: "\n", with: "\\n")
  1934. .replacingOccurrences(of: "\r", with: "")
  1935. return "'\(escaped)'"
  1936. }
  1937. /// Loads a Zoom Workplace page in the shared web view so OAuth cookies apply to later joins (panel may stay closed).
  1938. @MainActor
  1939. private func primeZoomEmbeddedWebSession() {
  1940. guard isUserLoggedIn(), let webView = embeddedBrowserWebView else { return }
  1941. guard let homeURL = URL(string: "https://app.zoom.us/wc/home") else { return }
  1942. webView.load(URLRequest(url: homeURL))
  1943. }
  1944. private func zoomOAuthErrorIndicatesMissingZAKScope(_ error: Error) -> Bool {
  1945. if case ZoomOAuthError.missingRequiredScope(let raw) = error {
  1946. let lower = raw.lowercased()
  1947. return lower.contains("user_zak") || lower.contains("zak")
  1948. }
  1949. let lower = error.localizedDescription.lowercased()
  1950. return lower.contains("user_zak") || lower.contains("does not contain scopes")
  1951. }
  1952. /// Fetches ZAK for embedded join. If the saved token predates `user_zak:read`, opens Zoom OAuth once (you stay signed in to the app).
  1953. @MainActor
  1954. private func fetchEmbeddedZoomZAKAllowingReConsent() async -> String? {
  1955. do {
  1956. let accessToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  1957. return try await fetchZoomUserZAK(accessToken: accessToken)
  1958. } catch {
  1959. if case ZoomOAuthError.authenticationCancelled = error { return nil }
  1960. guard zoomOAuthErrorIndicatesMissingZAKScope(error) else {
  1961. NSLog("ViewController: ZAK unavailable for embedded join — \(error.localizedDescription)")
  1962. return nil
  1963. }
  1964. }
  1965. do {
  1966. zoomOAuth.clearSavedTokens()
  1967. let accessToken = try await zoomOAuth.validAccessToken(
  1968. presentingWindow: view.window,
  1969. requiringAnyOf: ["user_zak:read"]
  1970. )
  1971. return try await fetchZoomUserZAK(accessToken: accessToken)
  1972. } catch {
  1973. if case ZoomOAuthError.authenticationCancelled = error { return nil }
  1974. NSLog("ViewController: ZAK unavailable after OAuth refresh — \(error.localizedDescription)")
  1975. return nil
  1976. }
  1977. }
  1978. @MainActor
  1979. private func zoomMeetingURLWithSignedInDisplayNameFallback(_ url: URL) -> URL {
  1980. guard let name = zoomSignedInDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines),
  1981. name.isEmpty == false else { return url }
  1982. var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents()
  1983. var queryItems = components.queryItems ?? []
  1984. if queryItems.contains(where: { $0.name.caseInsensitiveCompare("uname") == .orderedSame }) == false {
  1985. queryItems.append(URLQueryItem(name: "uname", value: name))
  1986. }
  1987. components.queryItems = queryItems
  1988. return components.url ?? url
  1989. }
  1990. /// When signed in, attach a fresh ZAK so `/wc/join/` loads with the same Zoom account as native OAuth.
  1991. @MainActor
  1992. private func embeddedZoomMeetingLoadURL(for url: URL) async -> URL {
  1993. let normalized = zoomEmbeddedWebHostURL(for: url)
  1994. guard isZoomMeetingJoinOrStartURL(normalized), isUserLoggedIn() else { return normalized }
  1995. if let existingZAK = zakQueryValueFromZoomURL(normalized) {
  1996. let displayName = zoomSignedInDisplayName
  1997. embeddedZoomPendingWebAuth = (zak: existingZAK, displayName: displayName)
  1998. return zoomMeetingURLByAppendingWebAuth(normalized, zak: existingZAK, displayName: displayName)
  1999. }
  2000. primeZoomEmbeddedWebSession()
  2001. if let zak = await fetchEmbeddedZoomZAKAllowingReConsent() {
  2002. let displayName = zoomSignedInDisplayName
  2003. embeddedZoomPendingWebAuth = (zak: zak, displayName: displayName)
  2004. return zoomMeetingURLByAppendingWebAuth(normalized, zak: zak, displayName: displayName)
  2005. }
  2006. // No ZAK (scope not on OAuth app yet): still pre-fill name and rely on shared web cookies.
  2007. embeddedZoomPendingWebAuth = nil
  2008. return zoomMeetingURLWithSignedInDisplayNameFallback(normalized)
  2009. }
  2010. @MainActor
  2011. private func applyEmbeddedZoomWebAuthenticationIfNeeded(webView: WKWebView) {
  2012. guard let pending = embeddedZoomPendingWebAuth else { return }
  2013. guard let pageURL = webView.url, isZoomMeetingJoinOrStartURL(pageURL) else { return }
  2014. let title = webView.title ?? ""
  2015. let onGuestJoinScreen = title.localizedCaseInsensitiveContains("Enter Meeting Info")
  2016. || title.localizedCaseInsensitiveContains("Enter your name")
  2017. guard onGuestJoinScreen else { return }
  2018. let zakLiteral = javaScriptStringLiteral(pending.zak)
  2019. let nameLiteral = pending.displayName.map(javaScriptStringLiteral) ?? "null"
  2020. let script = """
  2021. (function() {
  2022. var zak = \(zakLiteral);
  2023. var uname = \(nameLiteral);
  2024. try {
  2025. var next = new URL(window.location.href);
  2026. if (!next.searchParams.get('zak')) {
  2027. next.searchParams.set('zak', zak);
  2028. if (uname) next.searchParams.set('uname', uname);
  2029. window.location.replace(next.toString());
  2030. return;
  2031. }
  2032. } catch (e) {}
  2033. try {
  2034. sessionStorage.setItem('zak', zak);
  2035. if (uname) sessionStorage.setItem('uname', uname);
  2036. } catch (e2) {}
  2037. })();
  2038. """
  2039. webView.evaluateJavaScript(script, completionHandler: nil)
  2040. }
  2041. /// Prefer `/wc/join/` so the embedded web client loads directly (avoids Workplace app vs browser chooser on `/s/` start links).
  2042. private func embeddedWebClientURLForZoomMeeting(
  2043. meetingId: Int?,
  2044. joinURLString: String?,
  2045. startURLString: String? = nil
  2046. ) -> URL? {
  2047. let trimmedJoin = joinURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  2048. let trimmedStart = startURLString?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  2049. var passcode: String?
  2050. var zakFromAPI: String?
  2051. if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
  2052. zakFromAPI = zakQueryValueFromZoomURL(startURL)
  2053. }
  2054. if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
  2055. if joinURL.path.contains("/wc/join/") {
  2056. return zoomEmbeddedWebHostURL(for: joinURL)
  2057. }
  2058. passcode = URLComponents(url: joinURL, resolvingAgainstBaseURL: false)?
  2059. .queryItems?
  2060. .first(where: { $0.name == "pwd" })?
  2061. .value
  2062. if zakFromAPI == nil {
  2063. zakFromAPI = zakQueryValueFromZoomURL(joinURL)
  2064. }
  2065. }
  2066. let numericId: String? = {
  2067. if let meetingId { return String(meetingId) }
  2068. if trimmedJoin.isEmpty == false, let joinURL = URL(string: trimmedJoin) {
  2069. return meetingIdDigitsFromZoomJoinPath(joinURL.path)
  2070. }
  2071. if trimmedStart.isEmpty == false, let startURL = URL(string: trimmedStart) {
  2072. return meetingIdDigitsFromZoomJoinPath(startURL.path)
  2073. }
  2074. return nil
  2075. }()
  2076. guard let numericId, numericId.isEmpty == false else { return nil }
  2077. return zoomWebClientJoinURL(
  2078. meetingIdDigits: numericId,
  2079. passcode: passcode ?? "",
  2080. zak: zakFromAPI,
  2081. displayName: zoomSignedInDisplayName
  2082. )
  2083. }
  2084. private func meetingIdDigitsFromZoomJoinPath(_ path: String) -> String? {
  2085. let parts = path.split(separator: "/").map(String.init)
  2086. for (index, segment) in parts.enumerated() {
  2087. let lowered = segment.lowercased()
  2088. if (lowered == "j" || lowered == "join" || lowered == "wc"), index + 1 < parts.count {
  2089. let digits = parts[index + 1].filter(\.isNumber)
  2090. if digits.count >= 9 { return digits }
  2091. }
  2092. }
  2093. return nil
  2094. }
  2095. private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
  2096. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  2097. guard trimmed.isEmpty == false else { return nil }
  2098. let normalized: String
  2099. if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
  2100. normalized = trimmed
  2101. } else {
  2102. normalized = "https://\(trimmed)"
  2103. }
  2104. guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
  2105. let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
  2106. || host == "zoom.com" || host.hasSuffix(".zoom.com")
  2107. guard isZoom else { return nil }
  2108. return url
  2109. }
  2110. /// Opens Zoom meeting join/start links in the right-panel `WKWebView` (same store as embedded Zoom OAuth).
  2111. /// Other URLs open in the system default browser.
  2112. /// For Zoom meeting join/start links, asks for recording consent first, persists transcription languages from that sheet,
  2113. /// then optionally begins local audio recording (when the user allows and recording is not already active).
  2114. @MainActor
  2115. private func openWebURLPreferringInApp(
  2116. _ url: URL,
  2117. zoomMeetingDisplayName: String? = nil,
  2118. recordingConsentWindowTitle: String = "Meeting"
  2119. ) {
  2120. let shouldRecordLocally: Bool
  2121. if isZoomMeetingJoinOrStartURL(url) {
  2122. let consentChoice = requestMeetingRecordingConsent(title: recordingConsentWindowTitle)
  2123. if consentChoice == .dismissed { return }
  2124. shouldRecordLocally = (consentChoice == .allowAndContinue)
  2125. } else {
  2126. shouldRecordLocally = false
  2127. }
  2128. let opened: Bool
  2129. if isZoomMeetingJoinOrStartURL(url), embeddedBrowserWebView != nil {
  2130. presentURLInEmbeddedRightPanel(url)
  2131. opened = true
  2132. } else {
  2133. opened = openURLInDefaultBrowser(url)
  2134. }
  2135. if opened {
  2136. dismissJoinMeetingPanel()
  2137. if shouldRecordLocally {
  2138. beginZoomMeetingRecordingIfAppropriate(sourceURL: url, meetingDisplayName: zoomMeetingDisplayName)
  2139. }
  2140. } else {
  2141. showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
  2142. }
  2143. }
  2144. /// True when `url` looks like joining or starting a Zoom call (not OAuth, API, or generic marketing pages).
  2145. private func isZoomMeetingJoinOrStartURL(_ url: URL) -> Bool {
  2146. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2147. if host.hasPrefix("api.") { return false }
  2148. let path = url.path.lowercased()
  2149. if path.hasPrefix("/oauth") || path.hasPrefix("/signin") { return false }
  2150. if path.contains("/j/") { return true }
  2151. if path.contains("/wc/join") { return true }
  2152. if path.contains("/join/") { return true }
  2153. if path.contains("/my/") { return true }
  2154. if path.contains("/w/") { return true }
  2155. if path.contains("/s/") { return true }
  2156. if path.contains("videomeeting") { return true }
  2157. if path.contains("/start") && path.contains("meeting") { return true }
  2158. return false
  2159. }
  2160. /// Active join / in-call pages in the embedded Zoom web client (not Workplace hub routes under `/wc/`).
  2161. private func isZoomEmbeddedWebClientInCallURL(_ url: URL) -> Bool {
  2162. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2163. if isZoomMeetingExplicitLeaveURL(url) { return false }
  2164. let path = url.path.lowercased()
  2165. if path.contains("/wc/join") { return true }
  2166. if path.contains("/wc/inmeeting") { return true }
  2167. if path.contains("/wc/video") { return true }
  2168. // Unknown `/wc/…` SPA routes during a call are treated as in-meeting unless they are hub pages.
  2169. if path.contains("/wc/") {
  2170. return isZoomWorkplaceHubURL(url) == false
  2171. }
  2172. // Non–web-client join links opened in the embedded panel (rare).
  2173. return isZoomMeetingJoinOrStartURL(url)
  2174. }
  2175. /// User explicitly left or the meeting ended (stop recording soon after this is detected).
  2176. private func isZoomMeetingExplicitLeaveURL(_ url: URL) -> Bool {
  2177. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2178. let path = url.path.lowercased()
  2179. let absolute = url.absoluteString.lowercased()
  2180. if path.contains("/leave") || path.contains("postattendee") || path.contains("/feedback") { return true }
  2181. return absolute.contains("meeting_ended") || absolute.contains("meeting-ended")
  2182. }
  2183. /// Workplace hub, sign-in, or marketing home — not an active call (may appear briefly during join).
  2184. private func isZoomWorkplaceHubURL(_ url: URL) -> Bool {
  2185. guard let host = url.host?.lowercased(), host.contains("zoom.") else { return false }
  2186. let path = url.path.lowercased()
  2187. let workplaceHubMarkers = [
  2188. "/wc/home", "/wc/profile", "/wc/calendar", "/wc/chat", "/wc/mail",
  2189. "/wc/whiteboard", "/wc/meeting", "/wc/recordings", "/wc/notes",
  2190. "/wc/contacts", "/wc/scheduler", "/wc/documents", "/wc/apps"
  2191. ]
  2192. if workplaceHubMarkers.contains(where: { path == $0 || path.hasPrefix($0 + "/") }) { return true }
  2193. if path == "/wc" || path == "/wc/" { return true }
  2194. if path.contains("/wc/") == false, path.isEmpty || path == "/" || path.hasPrefix("/signin") { return true }
  2195. return false
  2196. }
  2197. /// Workplace hub, sign-in, or explicit post-meeting pages — not an active call.
  2198. private func isZoomMeetingLeaveOrEndURL(_ url: URL) -> Bool {
  2199. isZoomMeetingExplicitLeaveURL(url) || isZoomWorkplaceHubURL(url)
  2200. }
  2201. private func embeddedWebViewTitleIndicatesLeftMeeting(_ title: String) -> Bool {
  2202. let t = title.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2203. guard t.isEmpty == false else { return false }
  2204. if t.contains("zoom meeting") || t.contains("zoom webinar") { return false }
  2205. if t.contains("waiting for the host") || t.contains("waiting for host") { return false }
  2206. let hubTitles = [
  2207. "zoom workplace", "meetings | zoom", "home | zoom", "calendar | zoom",
  2208. "team chat | zoom", "chat | zoom", "sign in", "sign-in", "thank you for"
  2209. ]
  2210. return hubTitles.contains(where: { t.contains($0) })
  2211. }
  2212. @MainActor
  2213. private func updateEmbeddedMeetingRecordingPresence(for url: URL?, webViewTitle: String? = nil) {
  2214. guard MeetingRecordingManager.shared.isRecording else {
  2215. stopEmbeddedMeetingRecordingMonitor()
  2216. return
  2217. }
  2218. let inCall = url.map { isZoomEmbeddedWebClientInCallURL($0) } ?? false
  2219. let explicitLeaveURL = url.map { isZoomMeetingExplicitLeaveURL($0) } ?? false
  2220. let workplaceHubURL = url.map { isZoomWorkplaceHubURL($0) } ?? false
  2221. let leftByTitle = webViewTitle.map { embeddedWebViewTitleIndicatesLeftMeeting($0) } ?? false
  2222. if inCall {
  2223. embeddedMeetingReachedInCallPage = true
  2224. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2225. }
  2226. MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(inCall)
  2227. // Definite leave/end pages and hub titles stop recording immediately.
  2228. if explicitLeaveURL || leftByTitle {
  2229. endZoomMeetingRecordingAutomatically()
  2230. return
  2231. }
  2232. let elapsed = MeetingRecordingManager.shared.currentRecordingElapsed
  2233. if elapsed < MeetingRecordingManager.recordingLeaveGraceInterval {
  2234. return
  2235. }
  2236. // User may have joined via the desktop Zoom app while the embedded panel shows a hub page.
  2237. if ZoomMeetingPresenceWatcher.callPresenceLikelyVisible() {
  2238. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2239. return
  2240. }
  2241. let absentFromCall = workplaceHubURL || (embeddedMeetingReachedInCallPage && !inCall)
  2242. if absentFromCall {
  2243. embeddedMeetingAbsentCallConsecutiveMisses += 1
  2244. } else {
  2245. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2246. }
  2247. guard embeddedMeetingAbsentCallConsecutiveMisses >= Self.embeddedMeetingAbsentCallMissThreshold else { return }
  2248. endZoomMeetingRecordingAutomatically()
  2249. }
  2250. @MainActor
  2251. private func startEmbeddedMeetingRecordingMonitorIfNeeded() {
  2252. stopEmbeddedMeetingRecordingMonitor()
  2253. guard MeetingRecordingManager.shared.isRecording else { return }
  2254. guard embeddedBrowserWebView != nil else { return }
  2255. embeddedMeetingReachedInCallPage = false
  2256. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2257. let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
  2258. guard let self else { return }
  2259. let webView = self.embeddedBrowserWebView
  2260. self.updateEmbeddedMeetingRecordingPresence(for: webView?.url, webViewTitle: webView?.title)
  2261. }
  2262. RunLoop.main.add(timer, forMode: .common)
  2263. embeddedMeetingRecordingMonitorTimer = timer
  2264. }
  2265. @MainActor
  2266. private func stopEmbeddedMeetingRecordingMonitor() {
  2267. embeddedMeetingRecordingMonitorTimer?.invalidate()
  2268. embeddedMeetingRecordingMonitorTimer = nil
  2269. embeddedMeetingReachedInCallPage = false
  2270. embeddedMeetingAbsentCallConsecutiveMisses = 0
  2271. }
  2272. @MainActor
  2273. private func endZoomMeetingRecordingAutomatically() {
  2274. guard MeetingRecordingManager.shared.isRecording else { return }
  2275. stopEmbeddedMeetingRecordingMonitor()
  2276. Task { @MainActor in
  2277. guard MeetingRecordingManager.shared.isRecording else { return }
  2278. do {
  2279. try await MeetingRecordingManager.shared.stopRecording()
  2280. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  2281. self.renderAiCompanionList()
  2282. self.updateAiCompanionRecordingUI()
  2283. } catch {
  2284. NSLog("ViewController: auto-stop recording failed — \(error.localizedDescription)")
  2285. }
  2286. }
  2287. }
  2288. @MainActor
  2289. private func beginZoomMeetingRecordingIfAppropriate(sourceURL: URL, meetingDisplayName: String?) {
  2290. guard isZoomMeetingJoinOrStartURL(sourceURL) else { return }
  2291. guard MeetingRecordingManager.shared.isRecording == false else { return }
  2292. Task { @MainActor in
  2293. do {
  2294. try await MeetingRecordingManager.shared.startRecording(sourceURL: sourceURL, meetingDisplayName: meetingDisplayName)
  2295. self.updateAiCompanionRecordingUI()
  2296. if self.embeddedBrowserWebView != nil,
  2297. (self.embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
  2298. self.startEmbeddedMeetingRecordingMonitorIfNeeded()
  2299. }
  2300. } catch {
  2301. self.showSimpleAlert(title: "Recording could not start", message: error.localizedDescription)
  2302. }
  2303. }
  2304. }
  2305. @discardableResult
  2306. private func openURLInDefaultBrowser(_ url: URL) -> Bool {
  2307. NSWorkspace.shared.open(url)
  2308. }
  2309. @MainActor
  2310. private func presentURLInEmbeddedRightPanel(_ url: URL) {
  2311. guard let webView = embeddedBrowserWebView else {
  2312. NSWorkspace.shared.open(url)
  2313. return
  2314. }
  2315. updateEmbeddedRightPanelWidthForOpenState()
  2316. if isZoomMeetingJoinOrStartURL(url) {
  2317. Task { await ensureMediaCaptureAccessForEmbeddedZoomWebClient() }
  2318. }
  2319. Task { @MainActor in
  2320. let loadURL = await self.embeddedZoomMeetingLoadURL(for: url)
  2321. guard self.embeddedBrowserWebView === webView else { return }
  2322. webView.load(URLRequest(url: loadURL))
  2323. self.updateEmbeddedMeetingRecordingPresence(for: loadURL)
  2324. self.refreshEmbeddedBrowserNavButtons()
  2325. self.view.window?.contentView?.layoutSubtreeIfNeeded()
  2326. }
  2327. }
  2328. /// Sandbox camera entitlement + TCC prompt so `WKWebView` can enumerate devices for Zoom web meetings.
  2329. private func ensureMediaCaptureAccessForEmbeddedZoomWebClient() async {
  2330. _ = await cameraAccessGrantedForWebCapture()
  2331. _ = await microphoneAccessGrantedForWebCapture()
  2332. }
  2333. private func cameraAccessGrantedForWebCapture() async -> Bool {
  2334. switch AVCaptureDevice.authorizationStatus(for: .video) {
  2335. case .authorized:
  2336. return true
  2337. case .notDetermined:
  2338. return await AVCaptureDevice.requestAccess(for: .video)
  2339. case .denied, .restricted:
  2340. return false
  2341. @unknown default:
  2342. return false
  2343. }
  2344. }
  2345. private func microphoneAccessGrantedForWebCapture() async -> Bool {
  2346. switch AVCaptureDevice.authorizationStatus(for: .audio) {
  2347. case .authorized:
  2348. return true
  2349. case .notDetermined:
  2350. return await AVCaptureDevice.requestAccess(for: .audio)
  2351. case .denied, .restricted:
  2352. return false
  2353. @unknown default:
  2354. return false
  2355. }
  2356. }
  2357. /// Makes the embedded web panel fill the entire right-side content area (to match the reference behavior).
  2358. @MainActor
  2359. private func updateEmbeddedRightPanelWidthForOpenState() {
  2360. guard let widthConstraint = embeddedBrowserPanelWidthConstraint else { return }
  2361. view.layoutSubtreeIfNeeded()
  2362. view.window?.contentView?.layoutSubtreeIfNeeded()
  2363. var rowW = homeBodyRowView?.bounds.width ?? 0
  2364. if rowW < 1 {
  2365. rowW = view.bounds.width
  2366. }
  2367. // Right panel spans from (bodyRow.leading + gap) to bodyRow.trailing when the main column is collapsed (width 0).
  2368. // Using the full row width here over-constrains by the column gap and nudges the window wider.
  2369. let available = max(0, rowW - homeEmbeddedBrowserColumnGap)
  2370. widthConstraint.constant = max(320, floor(available))
  2371. // Collapse the left main content column while web is visible.
  2372. homeMainContentColumnView?.isHidden = true
  2373. embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = true
  2374. }
  2375. @MainActor
  2376. private func refreshEmbeddedBrowserNavButtons() {
  2377. embeddedBrowserBackButton?.isEnabled = embeddedBrowserWebView?.canGoBack == true
  2378. embeddedBrowserForwardButton?.isEnabled = embeddedBrowserWebView?.canGoForward == true
  2379. }
  2380. @objc private func embeddedBrowserBackTapped() {
  2381. embeddedBrowserWebView?.goBack()
  2382. }
  2383. @objc private func embeddedBrowserForwardTapped() {
  2384. embeddedBrowserWebView?.goForward()
  2385. }
  2386. @objc private func embeddedBrowserReloadTapped() {
  2387. embeddedBrowserWebView?.reload()
  2388. }
  2389. @objc private func embeddedBrowserClosePanelTapped() {
  2390. closeEmbeddedBrowserPanel()
  2391. }
  2392. @MainActor
  2393. private func closeEmbeddedBrowserPanel(stoppingWebLoad: Bool = true) {
  2394. if MeetingRecordingManager.shared.isRecording {
  2395. endZoomMeetingRecordingAutomatically()
  2396. }
  2397. if let cont = oauthSessionContinuation {
  2398. oauthSessionContinuation = nil
  2399. oauthSessionCallbackPrefix = nil
  2400. cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
  2401. }
  2402. if stoppingWebLoad {
  2403. embeddedBrowserWebView?.stopLoading()
  2404. }
  2405. embeddedZoomPendingWebAuth = nil
  2406. stopEmbeddedMeetingRecordingMonitor()
  2407. MeetingRecordingManager.shared.setEmbeddedWebClientCallUIVisible(false)
  2408. embeddedBrowserPanelWidthConstraint?.constant = 0
  2409. embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
  2410. homeMainContentColumnView?.isHidden = false
  2411. refreshEmbeddedBrowserNavButtons()
  2412. view.window?.contentView?.layoutSubtreeIfNeeded()
  2413. }
  2414. func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL {
  2415. guard let webView = embeddedBrowserWebView else {
  2416. throw EmbeddedOAuthFlowError.webViewUnavailable
  2417. }
  2418. do {
  2419. return try await withThrowingTaskGroup(of: URL.self) { group in
  2420. group.addTask { @MainActor in
  2421. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  2422. self.oauthSessionContinuation = continuation
  2423. self.oauthSessionCallbackPrefix = callbackURLPrefix
  2424. self.updateEmbeddedRightPanelWidthForOpenState()
  2425. webView.load(URLRequest(url: startURL))
  2426. self.refreshEmbeddedBrowserNavButtons()
  2427. }
  2428. }
  2429. group.addTask {
  2430. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  2431. await MainActor.run {
  2432. if let cont = self.oauthSessionContinuation {
  2433. self.oauthSessionContinuation = nil
  2434. self.oauthSessionCallbackPrefix = nil
  2435. webView.stopLoading()
  2436. cont.resume(throwing: EmbeddedOAuthFlowError.timedOut)
  2437. }
  2438. }
  2439. throw EmbeddedOAuthFlowError.timedOut
  2440. }
  2441. let url = try await group.next()!
  2442. group.cancelAll()
  2443. await MainActor.run {
  2444. self.oauthSessionContinuation = nil
  2445. self.oauthSessionCallbackPrefix = nil
  2446. if let primeURL = URL(string: "https://app.zoom.us/wc/home") {
  2447. webView.load(URLRequest(url: primeURL))
  2448. }
  2449. self.closeEmbeddedBrowserPanel(stoppingWebLoad: false)
  2450. }
  2451. return url
  2452. }
  2453. } catch {
  2454. await MainActor.run {
  2455. self.oauthSessionContinuation = nil
  2456. self.oauthSessionCallbackPrefix = nil
  2457. webView.stopLoading()
  2458. self.embeddedBrowserPanelWidthConstraint?.constant = 0
  2459. self.refreshEmbeddedBrowserNavButtons()
  2460. webView.load(URLRequest(url: URL(string: "about:blank")!))
  2461. self.view.window?.contentView?.layoutSubtreeIfNeeded()
  2462. }
  2463. throw error
  2464. }
  2465. }
  2466. private func resetJoinMeetingPanelReferences() {
  2467. joinMeetingWindow = nil
  2468. joinURLField = nil
  2469. joinMeetingIDField = nil
  2470. joinPasscodeField = nil
  2471. joinMeetingDisplayNameField = nil
  2472. joinURLFieldsContainer = nil
  2473. joinIDFieldsContainer = nil
  2474. joinModeSegment = nil
  2475. }
  2476. @objc private func logoutTapped() {
  2477. meetingsRefreshTimer?.invalidate()
  2478. meetingsRefreshTimer = nil
  2479. meetingsLoadTask?.cancel()
  2480. meetingsLoadTask = nil
  2481. isLoadingMeetings = false
  2482. meetingsRateLimitedUntil = nil
  2483. clearMeetingsScrollObserver()
  2484. googleOAuth.clearSavedTokens()
  2485. zoomOAuth.clearSavedTokens()
  2486. zoomSignedInDisplayName = nil
  2487. embeddedZoomPendingWebAuth = nil
  2488. persistLoggedInState(false)
  2489. allScheduledMeetings = []
  2490. applyAuthenticationUIState(triggerInitialRefresh: false)
  2491. Task { @MainActor in
  2492. self.applyMeetings([])
  2493. self.renderAiCompanionList()
  2494. self.updateAiCompanionRecordingUI()
  2495. }
  2496. }
  2497. @objc private func topBarAuthButtonTapped() {
  2498. if isUserLoggedIn() {
  2499. showProfileMenu()
  2500. } else {
  2501. zoomPrimarySignInTapped()
  2502. }
  2503. }
  2504. @objc private func profileMenuLogoutTapped() {
  2505. profileMenuPopover?.performClose(nil)
  2506. logoutTapped()
  2507. }
  2508. private func showProfileMenu() {
  2509. guard let button = topBarAuthButton else { return }
  2510. // Recreate so the popover always matches current light/dark mode.
  2511. profileMenuPopover = makeProfileMenuPopover()
  2512. guard let popover = profileMenuPopover else { return }
  2513. if popover.isShown {
  2514. popover.performClose(nil)
  2515. return
  2516. }
  2517. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
  2518. }
  2519. private func makeProfileMenuPopover() -> NSPopover {
  2520. let popover = NSPopover()
  2521. popover.behavior = .transient
  2522. popover.animates = true
  2523. let contentController = NSViewController()
  2524. let root = NSView(frame: NSRect(x: 0, y: 0, width: 198, height: 86))
  2525. root.wantsLayer = true
  2526. root.layer?.cornerRadius = 12
  2527. root.layer?.backgroundColor = (palette.isDarkMode
  2528. ? NSColor(calibratedRed: 30 / 255, green: 33 / 255, blue: 40 / 255, alpha: 1)
  2529. : NSColor(calibratedRed: 246 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
  2530. ).cgColor
  2531. root.layer?.borderWidth = 1
  2532. root.layer?.borderColor = (palette.isDarkMode
  2533. ? NSColor.white.withAlphaComponent(0.08)
  2534. : NSColor.black.withAlphaComponent(0.08)
  2535. ).cgColor
  2536. let logoutButton = NSButton(title: "Logout", target: self, action: #selector(profileMenuLogoutTapped))
  2537. logoutButton.translatesAutoresizingMaskIntoConstraints = false
  2538. logoutButton.isBordered = false
  2539. logoutButton.wantsLayer = true
  2540. logoutButton.layer?.cornerRadius = 9
  2541. logoutButton.layer?.backgroundColor = (palette.isDarkMode
  2542. ? NSColor.white.withAlphaComponent(0.08)
  2543. : NSColor(calibratedWhite: 0.0, alpha: 0.06)
  2544. ).cgColor
  2545. logoutButton.contentTintColor = (palette.isDarkMode
  2546. ? .white
  2547. : NSColor(calibratedRed: 22 / 255, green: 24 / 255, blue: 29 / 255, alpha: 1)
  2548. )
  2549. logoutButton.font = .systemFont(ofSize: 13, weight: .semibold)
  2550. logoutButton.alignment = .left
  2551. logoutButton.imagePosition = .imageLeading
  2552. logoutButton.imageHugsTitle = true
  2553. logoutButton.bezelStyle = .shadowlessSquare
  2554. logoutButton.title = " Logout"
  2555. if let symbol = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: "Logout") {
  2556. logoutButton.image = symbol.withSymbolConfiguration(.init(pointSize: 13, weight: .semibold))
  2557. }
  2558. let subtitle = makeLabel(
  2559. "Account",
  2560. size: 11,
  2561. color: palette.isDarkMode
  2562. ? NSColor.white.withAlphaComponent(0.72)
  2563. : NSColor(calibratedRed: 88 / 255, green: 92 / 255, blue: 102 / 255, alpha: 1),
  2564. weight: .medium,
  2565. centered: false
  2566. )
  2567. subtitle.translatesAutoresizingMaskIntoConstraints = false
  2568. root.addSubview(subtitle)
  2569. root.addSubview(logoutButton)
  2570. NSLayoutConstraint.activate([
  2571. subtitle.topAnchor.constraint(equalTo: root.topAnchor, constant: 10),
  2572. subtitle.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12),
  2573. subtitle.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
  2574. logoutButton.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 8),
  2575. logoutButton.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 10),
  2576. logoutButton.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -10),
  2577. logoutButton.heightAnchor.constraint(equalToConstant: 34)
  2578. ])
  2579. contentController.view = root
  2580. popover.contentViewController = contentController
  2581. popover.contentSize = root.frame.size
  2582. return popover
  2583. }
  2584. @objc private func topBarPlaceholderTapped() {
  2585. // Reserved for future titlebar control actions.
  2586. }
  2587. @objc private func upgradeToProTapped() {
  2588. if storeKitCoordinator.hasLifetimePremiumAccess {
  2589. openManageSubscriptions()
  2590. } else {
  2591. showPaywall()
  2592. }
  2593. }
  2594. private func openManageSubscriptions() {
  2595. if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
  2596. openWebURLPreferringInApp(url)
  2597. }
  2598. }
  2599. @objc private func refreshMeetingsTapped() {
  2600. guard isUserLoggedIn() else {
  2601. applyGuestLoginMessages()
  2602. return
  2603. }
  2604. guard ensurePremiumAccess() else {
  2605. applyPremiumLockMessages()
  2606. return
  2607. }
  2608. Task { @MainActor in
  2609. self.animateMeetingsRefresh()
  2610. self.setMeetingsLoadingUI(true)
  2611. }
  2612. triggerMeetingsRefresh(force: true)
  2613. }
  2614. @MainActor
  2615. private func setMeetingsLoadingUI(_ loading: Bool) {
  2616. refreshMeetingsButton?.isEnabled = loading == false
  2617. meetingsPageRefreshButton?.isEnabled = loading == false
  2618. if loading {
  2619. meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
  2620. }
  2621. }
  2622. @MainActor
  2623. private func animateMeetingsRefresh() {
  2624. guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
  2625. let views = stack.arrangedSubviews
  2626. let dimmed: CGFloat = 0.86
  2627. NSAnimationContext.runAnimationGroup { context in
  2628. context.duration = 0.07
  2629. context.timingFunction = CAMediaTimingFunction(name: .linear)
  2630. views.forEach { $0.animator().alphaValue = dimmed }
  2631. } completionHandler: {
  2632. NSAnimationContext.runAnimationGroup { context in
  2633. context.duration = 0.08
  2634. context.timingFunction = CAMediaTimingFunction(name: .linear)
  2635. views.forEach { $0.animator().alphaValue = 1.0 }
  2636. }
  2637. }
  2638. }
  2639. private func startMeetingsAutoRefresh() {
  2640. guard isUserLoggedIn() else { return }
  2641. meetingsRefreshTimer?.invalidate()
  2642. // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
  2643. meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
  2644. Task { @MainActor [weak self] in
  2645. self?.triggerMeetingsRefresh()
  2646. }
  2647. }
  2648. }
  2649. @MainActor
  2650. private func triggerMeetingsRefresh(force: Bool = false) {
  2651. guard isUserLoggedIn() else { return }
  2652. guard storeKitCoordinator.hasPremiumAccess else { return }
  2653. let now = Date()
  2654. if let until = meetingsRateLimitedUntil, now < until {
  2655. applyMeetingsRateLimitStatusLabels()
  2656. return
  2657. }
  2658. if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
  2659. return
  2660. }
  2661. if isLoadingMeetings { return }
  2662. lastMeetingsRefreshAt = now
  2663. isLoadingMeetings = true
  2664. meetingsLoadTask?.cancel()
  2665. meetingsLoadTask = Task { await self.loadScheduledMeetings() }
  2666. }
  2667. private func clearMeetingsScrollObserver() {
  2668. if let meetingsScrollObserver {
  2669. NotificationCenter.default.removeObserver(meetingsScrollObserver)
  2670. }
  2671. meetingsScrollObserver = nil
  2672. meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
  2673. meetingsScrollView = nil
  2674. }
  2675. private func clearMeetingsPageScrollObserver() {
  2676. if let meetingsPageScrollObserver {
  2677. NotificationCenter.default.removeObserver(meetingsPageScrollObserver)
  2678. }
  2679. meetingsPageScrollObserver = nil
  2680. meetingsPageScrollView?.contentView.postsBoundsChangedNotifications = false
  2681. meetingsPageScrollView = nil
  2682. }
  2683. private func removeSearchFieldObserver() {
  2684. if let searchTextObserver {
  2685. NotificationCenter.default.removeObserver(searchTextObserver)
  2686. }
  2687. searchTextObserver = nil
  2688. }
  2689. private func removeMeetingSearchObserver() {
  2690. if let meetingSearchTextObserver {
  2691. NotificationCenter.default.removeObserver(meetingSearchTextObserver)
  2692. }
  2693. meetingSearchTextObserver = nil
  2694. }
  2695. private func installSearchShortcutMonitor() {
  2696. removeSearchShortcutMonitor()
  2697. searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  2698. guard let self else { return event }
  2699. if event.keyCode == 53, self.meetingSearchOverlayView?.isHidden == false {
  2700. DispatchQueue.main.async {
  2701. self.hideMeetingSearchPopup()
  2702. }
  2703. return nil
  2704. }
  2705. guard event.modifierFlags.contains(.command),
  2706. event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
  2707. guard self.homeSearchPill != nil else { return event }
  2708. DispatchQueue.main.async {
  2709. self.showMeetingSearchPopup(seedQuery: self.meetingSearchField?.stringValue ?? "")
  2710. }
  2711. return nil
  2712. }
  2713. searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  2714. guard let self else { return event }
  2715. guard let window = self.view.window, event.window === window else { return event }
  2716. guard self.meetingSearchOverlayView?.isHidden == false else { return event }
  2717. guard let panel = self.meetingSearchPanelView else { return event }
  2718. let rectInWindow = panel.convert(panel.bounds, to: nil)
  2719. if rectInWindow.contains(event.locationInWindow) == false {
  2720. DispatchQueue.main.async {
  2721. self.hideMeetingSearchPopup()
  2722. }
  2723. }
  2724. return event
  2725. }
  2726. }
  2727. private func removeSearchShortcutMonitor() {
  2728. if let searchShortcutMonitor {
  2729. NSEvent.removeMonitor(searchShortcutMonitor)
  2730. }
  2731. searchShortcutMonitor = nil
  2732. if let searchOutsideClickMonitor {
  2733. NSEvent.removeMonitor(searchOutsideClickMonitor)
  2734. }
  2735. searchOutsideClickMonitor = nil
  2736. }
  2737. private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
  2738. guard let fr = window.firstResponder else { return false }
  2739. if fr === field { return true }
  2740. if let editor = field.currentEditor(), fr === editor { return true }
  2741. return false
  2742. }
  2743. @MainActor
  2744. private func applySearchPillFocusBorder(focused: Bool) {
  2745. homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
  2746. homeSearchPill?.layer?.borderColor = accentBlue.cgColor
  2747. }
  2748. @MainActor
  2749. private func focusHomeSearchField() {
  2750. guard let field = homeSearchField else { return }
  2751. view.window?.makeFirstResponder(field)
  2752. }
  2753. @MainActor
  2754. private func showMeetingSearchPopup(seedQuery: String) {
  2755. guard let overlay = meetingSearchOverlayView else { return }
  2756. overlay.isHidden = false
  2757. overlay.alphaValue = 0
  2758. NSAnimationContext.runAnimationGroup { context in
  2759. context.duration = 0.16
  2760. overlay.animator().alphaValue = 1
  2761. }
  2762. if let field = meetingSearchField {
  2763. field.stringValue = seedQuery
  2764. view.window?.makeFirstResponder(field)
  2765. }
  2766. applyGlobalMeetingSearch(query: seedQuery)
  2767. }
  2768. @MainActor
  2769. private func hideMeetingSearchPopup() {
  2770. guard let overlay = meetingSearchOverlayView else { return }
  2771. overlay.isHidden = true
  2772. overlay.alphaValue = 1
  2773. view.window?.makeFirstResponder(nil)
  2774. }
  2775. private func applyGlobalMeetingSearch(query: String) {
  2776. let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2777. let now = Date()
  2778. let dateFormatter = DateFormatter()
  2779. dateFormatter.dateStyle = .medium
  2780. dateFormatter.timeStyle = .short
  2781. let filtered: [ScheduledMeeting]
  2782. if trimmed.isEmpty {
  2783. filtered = allScheduledMeetings
  2784. } else {
  2785. filtered = allScheduledMeetings.filter { meeting in
  2786. let dateText = dateFormatter.string(from: meeting.start).lowercased()
  2787. return meeting.title.lowercased().contains(trimmed)
  2788. || meeting.host.lowercased().contains(trimmed)
  2789. || meeting.source.lowercased().contains(trimmed)
  2790. || (meeting.meetingID?.lowercased().contains(trimmed) ?? false)
  2791. || dateText.contains(trimmed)
  2792. }
  2793. }
  2794. globalSearchResults = filtered.sorted { lhs, rhs in
  2795. let lhsIsUpcoming = lhs.start >= now
  2796. let rhsIsUpcoming = rhs.start >= now
  2797. if lhsIsUpcoming != rhsIsUpcoming {
  2798. return lhsIsUpcoming && !rhsIsUpcoming
  2799. }
  2800. if lhsIsUpcoming {
  2801. return lhs.start < rhs.start
  2802. }
  2803. return lhs.start > rhs.start
  2804. }
  2805. renderMeetingSearchResults()
  2806. }
  2807. private func renderMeetingSearchResults() {
  2808. guard let stack = meetingSearchResultsStack else { return }
  2809. stack.arrangedSubviews.forEach { row in
  2810. stack.removeArrangedSubview(row)
  2811. row.removeFromSuperview()
  2812. }
  2813. meetingSearchEmptyLabel?.isHidden = globalSearchResults.isEmpty == false
  2814. for (index, meeting) in globalSearchResults.enumerated() {
  2815. let row = makeMeetingSearchResultRow(meeting)
  2816. row.tag = index
  2817. stack.addArrangedSubview(row)
  2818. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2819. }
  2820. }
  2821. private func makeMeetingSearchResultRow(_ meeting: ScheduledMeeting) -> NSButton {
  2822. let row = NSButton(title: "", target: self, action: #selector(meetingSearchResultTapped(_:)))
  2823. row.isBordered = false
  2824. row.bezelStyle = .regularSquare
  2825. row.setButtonType(.momentaryChange)
  2826. row.wantsLayer = true
  2827. row.layer?.backgroundColor = meetingCardBackground.cgColor
  2828. row.layer?.cornerRadius = 10
  2829. row.layer?.borderWidth = 1
  2830. row.layer?.borderColor = palette.inputBorder.cgColor
  2831. row.translatesAutoresizingMaskIntoConstraints = false
  2832. row.heightAnchor.constraint(equalToConstant: 84).isActive = true
  2833. let title = makeLabel(meeting.title, size: 14, color: primaryText, weight: .semibold, centered: false)
  2834. title.lineBreakMode = .byTruncatingTail
  2835. let subtitle = makeLabel(compactMeetingSummary(meeting), size: 12, color: secondaryText, weight: .regular, centered: false)
  2836. subtitle.lineBreakMode = .byTruncatingTail
  2837. let meta = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: mutedText, weight: .regular, centered: false)
  2838. meta.lineBreakMode = .byTruncatingTail
  2839. [title, subtitle, meta].forEach {
  2840. $0.translatesAutoresizingMaskIntoConstraints = false
  2841. row.addSubview($0)
  2842. }
  2843. NSLayoutConstraint.activate([
  2844. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  2845. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12),
  2846. title.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12),
  2847. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  2848. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2849. subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  2850. meta.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
  2851. meta.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2852. meta.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  2853. ])
  2854. return row
  2855. }
  2856. private func compactMeetingSummary(_ meeting: ScheduledMeeting) -> String {
  2857. let dateFormatter = DateFormatter()
  2858. dateFormatter.dateFormat = "EEE, MMM d"
  2859. let timeFormatter = DateFormatter()
  2860. timeFormatter.dateFormat = "h:mm a"
  2861. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  2862. dateFormatter.timeZone = displayTimeZone
  2863. timeFormatter.timeZone = displayTimeZone
  2864. let start = timeFormatter.string(from: meeting.start)
  2865. let end = meeting.end.map { timeFormatter.string(from: $0) }
  2866. let range = end.map { "\(start) - \($0)" } ?? start
  2867. if let id = meeting.meetingID, id.isEmpty == false {
  2868. return "\(dateFormatter.string(from: meeting.start)) • \(range) • ID \(id)"
  2869. }
  2870. return "\(dateFormatter.string(from: meeting.start)) • \(range)"
  2871. }
  2872. @objc private func topSearchPillTapped() {
  2873. showMeetingSearchPopup(seedQuery: meetingSearchField?.stringValue ?? "")
  2874. }
  2875. @objc private func meetingSearchResultTapped(_ sender: NSButton) {
  2876. guard let meeting = globalSearchResults[safe: sender.tag] else { return }
  2877. if let url = meeting.webURL {
  2878. openWebURLPreferringInApp(url, zoomMeetingDisplayName: meeting.title, recordingConsentWindowTitle: "Join Meeting")
  2879. } else {
  2880. let pasteboard = NSPasteboard.general
  2881. pasteboard.clearContents()
  2882. if let id = meeting.meetingID, id.isEmpty == false {
  2883. pasteboard.setString(id, forType: .string)
  2884. } else {
  2885. pasteboard.setString(meeting.title, forType: .string)
  2886. }
  2887. }
  2888. hideMeetingSearchPopup()
  2889. }
  2890. private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
  2891. clearMeetingsScrollObserver()
  2892. meetingsScrollView = scrollView
  2893. scrollView.contentView.postsBoundsChangedNotifications = true
  2894. meetingsScrollObserver = NotificationCenter.default.addObserver(
  2895. forName: NSView.boundsDidChangeNotification,
  2896. object: scrollView.contentView,
  2897. queue: .main
  2898. ) { [weak self, weak scrollView] _ in
  2899. guard let self, let scrollView else { return }
  2900. self.refreshMeetingsIfScrolledToEdge(scrollView)
  2901. }
  2902. }
  2903. private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
  2904. guard let documentView = scrollView.documentView else { return }
  2905. let visibleRect = scrollView.contentView.bounds
  2906. let contentHeight = documentView.bounds.height
  2907. let viewportHeight = visibleRect.height
  2908. if contentHeight <= viewportHeight + 2 {
  2909. return
  2910. }
  2911. let maxOffset = max(contentHeight - viewportHeight, 0)
  2912. let y = visibleRect.origin.y
  2913. let threshold: CGFloat = 12
  2914. let reachedTop = y <= threshold
  2915. let reachedBottom = y >= (maxOffset - threshold)
  2916. guard reachedTop || reachedBottom else { return }
  2917. let now = Date()
  2918. if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
  2919. return
  2920. }
  2921. lastScrollEdgeRefreshAt = now
  2922. triggerMeetingsRefresh(force: true)
  2923. }
  2924. private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
  2925. clearMeetingsPageScrollObserver()
  2926. meetingsPageScrollView = scrollView
  2927. scrollView.contentView.postsBoundsChangedNotifications = true
  2928. meetingsPageScrollObserver = NotificationCenter.default.addObserver(
  2929. forName: NSView.boundsDidChangeNotification,
  2930. object: scrollView.contentView,
  2931. queue: .main
  2932. ) { [weak self, weak scrollView] _ in
  2933. guard let self, let scrollView else { return }
  2934. self.loadMoreMeetingsIfNeededForScroll(scrollView)
  2935. }
  2936. }
  2937. private func loadMoreMeetingsIfNeededForScroll(_ scrollView: NSScrollView) {
  2938. guard meetingsPageVisibleCount < meetingsPageFilteredMeetings.count else { return }
  2939. guard let documentView = scrollView.documentView else { return }
  2940. let visibleRect = scrollView.contentView.bounds
  2941. let contentHeight = documentView.bounds.height
  2942. let viewportHeight = visibleRect.height
  2943. guard contentHeight > 0, viewportHeight > 0 else { return }
  2944. let maxOffset = max(contentHeight - viewportHeight, 0)
  2945. let y = visibleRect.origin.y
  2946. let threshold: CGFloat = 72
  2947. guard y >= (maxOffset - threshold) else { return }
  2948. let now = Date()
  2949. guard now.timeIntervalSince(lastMeetingsPageAutoLoadAt) >= meetingsPageAutoLoadCooldown else { return }
  2950. lastMeetingsPageAutoLoadAt = now
  2951. let nextCount = meetingsPageVisibleCount + meetingsPageSize
  2952. meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
  2953. renderMeetingsPageList()
  2954. }
  2955. @MainActor
  2956. private func resetLoginSigningInState() {
  2957. isSigningIn = false
  2958. googleButton?.title = "G"
  2959. updateTopBarAuthButton()
  2960. googleButton?.isEnabled = true
  2961. }
  2962. @MainActor
  2963. private func completeLoginAndRestart(profile: GoogleUserProfile?) {
  2964. persistLoggedInState(true)
  2965. let trimmedName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  2966. zoomSignedInDisplayName = trimmedName.isEmpty ? nil : trimmedName
  2967. showHomeView(profile: profile)
  2968. if let window = view.window {
  2969. window.makeKeyAndOrderFront(nil)
  2970. }
  2971. NSApp.activate(ignoringOtherApps: true)
  2972. }
  2973. @MainActor
  2974. private func updateTopBarAuthButton() {
  2975. guard let button = topBarAuthButton else { return }
  2976. let loggedIn = isUserLoggedIn()
  2977. if loggedIn {
  2978. button.title = topBarProfileInitial
  2979. button.toolTip = "Profile"
  2980. button.font = .systemFont(ofSize: 14, weight: .bold)
  2981. button.contentTintColor = primaryText
  2982. button.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
  2983. button.layer?.cornerRadius = 10
  2984. topBarAuthButtonWidthConstraint?.constant = 34
  2985. } else {
  2986. button.title = "Sign in with Zoom"
  2987. button.toolTip = "Sign in with Zoom"
  2988. button.font = .systemFont(ofSize: 12, weight: .semibold)
  2989. button.contentTintColor = .white
  2990. button.layer?.backgroundColor = accentBlue.cgColor
  2991. button.layer?.cornerRadius = 11
  2992. topBarAuthButtonWidthConstraint?.constant = 120
  2993. }
  2994. button.isEnabled = isSigningIn == false
  2995. }
  2996. @MainActor
  2997. private func applyGuestLoginMessages() {
  2998. let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
  2999. let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
  3000. let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
  3001. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  3002. emptyMeetingLabel?.isHidden = isHome == false
  3003. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  3004. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  3005. meetingsPageEmptyLabel?.isHidden = isMeetings == false
  3006. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  3007. meetingsPageCountLabel?.stringValue = "0 scheduled"
  3008. schedulerMeetingsEmptyLabel?.isHidden = isScheduler == false
  3009. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  3010. }
  3011. @MainActor
  3012. private func applyPremiumLockMessages() {
  3013. // Keep underlying section labels hidden while locked to avoid duplicate text beneath overlays.
  3014. meetingsStatusLabel?.stringValue = ""
  3015. emptyMeetingLabel?.isHidden = true
  3016. meetingsPageStatusLabel?.stringValue = ""
  3017. meetingsPageEmptyLabel?.isHidden = true
  3018. meetingsPageCountLabel?.stringValue = ""
  3019. schedulerMeetingsEmptyLabel?.isHidden = true
  3020. }
  3021. private func ensurePremiumAccess() -> Bool {
  3022. guard storeKitCoordinator.hasPremiumAccess else {
  3023. showPaywall()
  3024. return false
  3025. }
  3026. return true
  3027. }
  3028. @MainActor
  3029. private func applyAuthenticationUIState(triggerInitialRefresh: Bool) {
  3030. let loggedIn = isUserLoggedIn()
  3031. updateTopBarAuthButton()
  3032. settingsGoogleActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
  3033. settingsGoogleActionButton?.isEnabled = true
  3034. updatePremiumLockedSections()
  3035. meetingsRefreshTimer?.invalidate()
  3036. meetingsRefreshTimer = nil
  3037. if loggedIn == false {
  3038. meetingsRateLimitedUntil = nil
  3039. }
  3040. if loggedIn {
  3041. if storeKitCoordinator.hasPremiumAccess {
  3042. startMeetingsAutoRefresh()
  3043. if triggerInitialRefresh {
  3044. triggerMeetingsRefresh(force: false)
  3045. }
  3046. } else {
  3047. applyMeetings([])
  3048. applyPremiumLockMessages()
  3049. }
  3050. } else {
  3051. applyGuestLoginMessages()
  3052. }
  3053. }
  3054. @MainActor
  3055. private func applyMeetingsRateLimitStatusLabels() {
  3056. guard let until = meetingsRateLimitedUntil, until > Date() else { return }
  3057. let minutes = max(1, Int(ceil(until.timeIntervalSinceNow / 60.0)))
  3058. let message = "Zoom rate limit reached. Please try again in \(minutes) min."
  3059. meetingsStatusLabel?.stringValue = message
  3060. meetingsPageStatusLabel?.stringValue = message
  3061. }
  3062. @MainActor
  3063. private func persistZoomAccountTimeZone(_ timeZone: TimeZone?) {
  3064. zoomAccountTimeZone = timeZone
  3065. if let timeZone {
  3066. UserDefaults.standard.set(timeZone.identifier, forKey: Self.zoomAccountTimeZoneDefaultsKey)
  3067. } else {
  3068. UserDefaults.standard.removeObject(forKey: Self.zoomAccountTimeZoneDefaultsKey)
  3069. }
  3070. }
  3071. /// Returns false if the user cancelled or left credentials empty.
  3072. @MainActor
  3073. private func ensureZoomOAuthClientConfigured() -> Bool {
  3074. if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
  3075. return true
  3076. }
  3077. return presentZoomOAuthCredentialPrompt()
  3078. }
  3079. private func showSimpleError(_ title: String, error: Error) {
  3080. let alert = NSAlert()
  3081. alert.alertStyle = .warning
  3082. alert.messageText = title
  3083. alert.informativeText = error.localizedDescription
  3084. alert.runModal()
  3085. }
  3086. private func showSimpleAlert(title: String, message: String) {
  3087. let alert = NSAlert()
  3088. alert.alertStyle = .informational
  3089. alert.messageText = title
  3090. alert.informativeText = message
  3091. alert.runModal()
  3092. }
  3093. private struct ScheduledMeeting {
  3094. let title: String
  3095. let start: Date
  3096. let end: Date?
  3097. let meetingTimeZone: TimeZone?
  3098. let host: String
  3099. let source: String
  3100. let meetingID: String?
  3101. let typeLabel: String
  3102. let statusLabel: String
  3103. let webURL: URL?
  3104. }
  3105. @MainActor
  3106. private func applyMeetings(_ meetings: [ScheduledMeeting]) {
  3107. allScheduledMeetings = meetings
  3108. if meetings.isEmpty == false {
  3109. let calendar = Calendar.current
  3110. let selectedStart = calendar.startOfDay(for: selectedMeetingsDayStart)
  3111. let selectedEnd = calendar.date(byAdding: .day, value: 1, to: selectedStart) ?? selectedStart.addingTimeInterval(60 * 60 * 24)
  3112. let selectedDayHasMeetings = meetings.contains { meeting in
  3113. meeting.start >= selectedStart && meeting.start < selectedEnd
  3114. }
  3115. if selectedDayHasMeetings == false,
  3116. let firstUpcoming = meetings
  3117. .filter({ $0.start >= Date() })
  3118. .sorted(by: { $0.start < $1.start })
  3119. .first {
  3120. selectedMeetingsDayStart = calendar.startOfDay(for: firstUpcoming.start)
  3121. } else if selectedDayHasMeetings == false,
  3122. let latest = meetings.max(by: { $0.start < $1.start }) {
  3123. // All instances are in the past: jump to the most recent meeting day so the list is not stuck empty.
  3124. selectedMeetingsDayStart = calendar.startOfDay(for: latest.start)
  3125. }
  3126. }
  3127. applyFilteredMeetings()
  3128. applyMeetingsPageFilters(resetVisible: true)
  3129. updateWidgetMeetingStoreFromCurrentMeetings()
  3130. rescheduleMeetingRemindersIfNeeded()
  3131. }
  3132. @MainActor
  3133. private func applyFilteredMeetings() {
  3134. guard let stack = meetingsListStack else { return }
  3135. let showingHomeSection = selectedHomeSidebarItem == HomeSection.home.rawValue
  3136. stack.arrangedSubviews.forEach { view in
  3137. stack.removeArrangedSubview(view)
  3138. view.removeFromSuperview()
  3139. }
  3140. guard isUserLoggedIn() else {
  3141. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  3142. emptyMeetingLabel?.isHidden = showingHomeSection == false
  3143. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  3144. applySchedulerMeetingsForSelectedDate()
  3145. renderSchedulerCalendarGrid()
  3146. return
  3147. }
  3148. guard storeKitCoordinator.hasPremiumAccess else {
  3149. meetingsStatusLabel?.stringValue = ""
  3150. emptyMeetingLabel?.isHidden = true
  3151. applySchedulerMeetingsForSelectedDate()
  3152. renderSchedulerCalendarGrid()
  3153. return
  3154. }
  3155. if let until = meetingsRateLimitedUntil, until > Date() {
  3156. applyMeetingsRateLimitStatusLabels()
  3157. }
  3158. let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  3159. let calendar = Calendar.current
  3160. let dayStart = selectedMeetingsDayStart
  3161. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
  3162. let source = allScheduledMeetings.filter { meeting in
  3163. meeting.start >= dayStart && meeting.start < dayEnd
  3164. }
  3165. let filtered: [ScheduledMeeting]
  3166. if query.isEmpty {
  3167. filtered = source
  3168. } else {
  3169. filtered = source.filter { meeting in
  3170. meeting.title.lowercased().contains(query)
  3171. || meeting.host.lowercased().contains(query)
  3172. || meeting.source.lowercased().contains(query)
  3173. }
  3174. }
  3175. let ordered = filtered.sorted(by: { $0.start < $1.start })
  3176. if ordered.isEmpty {
  3177. emptyMeetingLabel?.isHidden = showingHomeSection == false
  3178. let isRateLimited = meetingsRateLimitedUntil.map { $0 > Date() } ?? false
  3179. if isRateLimited == false {
  3180. if source.isEmpty {
  3181. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  3182. emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
  3183. } else {
  3184. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  3185. emptyMeetingLabel?.stringValue = "No meetings match your search."
  3186. }
  3187. }
  3188. applySchedulerMeetingsForSelectedDate()
  3189. renderSchedulerCalendarGrid()
  3190. return
  3191. }
  3192. emptyMeetingLabel?.isHidden = true
  3193. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  3194. for meeting in ordered {
  3195. let card = makeMeetingRowCard(meeting)
  3196. stack.addArrangedSubview(card)
  3197. // Make each meeting card span the full list width (like Zoom).
  3198. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  3199. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3200. }
  3201. }
  3202. applySchedulerMeetingsForSelectedDate()
  3203. renderSchedulerCalendarGrid()
  3204. }
  3205. @MainActor
  3206. private func applyMeetingsPageFilters(resetVisible: Bool) {
  3207. guard isUserLoggedIn() else {
  3208. meetingsPageFilteredMeetings = []
  3209. meetingsPageVisibleCount = 0
  3210. renderMeetingsPageList()
  3211. return
  3212. }
  3213. guard storeKitCoordinator.hasPremiumAccess else {
  3214. meetingsPageFilteredMeetings = []
  3215. meetingsPageVisibleCount = 0
  3216. renderMeetingsPageList()
  3217. return
  3218. }
  3219. let calendar = Calendar.current
  3220. let now = Date()
  3221. let activePreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? meetingsPageSelectedRangePreset
  3222. meetingsPageSelectedRangePreset = activePreset
  3223. let activeFromDate: Date?
  3224. let activeToDate: Date?
  3225. if activePreset == "Custom range",
  3226. let fromPicker = meetingsPageFromDatePicker,
  3227. let toPicker = meetingsPageToDatePicker {
  3228. let fromDay = calendar.startOfDay(for: fromPicker.dateValue)
  3229. let toDay = calendar.startOfDay(for: toPicker.dateValue)
  3230. if fromDay <= toDay {
  3231. activeFromDate = fromDay
  3232. activeToDate = toDay
  3233. } else {
  3234. activeFromDate = toDay
  3235. activeToDate = fromDay
  3236. }
  3237. meetingsPageFromDateFilter = activeFromDate
  3238. meetingsPageToDateFilter = activeToDate
  3239. } else {
  3240. activeFromDate = meetingsPageFromDateFilter
  3241. activeToDate = meetingsPageToDateFilter
  3242. }
  3243. var filtered = allScheduledMeetings.filter { meeting in
  3244. guard meeting.start >= now else { return false }
  3245. switch activePreset {
  3246. case "Today":
  3247. let dayStart = calendar.startOfDay(for: now)
  3248. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now
  3249. return meeting.start >= now && meeting.start < dayEnd
  3250. case "This week":
  3251. let weekInterval = calendar.dateInterval(of: .weekOfYear, for: now)
  3252. let weekEnd = weekInterval?.end ?? now
  3253. return meeting.start >= now && meeting.start < weekEnd
  3254. case "This month":
  3255. let monthInterval = calendar.dateInterval(of: .month, for: now)
  3256. let monthEnd = monthInterval?.end ?? now
  3257. return meeting.start >= now && meeting.start < monthEnd
  3258. case "Custom range":
  3259. if let fromDate = activeFromDate {
  3260. let startOfFrom = calendar.startOfDay(for: fromDate)
  3261. if meeting.start < startOfFrom { return false }
  3262. }
  3263. if let toDate = activeToDate {
  3264. let startOfTo = calendar.startOfDay(for: toDate)
  3265. let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
  3266. if meeting.start >= endOfTo { return false }
  3267. }
  3268. return true
  3269. default:
  3270. return true
  3271. }
  3272. }
  3273. filtered.sort { $0.start < $1.start }
  3274. meetingsPageFilteredMeetings = filtered
  3275. if resetVisible {
  3276. meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
  3277. } else if filtered.count < meetingsPageVisibleCount {
  3278. meetingsPageVisibleCount = filtered.count
  3279. }
  3280. renderMeetingsPageList()
  3281. }
  3282. @MainActor
  3283. private func renderMeetingsPageList() {
  3284. guard let stack = meetingsPageListStack else { return }
  3285. stack.arrangedSubviews.forEach { view in
  3286. stack.removeArrangedSubview(view)
  3287. view.removeFromSuperview()
  3288. }
  3289. guard isUserLoggedIn() else {
  3290. meetingsPageCountLabel?.stringValue = "0 scheduled"
  3291. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  3292. meetingsPageEmptyLabel?.isHidden = false
  3293. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  3294. meetingsPageLoadMoreButton?.isHidden = true
  3295. return
  3296. }
  3297. guard storeKitCoordinator.hasPremiumAccess else {
  3298. meetingsPageCountLabel?.stringValue = ""
  3299. meetingsPageStatusLabel?.stringValue = ""
  3300. meetingsPageEmptyLabel?.isHidden = true
  3301. meetingsPageLoadMoreButton?.isHidden = true
  3302. return
  3303. }
  3304. let totalCount = meetingsPageFilteredMeetings.count
  3305. let visibleCount = min(meetingsPageVisibleCount, totalCount)
  3306. meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
  3307. if totalCount == 0 {
  3308. meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
  3309. meetingsPageEmptyLabel?.isHidden = false
  3310. meetingsPageLoadMoreButton?.isHidden = true
  3311. return
  3312. }
  3313. meetingsPageEmptyLabel?.isHidden = true
  3314. meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
  3315. for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
  3316. let card = makeMeetingRowCard(meeting)
  3317. stack.addArrangedSubview(card)
  3318. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  3319. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3320. }
  3321. }
  3322. meetingsPageLoadMoreButton?.isHidden = true
  3323. }
  3324. @objc private func meetingsPageApplyFiltersTapped() {
  3325. let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
  3326. meetingsPageSelectedRangePreset = selectedPreset
  3327. updateMeetingsDatePickersEnabledState()
  3328. if selectedPreset == "Custom range",
  3329. let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
  3330. let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
  3331. let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
  3332. if fromDay <= toDay {
  3333. meetingsPageFromDateFilter = fromDay
  3334. meetingsPageToDateFilter = toDay
  3335. } else {
  3336. meetingsPageFromDateFilter = toDay
  3337. meetingsPageToDateFilter = fromDay
  3338. }
  3339. } else {
  3340. meetingsPageFromDateFilter = nil
  3341. meetingsPageToDateFilter = nil
  3342. }
  3343. Task { @MainActor in
  3344. self.applyMeetingsPageFilters(resetVisible: true)
  3345. }
  3346. }
  3347. @objc private func meetingsPageResetFiltersTapped() {
  3348. meetingsPageSelectedRangePreset = "All"
  3349. meetingsPageFromDateFilter = nil
  3350. meetingsPageToDateFilter = nil
  3351. meetingsPageCategoryFilter?.selectItem(withTitle: "All")
  3352. meetingsPageFromDatePicker?.dateValue = Date()
  3353. meetingsPageToDatePicker?.dateValue = Date()
  3354. updateMeetingsDatePickersEnabledState()
  3355. Task { @MainActor in
  3356. self.applyMeetingsPageFilters(resetVisible: true)
  3357. }
  3358. }
  3359. @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
  3360. meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
  3361. updateMeetingsDatePickersEnabledState()
  3362. Task { @MainActor in
  3363. self.applyMeetingsPageFilters(resetVisible: true)
  3364. }
  3365. }
  3366. private func updateMeetingsDatePickersEnabledState() {
  3367. let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
  3368. meetingsPageFromDatePicker?.isEnabled = isCustomRange
  3369. meetingsPageToDatePicker?.isEnabled = isCustomRange
  3370. meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  3371. meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  3372. }
  3373. private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
  3374. let calendar = Calendar.current
  3375. let safeDayStart = calendar.startOfDay(for: dayStart)
  3376. let dayEnd = calendar.date(byAdding: .day, value: 1, to: safeDayStart) ?? safeDayStart.addingTimeInterval(60 * 60 * 24)
  3377. return allScheduledMeetings
  3378. .filter { meeting in
  3379. meeting.start >= safeDayStart && meeting.start < dayEnd
  3380. }
  3381. .sorted(by: { $0.start < $1.start })
  3382. }
  3383. @MainActor
  3384. private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
  3385. let calendar = Calendar.current
  3386. selectedMeetingsDayStart = calendar.startOfDay(for: newDayStart)
  3387. let monthComponents = calendar.dateComponents([.year, .month], from: selectedMeetingsDayStart)
  3388. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? selectedMeetingsDayStart
  3389. updateMeetingsDayUI()
  3390. applyFilteredMeetings()
  3391. }
  3392. @MainActor
  3393. private func updateMeetingsDayUI() {
  3394. let dayStart = selectedMeetingsDayStart
  3395. let formatter = DateFormatter()
  3396. formatter.dateFormat = "EEEE, MMM d"
  3397. meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
  3398. let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
  3399. meetingsTodayButton?.isEnabled = isToday == false
  3400. meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  3401. schedulerTodayButton?.isEnabled = isToday == false
  3402. schedulerTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  3403. updateSchedulerDateHeader()
  3404. applySchedulerMeetingsForSelectedDate()
  3405. renderSchedulerCalendarGrid()
  3406. }
  3407. private func meetingsDayDisplayName(for dayStart: Date) -> String {
  3408. let calendar = Calendar.current
  3409. if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
  3410. if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
  3411. calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
  3412. if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
  3413. calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
  3414. let formatter = DateFormatter()
  3415. formatter.dateFormat = "EEEE, MMM d"
  3416. return formatter.string(from: dayStart)
  3417. }
  3418. @objc private func meetingsPrevDayTapped() {
  3419. let calendar = Calendar.current
  3420. let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
  3421. Task { @MainActor in
  3422. self.setSelectedMeetingsDayStart(prev)
  3423. }
  3424. }
  3425. @objc private func meetingsNextDayTapped() {
  3426. let calendar = Calendar.current
  3427. let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
  3428. Task { @MainActor in
  3429. self.setSelectedMeetingsDayStart(next)
  3430. }
  3431. }
  3432. @objc private func meetingsTodayTapped() {
  3433. Task { @MainActor in
  3434. self.setSelectedMeetingsDayStart(Date())
  3435. }
  3436. }
  3437. private func updateSchedulerDateHeader() {
  3438. let formatter = DateFormatter()
  3439. formatter.dateFormat = "MMMM yyyy"
  3440. schedulerMonthLabel?.stringValue = formatter.string(from: schedulerVisibleMonthStart)
  3441. let selectedFormatter = DateFormatter()
  3442. selectedFormatter.dateFormat = "EEEE, MMM d"
  3443. schedulerSelectedDateLabel?.stringValue = selectedFormatter.string(from: selectedMeetingsDayStart)
  3444. }
  3445. @MainActor
  3446. private func applySchedulerMeetingsForSelectedDate() {
  3447. guard let stack = schedulerMeetingsListStack else { return }
  3448. stack.arrangedSubviews.forEach { view in
  3449. stack.removeArrangedSubview(view)
  3450. view.removeFromSuperview()
  3451. }
  3452. guard isUserLoggedIn() else {
  3453. schedulerMeetingsEmptyLabel?.isHidden = false
  3454. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  3455. return
  3456. }
  3457. guard storeKitCoordinator.hasPremiumAccess else {
  3458. schedulerMeetingsEmptyLabel?.isHidden = true
  3459. return
  3460. }
  3461. let meetings = meetingsForDay(selectedMeetingsDayStart)
  3462. if meetings.isEmpty {
  3463. schedulerMeetingsEmptyLabel?.isHidden = false
  3464. schedulerMeetingsEmptyLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: selectedMeetingsDayStart))."
  3465. return
  3466. }
  3467. schedulerMeetingsEmptyLabel?.isHidden = true
  3468. for meeting in meetings {
  3469. let card = makeMeetingRowCard(meeting)
  3470. stack.addArrangedSubview(card)
  3471. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  3472. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3473. }
  3474. }
  3475. }
  3476. @MainActor
  3477. private func renderSchedulerCalendarGrid() {
  3478. guard let gridStack = schedulerCalendarGridStack else { return }
  3479. schedulerDayButtons = [:]
  3480. schedulerDateByButtonTag = [:]
  3481. nextSchedulerButtonTag = 1
  3482. gridStack.arrangedSubviews.forEach { row in
  3483. gridStack.removeArrangedSubview(row)
  3484. row.removeFromSuperview()
  3485. }
  3486. let calendar = Calendar.current
  3487. let monthStart = schedulerVisibleMonthStart
  3488. let monthRange = calendar.range(of: .day, in: .month, for: monthStart) ?? 1..<32
  3489. let monthWeekday = calendar.component(.weekday, from: monthStart)
  3490. let leadingSlots = monthWeekday - 1
  3491. let totalSlots = 42
  3492. for week in 0..<6 {
  3493. let row = NSStackView()
  3494. row.orientation = .horizontal
  3495. row.distribution = .fillEqually
  3496. row.spacing = 6
  3497. row.translatesAutoresizingMaskIntoConstraints = false
  3498. gridStack.addArrangedSubview(row)
  3499. row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
  3500. for weekday in 0..<7 {
  3501. let cellIndex = (week * 7) + weekday
  3502. let dayOffset = cellIndex - leadingSlots
  3503. let dayDate = calendar.date(byAdding: .day, value: dayOffset, to: monthStart) ?? monthStart
  3504. let dayNumber = calendar.component(.day, from: dayDate)
  3505. let inCurrentMonth = monthRange.contains(dayNumber) && calendar.isDate(dayDate, equalTo: monthStart, toGranularity: .month)
  3506. let hasMeetings = isUserLoggedIn() && meetingsForDay(dayDate).isEmpty == false
  3507. let button = makeSchedulerDayCell(
  3508. day: dayNumber,
  3509. date: dayDate,
  3510. inCurrentMonth: inCurrentMonth,
  3511. selected: calendar.isDate(dayDate, inSameDayAs: selectedMeetingsDayStart),
  3512. isToday: calendar.isDateInToday(dayDate),
  3513. hasMeetings: hasMeetings
  3514. )
  3515. row.addArrangedSubview(button)
  3516. schedulerDayButtons[calendar.startOfDay(for: dayDate)] = button
  3517. }
  3518. }
  3519. // Keep a consistent 6x7 grid footprint.
  3520. if totalSlots == 42 {
  3521. gridStack.needsLayout = true
  3522. }
  3523. }
  3524. private func makeSchedulerDayCell(
  3525. day: Int,
  3526. date: Date,
  3527. inCurrentMonth: Bool,
  3528. selected: Bool,
  3529. isToday: Bool,
  3530. hasMeetings: Bool
  3531. ) -> NSButton {
  3532. let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
  3533. button.isBordered = false
  3534. button.bezelStyle = .shadowlessSquare
  3535. button.focusRingType = .none
  3536. button.font = .systemFont(ofSize: 16, weight: selected ? .semibold : .regular)
  3537. button.alignment = .center
  3538. button.wantsLayer = true
  3539. button.layer?.cornerRadius = 16
  3540. button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
  3541. let normalBackground = selected
  3542. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
  3543. : NSColor.clear
  3544. let hoverBackground = selected
  3545. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
  3546. : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
  3547. button.normalColor = normalBackground
  3548. button.hoverColor = hoverBackground
  3549. button.onHoverChanged = { [weak button] hovering in
  3550. guard let button, selected == false else { return }
  3551. if hovering {
  3552. button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
  3553. button.layer?.borderColor = (hasMeetings
  3554. ? self.accentBlue.withAlphaComponent(0.65)
  3555. : self.accentBlue.withAlphaComponent(0.45)
  3556. ).cgColor
  3557. } else if isToday {
  3558. button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
  3559. button.layer?.borderWidth = 1
  3560. } else if hasMeetings {
  3561. button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
  3562. button.layer?.borderWidth = 1
  3563. } else {
  3564. button.layer?.borderColor = NSColor.clear.cgColor
  3565. button.layer?.borderWidth = 0
  3566. }
  3567. }
  3568. if selected {
  3569. button.layer?.borderColor = accentBlue.cgColor
  3570. } else if isToday {
  3571. button.layer?.borderColor = accentOrange.withAlphaComponent(0.8).cgColor
  3572. button.layer?.borderWidth = 1
  3573. } else if hasMeetings {
  3574. button.layer?.borderColor = accentBlue.withAlphaComponent(0.45).cgColor
  3575. } else {
  3576. button.layer?.borderColor = NSColor.clear.cgColor
  3577. }
  3578. button.contentTintColor = selected
  3579. ? primaryText
  3580. : (inCurrentMonth ? primaryText : mutedText.withAlphaComponent(0.75))
  3581. let tag = nextSchedulerButtonTag
  3582. nextSchedulerButtonTag += 1
  3583. button.tag = tag
  3584. schedulerDateByButtonTag[tag] = Calendar.current.startOfDay(for: date)
  3585. button.translatesAutoresizingMaskIntoConstraints = false
  3586. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  3587. return button
  3588. }
  3589. @objc private func schedulerDayTapped(_ sender: NSButton) {
  3590. guard let selectedDate = schedulerDateByButtonTag[sender.tag] else { return }
  3591. Task { @MainActor in
  3592. self.setSelectedMeetingsDayStart(selectedDate)
  3593. }
  3594. }
  3595. @objc private func schedulerPrevMonthTapped() {
  3596. let calendar = Calendar.current
  3597. let prev = calendar.date(byAdding: .month, value: -1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  3598. let monthComponents = calendar.dateComponents([.year, .month], from: prev)
  3599. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? prev
  3600. updateSchedulerDateHeader()
  3601. renderSchedulerCalendarGrid()
  3602. }
  3603. @objc private func schedulerNextMonthTapped() {
  3604. let calendar = Calendar.current
  3605. let next = calendar.date(byAdding: .month, value: 1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  3606. let monthComponents = calendar.dateComponents([.year, .month], from: next)
  3607. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? next
  3608. updateSchedulerDateHeader()
  3609. renderSchedulerCalendarGrid()
  3610. }
  3611. @objc private func schedulerTodayTapped() {
  3612. Task { @MainActor in
  3613. self.setSelectedMeetingsDayStart(Date())
  3614. }
  3615. }
  3616. private func loadScheduledMeetings() async {
  3617. guard isUserLoggedIn() else {
  3618. await MainActor.run {
  3619. self.isLoadingMeetings = false
  3620. self.setMeetingsLoadingUI(false)
  3621. self.applyMeetings([])
  3622. self.applyGuestLoginMessages()
  3623. }
  3624. return
  3625. }
  3626. let hasPremium = await MainActor.run { self.storeKitCoordinator.hasPremiumAccess }
  3627. guard hasPremium else {
  3628. await MainActor.run {
  3629. self.isLoadingMeetings = false
  3630. self.setMeetingsLoadingUI(false)
  3631. self.applyMeetings([])
  3632. self.applyPremiumLockMessages()
  3633. }
  3634. return
  3635. }
  3636. let shouldSkipForRateLimit = await MainActor.run { () -> Bool in
  3637. if let until = self.meetingsRateLimitedUntil, until > Date() {
  3638. self.applyMeetingsRateLimitStatusLabels()
  3639. return true
  3640. }
  3641. self.setMeetingsLoadingUI(true)
  3642. return false
  3643. }
  3644. if shouldSkipForRateLimit {
  3645. await MainActor.run {
  3646. self.isLoadingMeetings = false
  3647. self.setMeetingsLoadingUI(false)
  3648. }
  3649. return
  3650. }
  3651. defer {
  3652. Task { @MainActor in
  3653. self.isLoadingMeetings = false
  3654. self.setMeetingsLoadingUI(false)
  3655. }
  3656. }
  3657. do {
  3658. let zoomToken = try await zoomOAuth.validAccessToken(
  3659. presentingWindow: view.window,
  3660. requiringAnyOf: ["meeting:read:list_meetings"]
  3661. )
  3662. let needsProfileTimeZone = await MainActor.run { self.zoomAccountTimeZone == nil }
  3663. if needsProfileTimeZone {
  3664. if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
  3665. let profileTZ = parseZoomTimeZone(profile.timezone)
  3666. await MainActor.run { self.persistZoomAccountTimeZone(profileTZ) }
  3667. }
  3668. }
  3669. let fallbackTZ = await MainActor.run { self.zoomAccountTimeZone }
  3670. let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: fallbackTZ)
  3671. await MainActor.run {
  3672. self.meetingsRateLimitedUntil = nil
  3673. self.applyMeetings(zoomMeetings)
  3674. }
  3675. } catch {
  3676. await MainActor.run {
  3677. if case ZoomOAuthError.missingClientId = error {
  3678. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3679. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3680. self.promptForZoomOAuthCredentialsIfNeeded()
  3681. } else if case ZoomOAuthError.missingClientSecret = error {
  3682. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3683. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  3684. self.promptForZoomOAuthCredentialsIfNeeded()
  3685. } else if case ZoomOAuthError.missingRequiredScope = error {
  3686. self.zoomOAuth.clearSavedTokens()
  3687. self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  3688. self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  3689. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  3690. let seconds = max(retryAfterSeconds ?? 300, 30)
  3691. self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
  3692. self.applyMeetingsRateLimitStatusLabels()
  3693. } else {
  3694. self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  3695. self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  3696. }
  3697. if case ZoomOAuthError.missingClientId = error {
  3698. self.applyMeetings([])
  3699. } else if case ZoomOAuthError.missingClientSecret = error {
  3700. self.applyMeetings([])
  3701. } else if case ZoomOAuthError.missingRequiredScope = error {
  3702. self.applyMeetings([])
  3703. }
  3704. self.applyFilteredMeetings()
  3705. self.applyMeetingsPageFilters(resetVisible: false)
  3706. }
  3707. }
  3708. }
  3709. @MainActor
  3710. private func presentZoomOAuthCredentialPrompt() -> Bool {
  3711. let alert = NSAlert()
  3712. alert.alertStyle = .informational
  3713. alert.messageText = "Configure Zoom OAuth"
  3714. 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)."
  3715. let wrapper = NSStackView()
  3716. wrapper.orientation = .vertical
  3717. wrapper.spacing = 8
  3718. wrapper.translatesAutoresizingMaskIntoConstraints = false
  3719. let clientIdField = NSTextField()
  3720. clientIdField.placeholderString = "Zoom Client ID"
  3721. clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
  3722. let clientSecretField = NSSecureTextField()
  3723. clientSecretField.placeholderString = "Zoom Client Secret"
  3724. clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
  3725. [clientIdField, clientSecretField].forEach { field in
  3726. field.translatesAutoresizingMaskIntoConstraints = false
  3727. field.widthAnchor.constraint(equalToConstant: 420).isActive = true
  3728. wrapper.addArrangedSubview(field)
  3729. }
  3730. alert.accessoryView = wrapper
  3731. alert.addButton(withTitle: "Save")
  3732. alert.addButton(withTitle: "Cancel")
  3733. let result = alert.runModal()
  3734. if result == .alertFirstButtonReturn {
  3735. var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3736. var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3737. if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
  3738. if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
  3739. if clientId.isEmpty == false, clientSecret.isEmpty == false {
  3740. zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
  3741. return true
  3742. }
  3743. meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
  3744. }
  3745. return false
  3746. }
  3747. @MainActor
  3748. private func promptForZoomOAuthCredentialsIfNeeded() {
  3749. guard isPromptingZoomCredentials == false else { return }
  3750. isPromptingZoomCredentials = true
  3751. defer { isPromptingZoomCredentials = false }
  3752. if presentZoomOAuthCredentialPrompt() {
  3753. meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
  3754. triggerMeetingsRefresh(force: true)
  3755. }
  3756. }
  3757. private struct ZoomUserMeResponse: Decodable {
  3758. let first_name: String?
  3759. let last_name: String?
  3760. let display_name: String?
  3761. let email: String?
  3762. let pic_url: String?
  3763. let timezone: String?
  3764. var displayName: String? {
  3765. if let display_name, display_name.isEmpty == false { return display_name }
  3766. let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
  3767. return parts.isEmpty ? nil : parts.joined(separator: " ")
  3768. }
  3769. var pictureURL: String? {
  3770. guard let pic_url, pic_url.isEmpty == false else { return nil }
  3771. return pic_url
  3772. }
  3773. }
  3774. private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
  3775. let url = URL(string: "https://api.zoom.us/v2/users/me")!
  3776. var request = URLRequest(url: url)
  3777. request.httpMethod = "GET"
  3778. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3779. let (data, response) = try await URLSession.shared.data(for: request)
  3780. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3781. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
  3782. }
  3783. return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
  3784. }
  3785. private struct ZoomZAKTokenResponse: Decodable {
  3786. let token: String
  3787. }
  3788. /// 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)).
  3789. private func fetchZoomUserZAK(accessToken: String) async throws -> String {
  3790. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/token")!
  3791. components.queryItems = [URLQueryItem(name: "type", value: "zak")]
  3792. guard let url = components.url else {
  3793. throw GoogleOAuthError.tokenExchangeFailed("Invalid ZAK request URL")
  3794. }
  3795. var request = URLRequest(url: url)
  3796. request.httpMethod = "GET"
  3797. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3798. let (data, response) = try await URLSession.shared.data(for: request)
  3799. guard let http = response as? HTTPURLResponse else {
  3800. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (ZAK)")
  3801. }
  3802. if http.statusCode == 429 {
  3803. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  3804. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3805. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3806. }
  3807. guard (200..<300).contains(http.statusCode) else {
  3808. let raw = String(data: data, encoding: .utf8) ?? "Failed to load ZAK"
  3809. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3810. throw ZoomOAuthError.missingRequiredScope(raw)
  3811. }
  3812. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3813. }
  3814. let decoded = try JSONDecoder().decode(ZoomZAKTokenResponse.self, from: data)
  3815. let trimmed = decoded.token.trimmingCharacters(in: .whitespacesAndNewlines)
  3816. guard trimmed.isEmpty == false else {
  3817. throw GoogleOAuthError.tokenExchangeFailed("Empty ZAK from Zoom")
  3818. }
  3819. return trimmed
  3820. }
  3821. private struct ZoomCreateMeetingBody: Encodable {
  3822. let topic: String
  3823. let type: Int
  3824. let start_time: String
  3825. let duration: Int
  3826. let timezone: String
  3827. }
  3828. private struct ZoomCreateMeetingAPIResult: Decodable {
  3829. let id: Int?
  3830. let join_url: String?
  3831. let start_url: String?
  3832. }
  3833. private struct ZoomInstantMeetingBody: Encodable {
  3834. let topic: String
  3835. let type: Int
  3836. }
  3837. /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
  3838. private func createZoomMeeting(accessToken: String, topic: String, startTimeUTC: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
  3839. let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
  3840. var request = URLRequest(url: url)
  3841. request.httpMethod = "POST"
  3842. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3843. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  3844. let body = ZoomCreateMeetingBody(
  3845. topic: topic,
  3846. type: 2,
  3847. start_time: startTimeUTC,
  3848. duration: durationMinutes,
  3849. timezone: timeZone.identifier
  3850. )
  3851. request.httpBody = try JSONEncoder().encode(body)
  3852. let (data, response) = try await URLSession.shared.data(for: request)
  3853. guard let http = response as? HTTPURLResponse else {
  3854. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
  3855. }
  3856. if http.statusCode == 429 {
  3857. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  3858. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3859. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3860. }
  3861. guard (200..<300).contains(http.statusCode) else {
  3862. let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
  3863. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3864. throw ZoomOAuthError.missingRequiredScope(raw)
  3865. }
  3866. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3867. }
  3868. return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
  3869. }
  3870. /// [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`.
  3871. private func createZoomInstantMeeting(accessToken: String, topic: String = "Instant meeting") async throws -> ZoomCreateMeetingAPIResult {
  3872. let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
  3873. var request = URLRequest(url: url)
  3874. request.httpMethod = "POST"
  3875. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3876. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  3877. request.httpBody = try JSONEncoder().encode(ZoomInstantMeetingBody(topic: topic, type: 1))
  3878. let (data, response) = try await URLSession.shared.data(for: request)
  3879. guard let http = response as? HTTPURLResponse else {
  3880. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
  3881. }
  3882. if http.statusCode == 429 {
  3883. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  3884. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3885. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3886. }
  3887. guard (200..<300).contains(http.statusCode) else {
  3888. let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
  3889. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3890. throw ZoomOAuthError.missingRequiredScope(raw)
  3891. }
  3892. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3893. }
  3894. return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
  3895. }
  3896. private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
  3897. struct ZoomMeeting: Decodable {
  3898. let id: Int?
  3899. let topic: String?
  3900. let start_time: String?
  3901. let timezone: String?
  3902. let duration: Int?
  3903. let host_id: String?
  3904. let type: Int?
  3905. let status: String?
  3906. let join_url: String?
  3907. }
  3908. struct ZoomMeetingsPage: Decodable {
  3909. let meetings: [ZoomMeeting]
  3910. let next_page_token: String?
  3911. }
  3912. let iso = ISO8601DateFormatter()
  3913. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  3914. let fallbackISO = ISO8601DateFormatter()
  3915. fallbackISO.formatOptions = [.withInternetDateTime]
  3916. func inferredTimeZone(from startRaw: String, explicitZoomTimezone: String?) -> TimeZone? {
  3917. if let parsedExplicit = parseZoomTimeZone(explicitZoomTimezone) {
  3918. return parsedExplicit
  3919. }
  3920. if let fallbackTimeZone { return fallbackTimeZone }
  3921. if startRaw.hasSuffix("Z") || startRaw.hasSuffix("z") {
  3922. return TimeZone(secondsFromGMT: 0)
  3923. }
  3924. let startPattern = #"([+-]\d{2}:?\d{2}|[+-]\d{2})$"#
  3925. guard let regex = try? NSRegularExpression(pattern: startPattern) else { return nil }
  3926. let nsRange = NSRange(startRaw.startIndex..<startRaw.endIndex, in: startRaw)
  3927. guard let match = regex.firstMatch(in: startRaw, options: [], range: nsRange),
  3928. let offsetRange = Range(match.range(at: 1), in: startRaw) else {
  3929. return nil
  3930. }
  3931. return parseZoomTimeZone(String(startRaw[offsetRange]))
  3932. }
  3933. func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
  3934. raw.compactMap { meeting in
  3935. guard let startRaw = meeting.start_time else { return nil }
  3936. let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
  3937. guard let start else { return nil }
  3938. let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
  3939. let meetingIDString = meeting.id.map(String.init)
  3940. let overrideZone = meetingIDString
  3941. .flatMap { self.meetingTimeZoneOverrides[$0] }
  3942. .flatMap { TimeZone(identifier: $0) }
  3943. let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
  3944. let webURL = embeddedWebClientURLForZoomMeeting(meetingId: meeting.id, joinURLString: meeting.join_url)
  3945. return ScheduledMeeting(
  3946. title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
  3947. start: start,
  3948. end: end,
  3949. meetingTimeZone: displayZone,
  3950. host: meeting.host_id ?? "Zoom Host",
  3951. source: "Zoom",
  3952. meetingID: meetingIDString,
  3953. typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
  3954. statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
  3955. webURL: webURL
  3956. )
  3957. }
  3958. }
  3959. var allMeetings: [ZoomMeeting] = []
  3960. var nextPageToken: String?
  3961. var pageCount = 0
  3962. repeat {
  3963. pageCount += 1
  3964. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  3965. var items: [URLQueryItem] = [
  3966. URLQueryItem(name: "type", value: "scheduled"),
  3967. URLQueryItem(name: "page_size", value: "30")
  3968. ]
  3969. if let nextPageToken, nextPageToken.isEmpty == false {
  3970. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  3971. }
  3972. components.queryItems = items
  3973. var request = URLRequest(url: components.url!)
  3974. request.httpMethod = "GET"
  3975. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3976. let (data, response) = try await URLSession.shared.data(for: request)
  3977. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3978. let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
  3979. if (response as? HTTPURLResponse)?.statusCode == 429 {
  3980. let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
  3981. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3982. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3983. }
  3984. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3985. throw ZoomOAuthError.missingRequiredScope(raw)
  3986. }
  3987. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3988. }
  3989. let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
  3990. allMeetings.append(contentsOf: decoded.meetings)
  3991. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  3992. nextPageToken = (token?.isEmpty == false) ? token : nil
  3993. } while nextPageToken != nil && pageCount < maxMeetingListPages
  3994. return mapMeetings(allMeetings)
  3995. }
  3996. // MARK: - AI Companion Meeting Summaries (Zoom API)
  3997. /// In-memory model for an AI Companion meeting summary. Mirrors the fields returned by
  3998. /// `GET /v2/meetings/meeting_summaries` and `GET /v2/meetings/{meetingId}/meeting_summary`.
  3999. /// Docs: https://developers.zoom.us/changelog/meetings/november-20-2023/
  4000. struct AICompanionSummary: Equatable {
  4001. struct SummarySection: Equatable {
  4002. let label: String
  4003. let summary: String
  4004. }
  4005. let meetingUUID: String?
  4006. let meetingID: String?
  4007. let meetingTopic: String?
  4008. let meetingHostEmail: String?
  4009. let meetingStartTime: Date?
  4010. let meetingEndTime: Date?
  4011. let summaryTitle: String?
  4012. let summaryOverview: String?
  4013. let summaryDetails: [SummarySection]
  4014. let nextSteps: [String]
  4015. let summaryContent: String?
  4016. let summaryDocURL: URL?
  4017. let summaryCreatedTime: Date?
  4018. let summaryLastModifiedTime: Date?
  4019. }
  4020. private struct ZoomMeetingSummaryAPIItem: Decodable {
  4021. struct SummaryDetail: Decodable {
  4022. let label: String?
  4023. let summary: String?
  4024. }
  4025. let meeting_host_id: String?
  4026. let meeting_host_email: String?
  4027. let meeting_uuid: String?
  4028. let meeting_id: Int64?
  4029. let meeting_topic: String?
  4030. let meeting_start_time: String?
  4031. let meeting_end_time: String?
  4032. let summary_start_time: String?
  4033. let summary_end_time: String?
  4034. let summary_created_time: String?
  4035. let summary_last_modified_time: String?
  4036. let summary_title: String?
  4037. let summary_overview: String?
  4038. let summary_details: [SummaryDetail]?
  4039. let next_steps: [String]?
  4040. let summary_content: String?
  4041. let summary_doc_url: String?
  4042. func toModel(using iso: ISO8601DateFormatter, fallback: ISO8601DateFormatter) -> ViewController.AICompanionSummary {
  4043. func parseDate(_ s: String?) -> Date? {
  4044. guard let s, s.isEmpty == false else { return nil }
  4045. return iso.date(from: s) ?? fallback.date(from: s)
  4046. }
  4047. let details: [AICompanionSummary.SummarySection] = (summary_details ?? []).compactMap { detail in
  4048. let label = (detail.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  4049. let body = (detail.summary ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  4050. if label.isEmpty && body.isEmpty { return nil }
  4051. return AICompanionSummary.SummarySection(label: label, summary: body)
  4052. }
  4053. let steps: [String] = (next_steps ?? [])
  4054. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  4055. .filter { $0.isEmpty == false }
  4056. let docURL: URL? = {
  4057. guard let raw = summary_doc_url?.trimmingCharacters(in: .whitespacesAndNewlines),
  4058. raw.isEmpty == false else { return nil }
  4059. return URL(string: raw)
  4060. }()
  4061. return AICompanionSummary(
  4062. meetingUUID: meeting_uuid,
  4063. meetingID: meeting_id.map(String.init),
  4064. meetingTopic: meeting_topic,
  4065. meetingHostEmail: meeting_host_email,
  4066. meetingStartTime: parseDate(meeting_start_time),
  4067. meetingEndTime: parseDate(meeting_end_time),
  4068. summaryTitle: summary_title,
  4069. summaryOverview: summary_overview,
  4070. summaryDetails: details,
  4071. nextSteps: steps,
  4072. summaryContent: summary_content,
  4073. summaryDocURL: docURL,
  4074. summaryCreatedTime: parseDate(summary_created_time),
  4075. summaryLastModifiedTime: parseDate(summary_last_modified_time)
  4076. )
  4077. }
  4078. }
  4079. /// Fetches AI Companion summaries: prefers `GET /v2/meetings/meeting_summaries` when the token has
  4080. /// `meeting:read:list_summaries`. User-managed apps usually lack that scope; we then list **past**
  4081. /// meetings via `meeting:read:list_meetings` and load each `meeting:read:summary`.
  4082. private func fetchZoomMeetingSummaries(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
  4083. if let bulk = try await fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: accessToken, from: from, to: to) {
  4084. return bulk
  4085. }
  4086. return try await fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: accessToken, from: from, to: to)
  4087. }
  4088. private func zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: Data, statusCode: Int) -> Bool {
  4089. guard (200..<300).contains(statusCode) == false else { return false }
  4090. // Bulk list is often unavailable; a generic 403 still means "try past meetings + per-meeting summary".
  4091. if statusCode == 403 { return true }
  4092. let raw = String(data: data, encoding: .utf8) ?? ""
  4093. if raw.localizedCaseInsensitiveContains("does not contain scopes") { return true }
  4094. if raw.localizedCaseInsensitiveContains("list_summaries") { return true }
  4095. if raw.localizedCaseInsensitiveContains("meeting_summaries") && raw.localizedCaseInsensitiveContains("scope") {
  4096. return true
  4097. }
  4098. return false
  4099. }
  4100. /// Returns `nil` when the token cannot use the bulk list endpoint (typical for user-managed apps).
  4101. private func fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary]? {
  4102. struct Page: Decodable {
  4103. let summaries: [ZoomMeetingSummaryAPIItem]?
  4104. let meeting_summaries: [ZoomMeetingSummaryAPIItem]?
  4105. let next_page_token: String?
  4106. }
  4107. let iso = ISO8601DateFormatter()
  4108. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  4109. let fallbackISO = ISO8601DateFormatter()
  4110. fallbackISO.formatOptions = [.withInternetDateTime]
  4111. let dayFormatter = DateFormatter()
  4112. dayFormatter.dateFormat = "yyyy-MM-dd"
  4113. dayFormatter.timeZone = TimeZone(identifier: "UTC")
  4114. var collected: [AICompanionSummary] = []
  4115. var seenUUIDs = Set<String>()
  4116. var nextPageToken: String?
  4117. var safetyCounter = 0
  4118. repeat {
  4119. var components = URLComponents(string: "https://api.zoom.us/v2/meetings/meeting_summaries")!
  4120. var items: [URLQueryItem] = [
  4121. URLQueryItem(name: "page_size", value: "30")
  4122. ]
  4123. if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
  4124. if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
  4125. if let nextPageToken, nextPageToken.isEmpty == false {
  4126. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  4127. }
  4128. components.queryItems = items
  4129. var request = URLRequest(url: components.url!)
  4130. request.httpMethod = "GET"
  4131. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4132. let (data, response) = try await URLSession.shared.data(for: request)
  4133. guard let http = response as? HTTPURLResponse else {
  4134. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
  4135. }
  4136. if http.statusCode == 429 {
  4137. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4138. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4139. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4140. }
  4141. guard (200..<300).contains(http.statusCode) else {
  4142. if safetyCounter == 0, zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: data, statusCode: http.statusCode) {
  4143. return nil
  4144. }
  4145. let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summaries"
  4146. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4147. throw ZoomOAuthError.missingRequiredScope(raw)
  4148. }
  4149. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4150. }
  4151. let decoded = try JSONDecoder().decode(Page.self, from: data)
  4152. let raw = decoded.summaries ?? decoded.meeting_summaries ?? []
  4153. for item in raw {
  4154. let model = item.toModel(using: iso, fallback: fallbackISO)
  4155. let dedupKey = model.meetingUUID ?? (model.meetingID ?? UUID().uuidString)
  4156. if seenUUIDs.insert(dedupKey).inserted {
  4157. collected.append(model)
  4158. }
  4159. }
  4160. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  4161. nextPageToken = (token?.isEmpty == false) ? token : nil
  4162. safetyCounter += 1
  4163. if safetyCounter >= 10 { break }
  4164. } while nextPageToken != nil
  4165. return collected
  4166. }
  4167. private func fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
  4168. struct PastMeeting: Decodable {
  4169. let id: Int?
  4170. let uuid: String?
  4171. let topic: String?
  4172. let start_time: String?
  4173. }
  4174. struct PastMeetingsPage: Decodable {
  4175. let meetings: [PastMeeting]
  4176. let next_page_token: String?
  4177. }
  4178. let iso = ISO8601DateFormatter()
  4179. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  4180. let fallbackISO = ISO8601DateFormatter()
  4181. fallbackISO.formatOptions = [.withInternetDateTime]
  4182. let dayFormatter = DateFormatter()
  4183. dayFormatter.dateFormat = "yyyy-MM-dd"
  4184. dayFormatter.timeZone = TimeZone(identifier: "UTC")
  4185. func parseStart(_ raw: String?) -> Date? {
  4186. guard let raw, raw.isEmpty == false else { return nil }
  4187. return iso.date(from: raw) ?? fallbackISO.date(from: raw)
  4188. }
  4189. func isInSelectedRange(start: Date?) -> Bool {
  4190. guard let start else { return true }
  4191. if let from, start < from { return false }
  4192. if let to, start > to { return false }
  4193. return true
  4194. }
  4195. var candidates: [(meetingId: String, start: Date?)] = []
  4196. var nextPageToken: String?
  4197. var pageIdx = 0
  4198. repeat {
  4199. // Zoom expects `previous_meetings` (not `past`) for past instances; see List meetings API.
  4200. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  4201. var items: [URLQueryItem] = [
  4202. URLQueryItem(name: "type", value: "previous_meetings"),
  4203. URLQueryItem(name: "page_size", value: "30")
  4204. ]
  4205. if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
  4206. if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
  4207. if let nextPageToken, nextPageToken.isEmpty == false {
  4208. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  4209. }
  4210. components.queryItems = items
  4211. var request = URLRequest(url: components.url!)
  4212. request.httpMethod = "GET"
  4213. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4214. let (data, response) = try await URLSession.shared.data(for: request)
  4215. guard let http = response as? HTTPURLResponse else {
  4216. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (past meetings)")
  4217. }
  4218. if http.statusCode == 429 {
  4219. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4220. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4221. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4222. }
  4223. guard (200..<300).contains(http.statusCode) else {
  4224. let raw = String(data: data, encoding: .utf8) ?? "Failed to load past meetings"
  4225. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4226. throw ZoomOAuthError.missingRequiredScope(raw)
  4227. }
  4228. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4229. }
  4230. let decoded = try JSONDecoder().decode(PastMeetingsPage.self, from: data)
  4231. for m in decoded.meetings {
  4232. guard let numericId = m.id else { continue }
  4233. let start = parseStart(m.start_time)
  4234. guard isInSelectedRange(start: start) else { continue }
  4235. candidates.append((String(numericId), start))
  4236. }
  4237. let t = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  4238. nextPageToken = (t?.isEmpty == false) ? t : nil
  4239. pageIdx += 1
  4240. if pageIdx >= 15 { break }
  4241. if candidates.count >= 80 { break }
  4242. } while nextPageToken != nil
  4243. candidates.sort { ($0.start ?? .distantPast) > ($1.start ?? .distantPast) }
  4244. var summaries: [AICompanionSummary] = []
  4245. let maxDetailFetches = 40
  4246. for c in candidates.prefix(maxDetailFetches) {
  4247. if let detail = try await fetchZoomMeetingSummaryDetail(accessToken: accessToken, meetingIdOrUUID: c.meetingId) {
  4248. summaries.append(detail)
  4249. }
  4250. }
  4251. return summaries
  4252. }
  4253. /// Fetches a single AI Companion summary by meeting ID or UUID via
  4254. /// `GET /v2/meetings/{meetingId}/meeting_summary`. UUIDs containing `/` or `//` must be
  4255. /// double-URL-encoded per Zoom's API conventions.
  4256. private func fetchZoomMeetingSummaryDetail(accessToken: String, meetingIdOrUUID: String) async throws -> AICompanionSummary? {
  4257. let trimmed = meetingIdOrUUID.trimmingCharacters(in: .whitespacesAndNewlines)
  4258. guard trimmed.isEmpty == false else { return nil }
  4259. let isLikelyUUID = trimmed.contains("/") || trimmed.contains("=")
  4260. let pathSegment: String
  4261. if isLikelyUUID {
  4262. // Zoom requires double encoding for UUIDs that begin with `/` or contain `//`.
  4263. let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
  4264. let once = trimmed.addingPercentEncoding(withAllowedCharacters: allowed) ?? trimmed
  4265. pathSegment = once.addingPercentEncoding(withAllowedCharacters: allowed) ?? once
  4266. } else {
  4267. pathSegment = trimmed
  4268. }
  4269. let urlString = "https://api.zoom.us/v2/meetings/\(pathSegment)/meeting_summary"
  4270. guard let url = URL(string: urlString) else {
  4271. throw GoogleOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
  4272. }
  4273. var request = URLRequest(url: url)
  4274. request.httpMethod = "GET"
  4275. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  4276. let (data, response) = try await URLSession.shared.data(for: request)
  4277. guard let http = response as? HTTPURLResponse else {
  4278. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
  4279. }
  4280. if http.statusCode == 404 || http.statusCode == 204 { return nil }
  4281. if http.statusCode == 429 {
  4282. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  4283. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  4284. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  4285. }
  4286. guard (200..<300).contains(http.statusCode) else {
  4287. let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summary"
  4288. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  4289. throw ZoomOAuthError.missingRequiredScope(raw)
  4290. }
  4291. throw GoogleOAuthError.tokenExchangeFailed(raw)
  4292. }
  4293. let iso = ISO8601DateFormatter()
  4294. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  4295. let fallbackISO = ISO8601DateFormatter()
  4296. fallbackISO.formatOptions = [.withInternetDateTime]
  4297. let item = try JSONDecoder().decode(ZoomMeetingSummaryAPIItem.self, from: data)
  4298. return item.toModel(using: iso, fallback: fallbackISO)
  4299. }
  4300. private func makeSettingsView() -> NSView {
  4301. let panel = NSView()
  4302. panel.translatesAutoresizingMaskIntoConstraints = false
  4303. let scroll = NSScrollView()
  4304. scroll.translatesAutoresizingMaskIntoConstraints = false
  4305. scroll.drawsBackground = false
  4306. scroll.hasHorizontalScroller = false
  4307. scroll.hasVerticalScroller = true
  4308. scroll.autohidesScrollers = true
  4309. scroll.borderType = .noBorder
  4310. scroll.scrollerStyle = .overlay
  4311. scroll.automaticallyAdjustsContentInsets = false
  4312. let clip = TopAlignedClipView()
  4313. clip.drawsBackground = false
  4314. scroll.contentView = clip
  4315. panel.addSubview(scroll)
  4316. let content = NSView()
  4317. content.translatesAutoresizingMaskIntoConstraints = false
  4318. scroll.documentView = content
  4319. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  4320. card.translatesAutoresizingMaskIntoConstraints = false
  4321. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  4322. content.addSubview(card)
  4323. let stack = NSStackView()
  4324. stack.translatesAutoresizingMaskIntoConstraints = false
  4325. stack.orientation = .vertical
  4326. stack.spacing = 18
  4327. stack.alignment = .leading
  4328. card.addSubview(stack)
  4329. let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
  4330. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  4331. stack.addArrangedSubview(pageTitle)
  4332. stack.addArrangedSubview(pageSubtitle)
  4333. stack.setCustomSpacing(24, after: pageSubtitle)
  4334. let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4335. stack.addArrangedSubview(appearanceTitle)
  4336. let darkModeRow = makeSettingsDarkModeRow()
  4337. stack.addArrangedSubview(darkModeRow)
  4338. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4339. stack.setCustomSpacing(24, after: darkModeRow)
  4340. let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4341. stack.addArrangedSubview(accountTitle)
  4342. let googleAccountRow = makeSettingsGoogleAccountRow()
  4343. stack.addArrangedSubview(googleAccountRow)
  4344. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4345. stack.setCustomSpacing(24, after: googleAccountRow)
  4346. let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4347. stack.addArrangedSubview(appTitle)
  4348. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  4349. stack.addArrangedSubview(shareButton)
  4350. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4351. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  4352. stack.addArrangedSubview(upgradeButton)
  4353. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4354. settingsUpgradeButton = upgradeButton
  4355. stack.setCustomSpacing(24, after: upgradeButton)
  4356. let notificationsTitle = textLabel("Notifications", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4357. let remindersSection = makeSettingsRemindersSection()
  4358. let notificationsBlock = NSStackView()
  4359. notificationsBlock.translatesAutoresizingMaskIntoConstraints = false
  4360. notificationsBlock.orientation = .vertical
  4361. notificationsBlock.spacing = 18
  4362. notificationsBlock.alignment = .leading
  4363. notificationsBlock.addArrangedSubview(notificationsTitle)
  4364. notificationsBlock.addArrangedSubview(remindersSection)
  4365. remindersSection.widthAnchor.constraint(equalTo: notificationsBlock.widthAnchor).isActive = true
  4366. stack.addArrangedSubview(notificationsBlock)
  4367. notificationsBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4368. settingsNotificationsBlock = notificationsBlock
  4369. stack.setCustomSpacing(24, after: notificationsBlock)
  4370. let transcriptionTitle = textLabel("Ai Assistant transcription", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4371. let transcriptionSubtitle = textLabel(
  4372. "Preferred languages are used for Apple on-device speech recognition after each meeting recording.",
  4373. font: .systemFont(ofSize: 12, weight: .regular),
  4374. color: secondaryText
  4375. )
  4376. transcriptionSubtitle.maximumNumberOfLines = 0
  4377. transcriptionSubtitle.lineBreakMode = .byWordWrapping
  4378. let transcriptionBlock = NSStackView()
  4379. transcriptionBlock.translatesAutoresizingMaskIntoConstraints = false
  4380. transcriptionBlock.orientation = .vertical
  4381. transcriptionBlock.spacing = 12
  4382. transcriptionBlock.alignment = .leading
  4383. transcriptionBlock.addArrangedSubview(transcriptionTitle)
  4384. transcriptionBlock.addArrangedSubview(transcriptionSubtitle)
  4385. let lang1Row = makeSettingsSpeechLanguageRow(isPrimary: true)
  4386. let lang2Row = makeSettingsSpeechLanguageRow(isPrimary: false)
  4387. transcriptionBlock.addArrangedSubview(lang1Row)
  4388. transcriptionBlock.addArrangedSubview(lang2Row)
  4389. lang1Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
  4390. lang2Row.widthAnchor.constraint(equalTo: transcriptionBlock.widthAnchor).isActive = true
  4391. stack.addArrangedSubview(transcriptionBlock)
  4392. transcriptionBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4393. stack.setCustomSpacing(24, after: transcriptionBlock)
  4394. let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  4395. stack.addArrangedSubview(legalTitle)
  4396. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  4397. stack.addArrangedSubview(privacyButton)
  4398. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4399. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  4400. stack.addArrangedSubview(supportButton)
  4401. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4402. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  4403. stack.addArrangedSubview(termsButton)
  4404. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  4405. NSLayoutConstraint.activate([
  4406. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  4407. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  4408. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  4409. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  4410. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  4411. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  4412. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  4413. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  4414. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  4415. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  4416. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
  4417. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  4418. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  4419. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  4420. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  4421. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  4422. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  4423. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  4424. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  4425. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  4426. ])
  4427. updatePremiumButtons()
  4428. return panel
  4429. }
  4430. @MainActor
  4431. private func rebuildHomeSettingsViewPreservingSelection() {
  4432. guard let column = homeSettingsHostColumn else { return }
  4433. homeSettingsView?.removeFromSuperview()
  4434. let newSettings = makeSettingsView()
  4435. newSettings.translatesAutoresizingMaskIntoConstraints = false
  4436. column.addSubview(newSettings)
  4437. NSLayoutConstraint.activate([
  4438. newSettings.topAnchor.constraint(equalTo: column.topAnchor),
  4439. newSettings.leadingAnchor.constraint(equalTo: column.leadingAnchor),
  4440. newSettings.trailingAnchor.constraint(equalTo: column.trailingAnchor),
  4441. newSettings.bottomAnchor.constraint(equalTo: column.bottomAnchor)
  4442. ])
  4443. homeSettingsView = newSettings
  4444. updateSelectedHomeSectionUI()
  4445. }
  4446. private enum SettingsAction: Int {
  4447. case shareApp = 1
  4448. case upgrade = 2
  4449. case privacyPolicy = 4
  4450. case support = 5
  4451. case termsOfServices = 6
  4452. }
  4453. private func makeSettingsDarkModeRow() -> NSView {
  4454. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4455. row.translatesAutoresizingMaskIntoConstraints = false
  4456. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  4457. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4458. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  4459. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  4460. let toggle = NSSwitch()
  4461. toggle.translatesAutoresizingMaskIntoConstraints = false
  4462. toggle.state = darkModeEnabled ? .on : .off
  4463. toggle.target = self
  4464. toggle.action = #selector(settingsDarkModeToggled(_:))
  4465. settingsDarkModeSwitch = toggle
  4466. row.addSubview(icon)
  4467. row.addSubview(title)
  4468. row.addSubview(toggle)
  4469. NSLayoutConstraint.activate([
  4470. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4471. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4472. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  4473. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4474. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4475. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4476. ])
  4477. return row
  4478. }
  4479. private func makeSettingsSpeechLanguageRow(isPrimary: Bool) -> NSView {
  4480. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4481. row.translatesAutoresizingMaskIntoConstraints = false
  4482. row.heightAnchor.constraint(equalToConstant: 72).isActive = true
  4483. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4484. let titleText = isPrimary ? "Preferred Language 1" : "Preferred Language 2"
  4485. let titleLabel = textLabel(titleText, font: .systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  4486. let popup = NSPopUpButton(frame: .zero, pullsDown: false)
  4487. popup.translatesAutoresizingMaskIntoConstraints = false
  4488. popup.target = self
  4489. popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
  4490. let options = TranscriptionLanguagePreferences.supportedSpeechLocaleOptions()
  4491. if isPrimary == false {
  4492. popup.addItem(withTitle: "None")
  4493. popup.lastItem?.representedObject = ""
  4494. }
  4495. for option in options {
  4496. popup.addItem(withTitle: option.displayName)
  4497. popup.lastItem?.representedObject = option.identifier
  4498. }
  4499. if isPrimary {
  4500. let selected = TranscriptionLanguagePreferences.selectedPrimaryIdentifier()
  4501. ?? options.first?.identifier
  4502. ?? Locale.current.identifier
  4503. selectTranscriptionLanguage(identifier: selected, in: popup)
  4504. settingsPagePrimaryLanguagePopup = popup
  4505. } else {
  4506. if let selected = TranscriptionLanguagePreferences.selectedSecondaryIdentifier() {
  4507. selectTranscriptionLanguage(identifier: selected, in: popup)
  4508. } else {
  4509. popup.selectItem(at: 0)
  4510. }
  4511. settingsPageSecondaryLanguagePopup = popup
  4512. }
  4513. row.addSubview(titleLabel)
  4514. row.addSubview(popup)
  4515. NSLayoutConstraint.activate([
  4516. titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4517. titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  4518. titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4519. popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4520. popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4521. popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
  4522. popup.heightAnchor.constraint(equalToConstant: 28)
  4523. ])
  4524. return row
  4525. }
  4526. @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
  4527. guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
  4528. primary.isEmpty == false else { return }
  4529. var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
  4530. if secondary?.isEmpty == true {
  4531. secondary = nil
  4532. }
  4533. if let secondaryValue = secondary,
  4534. secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
  4535. secondary = nil
  4536. settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
  4537. }
  4538. TranscriptionLanguagePreferences.updatePreferredLanguages(primary: primary, secondary: secondary)
  4539. }
  4540. private func makeSettingsRemindersSection() -> NSView {
  4541. let container = NSStackView()
  4542. container.translatesAutoresizingMaskIntoConstraints = false
  4543. container.orientation = .vertical
  4544. container.spacing = 1
  4545. container.alignment = .leading
  4546. let masterRow = makeReminderToggleRow(
  4547. icon: "🔔",
  4548. title: "Reminders",
  4549. state: remindersEnabled,
  4550. action: #selector(settingsReminderMasterToggled(_:)),
  4551. isSubRow: false
  4552. )
  4553. container.addArrangedSubview(masterRow)
  4554. masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4555. let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
  4556. let day1Row = makeReminderToggleRow(
  4557. icon: "📅",
  4558. title: "1 Day Before",
  4559. state: remind1Day,
  4560. action: #selector(settingsReminder1DayToggled(_:)),
  4561. isSubRow: true
  4562. )
  4563. day1Row.alphaValue = subAlpha
  4564. container.addArrangedSubview(day1Row)
  4565. day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4566. let hours12Row = makeReminderToggleRow(
  4567. icon: "🕛",
  4568. title: "12 Hours Before",
  4569. state: remind12Hours,
  4570. action: #selector(settingsReminder12HoursToggled(_:)),
  4571. isSubRow: true
  4572. )
  4573. hours12Row.alphaValue = subAlpha
  4574. container.addArrangedSubview(hours12Row)
  4575. hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4576. let hour1Row = makeReminderToggleRow(
  4577. icon: "⏰",
  4578. title: "1 Hour Before",
  4579. state: remind1Hour,
  4580. action: #selector(settingsReminder1HourToggled(_:)),
  4581. isSubRow: true
  4582. )
  4583. hour1Row.alphaValue = subAlpha
  4584. container.addArrangedSubview(hour1Row)
  4585. hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  4586. // Keep references so toggles can update UI without rebuilding the whole settings view.
  4587. if let masterSwitch = (masterRow.subviews.compactMap { $0 as? NSSwitch }.first) {
  4588. settingsRemindersMasterSwitch = masterSwitch
  4589. }
  4590. if let day1Switch = (day1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  4591. settingsReminders1DaySwitch = day1Switch
  4592. }
  4593. if let hours12Switch = (hours12Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  4594. settingsReminders12HoursSwitch = hours12Switch
  4595. }
  4596. if let hour1Switch = (hour1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  4597. settingsReminders1HourSwitch = hour1Switch
  4598. }
  4599. settingsReminders1DayRow = day1Row
  4600. settingsReminders12HoursRow = hours12Row
  4601. settingsReminders1HourRow = hour1Row
  4602. return container
  4603. }
  4604. private func makeReminderToggleRow(
  4605. icon: String,
  4606. title: String,
  4607. state: Bool,
  4608. action: Selector,
  4609. isSubRow: Bool
  4610. ) -> NSView {
  4611. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4612. row.translatesAutoresizingMaskIntoConstraints = false
  4613. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  4614. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4615. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  4616. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: isSubRow ? .medium : .semibold), color: primaryText)
  4617. let toggle = NSSwitch()
  4618. toggle.translatesAutoresizingMaskIntoConstraints = false
  4619. toggle.state = state ? .on : .off
  4620. toggle.target = self
  4621. toggle.action = action
  4622. row.addSubview(iconLabel)
  4623. row.addSubview(titleLabel)
  4624. row.addSubview(toggle)
  4625. let leading: CGFloat = isSubRow ? 26 : 14
  4626. NSLayoutConstraint.activate([
  4627. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: leading),
  4628. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4629. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  4630. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  4631. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4632. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4633. ])
  4634. return row
  4635. }
  4636. private func makeSettingsGoogleAccountRow() -> NSView {
  4637. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  4638. row.translatesAutoresizingMaskIntoConstraints = false
  4639. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4640. let signedIn = isUserLoggedIn()
  4641. let titleText = signedIn ? "Zoom account connected" : "Zoom account not connected"
  4642. let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
  4643. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  4644. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  4645. subtitle.maximumNumberOfLines = 2
  4646. subtitle.lineBreakMode = .byTruncatingTail
  4647. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  4648. actionButton.translatesAutoresizingMaskIntoConstraints = false
  4649. actionButton.bezelStyle = .rounded
  4650. actionButton.controlSize = .regular
  4651. settingsGoogleActionButton = actionButton
  4652. row.addSubview(title)
  4653. row.addSubview(subtitle)
  4654. row.addSubview(actionButton)
  4655. NSLayoutConstraint.activate([
  4656. row.heightAnchor.constraint(equalToConstant: 78),
  4657. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  4658. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  4659. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  4660. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  4661. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  4662. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  4663. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  4664. ])
  4665. return row
  4666. }
  4667. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  4668. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  4669. button.translatesAutoresizingMaskIntoConstraints = false
  4670. button.isBordered = false
  4671. button.wantsLayer = true
  4672. button.layer?.cornerRadius = 10
  4673. button.normalColor = palette.inputBackground
  4674. button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
  4675. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4676. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  4677. button.tag = action.rawValue
  4678. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
  4679. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  4680. if action == .upgrade {
  4681. settingsUpgradeTitleLabel = titleLabel
  4682. }
  4683. button.addSubview(iconLabel)
  4684. button.addSubview(titleLabel)
  4685. NSLayoutConstraint.activate([
  4686. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  4687. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  4688. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  4689. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  4690. ])
  4691. return button
  4692. }
  4693. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  4694. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  4695. switch action {
  4696. case .shareApp:
  4697. showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
  4698. case .upgrade:
  4699. settingsUpgradePremiumTapped()
  4700. case .privacyPolicy:
  4701. openURLInDefaultBrowser(AppExternalURLs.privacyPolicy)
  4702. case .support:
  4703. openURLInDefaultBrowser(AppExternalURLs.support)
  4704. case .termsOfServices:
  4705. openURLInDefaultBrowser(AppExternalURLs.termsOfServices)
  4706. }
  4707. }
  4708. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  4709. if isUserLoggedIn() {
  4710. logoutTapped()
  4711. return
  4712. }
  4713. zoomPrimarySignInTapped()
  4714. }
  4715. private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  4716. let v = NSView()
  4717. v.wantsLayer = true
  4718. v.layer?.backgroundColor = color.cgColor
  4719. v.layer?.cornerRadius = cornerRadius
  4720. return v
  4721. }
  4722. private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  4723. view.wantsLayer = true
  4724. view.layer?.borderColor = borderColor.cgColor
  4725. view.layer?.borderWidth = borderWidth
  4726. if shadow {
  4727. view.layer?.shadowColor = NSColor.black.cgColor
  4728. view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
  4729. view.layer?.shadowRadius = 18
  4730. view.layer?.shadowOffset = NSSize(width: 0, height: -2)
  4731. } else {
  4732. view.layer?.shadowOpacity = 0
  4733. }
  4734. }
  4735. private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  4736. let label = NSTextField(labelWithString: text)
  4737. label.translatesAutoresizingMaskIntoConstraints = false
  4738. label.font = font
  4739. label.textColor = color
  4740. label.backgroundColor = .clear
  4741. label.isBezeled = false
  4742. label.isEditable = false
  4743. label.isSelectable = false
  4744. return label
  4745. }
  4746. private func styledLabel(
  4747. _ text: String,
  4748. font: NSFont,
  4749. color: NSColor,
  4750. kern: CGFloat? = nil,
  4751. lineSpacing: CGFloat? = nil
  4752. ) -> NSTextField {
  4753. let label = textLabel(text, font: font, color: color)
  4754. if kern == nil, lineSpacing == nil {
  4755. return label
  4756. }
  4757. let paragraph = NSMutableParagraphStyle()
  4758. paragraph.alignment = label.alignment
  4759. paragraph.lineBreakMode = .byWordWrapping
  4760. if let lineSpacing { paragraph.lineSpacing = lineSpacing }
  4761. var attrs: [NSAttributedString.Key: Any] = [
  4762. .font: font,
  4763. .foregroundColor: color,
  4764. .paragraphStyle: paragraph
  4765. ]
  4766. if let kern { attrs[.kern] = kern }
  4767. label.attributedStringValue = NSAttributedString(string: text, attributes: attrs)
  4768. return label
  4769. }
  4770. private func startStoreKit() {
  4771. storeKitStartupTask?.cancel()
  4772. storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
  4773. DispatchQueue.main.async {
  4774. guard let self else { return }
  4775. let newIsPremium = self.storeKitCoordinator.hasPremiumAccess
  4776. let wasPremium = self.lastKnownPremiumAccess
  4777. self.lastKnownPremiumAccess = newIsPremium
  4778. self.updatePremiumButtons()
  4779. if wasPremium != newIsPremium {
  4780. self.rebuildHomeSettingsViewPreservingSelection()
  4781. }
  4782. self.reconcileDesktopWidgetsForPremiumAccess()
  4783. if (wasPremium == false) && newIsPremium {
  4784. self.startMeetingsAutoRefresh()
  4785. self.triggerMeetingsRefresh(force: false)
  4786. self.schedulePremiumRatingPromptIfNeeded()
  4787. }
  4788. if wasPremium && !newIsPremium {
  4789. self.cancelAllMeetingReminders()
  4790. self.premiumRatingPromptWorkItem?.cancel()
  4791. self.premiumRatingPromptWorkItem = nil
  4792. self.showPaywall()
  4793. }
  4794. if newIsPremium {
  4795. self.launchPaywallWorkItem?.cancel()
  4796. self.launchPaywallWorkItem = nil
  4797. self.hasPresentedLaunchPaywall = true
  4798. } else {
  4799. self.scheduleLaunchPaywallIfNeeded()
  4800. }
  4801. }
  4802. }
  4803. storeKitStartupTask = Task { [weak self] in
  4804. await self?.storeKitCoordinator.start()
  4805. await MainActor.run {
  4806. self?.updatePremiumButtons()
  4807. self?.reconcileDesktopWidgetsForPremiumAccess()
  4808. self?.scheduleLaunchPaywallIfNeeded()
  4809. }
  4810. }
  4811. }
  4812. private func scheduleLaunchPaywallIfNeeded() {
  4813. guard hasPresentedLaunchPaywall == false else { return }
  4814. guard storeKitCoordinator.hasPremiumAccess == false else {
  4815. hasPresentedLaunchPaywall = true
  4816. return
  4817. }
  4818. guard launchPaywallWorkItem == nil else { return }
  4819. let workItem = DispatchWorkItem { [weak self] in
  4820. guard let self else { return }
  4821. self.launchPaywallWorkItem = nil
  4822. guard self.hasPresentedLaunchPaywall == false else { return }
  4823. guard self.storeKitCoordinator.hasPremiumAccess == false else {
  4824. self.hasPresentedLaunchPaywall = true
  4825. return
  4826. }
  4827. self.hasPresentedLaunchPaywall = true
  4828. self.showPaywall()
  4829. }
  4830. launchPaywallWorkItem = workItem
  4831. DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
  4832. }
  4833. @MainActor
  4834. private func updatePremiumButtons() {
  4835. let isPremium = storeKitCoordinator.hasPremiumAccess
  4836. let hasLifetimePremium = storeKitCoordinator.hasLifetimePremiumAccess
  4837. settingsUpgradeButton?.title = ""
  4838. settingsUpgradeTitleLabel?.stringValue = hasLifetimePremium ? "Premium Lifetime" : "Upgrade"
  4839. settingsUpgradeButton?.isHidden = hasLifetimePremium
  4840. settingsUpgradeButton?.isEnabled = hasLifetimePremium == false
  4841. settingsUpgradeButton?.alphaValue = hasLifetimePremium ? 0.6 : 1.0
  4842. settingsNotificationsBlock?.isHidden = isPremium == false
  4843. updatePremiumLockedSections()
  4844. if isPremium == false, isUserLoggedIn() {
  4845. applyMeetings([])
  4846. applyPremiumLockMessages()
  4847. }
  4848. if isPremium {
  4849. schedulePremiumRatingPromptIfNeeded()
  4850. }
  4851. updateTopBarPremiumButton()
  4852. }
  4853. @MainActor
  4854. private func updateTopBarPremiumButton() {
  4855. guard let button = topBarPremiumButton else { return }
  4856. let isPremium = storeKitCoordinator.hasPremiumAccess
  4857. if isPremium {
  4858. let title = "Manage Subscription"
  4859. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  4860. button.attributedTitle = NSAttributedString(string: title, attributes: [
  4861. .foregroundColor: NSColor.white,
  4862. .font: font
  4863. ])
  4864. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  4865. if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
  4866. let img = base.withSymbolConfiguration(symbolConfig) {
  4867. img.isTemplate = true
  4868. button.image = img
  4869. button.imagePosition = .imageLeading
  4870. button.imageHugsTitle = true
  4871. }
  4872. button.contentTintColor = NSColor.white
  4873. button.toolTip = title
  4874. button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
  4875. } else {
  4876. let title = "Upgrade to Pro"
  4877. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  4878. button.attributedTitle = NSAttributedString(string: title, attributes: [
  4879. .foregroundColor: NSColor.white,
  4880. .font: font
  4881. ])
  4882. button.image = nil
  4883. button.toolTip = title
  4884. button.layer?.backgroundColor = accentBlue.cgColor
  4885. button.contentTintColor = .white
  4886. }
  4887. }
  4888. @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
  4889. setDarkMode(sender.state == .on)
  4890. }
  4891. @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
  4892. let enabled = sender.state == .on
  4893. remindersEnabled = enabled
  4894. updateRemindersSubRowState()
  4895. if enabled {
  4896. requestNotificationPermissionIfNeeded { [weak self] granted in
  4897. guard let self else { return }
  4898. if granted {
  4899. self.rescheduleMeetingRemindersIfNeeded()
  4900. } else {
  4901. self.remindersEnabled = false
  4902. self.settingsRemindersMasterSwitch?.state = .off
  4903. self.updateRemindersSubRowState()
  4904. self.cancelAllMeetingReminders()
  4905. self.showSimpleAlert(
  4906. title: "Notifications disabled",
  4907. message: "To enable reminders, allow notifications for this app in System Settings → Notifications."
  4908. )
  4909. }
  4910. }
  4911. } else {
  4912. cancelAllMeetingReminders()
  4913. }
  4914. }
  4915. @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
  4916. remind1Day = sender.state == .on
  4917. rescheduleMeetingRemindersIfNeeded()
  4918. }
  4919. @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
  4920. remind12Hours = sender.state == .on
  4921. rescheduleMeetingRemindersIfNeeded()
  4922. }
  4923. @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
  4924. remind1Hour = sender.state == .on
  4925. rescheduleMeetingRemindersIfNeeded()
  4926. }
  4927. private func updateRemindersSubRowState() {
  4928. let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
  4929. settingsReminders1DayRow?.alphaValue = subAlpha
  4930. settingsReminders12HoursRow?.alphaValue = subAlpha
  4931. settingsReminders1HourRow?.alphaValue = subAlpha
  4932. let enabled = remindersEnabled
  4933. settingsReminders1DaySwitch?.isEnabled = enabled
  4934. settingsReminders12HoursSwitch?.isEnabled = enabled
  4935. settingsReminders1HourSwitch?.isEnabled = enabled
  4936. }
  4937. private func requestNotificationPermissionIfNeeded(completion: @escaping (Bool) -> Void) {
  4938. let center = UNUserNotificationCenter.current()
  4939. center.getNotificationSettings { settings in
  4940. switch settings.authorizationStatus {
  4941. case .notDetermined:
  4942. center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
  4943. DispatchQueue.main.async { completion(granted) }
  4944. }
  4945. case .authorized, .provisional, .ephemeral:
  4946. DispatchQueue.main.async { completion(true) }
  4947. default:
  4948. DispatchQueue.main.async { completion(false) }
  4949. }
  4950. }
  4951. }
  4952. private func rescheduleMeetingRemindersIfNeeded() {
  4953. guard storeKitCoordinator.hasPremiumAccess else {
  4954. cancelAllMeetingReminders()
  4955. return
  4956. }
  4957. guard remindersEnabled else {
  4958. cancelAllMeetingReminders()
  4959. return
  4960. }
  4961. // If nothing is selected, don't schedule anything (but keep master toggle on).
  4962. if remind1Day == false, remind12Hours == false, remind1Hour == false {
  4963. cancelAllMeetingReminders()
  4964. return
  4965. }
  4966. scheduleMeetingReminders(for: allScheduledMeetings)
  4967. }
  4968. private func cancelAllMeetingReminders() {
  4969. UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
  4970. }
  4971. private func scheduleMeetingReminders(for meetings: [ScheduledMeeting]) {
  4972. let center = UNUserNotificationCenter.current()
  4973. let now = Date()
  4974. struct Interval {
  4975. let offset: TimeInterval
  4976. let label: String
  4977. let suffix: String
  4978. let isEnabled: () -> Bool
  4979. }
  4980. let intervals: [Interval] = [
  4981. Interval(offset: 60 * 60 * 24, label: "Starts in 1 day", suffix: "1d", isEnabled: { self.remind1Day }),
  4982. Interval(offset: 60 * 60 * 12, label: "Starts in 12 hours", suffix: "12h", isEnabled: { self.remind12Hours }),
  4983. Interval(offset: 60 * 60, label: "Starts in 1 hour", suffix: "1h", isEnabled: { self.remind1Hour })
  4984. ]
  4985. var requests: [UNNotificationRequest] = []
  4986. for meeting in meetings {
  4987. let title = meeting.title.trimmingCharacters(in: .whitespacesAndNewlines)
  4988. guard title.isEmpty == false else { continue }
  4989. for interval in intervals where interval.isEnabled() {
  4990. let triggerDate = meeting.start.addingTimeInterval(-interval.offset)
  4991. guard triggerDate > now else { continue }
  4992. let content = UNMutableNotificationContent()
  4993. content.title = "📅 \(title)"
  4994. let subtitle = meeting.host.trimmingCharacters(in: .whitespacesAndNewlines)
  4995. content.subtitle = subtitle.isEmpty ? meeting.source : subtitle
  4996. content.body = interval.label
  4997. content.sound = .default
  4998. let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate)
  4999. let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
  5000. let id = "zoom.meeting.reminder.\(stableReminderKey(for: meeting)).\(interval.suffix)"
  5001. requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
  5002. }
  5003. }
  5004. center.removeAllPendingNotificationRequests()
  5005. for request in requests {
  5006. center.add(request, withCompletionHandler: nil)
  5007. }
  5008. }
  5009. private func stableReminderKey(for meeting: ScheduledMeeting) -> String {
  5010. if let id = meeting.meetingID?.trimmingCharacters(in: .whitespacesAndNewlines), id.isEmpty == false {
  5011. return id
  5012. }
  5013. if let url = meeting.webURL?.absoluteString, url.isEmpty == false {
  5014. return sha256Prefix(url)
  5015. }
  5016. return sha256Prefix("\(meeting.title)|\(meeting.start.timeIntervalSince1970)")
  5017. }
  5018. private func sha256Prefix(_ input: String) -> String {
  5019. let digest = SHA256.hash(data: Data(input.utf8))
  5020. let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
  5021. return String(hex.prefix(24))
  5022. }
  5023. private func setDarkMode(_ enabled: Bool) {
  5024. darkModeEnabled = enabled
  5025. palette = Palette(isDarkMode: enabled)
  5026. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  5027. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  5028. if let window = view.window {
  5029. applyWindowBackgroundForCurrentTheme(window)
  5030. }
  5031. let keepSelected = selectedHomeSidebarItem
  5032. selectedHomeSidebarItem = keepSelected
  5033. showHomeView(profile: nil, refreshMeetings: false)
  5034. updatePremiumLockedSections()
  5035. }
  5036. private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
  5037. // Keep the window's backing in sync with the app's current theme, otherwise rounded
  5038. // shell corners can briefly show the previous mode's lighter/darker color.
  5039. window.backgroundColor = appBackground
  5040. window.isOpaque = true
  5041. if let contentView = window.contentView {
  5042. contentView.wantsLayer = true
  5043. contentView.layer?.backgroundColor = appBackground.cgColor
  5044. }
  5045. }
  5046. private func systemPrefersDarkMode() -> Bool {
  5047. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  5048. let style = global?["AppleInterfaceStyle"] as? String
  5049. return style?.lowercased() == "dark"
  5050. }
  5051. @objc private func settingsUpgradePremiumTapped() {
  5052. guard storeKitCoordinator.hasLifetimePremiumAccess == false else { return }
  5053. showPaywall()
  5054. }
  5055. // MARK: - Paywall (ported from meetings_app)
  5056. private func showPaywall() {
  5057. if storeKitCoordinator.hasPremiumAccess && storeKitCoordinator.hasLifetimePremiumAccess == false {
  5058. selectedPremiumPlan = .lifetime
  5059. }
  5060. if let existing = paywallOverlayView {
  5061. refreshPaywallStoreUI()
  5062. if let superview = existing.superview {
  5063. superview.addSubview(existing)
  5064. }
  5065. updatePaywallResponsiveLayout()
  5066. NSApp.activate(ignoringOtherApps: true)
  5067. return
  5068. }
  5069. let overlay = NSView()
  5070. overlay.translatesAutoresizingMaskIntoConstraints = false
  5071. overlay.wantsLayer = true
  5072. overlay.alphaValue = 0
  5073. overlay.layer?.backgroundColor = NSColor.clear.cgColor
  5074. let dim = NSView()
  5075. dim.translatesAutoresizingMaskIntoConstraints = false
  5076. dim.wantsLayer = true
  5077. dim.layer?.backgroundColor = palette.isDarkMode
  5078. ? NSColor.black.withAlphaComponent(0.62).cgColor
  5079. : NSColor.black.withAlphaComponent(0.28).cgColor
  5080. overlay.addSubview(dim)
  5081. // Clicking outside the paywall dismisses it.
  5082. let outsideButton = NSButton(title: "", target: self, action: #selector(closePaywallClicked(_:)))
  5083. outsideButton.translatesAutoresizingMaskIntoConstraints = false
  5084. outsideButton.isBordered = false
  5085. outsideButton.bezelStyle = .regularSquare
  5086. outsideButton.contentTintColor = .clear
  5087. outsideButton.wantsLayer = true
  5088. outsideButton.layer?.backgroundColor = NSColor.clear.cgColor
  5089. overlay.addSubview(outsideButton)
  5090. let content = makePaywallContent()
  5091. overlay.addSubview(content)
  5092. view.addSubview(overlay)
  5093. NSLayoutConstraint.activate([
  5094. overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  5095. overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  5096. overlay.topAnchor.constraint(equalTo: view.topAnchor),
  5097. overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  5098. dim.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  5099. dim.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  5100. dim.topAnchor.constraint(equalTo: overlay.topAnchor),
  5101. dim.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  5102. outsideButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  5103. outsideButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  5104. outsideButton.topAnchor.constraint(equalTo: overlay.topAnchor),
  5105. outsideButton.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  5106. content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  5107. content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  5108. content.topAnchor.constraint(equalTo: overlay.topAnchor),
  5109. content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
  5110. ])
  5111. paywallPanelWidthConstraint = nil
  5112. paywallPanelHeightConstraint = nil
  5113. paywallOverlayView = overlay
  5114. NSAnimationContext.runAnimationGroup { context in
  5115. context.duration = 0.18
  5116. overlay.animator().alphaValue = 1
  5117. }
  5118. Task { [weak self] in
  5119. guard let self else { return }
  5120. await self.storeKitCoordinator.refreshProducts()
  5121. await MainActor.run {
  5122. self.refreshPaywallStoreUI()
  5123. self.updatePaywallResponsiveLayout()
  5124. }
  5125. }
  5126. }
  5127. @objc private func closePaywallClicked(_ sender: Any?) {
  5128. hidePaywall()
  5129. }
  5130. private func hidePaywall() {
  5131. guard let overlay = paywallOverlayView else { return }
  5132. NSAnimationContext.runAnimationGroup { context in
  5133. context.duration = 0.16
  5134. overlay.animator().alphaValue = 0
  5135. } completionHandler: { [weak self] in
  5136. overlay.removeFromSuperview()
  5137. self?.paywallPanelWidthConstraint = nil
  5138. self?.paywallPanelHeightConstraint = nil
  5139. self?.paywallPanelView = nil
  5140. self?.paywallBodyStack = nil
  5141. self?.paywallIsDesktopLayout = false
  5142. }
  5143. }
  5144. private func updatePaywallPanelSize() {
  5145. guard paywallPanelWidthConstraint != nil, paywallPanelHeightConstraint != nil else { return }
  5146. let safeInsetX: CGFloat = 80
  5147. let safeInsetY: CGFloat = 110
  5148. let maxWidth = max(640, view.bounds.width - safeInsetX)
  5149. let maxHeight = max(560, view.bounds.height - safeInsetY)
  5150. let panelWidth = min(1040, maxWidth)
  5151. let panelHeight = min(860, maxHeight)
  5152. if abs(paywallPanelWidthConstraint?.constant ?? 0 - panelWidth) > 1 {
  5153. paywallPanelWidthConstraint?.constant = panelWidth
  5154. }
  5155. if abs(paywallPanelHeightConstraint?.constant ?? 0 - panelHeight) > 1 {
  5156. paywallPanelHeightConstraint?.constant = panelHeight
  5157. }
  5158. }
  5159. private func updatePaywallResponsiveLayout() {
  5160. guard let bodyStack = paywallBodyStack, let panel = paywallPanelView else { return }
  5161. let width = panel.bounds.width
  5162. // Two-column desktop layout needs room for the plan column (520+) + a slimmer benefits column.
  5163. let shouldUseDesktop = width >= 900
  5164. guard shouldUseDesktop != paywallIsDesktopLayout else { return }
  5165. paywallIsDesktopLayout = shouldUseDesktop
  5166. if shouldUseDesktop {
  5167. bodyStack.orientation = .horizontal
  5168. bodyStack.alignment = .top
  5169. bodyStack.spacing = 26
  5170. } else {
  5171. bodyStack.orientation = .vertical
  5172. bodyStack.alignment = .leading
  5173. bodyStack.spacing = 18
  5174. }
  5175. let useWidePlans = width >= 880
  5176. paywallPlansRowWide?.isHidden = useWidePlans == false
  5177. paywallPlansGridNarrow?.isHidden = useWidePlans
  5178. bodyStack.layoutSubtreeIfNeeded()
  5179. }
  5180. private func makePaywallContent() -> NSView {
  5181. paywallPlanViews.removeAll()
  5182. paywallPlanPriceLabels.removeAll()
  5183. paywallPlanSubtitleLabels.removeAll()
  5184. paywallPlanStrikeLabels.removeAll()
  5185. premiumPlanByView.removeAll()
  5186. paywallOfferLabel = nil
  5187. paywallContinueLabel = nil
  5188. paywallContinueButton = nil
  5189. paywallContinueEnabled = true
  5190. paywallPlansRowWide = nil
  5191. paywallPlansGridNarrow = nil
  5192. let root = NSView()
  5193. root.translatesAutoresizingMaskIntoConstraints = false
  5194. root.wantsLayer = true
  5195. root.layer?.backgroundColor = NSColor.clear.cgColor
  5196. let panel = NSView()
  5197. panel.translatesAutoresizingMaskIntoConstraints = false
  5198. panel.wantsLayer = true
  5199. // Theme-aware surface so Light Mode doesn't render on a black backdrop.
  5200. panel.layer?.backgroundColor = palette.appBackground.cgColor
  5201. panel.layer?.cornerRadius = 0
  5202. styleSurface(panel, borderColor: NSColor.clear, borderWidth: 0, shadow: false)
  5203. root.addSubview(panel)
  5204. // Full-screen paywall: fill the entire app content area.
  5205. NSLayoutConstraint.activate([
  5206. panel.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  5207. panel.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  5208. panel.topAnchor.constraint(equalTo: root.topAnchor),
  5209. panel.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  5210. ])
  5211. paywallPanelWidthConstraint = nil
  5212. paywallPanelHeightConstraint = nil
  5213. // Responsive logic should measure the panel size (not the full-screen root).
  5214. self.paywallPanelView = panel
  5215. let contentStack = NSStackView()
  5216. contentStack.translatesAutoresizingMaskIntoConstraints = false
  5217. contentStack.orientation = .vertical
  5218. contentStack.spacing = 14
  5219. contentStack.alignment = .leading
  5220. panel.addSubview(contentStack)
  5221. let topRow = NSStackView()
  5222. topRow.translatesAutoresizingMaskIntoConstraints = false
  5223. topRow.orientation = .horizontal
  5224. topRow.alignment = .centerY
  5225. topRow.distribution = .fill
  5226. topRow.spacing = 10
  5227. topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 26, weight: .semibold), color: primaryText))
  5228. let topSpacer = NSView()
  5229. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  5230. topRow.addArrangedSubview(topSpacer)
  5231. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  5232. closeButton.translatesAutoresizingMaskIntoConstraints = false
  5233. closeButton.isBordered = false
  5234. closeButton.bezelStyle = .regularSquare
  5235. closeButton.wantsLayer = true
  5236. closeButton.layer?.cornerRadius = 14
  5237. closeButton.normalColor = palette.inputBackground
  5238. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  5239. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  5240. closeButton.layer?.borderWidth = 1
  5241. closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
  5242. closeButton.contentTintColor = secondaryText
  5243. closeButton.toolTip = "Continue with free plan"
  5244. panel.addSubview(closeButton)
  5245. NSLayoutConstraint.activate([
  5246. closeButton.widthAnchor.constraint(equalToConstant: 28),
  5247. closeButton.heightAnchor.constraint(equalToConstant: 28),
  5248. closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  5249. closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48)
  5250. ])
  5251. contentStack.addArrangedSubview(topRow)
  5252. topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5253. let hero = paywallHeroCard(
  5254. eyebrow: "PREMIUM EXPERIENCE",
  5255. title: "Elevate your meetings workflow",
  5256. subtitle: "Unlock automation, premium meeting tools, and priority support for every session."
  5257. )
  5258. contentStack.addArrangedSubview(hero)
  5259. hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5260. let features = paywallFeaturesRow()
  5261. contentStack.addArrangedSubview(features)
  5262. features.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5263. let bodyStack = NSStackView()
  5264. bodyStack.translatesAutoresizingMaskIntoConstraints = false
  5265. bodyStack.orientation = .vertical
  5266. bodyStack.spacing = 16
  5267. bodyStack.alignment = .leading
  5268. bodyStack.distribution = .fill
  5269. contentStack.addArrangedSubview(bodyStack)
  5270. paywallBodyStack = bodyStack
  5271. bodyStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  5272. let planColumn = NSStackView()
  5273. planColumn.translatesAutoresizingMaskIntoConstraints = false
  5274. planColumn.orientation = .vertical
  5275. planColumn.spacing = 12
  5276. planColumn.alignment = .leading
  5277. planColumn.distribution = .fill
  5278. bodyStack.addArrangedSubview(planColumn)
  5279. planColumn.widthAnchor.constraint(equalTo: bodyStack.widthAnchor).isActive = true
  5280. let weeklyCard = paywallPlanCard(
  5281. title: "Weekly",
  5282. price: paywallDisplayPrice(for: .weekly),
  5283. badge: "Basic",
  5284. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5285. subtitle: paywallPlanSubtitle(for: .weekly),
  5286. plan: .weekly,
  5287. strikePrice: nil
  5288. )
  5289. let monthlyCard = paywallPlanCard(
  5290. title: "Monthly",
  5291. price: paywallDisplayPrice(for: .monthly),
  5292. badge: "Popular",
  5293. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  5294. subtitle: paywallPlanSubtitle(for: .monthly),
  5295. plan: .monthly,
  5296. strikePrice: nil
  5297. )
  5298. let yearlyCard = paywallPlanCard(
  5299. title: "Yearly",
  5300. price: paywallDisplayPrice(for: .yearly),
  5301. badge: "Save 67%",
  5302. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5303. subtitle: paywallPlanSubtitle(for: .yearly),
  5304. plan: .yearly,
  5305. strikePrice: nil
  5306. )
  5307. let lifetimeCard = paywallPlanCard(
  5308. title: "Lifetime",
  5309. price: paywallDisplayPrice(for: .lifetime),
  5310. badge: "Best Value",
  5311. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5312. subtitle: paywallPlanSubtitle(for: .lifetime),
  5313. plan: .lifetime,
  5314. strikePrice: nil
  5315. )
  5316. let plansRowWide = NSStackView()
  5317. plansRowWide.translatesAutoresizingMaskIntoConstraints = false
  5318. plansRowWide.orientation = .horizontal
  5319. plansRowWide.spacing = 12
  5320. plansRowWide.alignment = .centerY
  5321. plansRowWide.distribution = .fillEqually
  5322. plansRowWide.addArrangedSubview(weeklyCard)
  5323. plansRowWide.addArrangedSubview(monthlyCard)
  5324. plansRowWide.addArrangedSubview(yearlyCard)
  5325. plansRowWide.addArrangedSubview(lifetimeCard)
  5326. paywallPlansRowWide = plansRowWide
  5327. planColumn.addArrangedSubview(plansRowWide)
  5328. plansRowWide.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5329. let plansGridNarrow = NSStackView()
  5330. plansGridNarrow.translatesAutoresizingMaskIntoConstraints = false
  5331. plansGridNarrow.orientation = .vertical
  5332. plansGridNarrow.spacing = 12
  5333. plansGridNarrow.alignment = .leading
  5334. plansGridNarrow.distribution = .fillEqually
  5335. let plansRow1 = NSStackView()
  5336. plansRow1.translatesAutoresizingMaskIntoConstraints = false
  5337. plansRow1.orientation = .horizontal
  5338. plansRow1.spacing = 12
  5339. plansRow1.alignment = .centerY
  5340. plansRow1.distribution = .fillEqually
  5341. plansRow1.addArrangedSubview(paywallPlanCard(
  5342. title: "Weekly",
  5343. price: paywallDisplayPrice(for: .weekly),
  5344. badge: "Basic Deal",
  5345. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5346. subtitle: paywallPlanSubtitle(for: .weekly),
  5347. plan: .weekly,
  5348. strikePrice: nil
  5349. ))
  5350. plansRow1.addArrangedSubview(paywallPlanCard(
  5351. title: "Monthly",
  5352. price: paywallDisplayPrice(for: .monthly),
  5353. badge: "Popular",
  5354. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  5355. subtitle: paywallPlanSubtitle(for: .monthly),
  5356. plan: .monthly,
  5357. strikePrice: nil
  5358. ))
  5359. let plansRow2 = NSStackView()
  5360. plansRow2.translatesAutoresizingMaskIntoConstraints = false
  5361. plansRow2.orientation = .horizontal
  5362. plansRow2.spacing = 12
  5363. plansRow2.alignment = .centerY
  5364. plansRow2.distribution = .fillEqually
  5365. plansRow2.addArrangedSubview(paywallPlanCard(
  5366. title: "Yearly",
  5367. price: paywallDisplayPrice(for: .yearly),
  5368. badge: "Best Deal",
  5369. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5370. subtitle: paywallPlanSubtitle(for: .yearly),
  5371. plan: .yearly,
  5372. strikePrice: nil
  5373. ))
  5374. plansRow2.addArrangedSubview(paywallPlanCard(
  5375. title: "Lifetime",
  5376. price: paywallDisplayPrice(for: .lifetime),
  5377. badge: "Save 50%",
  5378. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  5379. subtitle: paywallPlanSubtitle(for: .lifetime),
  5380. plan: .lifetime,
  5381. strikePrice: nil
  5382. ))
  5383. plansGridNarrow.addArrangedSubview(plansRow1)
  5384. plansGridNarrow.addArrangedSubview(plansRow2)
  5385. plansGridNarrow.isHidden = true
  5386. paywallPlansGridNarrow = plansGridNarrow
  5387. planColumn.addArrangedSubview(plansGridNarrow)
  5388. plansGridNarrow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5389. plansRow1.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  5390. plansRow2.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  5391. updatePaywallPlanSelection()
  5392. planColumn.setCustomSpacing(16, after: plansGridNarrow)
  5393. let trustRow = paywallTrustRow()
  5394. planColumn.addArrangedSubview(trustRow)
  5395. trustRow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5396. planColumn.setCustomSpacing(14, after: trustRow)
  5397. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  5398. offer.alignment = .center
  5399. paywallOfferLabel = offer
  5400. let offerWrap = NSView()
  5401. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  5402. offerWrap.addSubview(offer)
  5403. NSLayoutConstraint.activate([
  5404. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  5405. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  5406. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  5407. ])
  5408. planColumn.addArrangedSubview(offerWrap)
  5409. offerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5410. planColumn.setCustomSpacing(14, after: offerWrap)
  5411. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  5412. continueButton.translatesAutoresizingMaskIntoConstraints = false
  5413. continueButton.isBordered = false
  5414. continueButton.bezelStyle = .regularSquare
  5415. continueButton.wantsLayer = true
  5416. continueButton.layer?.cornerRadius = 14
  5417. continueButton.normalColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 1)
  5418. continueButton.hoverColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 0.92)
  5419. continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
  5420. styleSurface(continueButton, borderColor: NSColor.black.withAlphaComponent(0.12), borderWidth: 1, shadow: true)
  5421. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  5422. continueButton.addSubview(continueLabel)
  5423. NSLayoutConstraint.activate([
  5424. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  5425. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  5426. ])
  5427. paywallContinueButton = continueButton
  5428. paywallContinueLabel = continueLabel
  5429. let continueButtonWrap = NSView()
  5430. continueButtonWrap.translatesAutoresizingMaskIntoConstraints = false
  5431. continueButtonWrap.addSubview(continueButton)
  5432. planColumn.addArrangedSubview(continueButtonWrap)
  5433. NSLayoutConstraint.activate([
  5434. continueButtonWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor),
  5435. continueButton.topAnchor.constraint(equalTo: continueButtonWrap.topAnchor),
  5436. continueButton.bottomAnchor.constraint(equalTo: continueButtonWrap.bottomAnchor),
  5437. continueButton.centerXAnchor.constraint(equalTo: continueButtonWrap.centerXAnchor),
  5438. continueButton.widthAnchor.constraint(equalToConstant: 400),
  5439. continueButton.widthAnchor.constraint(lessThanOrEqualTo: continueButtonWrap.widthAnchor, constant: -40)
  5440. ])
  5441. planColumn.setCustomSpacing(16, after: continueButtonWrap)
  5442. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
  5443. secure.alignment = .center
  5444. let secureWrap = NSView()
  5445. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  5446. secureWrap.addSubview(secure)
  5447. NSLayoutConstraint.activate([
  5448. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  5449. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  5450. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  5451. ])
  5452. planColumn.addArrangedSubview(secureWrap)
  5453. secureWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5454. planColumn.setCustomSpacing(8, after: secureWrap)
  5455. let primaryFooterButton = paywallFooterButton(
  5456. title: storeKitCoordinator.hasPremiumAccess ? "Manage Subscription" : "Continue with free plan",
  5457. action: storeKitCoordinator.hasPremiumAccess
  5458. ? #selector(paywallManageSubscriptionClicked(_:))
  5459. : #selector(closePaywallClicked(_:))
  5460. )
  5461. let restoreButton = paywallFooterButton(
  5462. title: "Restore Purchase",
  5463. action: #selector(paywallRestorePurchaseClicked(_:))
  5464. )
  5465. let privacyButton = paywallFooterButton(
  5466. title: "Privacy Policy",
  5467. action: #selector(paywallPrivacyPolicyClicked(_:))
  5468. )
  5469. let supportButton = paywallFooterButton(
  5470. title: "Support",
  5471. action: #selector(paywallSupportClicked(_:))
  5472. )
  5473. let termsButton = paywallFooterButton(
  5474. title: "Terms of Services",
  5475. action: #selector(paywallTermsOfServicesClicked(_:))
  5476. )
  5477. // Footer: evenly spaced links across the paywall width.
  5478. let footerContent = NSStackView()
  5479. footerContent.translatesAutoresizingMaskIntoConstraints = false
  5480. footerContent.orientation = .horizontal
  5481. footerContent.alignment = .centerY
  5482. footerContent.distribution = .equalSpacing
  5483. footerContent.spacing = 36
  5484. footerContent.addArrangedSubview(primaryFooterButton)
  5485. footerContent.addArrangedSubview(restoreButton)
  5486. footerContent.addArrangedSubview(privacyButton)
  5487. footerContent.addArrangedSubview(supportButton)
  5488. footerContent.addArrangedSubview(termsButton)
  5489. let footerWrap = NSView()
  5490. footerWrap.translatesAutoresizingMaskIntoConstraints = false
  5491. footerWrap.addSubview(footerContent)
  5492. NSLayoutConstraint.activate([
  5493. footerContent.leadingAnchor.constraint(equalTo: footerWrap.leadingAnchor, constant: 44),
  5494. footerContent.trailingAnchor.constraint(equalTo: footerWrap.trailingAnchor, constant: -44),
  5495. footerContent.topAnchor.constraint(equalTo: footerWrap.topAnchor),
  5496. footerContent.bottomAnchor.constraint(equalTo: footerWrap.bottomAnchor)
  5497. ])
  5498. // Push footer to the bottom of the paywall.
  5499. let footerPushSpacer = NSView()
  5500. footerPushSpacer.translatesAutoresizingMaskIntoConstraints = false
  5501. footerPushSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  5502. footerPushSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  5503. planColumn.addArrangedSubview(footerPushSpacer)
  5504. planColumn.addArrangedSubview(footerWrap)
  5505. footerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  5506. // Keep a comfortable top margin, but center the paywall content vertically when there's room.
  5507. let contentTopMin = contentStack.topAnchor.constraint(greaterThanOrEqualTo: panel.topAnchor, constant: 84)
  5508. let contentBottomMax = contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -18)
  5509. let contentCenterY = contentStack.centerYAnchor.constraint(equalTo: panel.centerYAnchor)
  5510. contentCenterY.priority = .defaultLow
  5511. let contentBottomEq = contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -18)
  5512. contentBottomEq.priority = .defaultLow
  5513. NSLayoutConstraint.activate([
  5514. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 70),
  5515. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  5516. contentTopMin,
  5517. contentBottomMax,
  5518. contentCenterY,
  5519. contentBottomEq
  5520. ])
  5521. refreshPaywallStoreUI()
  5522. updatePaywallResponsiveLayout()
  5523. return root
  5524. }
  5525. private func paywallBenefitsSection() -> NSView {
  5526. let stack = NSStackView()
  5527. stack.translatesAutoresizingMaskIntoConstraints = false
  5528. stack.orientation = .vertical
  5529. stack.spacing = 8
  5530. stack.alignment = .leading
  5531. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
  5532. let rowOne = NSStackView()
  5533. rowOne.translatesAutoresizingMaskIntoConstraints = false
  5534. rowOne.orientation = .horizontal
  5535. rowOne.spacing = 10
  5536. rowOne.distribution = .fillEqually
  5537. rowOne.alignment = .centerY
  5538. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  5539. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  5540. let rowTwo = NSStackView()
  5541. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  5542. rowTwo.orientation = .horizontal
  5543. rowTwo.spacing = 10
  5544. rowTwo.distribution = .fillEqually
  5545. rowTwo.alignment = .centerY
  5546. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  5547. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  5548. stack.addArrangedSubview(rowOne)
  5549. stack.addArrangedSubview(rowTwo)
  5550. return stack
  5551. }
  5552. private func paywallHeroCard(eyebrow: String, title: String, subtitle: String) -> NSView {
  5553. let card = NSView()
  5554. card.translatesAutoresizingMaskIntoConstraints = false
  5555. card.wantsLayer = true
  5556. card.layer?.cornerRadius = 14
  5557. card.layer?.backgroundColor = palette.secondaryCardBackground.cgColor
  5558. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5559. let eyebrowLabel = styledLabel(
  5560. eyebrow,
  5561. font: NSFont.systemFont(ofSize: 11, weight: .semibold),
  5562. color: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 0.92),
  5563. kern: 0.8
  5564. )
  5565. eyebrowLabel.alignment = .left
  5566. let titleLabel = styledLabel(
  5567. title,
  5568. font: NSFont.systemFont(ofSize: 18, weight: .semibold),
  5569. color: primaryText,
  5570. lineSpacing: 1
  5571. )
  5572. titleLabel.alignment = .left
  5573. let subtitleLabel = styledLabel(
  5574. subtitle,
  5575. font: NSFont.systemFont(ofSize: 13, weight: .regular),
  5576. color: secondaryText,
  5577. lineSpacing: 2
  5578. )
  5579. subtitleLabel.maximumNumberOfLines = 2
  5580. subtitleLabel.lineBreakMode = .byTruncatingTail
  5581. subtitleLabel.alignment = .left
  5582. let stack = NSStackView()
  5583. stack.translatesAutoresizingMaskIntoConstraints = false
  5584. stack.orientation = .vertical
  5585. stack.alignment = .leading
  5586. stack.spacing = 6
  5587. stack.addArrangedSubview(eyebrowLabel)
  5588. stack.addArrangedSubview(titleLabel)
  5589. stack.addArrangedSubview(subtitleLabel)
  5590. card.addSubview(stack)
  5591. NSLayoutConstraint.activate([
  5592. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  5593. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  5594. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  5595. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
  5596. ])
  5597. return card
  5598. }
  5599. private func paywallFeaturesRow() -> NSView {
  5600. let row = NSStackView()
  5601. row.translatesAutoresizingMaskIntoConstraints = false
  5602. row.orientation = .horizontal
  5603. row.spacing = 10
  5604. row.alignment = .centerY
  5605. row.distribution = .fillEqually
  5606. row.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  5607. row.addArrangedSubview(paywallBenefitItem(icon: "💬", text: "Smart scheduling"))
  5608. row.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
  5609. row.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
  5610. return row
  5611. }
  5612. private func paywallTrustRow() -> NSView {
  5613. let row = NSStackView()
  5614. row.translatesAutoresizingMaskIntoConstraints = false
  5615. row.orientation = .horizontal
  5616. row.spacing = 10
  5617. row.alignment = .centerY
  5618. row.distribution = .fillEqually
  5619. row.addArrangedSubview(paywallTrustItem(title: "Cancel anytime", subtitle: "No lock-in"))
  5620. row.addArrangedSubview(paywallTrustItem(title: "Instant access", subtitle: "Unlock all tools"))
  5621. row.addArrangedSubview(paywallTrustItem(title: "Secure billing", subtitle: "Handled by Apple"))
  5622. return row
  5623. }
  5624. private func paywallTrustItem(title: String, subtitle: String) -> NSView {
  5625. let card = NSView()
  5626. card.translatesAutoresizingMaskIntoConstraints = false
  5627. card.wantsLayer = true
  5628. card.layer?.cornerRadius = 12
  5629. card.layer?.backgroundColor = palette.inputBackground.cgColor
  5630. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5631. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: primaryText)
  5632. let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  5633. let stack = NSStackView()
  5634. stack.translatesAutoresizingMaskIntoConstraints = false
  5635. stack.orientation = .vertical
  5636. stack.alignment = .centerX
  5637. stack.spacing = 2
  5638. stack.addArrangedSubview(titleLabel)
  5639. stack.addArrangedSubview(subtitleLabel)
  5640. card.addSubview(stack)
  5641. NSLayoutConstraint.activate([
  5642. card.heightAnchor.constraint(equalToConstant: 44),
  5643. stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  5644. stack.centerYAnchor.constraint(equalTo: card.centerYAnchor)
  5645. ])
  5646. return card
  5647. }
  5648. private func paywallBenefitItem(icon: String, text: String) -> NSView {
  5649. let card = NSView()
  5650. card.translatesAutoresizingMaskIntoConstraints = false
  5651. card.wantsLayer = true
  5652. card.layer?.cornerRadius = 10
  5653. card.layer?.backgroundColor = palette.inputBackground.cgColor
  5654. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  5655. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5656. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  5657. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  5658. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  5659. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  5660. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5661. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 13, weight: .medium), color: accentBlue)
  5662. iconWrap.addSubview(iconLabel)
  5663. NSLayoutConstraint.activate([
  5664. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  5665. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  5666. ])
  5667. let title = textLabel(text, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: primaryText)
  5668. card.addSubview(iconWrap)
  5669. card.addSubview(title)
  5670. NSLayoutConstraint.activate([
  5671. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  5672. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  5673. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  5674. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  5675. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  5676. ])
  5677. return card
  5678. }
  5679. private func paywallPlanCard(
  5680. title: String,
  5681. price: String,
  5682. badge: String,
  5683. badgeColor: NSColor,
  5684. subtitle: String?,
  5685. plan: PremiumPlan,
  5686. strikePrice: String?
  5687. ) -> NSView {
  5688. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  5689. wrapper.translatesAutoresizingMaskIntoConstraints = false
  5690. wrapper.isBordered = false
  5691. wrapper.bezelStyle = .regularSquare
  5692. wrapper.wantsLayer = true
  5693. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  5694. wrapper.heightAnchor.constraint(equalToConstant: 138).isActive = true
  5695. wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
  5696. let card = NSView()
  5697. card.translatesAutoresizingMaskIntoConstraints = false
  5698. card.wantsLayer = true
  5699. card.layer?.cornerRadius = 16
  5700. card.layer?.backgroundColor = palette.sectionCard.cgColor
  5701. wrapper.addSubview(card)
  5702. NSLayoutConstraint.activate([
  5703. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  5704. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  5705. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 14),
  5706. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  5707. ])
  5708. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  5709. let badgeLabel = styledLabel(
  5710. badge.uppercased(),
  5711. font: NSFont.systemFont(ofSize: 10.5, weight: .semibold),
  5712. color: .white,
  5713. kern: 0.6
  5714. )
  5715. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  5716. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  5717. badgeWrap.wantsLayer = true
  5718. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  5719. badgeWrap.layer?.borderWidth = 1
  5720. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  5721. badgeWrap.layer?.shadowOpacity = 0.20
  5722. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  5723. badgeWrap.layer?.shadowRadius = 3
  5724. badgeWrap.addSubview(badgeLabel)
  5725. NSLayoutConstraint.activate([
  5726. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  5727. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  5728. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  5729. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  5730. ])
  5731. wrapper.addSubview(badgeWrap)
  5732. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: accentBlue)
  5733. card.addSubview(titleLabel)
  5734. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: primaryText)
  5735. card.addSubview(priceLabel)
  5736. paywallPlanPriceLabels[plan, default: []].append(priceLabel)
  5737. NSLayoutConstraint.activate([
  5738. badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  5739. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  5740. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  5741. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
  5742. priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  5743. priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  5744. ])
  5745. if let subtitle {
  5746. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  5747. card.addSubview(sub)
  5748. paywallPlanSubtitleLabels[plan, default: []].append(sub)
  5749. NSLayoutConstraint.activate([
  5750. sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  5751. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
  5752. sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  5753. ])
  5754. }
  5755. if let strikePrice {
  5756. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  5757. card.addSubview(strike)
  5758. paywallPlanStrikeLabels[plan, default: []].append(strike)
  5759. NSLayoutConstraint.activate([
  5760. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  5761. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  5762. ])
  5763. }
  5764. paywallPlanViews[plan, default: []].append(card)
  5765. premiumPlanByView[ObjectIdentifier(card)] = plan
  5766. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  5767. guard let self, let card else { return }
  5768. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  5769. }
  5770. wrapper.onHoverChanged?(false)
  5771. return wrapper
  5772. }
  5773. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  5774. guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
  5775. selectedPremiumPlan = plan
  5776. updatePaywallPlanSelection()
  5777. }
  5778. private func updatePaywallPlanSelection() {
  5779. for (plan, views) in paywallPlanViews {
  5780. for view in views {
  5781. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  5782. }
  5783. }
  5784. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  5785. updatePaywallContinueState(isLoading: false)
  5786. }
  5787. private func paywallOfferText(for plan: PremiumPlan) -> String {
  5788. if storeKitCoordinator.hasLifetimePremiumAccess {
  5789. return "Lifetime premium is active on this Apple ID."
  5790. }
  5791. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  5792. let price = paywallDisplayPrice(for: plan)
  5793. switch plan {
  5794. case .weekly: return "\(price)/week"
  5795. case .monthly: return "\(price)/month"
  5796. case .yearly: return "\(price)/year"
  5797. case .lifetime: return "\(price) one-time purchase"
  5798. }
  5799. }
  5800. if let offer = product.subscription?.introductoryOffer,
  5801. offer.paymentMode == .freeTrial,
  5802. let trial = paywallSubscriptionPeriodDescription(offer.period) {
  5803. let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
  5804. if let renewal {
  5805. return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
  5806. }
  5807. return "Free for \(trial), then \(product.displayPrice)"
  5808. }
  5809. if plan == .lifetime {
  5810. return "\(product.displayPrice) one-time purchase"
  5811. }
  5812. if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
  5813. return "\(product.displayPrice)/\(renewal)"
  5814. }
  5815. let fallbackPrice = paywallDisplayPrice(for: plan)
  5816. switch plan {
  5817. case .weekly: return "\(fallbackPrice)/week"
  5818. case .monthly: return "\(fallbackPrice)/month"
  5819. case .yearly: return "\(fallbackPrice)/year"
  5820. case .lifetime: return "\(fallbackPrice) one-time purchase"
  5821. }
  5822. }
  5823. private func paywallFreeTrialText(for plan: PremiumPlan) -> String? {
  5824. guard let product = storeKitCoordinator.productsByID[plan.rawValue],
  5825. let offer = product.subscription?.introductoryOffer,
  5826. offer.paymentMode == .freeTrial,
  5827. let trial = paywallSubscriptionPeriodDescription(offer.period) else {
  5828. return nil
  5829. }
  5830. return trial
  5831. }
  5832. private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
  5833. if plan == .lifetime { return "One-time purchase" }
  5834. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  5835. switch plan {
  5836. case .weekly: return "Billed weekly"
  5837. case .monthly: return "Billed monthly"
  5838. case .yearly: return "Billed yearly"
  5839. case .lifetime: return "One-time purchase"
  5840. }
  5841. }
  5842. if let period = product.subscription?.subscriptionPeriod,
  5843. let unit = paywallSubscriptionPeriodDescription(period),
  5844. let trial = paywallFreeTrialText(for: plan) {
  5845. return "\(product.displayPrice)/\(unit) • \(trial) free trial"
  5846. }
  5847. if let period = product.subscription?.subscriptionPeriod,
  5848. let unit = paywallSubscriptionPeriodDescription(period) {
  5849. return "Billed \(unit)"
  5850. }
  5851. switch plan {
  5852. case .weekly: return "Billed weekly"
  5853. case .monthly: return "Billed monthly"
  5854. case .yearly: return "Billed yearly"
  5855. case .lifetime: return "One-time purchase"
  5856. }
  5857. }
  5858. private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
  5859. guard let period else { return nil }
  5860. let value = period.value
  5861. switch period.unit {
  5862. case .day:
  5863. return value == 1 ? "1 day" : "\(value) days"
  5864. case .week:
  5865. return value == 1 ? "1 week" : "\(value) weeks"
  5866. case .month:
  5867. return value == 1 ? "1 month" : "\(value) months"
  5868. case .year:
  5869. return value == 1 ? "1 year" : "\(value) years"
  5870. @unknown default:
  5871. return nil
  5872. }
  5873. }
  5874. private func refreshPaywallStoreUI() {
  5875. refreshPaywallPlanPrices()
  5876. updatePaywallPlanSelection()
  5877. updatePaywallContinueState(isLoading: false)
  5878. }
  5879. private func refreshPaywallPlanPrices() {
  5880. for plan in PremiumPlan.allCases {
  5881. let price = paywallDisplayPrice(for: plan)
  5882. paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
  5883. let subtitle = paywallPlanSubtitle(for: plan)
  5884. paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
  5885. }
  5886. }
  5887. private func paywallDisplayPrice(for plan: PremiumPlan) -> String {
  5888. if let product = storeKitCoordinator.productsByID[plan.rawValue] {
  5889. return product.displayPrice
  5890. }
  5891. switch plan {
  5892. case .weekly: return "3.99"
  5893. case .monthly: return "7.99"
  5894. case .yearly: return "24.99"
  5895. case .lifetime: return "44.99"
  5896. }
  5897. }
  5898. @objc private func paywallContinueClicked(_ sender: Any?) {
  5899. startSelectedPlanPurchase()
  5900. }
  5901. private func paywallFooterButton(title: String, action: Selector) -> NSButton {
  5902. let button = NSButton(title: title, target: self, action: action)
  5903. button.translatesAutoresizingMaskIntoConstraints = false
  5904. button.isBordered = false
  5905. button.bezelStyle = .shadowlessSquare
  5906. button.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  5907. button.contentTintColor = secondaryText
  5908. button.alignment = .center
  5909. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  5910. return button
  5911. }
  5912. @objc private func paywallPrivacyPolicyClicked(_ sender: Any?) {
  5913. openURLInDefaultBrowser(AppExternalURLs.privacyPolicy)
  5914. }
  5915. @objc private func paywallSupportClicked(_ sender: Any?) {
  5916. openURLInDefaultBrowser(AppExternalURLs.support)
  5917. }
  5918. @objc private func paywallTermsOfServicesClicked(_ sender: Any?) {
  5919. openURLInDefaultBrowser(AppExternalURLs.termsOfServices)
  5920. }
  5921. @objc private func paywallManageSubscriptionClicked(_ sender: Any?) {
  5922. openManageSubscriptions()
  5923. }
  5924. @objc private func paywallRestorePurchaseClicked(_ sender: Any?) {
  5925. Task { [weak self] in
  5926. guard let self else { return }
  5927. let message = await self.storeKitCoordinator.restorePurchases()
  5928. await MainActor.run {
  5929. self.refreshPaywallStoreUI()
  5930. self.updatePremiumButtons()
  5931. self.showSimpleAlert(title: "Restore Purchase", message: message)
  5932. }
  5933. }
  5934. }
  5935. private func startSelectedPlanPurchase() {
  5936. guard paywallContinueEnabled else { return }
  5937. paywallPurchaseTask?.cancel()
  5938. updatePaywallContinueState(isLoading: true)
  5939. let selectedPlan = selectedPremiumPlan
  5940. let hadPremiumBeforePurchase = storeKitCoordinator.hasPremiumAccess
  5941. let hadLifetimeBeforePurchase = storeKitCoordinator.hasLifetimePremiumAccess
  5942. paywallPurchaseTask = Task { [weak self] in
  5943. guard let self else { return }
  5944. if self.storeKitCoordinator.productsByID[selectedPlan.rawValue] == nil {
  5945. await self.storeKitCoordinator.refreshProducts()
  5946. }
  5947. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  5948. await MainActor.run {
  5949. self.updatePaywallContinueState(isLoading: false)
  5950. self.refreshPaywallStoreUI()
  5951. self.updatePremiumButtons()
  5952. switch result {
  5953. case .success:
  5954. if self.storeKitCoordinator.hasPremiumAccess {
  5955. // Ensure QA can repeatedly verify rating flow right after upgrading.
  5956. UserDefaults.standard.set(false, forKey: self.ratingCompletedKey)
  5957. UserDefaults.standard.set(false, forKey: self.ratingPromptShownKey)
  5958. self.schedulePremiumRatingPromptIfNeeded()
  5959. }
  5960. if selectedPlan == .lifetime, hadLifetimeBeforePurchase == false, hadPremiumBeforePurchase {
  5961. self.showSimpleAlert(
  5962. title: "Lifetime Activated",
  5963. 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."
  5964. )
  5965. } else {
  5966. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  5967. }
  5968. self.hidePaywall()
  5969. if self.storeKitCoordinator.hasPremiumAccess {
  5970. DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
  5971. self?.presentRatingPrompt(source: "premium")
  5972. }
  5973. }
  5974. case .pending:
  5975. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  5976. case .cancelled:
  5977. break
  5978. case .failed(let message):
  5979. let fallbackMessage = "StoreKit did not return products for this plan yet. Wait a few seconds and try again, or reopen the app."
  5980. if message.contains("Product not available") {
  5981. self.showSimpleAlert(title: "Purchase Failed", message: fallbackMessage)
  5982. } else {
  5983. self.showSimpleAlert(title: "Purchase Failed", message: message)
  5984. }
  5985. }
  5986. }
  5987. }
  5988. }
  5989. private func updatePaywallContinueState(isLoading: Bool) {
  5990. if isLoading {
  5991. paywallContinueEnabled = false
  5992. paywallContinueLabel?.stringValue = "Processing..."
  5993. paywallContinueButton?.alphaValue = 0.75
  5994. return
  5995. }
  5996. if storeKitCoordinator.hasLifetimePremiumAccess {
  5997. paywallContinueEnabled = false
  5998. paywallContinueLabel?.stringValue = "Lifetime Premium Active"
  5999. paywallContinueButton?.alphaValue = 0.75
  6000. } else if storeKitCoordinator.productsByID[selectedPremiumPlan.rawValue] == nil {
  6001. paywallContinueEnabled = false
  6002. paywallContinueLabel?.stringValue = "Store Unavailable"
  6003. paywallContinueButton?.alphaValue = 0.75
  6004. } else {
  6005. paywallContinueEnabled = true
  6006. if let trial = paywallFreeTrialText(for: selectedPremiumPlan) {
  6007. paywallContinueLabel?.stringValue = "Start with \(trial) trial"
  6008. } else {
  6009. paywallContinueLabel?.stringValue = "Continue"
  6010. }
  6011. paywallContinueButton?.alphaValue = 1.0
  6012. }
  6013. }
  6014. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  6015. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  6016. let idleBorder = palette.inputBorder
  6017. let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
  6018. let hoverIdleBackground =
  6019. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  6020. let selectedBackground = palette.isDarkMode
  6021. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  6022. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  6023. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  6024. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  6025. card.layer?.borderWidth = isSelected ? 2 : 1
  6026. card.layer?.shadowColor = NSColor.black.cgColor
  6027. card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  6028. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  6029. card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
  6030. }
  6031. // MARK: - Home UI
  6032. private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
  6033. let root = NSView()
  6034. let shell = NSView()
  6035. shell.wantsLayer = true
  6036. shell.layer?.backgroundColor = appShellBackground.cgColor
  6037. shell.layer?.cornerRadius = appShellCornerRadius
  6038. shell.layer?.borderWidth = 1
  6039. shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  6040. let chromeColumn = NSView()
  6041. chromeColumn.wantsLayer = true
  6042. chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  6043. chromeColumn.layer?.cornerRadius = appShellCornerRadius
  6044. chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
  6045. chromeColumn.layer?.masksToBounds = true
  6046. let chromeDivider = NSView()
  6047. chromeDivider.wantsLayer = true
  6048. chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
  6049. let chromeHeader = NSView()
  6050. chromeHeader.wantsLayer = true
  6051. chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  6052. homeSidebarRowViews = [:]
  6053. homeSidebarIconViews = [:]
  6054. homeSidebarLabels = [:]
  6055. let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Widgets", "Ai Assistant", "Settings"], selected: selectedHomeSidebarItem, style: .home)
  6056. let content = NSView()
  6057. content.wantsLayer = true
  6058. content.layer?.backgroundColor = NSColor.clear.cgColor
  6059. root.addSubview(shell)
  6060. shell.addSubview(chromeColumn)
  6061. shell.addSubview(content)
  6062. chromeColumn.addSubview(chromeDivider)
  6063. chromeColumn.addSubview(chromeHeader)
  6064. chromeColumn.addSubview(sidebar)
  6065. shell.translatesAutoresizingMaskIntoConstraints = false
  6066. chromeColumn.translatesAutoresizingMaskIntoConstraints = false
  6067. chromeDivider.translatesAutoresizingMaskIntoConstraints = false
  6068. chromeHeader.translatesAutoresizingMaskIntoConstraints = false
  6069. sidebar.translatesAutoresizingMaskIntoConstraints = false
  6070. content.translatesAutoresizingMaskIntoConstraints = false
  6071. NSLayoutConstraint.activate([
  6072. shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6073. shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6074. shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
  6075. shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  6076. chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
  6077. chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
  6078. chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
  6079. chromeColumn.widthAnchor.constraint(equalToConstant: 82),
  6080. chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  6081. chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  6082. chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6083. chromeDivider.widthAnchor.constraint(equalToConstant: 1),
  6084. chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  6085. chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  6086. chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6087. chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
  6088. sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  6089. sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6090. sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
  6091. sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  6092. content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  6093. content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
  6094. content.topAnchor.constraint(equalTo: shell.topAnchor),
  6095. content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
  6096. ])
  6097. let brandStack = NSStackView()
  6098. brandStack.orientation = .vertical
  6099. brandStack.spacing = 0
  6100. brandStack.alignment = .leading
  6101. let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
  6102. let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
  6103. brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
  6104. brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
  6105. [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
  6106. let topBar = NSView()
  6107. topBar.wantsLayer = true
  6108. topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  6109. let topBarDivider = NSView()
  6110. topBarDivider.wantsLayer = true
  6111. topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
  6112. let searchPill = NSView()
  6113. searchPill.wantsLayer = true
  6114. searchPill.layer?.backgroundColor = searchPillBackground.cgColor
  6115. searchPill.layer?.cornerRadius = 10
  6116. searchPill.layer?.borderWidth = 0
  6117. let searchIcon = NSImageView()
  6118. searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
  6119. searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
  6120. searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  6121. searchIcon.imageScaling = .scaleProportionallyUpOrDown
  6122. let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
  6123. searchHintLabel.isHidden = false
  6124. let searchField = SearchPillTextField()
  6125. searchField.isBordered = false
  6126. searchField.drawsBackground = false
  6127. searchField.backgroundColor = .clear
  6128. searchField.focusRingType = .none
  6129. searchField.isEditable = false
  6130. searchField.isSelectable = false
  6131. searchField.font = .systemFont(ofSize: 13, weight: .regular)
  6132. searchField.textColor = primaryText
  6133. searchField.alignment = .left
  6134. searchField.placeholderString = nil
  6135. if let cell = searchField.cell as? NSTextFieldCell {
  6136. cell.isBezeled = false
  6137. cell.isBordered = false
  6138. cell.backgroundColor = .clear
  6139. }
  6140. let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
  6141. guard let searchField, let searchHintLabel else { return }
  6142. let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
  6143. searchHintLabel.isHidden = shouldShow == false
  6144. }
  6145. searchField.onFocusChange = { [weak self] focused in
  6146. self?.applySearchPillFocusBorder(focused: focused)
  6147. updateSearchHintVisibility()
  6148. }
  6149. updateSearchHintVisibility()
  6150. let searchTap = NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped))
  6151. searchPill.addGestureRecognizer(searchTap)
  6152. searchField.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  6153. searchIcon.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  6154. searchHintLabel.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  6155. let searchRow = NSStackView()
  6156. searchRow.orientation = .horizontal
  6157. searchRow.spacing = 14
  6158. searchRow.alignment = .centerY
  6159. searchRow.addArrangedSubview(searchPill)
  6160. let rightTopBarCluster = NSStackView()
  6161. rightTopBarCluster.orientation = .horizontal
  6162. rightTopBarCluster.spacing = 10
  6163. rightTopBarCluster.alignment = .centerY
  6164. let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
  6165. topBarPremiumButton = upgradeToProButton
  6166. updateTopBarPremiumButton()
  6167. let authButton = NSButton(title: "", target: self, action: #selector(topBarAuthButtonTapped))
  6168. authButton.isBordered = false
  6169. authButton.wantsLayer = true
  6170. authButton.layer?.cornerRadius = 11
  6171. authButton.contentTintColor = .white
  6172. authButton.font = .systemFont(ofSize: 12, weight: .semibold)
  6173. authButton.bezelStyle = .shadowlessSquare
  6174. self.topBarAuthButton = authButton
  6175. [upgradeToProButton, authButton].forEach { rightTopBarCluster.addArrangedSubview($0) }
  6176. let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
  6177. let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
  6178. let actions = NSStackView(views: [
  6179. makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange, action: #selector(newMeetingTapped)),
  6180. makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
  6181. makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
  6182. ])
  6183. actions.orientation = .horizontal
  6184. actions.spacing = 12
  6185. actions.alignment = .centerY
  6186. actions.distribution = .fillEqually
  6187. let panel = NSView()
  6188. panel.wantsLayer = true
  6189. panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6190. panel.layer?.cornerRadius = 16
  6191. panel.layer?.borderWidth = 1
  6192. panel.layer?.borderColor = palette.inputBorder.cgColor
  6193. let todaysDateFormatter = DateFormatter()
  6194. todaysDateFormatter.dateFormat = "EEEE, MMM d"
  6195. let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
  6196. let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
  6197. let meetingsDayNav = NSStackView()
  6198. meetingsDayNav.orientation = .horizontal
  6199. meetingsDayNav.spacing = 4
  6200. meetingsDayNav.alignment = .centerY
  6201. let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
  6202. let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
  6203. let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
  6204. [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
  6205. let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
  6206. let meetingsScrollView = NSScrollView()
  6207. meetingsScrollView.drawsBackground = false
  6208. meetingsScrollView.hasVerticalScroller = true
  6209. meetingsScrollView.hasHorizontalScroller = false
  6210. meetingsScrollView.autohidesScrollers = true
  6211. let meetingsDocument = FlippedView()
  6212. let meetingsStack = NSStackView()
  6213. meetingsStack.orientation = .vertical
  6214. meetingsStack.spacing = 14
  6215. meetingsStack.alignment = .leading
  6216. // Keep meeting cards pinned to the top of the scroll content.
  6217. meetingsStack.setContentHuggingPriority(.required, for: .vertical)
  6218. meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  6219. let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
  6220. refreshMeetingsButton.isBordered = false
  6221. refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
  6222. refreshMeetingsButton.contentTintColor = primaryText
  6223. let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  6224. if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
  6225. let image = base.withSymbolConfiguration(refreshSymbolConfig) {
  6226. image.isTemplate = true
  6227. refreshMeetingsButton.image = image
  6228. refreshMeetingsButton.imagePosition = .imageLeading
  6229. refreshMeetingsButton.imageHugsTitle = true
  6230. refreshMeetingsButton.imageScaling = .scaleNone
  6231. }
  6232. refreshMeetingsButton.wantsLayer = true
  6233. refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
  6234. ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
  6235. : NSColor.black.withAlphaComponent(0.06)
  6236. ).cgColor
  6237. refreshMeetingsButton.layer?.cornerRadius = 11
  6238. refreshMeetingsButton.layer?.borderWidth = 1
  6239. refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
  6240. self.refreshMeetingsButton = refreshMeetingsButton
  6241. let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
  6242. placeholder.isHidden = true
  6243. let settingsView = makeSettingsView()
  6244. settingsView.isHidden = selectedHomeSidebarItem != "Settings"
  6245. let meetingsPageView = makeMeetingsPageView()
  6246. meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
  6247. let widgetsPageView = makeWidgetsPageView()
  6248. widgetsPageView.isHidden = selectedHomeSidebarItem != "Widgets"
  6249. let aiCompanionPageView = makeAiCompanionPageView()
  6250. aiCompanionPageView.isHidden = selectedHomeSidebarItem != HomeSection.aiCompanion.rawValue
  6251. let searchOverlay = NSView()
  6252. searchOverlay.wantsLayer = true
  6253. searchOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.45).cgColor
  6254. searchOverlay.isHidden = true
  6255. searchOverlay.translatesAutoresizingMaskIntoConstraints = false
  6256. let searchPanel = NSView()
  6257. searchPanel.wantsLayer = true
  6258. searchPanel.layer?.backgroundColor = secondaryCardBackground.cgColor
  6259. searchPanel.layer?.cornerRadius = 14
  6260. searchPanel.layer?.borderWidth = 1
  6261. searchPanel.layer?.borderColor = palette.inputBorder.cgColor
  6262. searchPanel.translatesAutoresizingMaskIntoConstraints = false
  6263. let popupTitle = makeLabel("Search Meetings", size: 18, color: primaryText, weight: .semibold, centered: false)
  6264. popupTitle.translatesAutoresizingMaskIntoConstraints = false
  6265. let popupSubtitle = makeLabel("Find any scheduled meeting instantly", size: 12, color: secondaryText, weight: .regular, centered: false)
  6266. popupSubtitle.translatesAutoresizingMaskIntoConstraints = false
  6267. let popupSearchChrome = NSView()
  6268. popupSearchChrome.translatesAutoresizingMaskIntoConstraints = false
  6269. popupSearchChrome.wantsLayer = true
  6270. popupSearchChrome.layer?.backgroundColor = searchPillBackground.cgColor
  6271. popupSearchChrome.layer?.cornerRadius = 10
  6272. popupSearchChrome.layer?.borderWidth = 1
  6273. popupSearchChrome.layer?.borderColor = palette.inputBorder.cgColor
  6274. let popupSearchIcon = NSImageView()
  6275. popupSearchIcon.translatesAutoresizingMaskIntoConstraints = false
  6276. popupSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search meetings")
  6277. popupSearchIcon.contentTintColor = secondaryText
  6278. popupSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
  6279. let popupSearchField = NSTextField()
  6280. popupSearchField.translatesAutoresizingMaskIntoConstraints = false
  6281. popupSearchField.isBordered = false
  6282. popupSearchField.drawsBackground = false
  6283. popupSearchField.backgroundColor = .clear
  6284. popupSearchField.focusRingType = .none
  6285. popupSearchField.font = .systemFont(ofSize: 15, weight: .regular)
  6286. popupSearchField.textColor = primaryText
  6287. popupSearchField.placeholderString = "Search all meetings by title, host, source, ID..."
  6288. let popupResultsScroll = NSScrollView()
  6289. popupResultsScroll.translatesAutoresizingMaskIntoConstraints = false
  6290. popupResultsScroll.drawsBackground = false
  6291. popupResultsScroll.hasVerticalScroller = true
  6292. popupResultsScroll.hasHorizontalScroller = false
  6293. popupResultsScroll.autohidesScrollers = true
  6294. let popupResultsDocument = FlippedView()
  6295. popupResultsDocument.translatesAutoresizingMaskIntoConstraints = false
  6296. let popupResultsStack = NSStackView()
  6297. popupResultsStack.translatesAutoresizingMaskIntoConstraints = false
  6298. popupResultsStack.orientation = .vertical
  6299. popupResultsStack.spacing = 10
  6300. popupResultsStack.alignment = .leading
  6301. let popupEmpty = makeLabel("No meetings match your search.", size: 13, color: secondaryText, weight: .regular, centered: true)
  6302. popupEmpty.translatesAutoresizingMaskIntoConstraints = false
  6303. popupEmpty.isHidden = true
  6304. root.addSubview(searchOverlay)
  6305. searchOverlay.addSubview(searchPanel)
  6306. searchPanel.addSubview(popupTitle)
  6307. searchPanel.addSubview(popupSubtitle)
  6308. searchPanel.addSubview(popupSearchChrome)
  6309. searchPanel.addSubview(popupResultsScroll)
  6310. searchPanel.addSubview(popupEmpty)
  6311. popupSearchChrome.addSubview(popupSearchIcon)
  6312. popupSearchChrome.addSubview(popupSearchField)
  6313. popupResultsScroll.documentView = popupResultsDocument
  6314. popupResultsDocument.addSubview(popupResultsStack)
  6315. let schedulerRoot = NSView()
  6316. schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
  6317. let schedulerSplit = NSStackView()
  6318. schedulerSplit.orientation = .horizontal
  6319. schedulerSplit.spacing = 14
  6320. schedulerSplit.distribution = .fillEqually
  6321. schedulerSplit.alignment = .top
  6322. let schedulerCalendarPanel = NSView()
  6323. schedulerCalendarPanel.wantsLayer = true
  6324. schedulerCalendarPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6325. schedulerCalendarPanel.layer?.cornerRadius = 16
  6326. schedulerCalendarPanel.layer?.borderWidth = 1
  6327. schedulerCalendarPanel.layer?.borderColor = palette.inputBorder.cgColor
  6328. let schedulerMeetingsPanel = NSView()
  6329. schedulerMeetingsPanel.wantsLayer = true
  6330. schedulerMeetingsPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6331. schedulerMeetingsPanel.layer?.cornerRadius = 16
  6332. schedulerMeetingsPanel.layer?.borderWidth = 1
  6333. schedulerMeetingsPanel.layer?.borderColor = palette.inputBorder.cgColor
  6334. let schedulerCalendarTopRow = NSStackView()
  6335. schedulerCalendarTopRow.orientation = .horizontal
  6336. schedulerCalendarTopRow.alignment = .centerY
  6337. schedulerCalendarTopRow.distribution = .fill
  6338. schedulerCalendarTopRow.spacing = 10
  6339. let schedulerConnectLabel = makeLabel("Zoom calendar", size: 18, color: accentBlue, weight: .semibold, centered: false)
  6340. let schedulerPlusButton = NSButton(title: "", target: self, action: #selector(scheduleMeetingTapped))
  6341. schedulerPlusButton.isBordered = false
  6342. schedulerPlusButton.bezelStyle = .shadowlessSquare
  6343. schedulerPlusButton.focusRingType = .none
  6344. schedulerPlusButton.wantsLayer = true
  6345. schedulerPlusButton.layer?.backgroundColor = accentBlue.cgColor
  6346. schedulerPlusButton.layer?.cornerRadius = 14
  6347. schedulerPlusButton.contentTintColor = .white
  6348. schedulerPlusButton.toolTip = "Connect calendar"
  6349. if let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Connect calendar") {
  6350. schedulerPlusButton.image = image
  6351. schedulerPlusButton.imagePosition = .imageOnly
  6352. }
  6353. schedulerPlusButton.translatesAutoresizingMaskIntoConstraints = false
  6354. schedulerPlusButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  6355. schedulerPlusButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  6356. let schedulerMonthRow = NSStackView()
  6357. schedulerMonthRow.orientation = .horizontal
  6358. schedulerMonthRow.alignment = .centerY
  6359. schedulerMonthRow.spacing = 8
  6360. let schedulerPrevMonthButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(schedulerPrevMonthTapped), dimension: 18, pointSize: 10, toolTip: "Previous month")
  6361. let schedulerMonthLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  6362. let schedulerNextMonthButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(schedulerNextMonthTapped), dimension: 18, pointSize: 10, toolTip: "Next month")
  6363. [schedulerPrevMonthButton, schedulerMonthLabel, schedulerNextMonthButton].forEach { schedulerMonthRow.addArrangedSubview($0) }
  6364. let schedulerWeekdaysRow = NSStackView()
  6365. schedulerWeekdaysRow.orientation = .horizontal
  6366. schedulerWeekdaysRow.distribution = .fillEqually
  6367. // Match date-row spacing so each date column aligns under weekday labels.
  6368. schedulerWeekdaysRow.spacing = 6
  6369. let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
  6370. for symbol in weekdaySymbols {
  6371. let weekdayLabel = makeLabel(symbol, size: 12, color: mutedText, weight: .medium, centered: true)
  6372. schedulerWeekdaysRow.addArrangedSubview(weekdayLabel)
  6373. }
  6374. let schedulerCalendarGrid = NSStackView()
  6375. schedulerCalendarGrid.orientation = .vertical
  6376. schedulerCalendarGrid.distribution = .fillEqually
  6377. schedulerCalendarGrid.alignment = .leading
  6378. schedulerCalendarGrid.spacing = 6
  6379. let schedulerRightTopRow = NSStackView()
  6380. schedulerRightTopRow.orientation = .horizontal
  6381. schedulerRightTopRow.alignment = .centerY
  6382. schedulerRightTopRow.spacing = 10
  6383. let schedulerSelectedDateLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  6384. let schedulerTodayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(schedulerTodayTapped))
  6385. schedulerTodayButton.toolTip = "Jump to today"
  6386. [schedulerTodayButton].forEach { schedulerRightTopRow.addArrangedSubview($0) }
  6387. let schedulerMeetingsHeader = makeLabel("Scheduled meetings", size: 13, color: secondaryText, weight: .medium, centered: false)
  6388. let schedulerMeetingsScroll = NSScrollView()
  6389. schedulerMeetingsScroll.drawsBackground = false
  6390. schedulerMeetingsScroll.hasVerticalScroller = true
  6391. schedulerMeetingsScroll.hasHorizontalScroller = false
  6392. schedulerMeetingsScroll.autohidesScrollers = true
  6393. let schedulerMeetingsDocument = FlippedView()
  6394. let schedulerMeetingsStack = NSStackView()
  6395. schedulerMeetingsStack.orientation = .vertical
  6396. schedulerMeetingsStack.spacing = 12
  6397. schedulerMeetingsStack.alignment = .leading
  6398. schedulerMeetingsStack.setContentHuggingPriority(.required, for: .vertical)
  6399. schedulerMeetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  6400. schedulerMeetingsScroll.documentView = schedulerMeetingsDocument
  6401. schedulerMeetingsDocument.addSubview(schedulerMeetingsStack)
  6402. let schedulerEmptyLabel = makeLabel("No meetings scheduled for this date.", size: 15, color: secondaryText, weight: .regular, centered: true)
  6403. schedulerEmptyLabel.isHidden = true
  6404. let bodyRow = NSView()
  6405. bodyRow.translatesAutoresizingMaskIntoConstraints = false
  6406. let mainContentColumn = NSView()
  6407. mainContentColumn.translatesAutoresizingMaskIntoConstraints = false
  6408. mainContentColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  6409. let rightWebPanel = NSView()
  6410. rightWebPanel.translatesAutoresizingMaskIntoConstraints = false
  6411. rightWebPanel.wantsLayer = true
  6412. rightWebPanel.layer?.cornerRadius = 16
  6413. rightWebPanel.layer?.masksToBounds = true
  6414. rightWebPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6415. rightWebPanel.layer?.borderWidth = 1
  6416. rightWebPanel.layer?.borderColor = palette.inputBorder.cgColor
  6417. let webToolbar = NSView()
  6418. webToolbar.translatesAutoresizingMaskIntoConstraints = false
  6419. webToolbar.wantsLayer = true
  6420. webToolbar.layer?.backgroundColor = chromeUnifiedBackground.withAlphaComponent(0.55).cgColor
  6421. func prepareEmbeddedToolButton(_ button: NSButton, symbol: String) {
  6422. button.translatesAutoresizingMaskIntoConstraints = false
  6423. button.bezelStyle = .toolbar
  6424. if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) {
  6425. button.image = image
  6426. }
  6427. }
  6428. let browserBack = NSButton()
  6429. let browserForward = NSButton()
  6430. let browserReload = NSButton()
  6431. let browserClose = NSButton()
  6432. prepareEmbeddedToolButton(browserBack, symbol: "chevron.left")
  6433. prepareEmbeddedToolButton(browserForward, symbol: "chevron.right")
  6434. prepareEmbeddedToolButton(browserReload, symbol: "arrow.clockwise")
  6435. prepareEmbeddedToolButton(browserClose, symbol: "xmark.circle.fill")
  6436. browserClose.toolTip = "Close web panel"
  6437. browserBack.target = self
  6438. browserBack.action = #selector(embeddedBrowserBackTapped)
  6439. browserForward.target = self
  6440. browserForward.action = #selector(embeddedBrowserForwardTapped)
  6441. browserReload.target = self
  6442. browserReload.action = #selector(embeddedBrowserReloadTapped)
  6443. browserClose.target = self
  6444. browserClose.action = #selector(embeddedBrowserClosePanelTapped)
  6445. let toolbarSpring = NSView()
  6446. toolbarSpring.translatesAutoresizingMaskIntoConstraints = false
  6447. toolbarSpring.setContentHuggingPriority(.defaultLow, for: .horizontal)
  6448. let webToolBarStack = NSStackView(views: [browserBack, browserForward, browserReload, toolbarSpring, browserClose])
  6449. webToolBarStack.orientation = .horizontal
  6450. webToolBarStack.spacing = 6
  6451. webToolBarStack.alignment = .centerY
  6452. webToolBarStack.translatesAutoresizingMaskIntoConstraints = false
  6453. let embeddedBrowserConfiguration = WKWebViewConfiguration()
  6454. embeddedBrowserConfiguration.websiteDataStore = zoomEmbeddedWebsiteDataStore
  6455. embeddedBrowserConfiguration.processPool = zoomEmbeddedWebProcessPool
  6456. embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
  6457. embeddedBrowserConfiguration.mediaTypesRequiringUserActionForPlayback = []
  6458. let meetingLifecycleController = WKUserContentController()
  6459. meetingLifecycleController.add(self, name: Self.embeddedZoomMeetingLifecycleHandlerName)
  6460. let lifecycleScript = WKUserScript(
  6461. source: Self.embeddedZoomMeetingLifecycleJavaScript,
  6462. injectionTime: .atDocumentEnd,
  6463. forMainFrameOnly: true
  6464. )
  6465. meetingLifecycleController.addUserScript(lifecycleScript)
  6466. embeddedBrowserConfiguration.userContentController = meetingLifecycleController
  6467. let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
  6468. embeddedWK.customUserAgent = embeddedZoomWebViewUserAgent
  6469. embeddedWK.translatesAutoresizingMaskIntoConstraints = false
  6470. embeddedWK.setContentCompressionResistancePriority(.init(1), for: .horizontal)
  6471. embeddedWK.setContentHuggingPriority(.init(1), for: .horizontal)
  6472. embeddedWK.navigationDelegate = self
  6473. embeddedWK.uiDelegate = self
  6474. content.addSubview(topBar)
  6475. content.addSubview(topBarDivider)
  6476. content.addSubview(bodyRow)
  6477. bodyRow.addSubview(mainContentColumn)
  6478. bodyRow.addSubview(rightWebPanel)
  6479. rightWebPanel.addSubview(webToolbar)
  6480. webToolbar.addSubview(webToolBarStack)
  6481. rightWebPanel.addSubview(embeddedWK)
  6482. let rightPanelWidthConstraint = rightWebPanel.widthAnchor.constraint(equalToConstant: 0)
  6483. let mainColumnCollapsedWidthConstraint = mainContentColumn.widthAnchor.constraint(equalToConstant: 0)
  6484. mainColumnCollapsedWidthConstraint.isActive = false
  6485. [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
  6486. $0.translatesAutoresizingMaskIntoConstraints = false
  6487. }
  6488. [brandStack].forEach {
  6489. shell.addSubview($0)
  6490. }
  6491. [searchRow, rightTopBarCluster].forEach {
  6492. topBar.addSubview($0)
  6493. }
  6494. [searchIcon, searchField, searchHintLabel].forEach {
  6495. searchPill.addSubview($0)
  6496. }
  6497. [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, widgetsPageView, aiCompanionPageView, schedulerRoot].forEach {
  6498. $0.translatesAutoresizingMaskIntoConstraints = false
  6499. mainContentColumn.addSubview($0)
  6500. }
  6501. [schedulerSplit, schedulerCalendarPanel, schedulerMeetingsPanel, schedulerCalendarTopRow, schedulerConnectLabel, schedulerPlusButton, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid, schedulerRightTopRow, schedulerSelectedDateLabel, schedulerTodayButton, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel, schedulerMeetingsDocument, schedulerMeetingsStack].forEach {
  6502. $0.translatesAutoresizingMaskIntoConstraints = false
  6503. }
  6504. schedulerRoot.addSubview(schedulerSplit)
  6505. [schedulerCalendarPanel, schedulerMeetingsPanel].forEach { schedulerSplit.addArrangedSubview($0) }
  6506. [schedulerCalendarTopRow, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid].forEach { schedulerCalendarPanel.addSubview($0) }
  6507. schedulerCalendarTopRow.addArrangedSubview(schedulerConnectLabel)
  6508. schedulerCalendarTopRow.addArrangedSubview(NSView())
  6509. schedulerCalendarTopRow.addArrangedSubview(schedulerPlusButton)
  6510. [schedulerSelectedDateLabel, schedulerRightTopRow, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel].forEach { schedulerMeetingsPanel.addSubview($0) }
  6511. topBar.translatesAutoresizingMaskIntoConstraints = false
  6512. topBarDivider.translatesAutoresizingMaskIntoConstraints = false
  6513. meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
  6514. meetingsStack.translatesAutoresizingMaskIntoConstraints = false
  6515. meetingsScrollView.documentView = meetingsDocument
  6516. meetingsDocument.addSubview(meetingsStack)
  6517. let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
  6518. searchRowCenterX.priority = .defaultHigh
  6519. let brandLeading = brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset)
  6520. brandLeadingConstraint = brandLeading
  6521. NSLayoutConstraint.activate([
  6522. brandLeading,
  6523. brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
  6524. brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
  6525. topBar.topAnchor.constraint(equalTo: content.topAnchor),
  6526. topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  6527. topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  6528. topBar.heightAnchor.constraint(equalToConstant: 56),
  6529. topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
  6530. topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  6531. topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  6532. topBarDivider.heightAnchor.constraint(equalToConstant: 1),
  6533. bodyRow.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
  6534. bodyRow.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
  6535. bodyRow.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
  6536. bodyRow.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
  6537. mainContentColumn.leadingAnchor.constraint(equalTo: bodyRow.leadingAnchor),
  6538. mainContentColumn.topAnchor.constraint(equalTo: bodyRow.topAnchor),
  6539. mainContentColumn.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
  6540. mainContentColumn.trailingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor, constant: -homeEmbeddedBrowserColumnGap),
  6541. rightWebPanel.topAnchor.constraint(equalTo: bodyRow.topAnchor),
  6542. rightWebPanel.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
  6543. rightWebPanel.trailingAnchor.constraint(equalTo: bodyRow.trailingAnchor),
  6544. rightPanelWidthConstraint,
  6545. webToolbar.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
  6546. webToolbar.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
  6547. webToolbar.topAnchor.constraint(equalTo: rightWebPanel.topAnchor),
  6548. webToolbar.heightAnchor.constraint(equalToConstant: 40),
  6549. webToolBarStack.leadingAnchor.constraint(equalTo: webToolbar.leadingAnchor, constant: 8),
  6550. webToolBarStack.trailingAnchor.constraint(equalTo: webToolbar.trailingAnchor, constant: -8),
  6551. webToolBarStack.centerYAnchor.constraint(equalTo: webToolbar.centerYAnchor),
  6552. embeddedWK.topAnchor.constraint(equalTo: webToolbar.bottomAnchor),
  6553. embeddedWK.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
  6554. embeddedWK.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
  6555. embeddedWK.bottomAnchor.constraint(equalTo: rightWebPanel.bottomAnchor),
  6556. searchRowCenterX,
  6557. searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  6558. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
  6559. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
  6560. searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
  6561. rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  6562. rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  6563. searchPill.heightAnchor.constraint(equalToConstant: 32),
  6564. searchPill.widthAnchor.constraint(equalToConstant: 320),
  6565. searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
  6566. searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  6567. searchIcon.widthAnchor.constraint(equalToConstant: 16),
  6568. searchIcon.heightAnchor.constraint(equalToConstant: 16),
  6569. searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
  6570. searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  6571. searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
  6572. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  6573. searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
  6574. searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  6575. authButton.heightAnchor.constraint(equalToConstant: 30),
  6576. timeTitle.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 20),
  6577. timeTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6578. dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
  6579. dateTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6580. actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
  6581. actions.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6582. actions.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 12),
  6583. actions.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -12),
  6584. actions.heightAnchor.constraint(equalToConstant: 100),
  6585. panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
  6586. panel.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6587. panel.widthAnchor.constraint(equalToConstant: 610),
  6588. panel.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 6),
  6589. panel.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -6),
  6590. panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  6591. panel.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -14),
  6592. panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
  6593. panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
  6594. meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  6595. meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  6596. meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
  6597. meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
  6598. noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  6599. noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  6600. noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
  6601. meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
  6602. meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  6603. meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  6604. meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
  6605. meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
  6606. meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
  6607. meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
  6608. meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
  6609. meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
  6610. refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  6611. refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  6612. refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
  6613. refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
  6614. placeholder.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  6615. placeholder.centerYAnchor.constraint(equalTo: mainContentColumn.centerYAnchor),
  6616. settingsView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6617. settingsView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6618. settingsView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6619. settingsView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6620. meetingsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6621. meetingsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6622. meetingsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6623. meetingsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6624. widgetsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6625. widgetsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6626. widgetsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6627. widgetsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6628. aiCompanionPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  6629. aiCompanionPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  6630. aiCompanionPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  6631. aiCompanionPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  6632. schedulerRoot.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 8),
  6633. schedulerRoot.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor, constant: 8),
  6634. schedulerRoot.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor, constant: -8),
  6635. schedulerRoot.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -8),
  6636. schedulerSplit.topAnchor.constraint(equalTo: schedulerRoot.topAnchor),
  6637. schedulerSplit.leadingAnchor.constraint(equalTo: schedulerRoot.leadingAnchor),
  6638. schedulerSplit.trailingAnchor.constraint(equalTo: schedulerRoot.trailingAnchor),
  6639. schedulerSplit.bottomAnchor.constraint(equalTo: schedulerRoot.bottomAnchor),
  6640. schedulerCalendarTopRow.topAnchor.constraint(equalTo: schedulerCalendarPanel.topAnchor, constant: 18),
  6641. schedulerCalendarTopRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6642. schedulerCalendarTopRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6643. schedulerMonthRow.topAnchor.constraint(equalTo: schedulerCalendarTopRow.bottomAnchor, constant: 18),
  6644. schedulerMonthRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6645. schedulerMonthRow.trailingAnchor.constraint(lessThanOrEqualTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6646. schedulerWeekdaysRow.topAnchor.constraint(equalTo: schedulerMonthRow.bottomAnchor, constant: 14),
  6647. schedulerWeekdaysRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6648. schedulerWeekdaysRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6649. schedulerCalendarGrid.topAnchor.constraint(equalTo: schedulerWeekdaysRow.bottomAnchor, constant: 10),
  6650. schedulerCalendarGrid.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  6651. schedulerCalendarGrid.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  6652. schedulerCalendarGrid.bottomAnchor.constraint(equalTo: schedulerCalendarPanel.bottomAnchor, constant: -16),
  6653. schedulerCalendarGrid.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  6654. schedulerSelectedDateLabel.topAnchor.constraint(equalTo: schedulerMeetingsPanel.topAnchor, constant: 18),
  6655. schedulerSelectedDateLabel.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  6656. schedulerSelectedDateLabel.trailingAnchor.constraint(lessThanOrEqualTo: schedulerRightTopRow.leadingAnchor, constant: -10),
  6657. schedulerRightTopRow.centerYAnchor.constraint(equalTo: schedulerSelectedDateLabel.centerYAnchor),
  6658. schedulerRightTopRow.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  6659. schedulerMeetingsHeader.topAnchor.constraint(equalTo: schedulerSelectedDateLabel.bottomAnchor, constant: 12),
  6660. schedulerMeetingsHeader.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  6661. schedulerMeetingsHeader.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  6662. schedulerMeetingsScroll.topAnchor.constraint(equalTo: schedulerMeetingsHeader.bottomAnchor, constant: 10),
  6663. schedulerMeetingsScroll.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 14),
  6664. schedulerMeetingsScroll.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -14),
  6665. schedulerMeetingsScroll.bottomAnchor.constraint(equalTo: schedulerMeetingsPanel.bottomAnchor, constant: -14),
  6666. schedulerMeetingsDocument.widthAnchor.constraint(equalTo: schedulerMeetingsScroll.contentView.widthAnchor),
  6667. schedulerMeetingsStack.topAnchor.constraint(equalTo: schedulerMeetingsDocument.topAnchor),
  6668. schedulerMeetingsStack.leadingAnchor.constraint(equalTo: schedulerMeetingsDocument.leadingAnchor),
  6669. schedulerMeetingsStack.trailingAnchor.constraint(equalTo: schedulerMeetingsDocument.trailingAnchor),
  6670. schedulerMeetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: schedulerMeetingsDocument.bottomAnchor),
  6671. schedulerEmptyLabel.centerXAnchor.constraint(equalTo: schedulerMeetingsPanel.centerXAnchor),
  6672. schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor),
  6673. searchOverlay.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  6674. searchOverlay.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  6675. searchOverlay.topAnchor.constraint(equalTo: root.topAnchor),
  6676. searchOverlay.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  6677. searchPanel.centerXAnchor.constraint(equalTo: searchOverlay.centerXAnchor),
  6678. searchPanel.centerYAnchor.constraint(equalTo: searchOverlay.centerYAnchor),
  6679. searchPanel.widthAnchor.constraint(equalToConstant: 640),
  6680. searchPanel.heightAnchor.constraint(equalToConstant: 460),
  6681. popupTitle.topAnchor.constraint(equalTo: searchPanel.topAnchor, constant: 18),
  6682. popupTitle.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  6683. popupTitle.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  6684. popupSubtitle.topAnchor.constraint(equalTo: popupTitle.bottomAnchor, constant: 4),
  6685. popupSubtitle.leadingAnchor.constraint(equalTo: popupTitle.leadingAnchor),
  6686. popupSubtitle.trailingAnchor.constraint(equalTo: popupTitle.trailingAnchor),
  6687. popupSearchChrome.topAnchor.constraint(equalTo: popupSubtitle.bottomAnchor, constant: 12),
  6688. popupSearchChrome.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  6689. popupSearchChrome.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  6690. popupSearchChrome.heightAnchor.constraint(equalToConstant: 40),
  6691. popupSearchIcon.leadingAnchor.constraint(equalTo: popupSearchChrome.leadingAnchor, constant: 12),
  6692. popupSearchIcon.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  6693. popupSearchIcon.widthAnchor.constraint(equalToConstant: 16),
  6694. popupSearchIcon.heightAnchor.constraint(equalToConstant: 16),
  6695. popupSearchField.leadingAnchor.constraint(equalTo: popupSearchIcon.trailingAnchor, constant: 8),
  6696. popupSearchField.trailingAnchor.constraint(equalTo: popupSearchChrome.trailingAnchor, constant: -12),
  6697. popupSearchField.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  6698. popupResultsScroll.topAnchor.constraint(equalTo: popupSearchChrome.bottomAnchor, constant: 12),
  6699. popupResultsScroll.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  6700. popupResultsScroll.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  6701. popupResultsScroll.bottomAnchor.constraint(equalTo: searchPanel.bottomAnchor, constant: -18),
  6702. popupResultsStack.topAnchor.constraint(equalTo: popupResultsDocument.topAnchor),
  6703. popupResultsStack.leadingAnchor.constraint(equalTo: popupResultsDocument.leadingAnchor),
  6704. popupResultsStack.trailingAnchor.constraint(equalTo: popupResultsDocument.trailingAnchor),
  6705. popupResultsStack.bottomAnchor.constraint(lessThanOrEqualTo: popupResultsDocument.bottomAnchor),
  6706. popupResultsStack.widthAnchor.constraint(equalTo: popupResultsScroll.contentView.widthAnchor),
  6707. popupEmpty.centerXAnchor.constraint(equalTo: popupResultsScroll.centerXAnchor),
  6708. popupEmpty.centerYAnchor.constraint(equalTo: popupResultsScroll.centerYAnchor)
  6709. ])
  6710. timeLabel = timeTitle
  6711. dateLabel = dateTitle
  6712. homeWelcomeLabel = nil
  6713. homeTimeLabelView = timeTitle
  6714. homeDateLabelView = dateTitle
  6715. homeActionsRow = actions
  6716. homeMeetingsPanel = panel
  6717. homePlaceholderLabel = placeholder
  6718. homeSettingsView = settingsView
  6719. homeSettingsHostColumn = mainContentColumn
  6720. meetingsPageRootView = meetingsPageView
  6721. widgetsPageRootView = widgetsPageView
  6722. aiCompanionPageRootView = aiCompanionPageView
  6723. schedulerRootView = schedulerRoot
  6724. self.schedulerMonthLabel = schedulerMonthLabel
  6725. schedulerCalendarGridStack = schedulerCalendarGrid
  6726. self.schedulerSelectedDateLabel = schedulerSelectedDateLabel
  6727. schedulerMeetingsListStack = schedulerMeetingsStack
  6728. schedulerMeetingsEmptyLabel = schedulerEmptyLabel
  6729. self.schedulerTodayButton = schedulerTodayButton
  6730. meetingsDayHeaderLabel = panelHeader
  6731. meetingsListStack = meetingsStack
  6732. meetingsStatusLabel = meetingsStatus
  6733. emptyMeetingLabel = noMeeting
  6734. meetingsPrevDayButton = prevDayButton
  6735. meetingsTodayButton = todayButton
  6736. meetingsNextDayButton = nextDayButton
  6737. observeMeetingsScrollEdges(in: meetingsScrollView)
  6738. updateClock()
  6739. updateMeetingsDayUI()
  6740. applyFilteredMeetings()
  6741. applyMeetingsPageFilters(resetVisible: true)
  6742. renderSchedulerCalendarGrid()
  6743. updateSchedulerDateHeader()
  6744. applySchedulerMeetingsForSelectedDate()
  6745. homeSearchField = searchField
  6746. homeSearchPill = searchPill
  6747. meetingSearchOverlayView = searchOverlay
  6748. meetingSearchPanelView = searchPanel
  6749. meetingSearchField = popupSearchField
  6750. meetingSearchResultsStack = popupResultsStack
  6751. meetingSearchEmptyLabel = popupEmpty
  6752. removeMeetingSearchObserver()
  6753. meetingSearchTextObserver = NotificationCenter.default.addObserver(
  6754. forName: NSControl.textDidChangeNotification,
  6755. object: popupSearchField,
  6756. queue: .main
  6757. ) { [weak self] _ in
  6758. guard let self else { return }
  6759. self.applyGlobalMeetingSearch(query: popupSearchField.stringValue)
  6760. }
  6761. applyGlobalMeetingSearch(query: "")
  6762. topBarAuthButtonWidthConstraint = authButton.widthAnchor.constraint(equalToConstant: 120)
  6763. topBarAuthButtonWidthConstraint?.isActive = true
  6764. updateTopBarAuthButton()
  6765. applyAuthenticationUIState(triggerInitialRefresh: false)
  6766. updatePremiumLockedSections()
  6767. embeddedBrowserWebView = embeddedWK
  6768. embeddedBrowserPanel = rightWebPanel
  6769. homeBodyRowView = bodyRow
  6770. homeMainContentColumnView = mainContentColumn
  6771. embeddedBrowserPanelWidthConstraint = rightPanelWidthConstraint
  6772. embeddedBrowserMainColumnCollapsedWidthConstraint = mainColumnCollapsedWidthConstraint
  6773. embeddedBrowserBackButton = browserBack
  6774. embeddedBrowserForwardButton = browserForward
  6775. refreshEmbeddedBrowserNavButtons()
  6776. return root
  6777. }
  6778. @objc private func premiumLockedAreaTapped(_ sender: NSButton) {
  6779. showPaywall()
  6780. }
  6781. @MainActor
  6782. private func updatePremiumLockedSections() {
  6783. let shouldLock = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  6784. refreshWidgetsPagePremiumState()
  6785. homePremiumLockOverlay = updatePremiumLockOverlay(
  6786. existing: homePremiumLockOverlay,
  6787. in: homeMeetingsPanel,
  6788. locked: shouldLock,
  6789. message: "Upgrade to Premium to access meetings"
  6790. )
  6791. meetingsPremiumLockOverlay = updatePremiumLockOverlay(
  6792. existing: meetingsPremiumLockOverlay,
  6793. in: meetingsPageRootView,
  6794. locked: shouldLock,
  6795. message: "Upgrade to Premium to unlock Meetings"
  6796. )
  6797. schedulerPremiumLockOverlay = updatePremiumLockOverlay(
  6798. existing: schedulerPremiumLockOverlay,
  6799. in: schedulerRootView,
  6800. locked: shouldLock,
  6801. message: "Upgrade to Premium to unlock Scheduler"
  6802. )
  6803. }
  6804. @MainActor
  6805. private func updatePremiumLockOverlay(
  6806. existing: NSButton?,
  6807. in target: NSView?,
  6808. locked: Bool,
  6809. message: String
  6810. ) -> NSButton? {
  6811. guard let target else { return existing }
  6812. let button: NSButton
  6813. if let existing, existing.superview === target {
  6814. button = existing
  6815. } else {
  6816. let created = NSButton(title: message, target: self, action: #selector(premiumLockedAreaTapped(_:)))
  6817. created.translatesAutoresizingMaskIntoConstraints = false
  6818. created.isBordered = false
  6819. created.bezelStyle = .shadowlessSquare
  6820. created.alignment = .center
  6821. created.wantsLayer = true
  6822. created.layer?.cornerRadius = 12
  6823. created.layer?.masksToBounds = true
  6824. created.layer?.backgroundColor = NSColor.clear.cgColor
  6825. created.font = .systemFont(ofSize: 15, weight: .semibold)
  6826. created.contentTintColor = .white
  6827. let blur = NSVisualEffectView()
  6828. blur.translatesAutoresizingMaskIntoConstraints = false
  6829. blur.material = .hudWindow
  6830. blur.blendingMode = .withinWindow
  6831. blur.state = .active
  6832. created.addSubview(blur, positioned: .below, relativeTo: nil)
  6833. let dim = NSView()
  6834. dim.translatesAutoresizingMaskIntoConstraints = false
  6835. dim.wantsLayer = true
  6836. dim.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  6837. created.addSubview(dim, positioned: .above, relativeTo: blur)
  6838. target.addSubview(created)
  6839. NSLayoutConstraint.activate([
  6840. created.leadingAnchor.constraint(equalTo: target.leadingAnchor),
  6841. created.trailingAnchor.constraint(equalTo: target.trailingAnchor),
  6842. created.topAnchor.constraint(equalTo: target.topAnchor),
  6843. created.bottomAnchor.constraint(equalTo: target.bottomAnchor),
  6844. blur.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  6845. blur.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  6846. blur.topAnchor.constraint(equalTo: created.topAnchor),
  6847. blur.bottomAnchor.constraint(equalTo: created.bottomAnchor),
  6848. dim.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  6849. dim.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  6850. dim.topAnchor.constraint(equalTo: created.topAnchor),
  6851. dim.bottomAnchor.constraint(equalTo: created.bottomAnchor)
  6852. ])
  6853. button = created
  6854. }
  6855. let blurView = button.subviews.compactMap { $0 as? NSVisualEffectView }.first
  6856. let dimView = button.subviews.first { $0 is NSVisualEffectView == false }
  6857. if palette.isDarkMode {
  6858. blurView?.material = .hudWindow
  6859. button.contentTintColor = .white
  6860. dimView?.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  6861. } else {
  6862. // Lighter, cleaner lock treatment in light mode.
  6863. blurView?.material = .popover
  6864. button.contentTintColor = NSColor(calibratedWhite: 0.09, alpha: 1.0)
  6865. dimView?.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.38).cgColor
  6866. }
  6867. button.title = message
  6868. button.isHidden = locked == false
  6869. return button
  6870. }
  6871. private func makeWidgetsPageView() -> NSView {
  6872. let canAdd = storeKitCoordinator.hasPremiumAccess
  6873. let host = NSHostingView(
  6874. rootView: WidgetsRootView(
  6875. canAddWidgets: canAdd,
  6876. onAddBlocked: { [weak self] in
  6877. self?.showPaywall()
  6878. }
  6879. )
  6880. )
  6881. host.wantsLayer = true
  6882. host.layer?.backgroundColor = NSColor.clear.cgColor
  6883. return host
  6884. }
  6885. private func makeAiCompanionPageView() -> NSView {
  6886. let root = NSView()
  6887. root.wantsLayer = true
  6888. root.layer?.backgroundColor = NSColor.clear.cgColor
  6889. // Let the window dictate width; long status copy must not inflate the layout minimum.
  6890. root.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  6891. let container = NSView()
  6892. container.translatesAutoresizingMaskIntoConstraints = false
  6893. container.wantsLayer = true
  6894. container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6895. container.layer?.cornerRadius = 16
  6896. container.layer?.borderWidth = 1
  6897. container.layer?.borderColor = palette.inputBorder.cgColor
  6898. root.addSubview(container)
  6899. // Header row: recordings list (AI Companion mode).
  6900. let title = makeLabel("Ai Assistant", size: 28, color: primaryText, weight: .bold, centered: false)
  6901. let subtitle = makeLabel("Get notes from your previous meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
  6902. let count = makeLabel("0 recordings", size: 12, color: secondaryText, weight: .medium, centered: false)
  6903. let titleStack = NSStackView(views: [title, subtitle, count])
  6904. titleStack.orientation = .vertical
  6905. titleStack.spacing = 4
  6906. titleStack.alignment = .leading
  6907. titleStack.translatesAutoresizingMaskIntoConstraints = false
  6908. // Old Zoom AI summaries controls are intentionally hidden/unused.
  6909. let loadingIndicator = NSProgressIndicator()
  6910. loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
  6911. loadingIndicator.style = .spinning
  6912. loadingIndicator.controlSize = .small
  6913. loadingIndicator.isDisplayedWhenStopped = false
  6914. loadingIndicator.isIndeterminate = true
  6915. loadingIndicator.isHidden = true
  6916. let stopRecording = NSButton(title: "Stop & save", target: self, action: #selector(aiCompanionStopRecordingTapped))
  6917. stopRecording.translatesAutoresizingMaskIntoConstraints = false
  6918. stopRecording.isBordered = false
  6919. stopRecording.wantsLayer = true
  6920. stopRecording.layer?.backgroundColor = accentBlue.cgColor
  6921. stopRecording.layer?.cornerRadius = 10
  6922. stopRecording.contentTintColor = .white
  6923. stopRecording.font = .systemFont(ofSize: 13, weight: .semibold)
  6924. stopRecording.isHidden = true
  6925. let toolbarRight = NSStackView(views: [stopRecording, loadingIndicator])
  6926. toolbarRight.orientation = .horizontal
  6927. toolbarRight.alignment = .centerY
  6928. toolbarRight.spacing = 10
  6929. toolbarRight.translatesAutoresizingMaskIntoConstraints = false
  6930. container.addSubview(titleStack)
  6931. container.addSubview(toolbarRight)
  6932. // Filter row matches `makeMeetingsPageView` styling; preset list is Ai Companion–specific.
  6933. let categoryFilter = NSPopUpButton()
  6934. categoryFilter.translatesAutoresizingMaskIntoConstraints = false
  6935. categoryFilter.addItems(withTitles: AiCompanionRecordingFilter.allCases.map(\.rawValue))
  6936. categoryFilter.selectItem(withTitle: AiCompanionRecordingFilter.all.rawValue)
  6937. categoryFilter.target = self
  6938. categoryFilter.action = #selector(aiCompanionFilterPresetChanged(_:))
  6939. let fromDatePicker = NSDatePicker()
  6940. fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
  6941. fromDatePicker.datePickerStyle = .textFieldAndStepper
  6942. fromDatePicker.datePickerMode = .single
  6943. fromDatePicker.datePickerElements = [.yearMonthDay]
  6944. fromDatePicker.dateValue = Date()
  6945. fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  6946. let toDatePicker = NSDatePicker()
  6947. toDatePicker.translatesAutoresizingMaskIntoConstraints = false
  6948. toDatePicker.datePickerStyle = .textFieldAndStepper
  6949. toDatePicker.datePickerMode = .single
  6950. toDatePicker.datePickerElements = [.yearMonthDay]
  6951. toDatePicker.dateValue = Date()
  6952. toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  6953. let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(aiCompanionFilterApplyTapped))
  6954. applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  6955. applyFiltersButton.isBordered = false
  6956. applyFiltersButton.wantsLayer = true
  6957. applyFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  6958. applyFiltersButton.layer?.cornerRadius = 10
  6959. applyFiltersButton.layer?.borderWidth = 1
  6960. applyFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  6961. applyFiltersButton.contentTintColor = primaryText
  6962. applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  6963. let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(aiCompanionFilterResetTapped))
  6964. resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  6965. resetFiltersButton.isBordered = false
  6966. resetFiltersButton.wantsLayer = true
  6967. resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  6968. resetFiltersButton.layer?.cornerRadius = 10
  6969. resetFiltersButton.layer?.borderWidth = 1
  6970. resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  6971. resetFiltersButton.contentTintColor = primaryText
  6972. resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  6973. let refreshButton = NSButton(title: "", target: self, action: #selector(aiCompanionFilterRefreshTapped))
  6974. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  6975. refreshButton.isBordered = false
  6976. refreshButton.wantsLayer = true
  6977. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  6978. refreshButton.layer?.cornerRadius = 10
  6979. refreshButton.layer?.borderWidth = 1
  6980. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  6981. refreshButton.contentTintColor = primaryText
  6982. refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
  6983. if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
  6984. refreshButton.image = image
  6985. refreshButton.imagePosition = .imageOnly
  6986. }
  6987. let filterBar = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
  6988. filterBar.orientation = .horizontal
  6989. filterBar.spacing = 10
  6990. filterBar.alignment = .centerY
  6991. filterBar.translatesAutoresizingMaskIntoConstraints = false
  6992. configureResponsiveMeetingsStyleFilterRow(
  6993. filterRow: filterBar,
  6994. categoryFilter: categoryFilter,
  6995. fromDatePicker: fromDatePicker,
  6996. toDatePicker: toDatePicker,
  6997. applyFiltersButton: applyFiltersButton,
  6998. resetFiltersButton: resetFiltersButton,
  6999. refreshButton: refreshButton
  7000. )
  7001. container.addSubview(filterBar)
  7002. // Hint text below filters (recording help — not Zoom API status).
  7003. let status = makeLabel("", size: 12, color: secondaryText, weight: .regular, centered: false)
  7004. status.translatesAutoresizingMaskIntoConstraints = false
  7005. status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  7006. status.lineBreakMode = .byWordWrapping
  7007. status.maximumNumberOfLines = 0
  7008. if let cell = status.cell as? NSTextFieldCell {
  7009. cell.wraps = true
  7010. }
  7011. container.addSubview(status)
  7012. // List scroll view (left column).
  7013. let listScroll = NSScrollView()
  7014. listScroll.translatesAutoresizingMaskIntoConstraints = false
  7015. listScroll.drawsBackground = false
  7016. listScroll.hasHorizontalScroller = false
  7017. listScroll.hasVerticalScroller = true
  7018. listScroll.autohidesScrollers = true
  7019. listScroll.borderType = .noBorder
  7020. listScroll.scrollerStyle = .overlay
  7021. listScroll.automaticallyAdjustsContentInsets = false
  7022. let listClip = TopAlignedClipView()
  7023. listClip.drawsBackground = false
  7024. listScroll.contentView = listClip
  7025. // Wrapper document view (same pattern as Settings scroll): without a bottom
  7026. // `>= clip` chain, a vertical stack as documentView can stretch to the clip height
  7027. // and leave rows pinned to the bottom.
  7028. let listDocument = NSView()
  7029. listDocument.translatesAutoresizingMaskIntoConstraints = false
  7030. let listStack = NSStackView()
  7031. listStack.translatesAutoresizingMaskIntoConstraints = false
  7032. listStack.orientation = .vertical
  7033. listStack.alignment = .leading
  7034. listStack.spacing = 10
  7035. listScroll.documentView = listDocument
  7036. listDocument.addSubview(listStack)
  7037. container.addSubview(listScroll)
  7038. let listEmpty = makeLabel("No recordings yet.", size: 12, color: secondaryText, weight: .regular, centered: true)
  7039. listEmpty.translatesAutoresizingMaskIntoConstraints = false
  7040. listEmpty.maximumNumberOfLines = 3
  7041. listEmpty.lineBreakMode = .byWordWrapping
  7042. listEmpty.isHidden = true
  7043. container.addSubview(listEmpty)
  7044. // Old detail pane (AI notes) removed.
  7045. let divider = NSView()
  7046. divider.translatesAutoresizingMaskIntoConstraints = false
  7047. divider.wantsLayer = true
  7048. divider.layer?.backgroundColor = palette.inputBorder.cgColor
  7049. divider.isHidden = true
  7050. container.addSubview(divider)
  7051. NSLayoutConstraint.activate([
  7052. container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  7053. container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  7054. container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  7055. container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  7056. titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
  7057. titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
  7058. titleStack.trailingAnchor.constraint(lessThanOrEqualTo: toolbarRight.leadingAnchor, constant: -16),
  7059. toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
  7060. toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
  7061. stopRecording.heightAnchor.constraint(equalToConstant: 30),
  7062. stopRecording.widthAnchor.constraint(greaterThanOrEqualToConstant: 110),
  7063. filterBar.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
  7064. filterBar.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  7065. filterBar.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
  7066. status.topAnchor.constraint(equalTo: filterBar.bottomAnchor, constant: 12),
  7067. status.leadingAnchor.constraint(equalTo: filterBar.leadingAnchor),
  7068. status.trailingAnchor.constraint(equalTo: filterBar.trailingAnchor),
  7069. listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
  7070. listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  7071. listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
  7072. listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  7073. listDocument.topAnchor.constraint(equalTo: listClip.topAnchor),
  7074. listDocument.leadingAnchor.constraint(equalTo: listClip.leadingAnchor),
  7075. listDocument.trailingAnchor.constraint(equalTo: listClip.trailingAnchor),
  7076. listDocument.widthAnchor.constraint(equalTo: listClip.widthAnchor),
  7077. listDocument.bottomAnchor.constraint(greaterThanOrEqualTo: listClip.bottomAnchor),
  7078. listStack.topAnchor.constraint(equalTo: listDocument.topAnchor, constant: 8),
  7079. listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
  7080. listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
  7081. listDocument.bottomAnchor.constraint(greaterThanOrEqualTo: listStack.bottomAnchor, constant: 8),
  7082. listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
  7083. listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
  7084. listEmpty.widthAnchor.constraint(lessThanOrEqualTo: listScroll.widthAnchor, constant: -24),
  7085. divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
  7086. divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
  7087. divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
  7088. divider.widthAnchor.constraint(equalToConstant: 1)
  7089. ])
  7090. aiCompanionListScroll = listScroll
  7091. aiCompanionListStack = listStack
  7092. aiCompanionStatusLabel = status
  7093. aiCompanionCountLabel = count
  7094. aiCompanionEmptyLabel = listEmpty
  7095. aiCompanionLoadingIndicator = loadingIndicator
  7096. aiCompanionDetailEmptyLabel = nil
  7097. aiCompanionDetailScroll = nil
  7098. aiCompanionDetailStack = nil
  7099. aiCompanionStopRecordingButton = stopRecording
  7100. aiCompanionFilterPopup = categoryFilter
  7101. aiCompanionFilterFromPicker = fromDatePicker
  7102. aiCompanionFilterToPicker = toDatePicker
  7103. aiCompanionFilterApplyButton = applyFiltersButton
  7104. aiCompanionFilterResetButton = resetFiltersButton
  7105. aiCompanionFilterRefreshButton = refreshButton
  7106. updateAiCompanionDatePickersEnabledState()
  7107. renderAiCompanionList()
  7108. updateAiCompanionRecordingUI()
  7109. return root
  7110. }
  7111. /// Mirrors `updateMeetingsDatePickersEnabledState` for the Ai Companion recordings filter row.
  7112. @MainActor
  7113. private func updateAiCompanionDatePickersEnabledState() {
  7114. let isCustomRange = aiCompanionRecordingFilter == .custom
  7115. aiCompanionFilterFromPicker?.isEnabled = isCustomRange
  7116. aiCompanionFilterToPicker?.isEnabled = isCustomRange
  7117. aiCompanionFilterFromPicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  7118. aiCompanionFilterToPicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  7119. }
  7120. @objc private func aiCompanionFilterPresetChanged(_ sender: NSPopUpButton) {
  7121. guard let title = sender.titleOfSelectedItem,
  7122. let preset = AiCompanionRecordingFilter(rawValue: title) else { return }
  7123. aiCompanionRecordingFilter = preset
  7124. updateAiCompanionDatePickersEnabledState()
  7125. renderAiCompanionList()
  7126. }
  7127. @objc private func aiCompanionFilterApplyTapped() {
  7128. renderAiCompanionList()
  7129. }
  7130. @objc private func aiCompanionFilterResetTapped() {
  7131. aiCompanionRecordingFilter = .all
  7132. aiCompanionFilterPopup?.selectItem(withTitle: AiCompanionRecordingFilter.all.rawValue)
  7133. aiCompanionFilterFromPicker?.dateValue = Date()
  7134. aiCompanionFilterToPicker?.dateValue = Date()
  7135. updateAiCompanionDatePickersEnabledState()
  7136. renderAiCompanionList()
  7137. }
  7138. @objc private func aiCompanionFilterRefreshTapped() {
  7139. recordedMeetings = MeetingRecordingManager.shared.recordings
  7140. renderAiCompanionList()
  7141. updateAiCompanionRecordingUI()
  7142. }
  7143. private func aiCompanionDateFilterHalfOpenInterval() -> (start: Date, endExclusive: Date)? {
  7144. let cal = Calendar.current
  7145. let now = Date()
  7146. switch aiCompanionRecordingFilter {
  7147. case .all:
  7148. return nil
  7149. case .today:
  7150. let d = cal.startOfDay(for: now)
  7151. guard let end = cal.date(byAdding: .day, value: 1, to: d) else { return nil }
  7152. return (d, end)
  7153. case .thisWeek:
  7154. guard let week = cal.dateInterval(of: .weekOfYear, for: now) else { return nil }
  7155. return (week.start, week.end)
  7156. case .previousWeek:
  7157. guard let thisWeek = cal.dateInterval(of: .weekOfYear, for: now) else { return nil }
  7158. let prevStart = cal.date(byAdding: .day, value: -7, to: thisWeek.start) ?? thisWeek.start
  7159. return (prevStart, thisWeek.start)
  7160. case .previousMonth:
  7161. let comps = cal.dateComponents([.year, .month], from: now)
  7162. guard let thisMonthStart = cal.date(from: comps) else { return nil }
  7163. guard let prevMonthStart = cal.date(byAdding: .month, value: -1, to: thisMonthStart) else { return nil }
  7164. return (prevMonthStart, thisMonthStart)
  7165. case .custom:
  7166. guard let fromPicker = aiCompanionFilterFromPicker, let toPicker = aiCompanionFilterToPicker else { return nil }
  7167. let a = cal.startOfDay(for: fromPicker.dateValue)
  7168. let b = cal.startOfDay(for: toPicker.dateValue)
  7169. let lo = min(a, b)
  7170. let hi = max(a, b)
  7171. guard let endExclusive = cal.date(byAdding: .day, value: 1, to: hi) else { return nil }
  7172. return (lo, endExclusive)
  7173. }
  7174. }
  7175. private func aiCompanionFilteredRecordings() -> [MeetingRecordingManager.RecordedMeeting] {
  7176. guard let interval = aiCompanionDateFilterHalfOpenInterval() else {
  7177. return recordedMeetings
  7178. }
  7179. return recordedMeetings.filter { $0.startedAt >= interval.start && $0.startedAt < interval.endExclusive }
  7180. }
  7181. @MainActor
  7182. private func updateAiCompanionRecordingUI() {
  7183. let mgr = MeetingRecordingManager.shared
  7184. aiCompanionStopRecordingButton?.isHidden = !mgr.isRecording
  7185. if mgr.isRecording {
  7186. aiCompanionStatusLabel?.stringValue =
  7187. "Recording… Stops automatically when you leave or the meeting ends."
  7188. aiCompanionStatusLabel?.font = .systemFont(ofSize: 12, weight: .regular)
  7189. aiCompanionStatusLabel?.textColor = secondaryText
  7190. } else {
  7191. aiCompanionStatusLabel?.stringValue = "Previous meetings"
  7192. aiCompanionStatusLabel?.font = .systemFont(ofSize: 15, weight: .semibold)
  7193. aiCompanionStatusLabel?.textColor = primaryText
  7194. }
  7195. refreshAiCompanionStatusPreferredMaxLayoutWidth()
  7196. }
  7197. /// Keeps the status field wrapping inside the card so a long path or error string cannot widen the window.
  7198. @MainActor
  7199. private func refreshAiCompanionStatusPreferredMaxLayoutWidth() {
  7200. guard let status = aiCompanionStatusLabel, let container = status.superview else { return }
  7201. let w = container.bounds.width
  7202. guard w > 1 else { return }
  7203. let margin: CGFloat = 48
  7204. status.preferredMaxLayoutWidth = max(160, w - margin)
  7205. }
  7206. @objc private func aiCompanionStopRecordingTapped() {
  7207. stopEmbeddedMeetingRecordingMonitor()
  7208. Task { @MainActor in
  7209. do {
  7210. try await MeetingRecordingManager.shared.stopRecording()
  7211. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  7212. self.renderAiCompanionList()
  7213. self.updateAiCompanionRecordingUI()
  7214. } catch {
  7215. self.showSimpleAlert(title: "Could not save recording", message: error.localizedDescription)
  7216. }
  7217. }
  7218. }
  7219. // MARK: - AI Companion list / detail rendering
  7220. @MainActor
  7221. private func renderAiCompanionList() {
  7222. guard let stack = aiCompanionListStack else { return }
  7223. aiCompanionRowButtons.removeAll()
  7224. stack.arrangedSubviews.forEach { view in
  7225. stack.removeArrangedSubview(view)
  7226. view.removeFromSuperview()
  7227. }
  7228. let displayed = aiCompanionFilteredRecordings()
  7229. let totalCount = recordedMeetings.count
  7230. let displayedCount = displayed.count
  7231. if aiCompanionRecordingFilter == .all {
  7232. aiCompanionCountLabel?.stringValue = totalCount == 1 ? "1 recording" : "\(totalCount) recordings"
  7233. } else {
  7234. aiCompanionCountLabel?.stringValue = "\(displayedCount) of \(totalCount) recordings"
  7235. }
  7236. let showEmpty = displayed.isEmpty
  7237. if showEmpty {
  7238. if totalCount == 0 {
  7239. aiCompanionEmptyLabel?.stringValue = "No previous meetings yet."
  7240. } else {
  7241. aiCompanionEmptyLabel?.stringValue = "No previous meetings matched the selected filter."
  7242. }
  7243. }
  7244. aiCompanionEmptyLabel?.isHidden = !showEmpty
  7245. for recording in displayed {
  7246. guard let idx = recordedMeetings.firstIndex(where: { $0.id == recording.id }) else { continue }
  7247. let row = makeRecordedMeetingCard(recording: recording, index: idx)
  7248. stack.addArrangedSubview(row)
  7249. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  7250. }
  7251. refreshLocalRecordingTranscriptPanelContentIfNeeded()
  7252. refreshLocalRecordingNotesPanelContentIfNeeded()
  7253. }
  7254. private func localRecordingNotesStatus(for recording: MeetingRecordingManager.RecordedMeeting) -> LocalRecordingNotesStatus {
  7255. if let raw = recording.notesStatusRaw, let s = LocalRecordingNotesStatus(rawValue: raw) {
  7256. if s == .notRequested,
  7257. let t = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
  7258. t.isEmpty == false {
  7259. return .ready
  7260. }
  7261. return s
  7262. }
  7263. if let t = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines), t.isEmpty == false {
  7264. return .ready
  7265. }
  7266. return .notRequested
  7267. }
  7268. private func aiCompanionCardDateLabel(for date: Date) -> String {
  7269. let formatter = DateFormatter()
  7270. formatter.locale = Locale(identifier: "en_GB")
  7271. formatter.dateFormat = "d-MMMM-yyyy"
  7272. return "Date: \(formatter.string(from: date))"
  7273. }
  7274. private func aiCompanionCardSavedTimeLabel(for date: Date) -> String {
  7275. let formatter = DateFormatter()
  7276. formatter.locale = Locale(identifier: "en_US_POSIX")
  7277. formatter.dateStyle = .none
  7278. formatter.timeStyle = .short
  7279. return "Saved time: \(formatter.string(from: date))"
  7280. }
  7281. private let aiCompanionGetNotesButtonHeight: CGFloat = 28
  7282. private func styleAiCompanionGetNotesButton(_ button: NSButton, enabled: Bool) {
  7283. let pillHeight = aiCompanionGetNotesButtonHeight
  7284. button.isBordered = false
  7285. button.wantsLayer = true
  7286. button.layer?.cornerRadius = pillHeight / 2
  7287. button.layer?.borderWidth = 1
  7288. button.font = .systemFont(ofSize: 12, weight: .semibold)
  7289. button.imagePosition = .imageLeading
  7290. button.imageHugsTitle = true
  7291. let enabledFill = palette.isDarkMode
  7292. ? NSColor(calibratedRed: 22 / 255, green: 34 / 255, blue: 54 / 255, alpha: 1)
  7293. : NSColor(calibratedRed: 236 / 255, green: 243 / 255, blue: 252 / 255, alpha: 1)
  7294. let disabledFill = palette.isDarkMode
  7295. ? NSColor(calibratedRed: 18 / 255, green: 26 / 255, blue: 40 / 255, alpha: 1)
  7296. : NSColor(calibratedRed: 245 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
  7297. if enabled {
  7298. button.layer?.backgroundColor = enabledFill.cgColor
  7299. button.layer?.borderColor = accentBlue.cgColor
  7300. button.contentTintColor = accentBlue
  7301. if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
  7302. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  7303. .applying(NSImage.SymbolConfiguration(hierarchicalColor: accentBlue))
  7304. button.image = icon.withSymbolConfiguration(config)
  7305. }
  7306. } else {
  7307. let mutedBlue = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.38 : 0.45)
  7308. button.layer?.backgroundColor = disabledFill.cgColor
  7309. button.layer?.borderColor = mutedBlue.cgColor
  7310. button.contentTintColor = mutedBlue
  7311. if let icon = NSImage(systemSymbolName: "note.text", accessibilityDescription: nil) {
  7312. let config = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  7313. .applying(NSImage.SymbolConfiguration(hierarchicalColor: mutedBlue))
  7314. button.image = icon.withSymbolConfiguration(config)
  7315. }
  7316. }
  7317. }
  7318. private func localRecordingNotesStatusLabel(
  7319. for recording: MeetingRecordingManager.RecordedMeeting,
  7320. notesStatus: LocalRecordingNotesStatus
  7321. ) -> (text: String, color: NSColor) {
  7322. if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
  7323. return ("Generating transcript…", secondaryText)
  7324. }
  7325. if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
  7326. return ("Transcript unavailable", NSColor.systemRed)
  7327. }
  7328. switch notesStatus {
  7329. case .notRequested:
  7330. let hasTranscript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
  7331. if hasTranscript {
  7332. return ("Notes not requested", secondaryText)
  7333. }
  7334. return ("Waiting for transcript…", secondaryText)
  7335. case .processing:
  7336. let msg = localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
  7337. return (msg, secondaryText)
  7338. case .ready:
  7339. return ("Notes ready", secondaryText)
  7340. case .failed:
  7341. let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7342. if err.isEmpty {
  7343. return ("Notes could not be generated", NSColor.systemRed)
  7344. }
  7345. return (err, NSColor.systemRed)
  7346. }
  7347. }
  7348. @MainActor
  7349. private func makeRecordedMeetingCard(recording: MeetingRecordingManager.RecordedMeeting, index: Int) -> NSView {
  7350. let card = NSView()
  7351. card.translatesAutoresizingMaskIntoConstraints = false
  7352. card.wantsLayer = true
  7353. card.layer?.cornerRadius = 12
  7354. card.layer?.borderWidth = 1
  7355. card.layer?.borderColor = palette.inputBorder.cgColor
  7356. card.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.04) : NSColor.black.withAlphaComponent(0.03)).cgColor
  7357. let title = makeLabel(recording.resolvedDisplayTitle, size: 15, color: primaryText, weight: .semibold, centered: false)
  7358. title.translatesAutoresizingMaskIntoConstraints = false
  7359. title.maximumNumberOfLines = 2
  7360. title.lineBreakMode = .byTruncatingTail
  7361. let dateLabel = makeLabel(aiCompanionCardDateLabel(for: recording.startedAt), size: 12, color: secondaryText, weight: .regular, centered: false)
  7362. dateLabel.translatesAutoresizingMaskIntoConstraints = false
  7363. let savedTimeLabel = makeLabel(aiCompanionCardSavedTimeLabel(for: recording.endedAt), size: 12, color: secondaryText, weight: .regular, centered: false)
  7364. savedTimeLabel.translatesAutoresizingMaskIntoConstraints = false
  7365. let notesStatus = localRecordingNotesStatus(for: recording)
  7366. let hasTranscript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
  7367. let transcriptionBusy = aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id)
  7368. let notesReady = notesStatus == .ready
  7369. let getNotesTitle = notesReady ? "View Notes" : "Get Notes"
  7370. let getNotesBtn = NSButton(title: getNotesTitle, target: self, action: #selector(recordingGetNotesTapped(_:)))
  7371. getNotesBtn.translatesAutoresizingMaskIntoConstraints = false
  7372. getNotesBtn.tag = index
  7373. let getNotesEnabled = (notesStatus != .processing)
  7374. && (notesReady || notesStatus == .failed || (hasTranscript && !transcriptionBusy))
  7375. getNotesBtn.isEnabled = getNotesEnabled
  7376. styleAiCompanionGetNotesButton(getNotesBtn, enabled: getNotesEnabled)
  7377. let statusInfo = localRecordingNotesStatusLabel(for: recording, notesStatus: notesStatus)
  7378. let statusLabel = makeLabel(statusInfo.text, size: 12, color: statusInfo.color, weight: .regular, centered: false)
  7379. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  7380. statusLabel.maximumNumberOfLines = 3
  7381. statusLabel.lineBreakMode = .byWordWrapping
  7382. if let cell = statusLabel.cell as? NSTextFieldCell {
  7383. cell.wraps = true
  7384. }
  7385. card.addSubview(title)
  7386. card.addSubview(dateLabel)
  7387. card.addSubview(savedTimeLabel)
  7388. card.addSubview(getNotesBtn)
  7389. card.addSubview(statusLabel)
  7390. NSLayoutConstraint.activate([
  7391. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  7392. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  7393. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7394. dateLabel.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 8),
  7395. dateLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7396. dateLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7397. savedTimeLabel.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 4),
  7398. savedTimeLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7399. savedTimeLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7400. getNotesBtn.topAnchor.constraint(equalTo: savedTimeLabel.bottomAnchor, constant: 12),
  7401. getNotesBtn.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7402. getNotesBtn.heightAnchor.constraint(equalToConstant: aiCompanionGetNotesButtonHeight),
  7403. getNotesBtn.widthAnchor.constraint(greaterThanOrEqualToConstant: 108),
  7404. statusLabel.topAnchor.constraint(equalTo: getNotesBtn.bottomAnchor, constant: 8),
  7405. statusLabel.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7406. statusLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  7407. statusLabel.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -14)
  7408. ])
  7409. return card
  7410. }
  7411. private func bodyTextForLocalRecordingTranscript(recording: MeetingRecordingManager.RecordedMeeting) -> String {
  7412. if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
  7413. return "Generating transcript…"
  7414. }
  7415. if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
  7416. return "Transcript could not be generated.\n\n\(err)"
  7417. }
  7418. if let text = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines), text.isEmpty == false {
  7419. return text
  7420. }
  7421. 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."
  7422. }
  7423. private func bodyTextForLocalRecordingNotes(recording: MeetingRecordingManager.RecordedMeeting) -> String {
  7424. switch localRecordingNotesStatus(for: recording) {
  7425. case .processing:
  7426. return localRecordingNotesProgressByRecordingId[recording.id] ?? "Generating notes…"
  7427. case .failed:
  7428. let err = recording.notesError?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7429. return err.isEmpty ? "Notes could not be generated." : "Notes unavailable.\n\n\(err)"
  7430. case .ready:
  7431. return recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7432. case .notRequested:
  7433. return "Tap Get notes on the recording card after the transcript is ready."
  7434. }
  7435. }
  7436. @MainActor
  7437. private func refreshLocalRecordingNotesPanelContentIfNeeded() {
  7438. guard let recordingID = aiCompanionLocalNotesPanelRecordingID,
  7439. let textView = aiCompanionLocalNotesTextView,
  7440. let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
  7441. textView.string = bodyTextForLocalRecordingNotes(recording: recording)
  7442. }
  7443. @MainActor
  7444. private func presentLocalRecordingNotesPanel(recording: MeetingRecordingManager.RecordedMeeting, initialText: String?) {
  7445. guard let hostWindow = view.window else { return }
  7446. let meetingTitle = recording.resolvedDisplayTitle
  7447. let trimmedInitial = initialText?.trimmingCharacters(in: .whitespacesAndNewlines)
  7448. let body: String = {
  7449. if let trimmedInitial, trimmedInitial.isEmpty == false { return trimmedInitial }
  7450. return bodyTextForLocalRecordingNotes(recording: recording)
  7451. }()
  7452. let hostIsDarkMode = darkModeEnabled
  7453. let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
  7454. let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
  7455. let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
  7456. if let panel = aiCompanionLocalNotesPanel, let textView = aiCompanionLocalNotesTextView {
  7457. panel.title = "Notes - \(meetingTitle)"
  7458. panel.appearance = NSAppearance(named: panelAppearanceName)
  7459. panel.backgroundColor = outerBackgroundColor
  7460. textView.string = body
  7461. aiCompanionLocalNotesPanelRecordingID = recording.id
  7462. aiCompanionLocalNotesRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
  7463. aiCompanionLocalNotesHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
  7464. aiCompanionLocalNotesSubtitleLabel?.stringValue = meetingTitle
  7465. if hostWindow.attachedSheet !== panel {
  7466. hostWindow.beginSheet(panel, completionHandler: nil)
  7467. }
  7468. panel.makeKeyAndOrderFront(nil)
  7469. NSApp.activate(ignoringOtherApps: true)
  7470. return
  7471. }
  7472. let panelWidth: CGFloat = 720
  7473. let panelHeight: CGFloat = 540
  7474. let panel = NSPanel(
  7475. contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
  7476. styleMask: [.titled, .closable],
  7477. backing: .buffered,
  7478. defer: false
  7479. )
  7480. panel.isReleasedWhenClosed = false
  7481. panel.title = "Notes - \(meetingTitle)"
  7482. panel.isFloatingPanel = false
  7483. panel.hidesOnDeactivate = false
  7484. panel.delegate = self
  7485. panel.appearance = NSAppearance(named: panelAppearanceName)
  7486. panel.backgroundColor = outerBackgroundColor
  7487. panel.standardWindowButton(.zoomButton)?.isHidden = true
  7488. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  7489. let root = NSView()
  7490. root.translatesAutoresizingMaskIntoConstraints = false
  7491. root.wantsLayer = true
  7492. root.layer?.backgroundColor = outerBackgroundColor.cgColor
  7493. let header = NSView()
  7494. header.translatesAutoresizingMaskIntoConstraints = false
  7495. header.wantsLayer = true
  7496. header.layer?.backgroundColor = headerBackgroundColor.cgColor
  7497. header.layer?.cornerRadius = 10
  7498. let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
  7499. let titleLabel = textLabel("Meeting notes", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
  7500. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  7501. let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
  7502. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  7503. subtitleLabel.lineBreakMode = .byTruncatingTail
  7504. let scroll = NSScrollView()
  7505. scroll.translatesAutoresizingMaskIntoConstraints = false
  7506. scroll.drawsBackground = true
  7507. scroll.backgroundColor = NSColor.textBackgroundColor
  7508. scroll.hasVerticalScroller = true
  7509. scroll.hasHorizontalScroller = false
  7510. scroll.borderType = .bezelBorder
  7511. let textView = NSTextView()
  7512. textView.isEditable = false
  7513. textView.isSelectable = true
  7514. textView.backgroundColor = NSColor.textBackgroundColor
  7515. textView.textColor = NSColor.labelColor
  7516. textView.font = .systemFont(ofSize: 13, weight: .regular)
  7517. textView.string = body
  7518. textView.textContainer?.widthTracksTextView = true
  7519. textView.textContainerInset = NSSize(width: 10, height: 10)
  7520. let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalNotesCopyTapped(_:)))
  7521. copyButton.translatesAutoresizingMaskIntoConstraints = false
  7522. copyButton.bezelStyle = .rounded
  7523. let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalNotesCloseTapped(_:)))
  7524. closeButton.translatesAutoresizingMaskIntoConstraints = false
  7525. closeButton.bezelStyle = .rounded
  7526. scroll.documentView = textView
  7527. header.addSubview(titleLabel)
  7528. header.addSubview(subtitleLabel)
  7529. header.addSubview(copyButton)
  7530. header.addSubview(closeButton)
  7531. root.addSubview(header)
  7532. root.addSubview(scroll)
  7533. NSLayoutConstraint.activate([
  7534. header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
  7535. header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7536. header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7537. titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
  7538. titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7539. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7540. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  7541. subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7542. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7543. subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
  7544. copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7545. copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
  7546. closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7547. closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
  7548. scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
  7549. scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7550. scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7551. scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
  7552. ])
  7553. panel.contentView = root
  7554. aiCompanionLocalNotesPanel = panel
  7555. aiCompanionLocalNotesTextView = textView
  7556. aiCompanionLocalNotesRootView = root
  7557. aiCompanionLocalNotesHeaderView = header
  7558. aiCompanionLocalNotesSubtitleLabel = subtitleLabel
  7559. aiCompanionLocalNotesPanelRecordingID = recording.id
  7560. hostWindow.beginSheet(panel, completionHandler: nil)
  7561. panel.makeKeyAndOrderFront(nil)
  7562. NSApp.activate(ignoringOtherApps: true)
  7563. }
  7564. @MainActor
  7565. private func startLocalRecordingNotesProcessing(recordingId: String, transcript: String, requestId: UUID, autoPresentOnReady: Bool) {
  7566. localRecordingNotesTaskByRecordingId[recordingId]?.cancel()
  7567. localRecordingNotesRequestIdByRecordingId[recordingId] = requestId
  7568. localRecordingNotesProgressByRecordingId[recordingId] = "Generating notes…"
  7569. MeetingRecordingManager.shared.setMeetingNotes(
  7570. recordingId: recordingId,
  7571. notesStatusRaw: LocalRecordingNotesStatus.processing.rawValue,
  7572. notesText: nil,
  7573. notesError: nil
  7574. )
  7575. recordedMeetings = MeetingRecordingManager.shared.recordings
  7576. renderAiCompanionList()
  7577. let task = Task { [weak self] in
  7578. guard let self else { return }
  7579. do {
  7580. // Notes language must follow the transcript text, not Apple Speech locale prefs
  7581. // (e.g. Urdu selected for recognition must not force Urdu notes for an English transcript).
  7582. let notes = try await self.meetingNotesService.generateNotes(from: transcript)
  7583. if Task.isCancelled {
  7584. await MainActor.run { [weak self] in
  7585. guard let self else { return }
  7586. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7587. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7588. }
  7589. }
  7590. return
  7591. }
  7592. await MainActor.run { [weak self] in
  7593. guard let self else { return }
  7594. guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
  7595. self.localRecordingNotesProgressByRecordingId[recordingId] = nil
  7596. MeetingRecordingManager.shared.setMeetingNotes(
  7597. recordingId: recordingId,
  7598. notesStatusRaw: LocalRecordingNotesStatus.ready.rawValue,
  7599. notesText: notes,
  7600. notesError: nil
  7601. )
  7602. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  7603. self.refreshLocalRecordingNotesPanelContentIfNeeded()
  7604. self.renderAiCompanionList()
  7605. if autoPresentOnReady, let rec = self.recordedMeetings.first(where: { $0.id == recordingId }) {
  7606. self.presentLocalRecordingNotesPanel(recording: rec, initialText: notes)
  7607. }
  7608. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7609. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7610. }
  7611. }
  7612. } catch {
  7613. if error is CancellationError {
  7614. await MainActor.run { [weak self] in
  7615. guard let self else { return }
  7616. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7617. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7618. }
  7619. }
  7620. return
  7621. }
  7622. await MainActor.run { [weak self] in
  7623. guard let self else { return }
  7624. guard self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId else { return }
  7625. self.localRecordingNotesProgressByRecordingId[recordingId] = nil
  7626. let msg = error.localizedDescription.isEmpty ? "Failed to generate notes." : error.localizedDescription
  7627. MeetingRecordingManager.shared.setMeetingNotes(
  7628. recordingId: recordingId,
  7629. notesStatusRaw: LocalRecordingNotesStatus.failed.rawValue,
  7630. notesText: nil,
  7631. notesError: msg
  7632. )
  7633. self.recordedMeetings = MeetingRecordingManager.shared.recordings
  7634. self.refreshLocalRecordingNotesPanelContentIfNeeded()
  7635. self.renderAiCompanionList()
  7636. if self.localRecordingNotesRequestIdByRecordingId[recordingId] == requestId {
  7637. self.localRecordingNotesTaskByRecordingId[recordingId] = nil
  7638. }
  7639. }
  7640. }
  7641. }
  7642. localRecordingNotesTaskByRecordingId[recordingId] = task
  7643. }
  7644. @objc private func recordingGetNotesTapped(_ sender: NSButton) {
  7645. let idx = sender.tag
  7646. guard recordedMeetings.indices.contains(idx) else { return }
  7647. let recording = recordedMeetings[idx]
  7648. let status = localRecordingNotesStatus(for: recording)
  7649. if status == .ready,
  7650. let text = recording.notesText?.trimmingCharacters(in: .whitespacesAndNewlines),
  7651. text.isEmpty == false {
  7652. presentLocalRecordingNotesPanel(recording: recording, initialText: text)
  7653. return
  7654. }
  7655. if status == .processing { return }
  7656. if aiCompanionTranscriptionActiveRecordingIDs.contains(recording.id) {
  7657. showSimpleAlert(title: "Transcript not ready", message: "Wait until the transcript finishes generating, then try Get notes again.")
  7658. return
  7659. }
  7660. let transcript = recording.transcriptText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  7661. if transcript.isEmpty {
  7662. if let err = recording.transcriptError?.trimmingCharacters(in: .whitespacesAndNewlines), err.isEmpty == false {
  7663. showSimpleAlert(title: "No transcript", message: "Transcription failed for this recording, so notes cannot be generated.")
  7664. } else {
  7665. showSimpleAlert(title: "No transcript yet", message: "Wait until the transcript is available (or open Transcript to check progress), then tap Get notes.")
  7666. }
  7667. return
  7668. }
  7669. let requestId = UUID()
  7670. startLocalRecordingNotesProcessing(recordingId: recording.id, transcript: transcript, requestId: requestId, autoPresentOnReady: true)
  7671. }
  7672. @objc private func aiCompanionLocalNotesCopyTapped(_ sender: NSButton) {
  7673. let text = aiCompanionLocalNotesTextView?.string ?? ""
  7674. NSPasteboard.general.clearContents()
  7675. NSPasteboard.general.setString(text, forType: .string)
  7676. }
  7677. @objc private func aiCompanionLocalNotesCloseTapped(_ sender: NSButton) {
  7678. guard let panel = aiCompanionLocalNotesPanel, let host = view.window else { return }
  7679. host.endSheet(panel)
  7680. }
  7681. @MainActor
  7682. private func refreshLocalRecordingTranscriptPanelContentIfNeeded() {
  7683. guard let recordingID = aiCompanionLocalTranscriptPanelRecordingID,
  7684. let textView = aiCompanionLocalTranscriptTextView,
  7685. let recording = recordedMeetings.first(where: { $0.id == recordingID }) else { return }
  7686. textView.string = bodyTextForLocalRecordingTranscript(recording: recording)
  7687. }
  7688. @MainActor
  7689. private func presentLocalRecordingTranscriptPanel(recording: MeetingRecordingManager.RecordedMeeting) {
  7690. guard let hostWindow = view.window else { return }
  7691. let meetingTitle = recording.resolvedDisplayTitle
  7692. let body = bodyTextForLocalRecordingTranscript(recording: recording)
  7693. let hostIsDarkMode = darkModeEnabled
  7694. let panelAppearanceName: NSAppearance.Name = hostIsDarkMode ? .darkAqua : .aqua
  7695. let outerBackgroundColor: NSColor = hostIsDarkMode ? .windowBackgroundColor : NSColor(calibratedWhite: 0.98, alpha: 1.0)
  7696. let headerBackgroundColor: NSColor = hostIsDarkMode ? .controlBackgroundColor : NSColor(calibratedWhite: 0.93, alpha: 1.0)
  7697. if let panel = aiCompanionLocalTranscriptPanel, let textView = aiCompanionLocalTranscriptTextView {
  7698. panel.title = "Transcript - \(meetingTitle)"
  7699. panel.appearance = NSAppearance(named: panelAppearanceName)
  7700. panel.backgroundColor = outerBackgroundColor
  7701. textView.string = body
  7702. aiCompanionLocalTranscriptPanelRecordingID = recording.id
  7703. aiCompanionLocalTranscriptRootView?.layer?.backgroundColor = outerBackgroundColor.cgColor
  7704. aiCompanionLocalTranscriptHeaderView?.layer?.backgroundColor = headerBackgroundColor.cgColor
  7705. aiCompanionLocalTranscriptSubtitleLabel?.stringValue = meetingTitle
  7706. if hostWindow.attachedSheet !== panel {
  7707. hostWindow.beginSheet(panel, completionHandler: nil)
  7708. }
  7709. panel.makeKeyAndOrderFront(nil)
  7710. NSApp.activate(ignoringOtherApps: true)
  7711. return
  7712. }
  7713. let panelWidth: CGFloat = 720
  7714. let panelHeight: CGFloat = 540
  7715. let panel = NSPanel(
  7716. contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: panelHeight),
  7717. styleMask: [.titled, .closable],
  7718. backing: .buffered,
  7719. defer: false
  7720. )
  7721. panel.isReleasedWhenClosed = false
  7722. panel.title = "Transcript - \(meetingTitle)"
  7723. panel.isFloatingPanel = false
  7724. panel.hidesOnDeactivate = false
  7725. panel.delegate = self
  7726. panel.appearance = NSAppearance(named: panelAppearanceName)
  7727. panel.backgroundColor = outerBackgroundColor
  7728. panel.standardWindowButton(.zoomButton)?.isHidden = true
  7729. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  7730. let root = NSView()
  7731. root.translatesAutoresizingMaskIntoConstraints = false
  7732. root.wantsLayer = true
  7733. root.layer?.backgroundColor = outerBackgroundColor.cgColor
  7734. let header = NSView()
  7735. header.translatesAutoresizingMaskIntoConstraints = false
  7736. header.wantsLayer = true
  7737. header.layer?.backgroundColor = headerBackgroundColor.cgColor
  7738. header.layer?.cornerRadius = 10
  7739. let headingColor: NSColor = hostIsDarkMode ? .labelColor : .black
  7740. let titleLabel = textLabel("Transcript", font: .systemFont(ofSize: 15, weight: .semibold), color: headingColor)
  7741. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  7742. let subtitleLabel = textLabel(meetingTitle, font: .systemFont(ofSize: 12, weight: .regular), color: .secondaryLabelColor)
  7743. subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
  7744. subtitleLabel.lineBreakMode = .byTruncatingTail
  7745. let scroll = NSScrollView()
  7746. scroll.translatesAutoresizingMaskIntoConstraints = false
  7747. scroll.drawsBackground = true
  7748. scroll.backgroundColor = NSColor.textBackgroundColor
  7749. scroll.hasVerticalScroller = true
  7750. scroll.hasHorizontalScroller = false
  7751. scroll.borderType = .bezelBorder
  7752. let textView = NSTextView()
  7753. textView.isEditable = false
  7754. textView.isSelectable = true
  7755. textView.backgroundColor = NSColor.textBackgroundColor
  7756. textView.textColor = NSColor.labelColor
  7757. textView.font = .systemFont(ofSize: 13, weight: .regular)
  7758. textView.string = body
  7759. textView.textContainer?.widthTracksTextView = true
  7760. textView.textContainerInset = NSSize(width: 10, height: 10)
  7761. let copyButton = NSButton(title: "Copy", target: self, action: #selector(aiCompanionLocalTranscriptCopyTapped(_:)))
  7762. copyButton.translatesAutoresizingMaskIntoConstraints = false
  7763. copyButton.bezelStyle = .rounded
  7764. let closeButton = NSButton(title: "Close", target: self, action: #selector(aiCompanionLocalTranscriptCloseTapped(_:)))
  7765. closeButton.translatesAutoresizingMaskIntoConstraints = false
  7766. closeButton.bezelStyle = .rounded
  7767. scroll.documentView = textView
  7768. header.addSubview(titleLabel)
  7769. header.addSubview(subtitleLabel)
  7770. header.addSubview(copyButton)
  7771. header.addSubview(closeButton)
  7772. root.addSubview(header)
  7773. root.addSubview(scroll)
  7774. NSLayoutConstraint.activate([
  7775. header.topAnchor.constraint(equalTo: root.topAnchor, constant: 12),
  7776. header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7777. header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7778. titleLabel.topAnchor.constraint(equalTo: header.topAnchor, constant: 12),
  7779. titleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7780. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7781. subtitleLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  7782. subtitleLabel.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 12),
  7783. subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: copyButton.leadingAnchor, constant: -12),
  7784. subtitleLabel.bottomAnchor.constraint(equalTo: header.bottomAnchor, constant: -12),
  7785. copyButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7786. copyButton.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -8),
  7787. closeButton.centerYAnchor.constraint(equalTo: header.centerYAnchor),
  7788. closeButton.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -12),
  7789. scroll.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 12),
  7790. scroll.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
  7791. scroll.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  7792. scroll.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16)
  7793. ])
  7794. panel.contentView = root
  7795. aiCompanionLocalTranscriptPanel = panel
  7796. aiCompanionLocalTranscriptTextView = textView
  7797. aiCompanionLocalTranscriptRootView = root
  7798. aiCompanionLocalTranscriptHeaderView = header
  7799. aiCompanionLocalTranscriptSubtitleLabel = subtitleLabel
  7800. aiCompanionLocalTranscriptPanelRecordingID = recording.id
  7801. hostWindow.beginSheet(panel, completionHandler: nil)
  7802. panel.makeKeyAndOrderFront(nil)
  7803. NSApp.activate(ignoringOtherApps: true)
  7804. }
  7805. @objc private func recordingTranscriptTapped(_ sender: NSButton) {
  7806. let idx = sender.tag
  7807. guard recordedMeetings.indices.contains(idx) else { return }
  7808. presentLocalRecordingTranscriptPanel(recording: recordedMeetings[idx])
  7809. }
  7810. @objc private func aiCompanionLocalTranscriptCopyTapped(_ sender: NSButton) {
  7811. let text = aiCompanionLocalTranscriptTextView?.string ?? ""
  7812. NSPasteboard.general.clearContents()
  7813. NSPasteboard.general.setString(text, forType: .string)
  7814. }
  7815. @objc private func aiCompanionLocalTranscriptCloseTapped(_ sender: NSButton) {
  7816. guard let panel = aiCompanionLocalTranscriptPanel, let host = view.window else { return }
  7817. host.endSheet(panel)
  7818. }
  7819. @objc
  7820. private func recordingPlayTapped(_ sender: NSButton) {
  7821. let idx = sender.tag
  7822. guard recordedMeetings.indices.contains(idx) else { return }
  7823. let recording = recordedMeetings[idx]
  7824. if recordedMeetingsPlayingID == recording.id {
  7825. recordedMeetingsPlayer?.stop()
  7826. recordedMeetingsPlayer = nil
  7827. recordedMeetingsPlayingID = nil
  7828. renderAiCompanionList()
  7829. return
  7830. }
  7831. do {
  7832. let url = try MeetingRecordingManager.shared.audioURL(for: recording)
  7833. recordedMeetingsPlayer?.stop()
  7834. recordedMeetingsPlayer = try AVAudioPlayer(contentsOf: url)
  7835. recordedMeetingsPlayer?.prepareToPlay()
  7836. recordedMeetingsPlayer?.play()
  7837. recordedMeetingsPlayingID = recording.id
  7838. renderAiCompanionList()
  7839. } catch {
  7840. showSimpleAlert(title: "Unable to play", message: "Could not open the audio recording.")
  7841. }
  7842. }
  7843. @MainActor
  7844. private func renderAiCompanionDetail() {
  7845. guard let stack = aiCompanionDetailStack else { return }
  7846. stack.arrangedSubviews.forEach { view in
  7847. stack.removeArrangedSubview(view)
  7848. view.removeFromSuperview()
  7849. }
  7850. guard isUserLoggedIn() else {
  7851. aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
  7852. aiCompanionDetailEmptyLabel?.isHidden = false
  7853. return
  7854. }
  7855. guard let index = aiCompanionSelectedIndex,
  7856. aiCompanionSummaries.indices.contains(index) else {
  7857. aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
  7858. ? "Run an Ai Assistant-enabled meeting to see notes here."
  7859. : "Select a meeting on the left to view its AI notes."
  7860. aiCompanionDetailEmptyLabel?.isHidden = false
  7861. return
  7862. }
  7863. aiCompanionDetailEmptyLabel?.isHidden = true
  7864. let summary = aiCompanionSummaries[index]
  7865. let topic = summary.summaryTitle?.isEmpty == false
  7866. ? summary.summaryTitle!
  7867. : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
  7868. let titleLabel = makeLabel(topic, size: 22, color: primaryText, weight: .bold, centered: false)
  7869. titleLabel.maximumNumberOfLines = 0
  7870. titleLabel.lineBreakMode = .byWordWrapping
  7871. stack.addArrangedSubview(titleLabel)
  7872. let metaText = aiCompanionDateText(for: summary)
  7873. let metaLabel = makeLabel(metaText, size: 12, color: secondaryText, weight: .regular, centered: false)
  7874. metaLabel.maximumNumberOfLines = 0
  7875. stack.addArrangedSubview(metaLabel)
  7876. let actionsRow = NSStackView()
  7877. actionsRow.orientation = .horizontal
  7878. actionsRow.spacing = 10
  7879. actionsRow.alignment = .centerY
  7880. actionsRow.translatesAutoresizingMaskIntoConstraints = false
  7881. let openDocButton = NSButton(title: "Open in Zoom Docs", target: self, action: #selector(aiCompanionOpenDocTapped))
  7882. openDocButton.translatesAutoresizingMaskIntoConstraints = false
  7883. openDocButton.isBordered = false
  7884. openDocButton.wantsLayer = true
  7885. openDocButton.layer?.backgroundColor = accentBlue.cgColor
  7886. openDocButton.layer?.cornerRadius = 10
  7887. openDocButton.contentTintColor = .white
  7888. openDocButton.font = .systemFont(ofSize: 12, weight: .semibold)
  7889. openDocButton.isEnabled = summary.summaryDocURL != nil
  7890. openDocButton.alphaValue = summary.summaryDocURL != nil ? 1.0 : 0.45
  7891. let copyButton = NSButton(title: "Copy as Markdown", target: self, action: #selector(aiCompanionCopyMarkdownTapped))
  7892. copyButton.translatesAutoresizingMaskIntoConstraints = false
  7893. copyButton.isBordered = false
  7894. copyButton.wantsLayer = true
  7895. copyButton.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
  7896. copyButton.layer?.cornerRadius = 10
  7897. copyButton.layer?.borderWidth = 1
  7898. copyButton.layer?.borderColor = palette.inputBorder.cgColor
  7899. copyButton.contentTintColor = primaryText
  7900. copyButton.font = .systemFont(ofSize: 12, weight: .semibold)
  7901. [openDocButton, copyButton].forEach { btn in
  7902. btn.heightAnchor.constraint(equalToConstant: 30).isActive = true
  7903. btn.widthAnchor.constraint(greaterThanOrEqualToConstant: 130).isActive = true
  7904. }
  7905. actionsRow.addArrangedSubview(openDocButton)
  7906. actionsRow.addArrangedSubview(copyButton)
  7907. stack.addArrangedSubview(actionsRow)
  7908. aiCompanionOpenDocButton = openDocButton
  7909. aiCompanionCopyMarkdownButton = copyButton
  7910. if let overview = summary.summaryOverview?.trimmingCharacters(in: .whitespacesAndNewlines), overview.isEmpty == false {
  7911. stack.addArrangedSubview(makeAiCompanionSectionHeader("Overview"))
  7912. stack.addArrangedSubview(makeAiCompanionBodyLabel(overview))
  7913. }
  7914. for section in summary.summaryDetails {
  7915. let labelText = section.label.isEmpty ? "Details" : section.label
  7916. stack.addArrangedSubview(makeAiCompanionSectionHeader(labelText))
  7917. if section.summary.isEmpty == false {
  7918. stack.addArrangedSubview(makeAiCompanionBodyLabel(section.summary))
  7919. }
  7920. }
  7921. if summary.nextSteps.isEmpty == false {
  7922. stack.addArrangedSubview(makeAiCompanionSectionHeader("Next steps"))
  7923. let bullets = summary.nextSteps.map { "• \($0)" }.joined(separator: "\n")
  7924. stack.addArrangedSubview(makeAiCompanionBodyLabel(bullets))
  7925. }
  7926. if summary.summaryOverview == nil && summary.summaryDetails.isEmpty && summary.nextSteps.isEmpty,
  7927. let fullContent = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
  7928. fullContent.isEmpty == false {
  7929. stack.addArrangedSubview(makeAiCompanionSectionHeader("Summary"))
  7930. stack.addArrangedSubview(makeAiCompanionBodyLabel(fullContent))
  7931. }
  7932. }
  7933. @MainActor
  7934. private func makeAiCompanionSectionHeader(_ text: String) -> NSTextField {
  7935. let label = makeLabel(text.uppercased(), size: 11, color: accentBlue, weight: .bold, centered: false)
  7936. label.maximumNumberOfLines = 1
  7937. return label
  7938. }
  7939. @MainActor
  7940. private func makeAiCompanionBodyLabel(_ text: String) -> NSTextField {
  7941. let label = makeLabel(text, size: 13, color: primaryText, weight: .regular, centered: false)
  7942. label.maximumNumberOfLines = 0
  7943. label.lineBreakMode = .byWordWrapping
  7944. label.isSelectable = true
  7945. return label
  7946. }
  7947. private func aiCompanionDateText(for summary: AICompanionSummary) -> String {
  7948. let formatter = DateFormatter()
  7949. formatter.dateStyle = .medium
  7950. formatter.timeStyle = .short
  7951. formatter.timeZone = TimeZone.current
  7952. let startText = summary.meetingStartTime.map { formatter.string(from: $0) }
  7953. let endText = summary.meetingEndTime.map { formatter.string(from: $0) }
  7954. switch (startText, endText) {
  7955. case let (start?, end?): return "\(start) → \(end)"
  7956. case let (start?, nil): return start
  7957. case let (nil, end?): return "Ended \(end)"
  7958. case (nil, nil): return "Date unavailable"
  7959. }
  7960. }
  7961. // MARK: - AI Companion data loading & actions
  7962. private func aiCompanionDateRange() -> (from: Date?, to: Date?) {
  7963. let now = Date()
  7964. let calendar = Calendar.current
  7965. let days: Int
  7966. switch aiCompanionSelectedRange {
  7967. case "Last 7 days": days = 7
  7968. case "Last 30 days": days = 30
  7969. case "Last 90 days": days = 90
  7970. case "All available": return (nil, nil)
  7971. default: days = 30
  7972. }
  7973. let from = calendar.date(byAdding: .day, value: -days, to: now) ?? now
  7974. return (from, now)
  7975. }
  7976. @MainActor
  7977. private func setAiCompanionLoadingUI(_ loading: Bool) {
  7978. if loading {
  7979. aiCompanionLoadingIndicator?.startAnimation(nil)
  7980. } else {
  7981. aiCompanionLoadingIndicator?.stopAnimation(nil)
  7982. }
  7983. aiCompanionRefreshButton?.isEnabled = !loading
  7984. aiCompanionRefreshButton?.alphaValue = loading ? 0.55 : 1.0
  7985. aiCompanionRangePopup?.isEnabled = !loading
  7986. }
  7987. private func loadAICompanionSummaries(force: Bool) async {
  7988. guard isUserLoggedIn() else {
  7989. await MainActor.run {
  7990. self.aiCompanionSummaries = []
  7991. self.aiCompanionSelectedIndex = nil
  7992. self.renderAiCompanionList()
  7993. self.renderAiCompanionDetail()
  7994. self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
  7995. }
  7996. return
  7997. }
  7998. if aiCompanionIsLoading && force == false { return }
  7999. aiCompanionIsLoading = true
  8000. await MainActor.run {
  8001. self.setAiCompanionLoadingUI(true)
  8002. self.aiCompanionStatusLabel?.stringValue = "Loading AI notes…"
  8003. }
  8004. defer {
  8005. aiCompanionIsLoading = false
  8006. Task { @MainActor in self.setAiCompanionLoadingUI(false) }
  8007. }
  8008. do {
  8009. let token = try await zoomOAuth.validAccessTokenForMeetingSummaries(presentingWindow: view.window)
  8010. let (from, to) = aiCompanionDateRange()
  8011. let summaries = try await fetchZoomMeetingSummaries(accessToken: token, from: from, to: to)
  8012. await MainActor.run {
  8013. self.aiCompanionSummaries = summaries.sorted(by: { lhs, rhs in
  8014. (lhs.meetingStartTime ?? .distantPast) > (rhs.meetingStartTime ?? .distantPast)
  8015. })
  8016. self.aiCompanionLastLoadedAt = Date()
  8017. if self.aiCompanionSummaries.isEmpty {
  8018. self.aiCompanionSelectedIndex = nil
  8019. } else {
  8020. if let idx = self.aiCompanionSelectedIndex, idx >= self.aiCompanionSummaries.count {
  8021. self.aiCompanionSelectedIndex = 0
  8022. } else if self.aiCompanionSelectedIndex == nil {
  8023. self.aiCompanionSelectedIndex = 0
  8024. }
  8025. }
  8026. self.renderAiCompanionList()
  8027. self.renderAiCompanionDetail()
  8028. if self.aiCompanionSummaries.isEmpty {
  8029. self.aiCompanionStatusLabel?.stringValue = "No AI notes found for this range yet."
  8030. } else {
  8031. let formatter = DateFormatter()
  8032. formatter.dateStyle = .none
  8033. formatter.timeStyle = .short
  8034. self.aiCompanionStatusLabel?.stringValue = "Updated \(formatter.string(from: Date()))"
  8035. }
  8036. }
  8037. } catch {
  8038. await MainActor.run {
  8039. self.aiCompanionSummaries = []
  8040. self.aiCompanionSelectedIndex = nil
  8041. self.renderAiCompanionList()
  8042. self.renderAiCompanionDetail()
  8043. if case ZoomOAuthError.missingRequiredScope = error {
  8044. self.zoomOAuth.clearSavedTokens()
  8045. 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."
  8046. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  8047. let minutes = max(1, Int(ceil(Double(retryAfterSeconds ?? 300) / 60.0)))
  8048. self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  8049. } else if case ZoomOAuthError.missingClientId = error {
  8050. self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
  8051. } else if case ZoomOAuthError.missingClientSecret = error {
  8052. self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
  8053. } else if case ZoomOAuthError.authenticationCancelled = error {
  8054. self.aiCompanionStatusLabel?.stringValue = "Sign-in cancelled. Please sign in with Zoom to load AI notes."
  8055. } else {
  8056. self.aiCompanionStatusLabel?.stringValue = "Unable to load AI notes right now. Please try again shortly."
  8057. }
  8058. }
  8059. }
  8060. }
  8061. private func aiCompanionRefreshIfStale() {
  8062. let stale: Bool
  8063. if let lastLoaded = aiCompanionLastLoadedAt {
  8064. stale = Date().timeIntervalSince(lastLoaded) > 60
  8065. } else {
  8066. stale = true
  8067. }
  8068. if stale {
  8069. Task { await self.loadAICompanionSummaries(force: false) }
  8070. }
  8071. }
  8072. @objc private func aiCompanionRefreshTapped() {
  8073. // Removed: Zoom AI summaries fetch (AI Companion now shows local recordings only).
  8074. }
  8075. @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
  8076. // Removed: Zoom AI summaries range filter (AI Companion now shows local recordings only).
  8077. }
  8078. @objc private func aiCompanionRowTapped(_ sender: NSButton) {
  8079. // Removed: Zoom AI summaries row selection (AI Companion now shows local recordings only).
  8080. }
  8081. @objc private func aiCompanionOpenDocTapped() {
  8082. // Removed: Zoom Docs deep link (AI Companion now shows local recordings only).
  8083. }
  8084. @objc private func aiCompanionCopyMarkdownTapped() {
  8085. // Removed: Zoom AI summary markdown export (AI Companion now shows local recordings only).
  8086. }
  8087. private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
  8088. if let content = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
  8089. content.isEmpty == false {
  8090. return content
  8091. }
  8092. var lines: [String] = []
  8093. let title = summary.summaryTitle?.isEmpty == false ? summary.summaryTitle! : (summary.meetingTopic ?? "Meeting summary")
  8094. lines.append("# \(title)")
  8095. lines.append("")
  8096. lines.append("_\(aiCompanionDateText(for: summary))_")
  8097. lines.append("")
  8098. if let overview = summary.summaryOverview, overview.isEmpty == false {
  8099. lines.append("## Overview")
  8100. lines.append(overview)
  8101. lines.append("")
  8102. }
  8103. for section in summary.summaryDetails {
  8104. let header = section.label.isEmpty ? "Details" : section.label
  8105. lines.append("## \(header)")
  8106. if section.summary.isEmpty == false {
  8107. lines.append(section.summary)
  8108. }
  8109. lines.append("")
  8110. }
  8111. if summary.nextSteps.isEmpty == false {
  8112. lines.append("## Next steps")
  8113. for step in summary.nextSteps {
  8114. lines.append("- \(step)")
  8115. }
  8116. lines.append("")
  8117. }
  8118. if let url = summary.summaryDocURL {
  8119. lines.append("Full document: \(url.absoluteString)")
  8120. }
  8121. return lines.joined(separator: "\n")
  8122. }
  8123. @MainActor
  8124. private func refreshWidgetsPagePremiumState() {
  8125. guard let host = widgetsPageRootView as? NSHostingView<WidgetsRootView> else { return }
  8126. host.rootView = WidgetsRootView(
  8127. canAddWidgets: storeKitCoordinator.hasPremiumAccess,
  8128. onAddBlocked: { [weak self] in
  8129. self?.showPaywall()
  8130. }
  8131. )
  8132. }
  8133. @MainActor
  8134. private func reconcileDesktopWidgetsForPremiumAccess() {
  8135. refreshWidgetsPagePremiumState()
  8136. if storeKitCoordinator.hasPremiumAccess {
  8137. DesktopWidgetWindowManager.shared.restore()
  8138. } else {
  8139. DesktopWidgetWindowManager.shared.removeAllWidgets()
  8140. }
  8141. }
  8142. @MainActor
  8143. private func updateWidgetMeetingStoreFromCurrentMeetings() {
  8144. let upcoming = allScheduledMeetings
  8145. .filter { $0.start >= Date() }
  8146. .sorted(by: { $0.start < $1.start })
  8147. .prefix(3)
  8148. .map { meeting in
  8149. WidgetMeetingSnapshot(
  8150. id: meeting.meetingID ?? meeting.title,
  8151. title: meeting.title,
  8152. timeText: widgetTimeText(for: meeting),
  8153. joinLink: meeting.webURL?.absoluteString,
  8154. startTime: meeting.start.timeIntervalSince1970,
  8155. endTime: meeting.end?.timeIntervalSince1970,
  8156. timeZoneIdentifier: meeting.meetingTimeZone?.identifier
  8157. )
  8158. }
  8159. WidgetMeetingStore.save(Array(upcoming))
  8160. DesktopWidgetWindowManager.shared.refreshForMeetingsChange()
  8161. }
  8162. private func widgetTimeText(for meeting: ScheduledMeeting) -> String {
  8163. let formatter = DateFormatter()
  8164. formatter.dateFormat = "h:mm a"
  8165. formatter.timeZone = meeting.meetingTimeZone ?? TimeZone.current
  8166. return formatter.string(from: meeting.start)
  8167. }
  8168. private func setupWidgetBridge() {
  8169. let center = NotificationCenter.default
  8170. center.addObserver(forName: .widgetOpenHomePage, object: nil, queue: .main) { [weak self] _ in
  8171. self?.menuBarShowSection(.home)
  8172. }
  8173. center.addObserver(forName: .widgetOpenMeetingsPage, object: nil, queue: .main) { [weak self] _ in
  8174. self?.menuBarShowSection(.meetings)
  8175. }
  8176. center.addObserver(forName: .widgetOpenSchedulerPage, object: nil, queue: .main) { [weak self] _ in
  8177. self?.menuBarShowSection(.scheduler)
  8178. }
  8179. center.addObserver(forName: .widgetOpenWidgetsPage, object: nil, queue: .main) { [weak self] _ in
  8180. self?.menuBarShowSection(.widgets)
  8181. }
  8182. center.addObserver(forName: .widgetOpenSettingsPage, object: nil, queue: .main) { [weak self] _ in
  8183. self?.menuBarShowSection(.settings)
  8184. }
  8185. center.addObserver(forName: .widgetStartNewMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  8186. self?.menuBarTriggerNewMeeting()
  8187. }
  8188. center.addObserver(forName: .widgetJoinMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  8189. self?.menuBarTriggerJoinMeeting()
  8190. }
  8191. center.addObserver(forName: .widgetScheduleMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  8192. self?.menuBarTriggerScheduleMeeting()
  8193. }
  8194. center.addObserver(forName: .widgetRefreshRequested, object: nil, queue: .main) { [weak self] _ in
  8195. self?.triggerMeetingsRefresh(force: true)
  8196. }
  8197. center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
  8198. guard let self else { return }
  8199. guard let link = notification.userInfo?["link"] as? String,
  8200. let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
  8201. self.openWebURLPreferringInApp(url, zoomMeetingDisplayName: nil, recordingConsentWindowTitle: "Join Meeting")
  8202. }
  8203. }
  8204. /// Meetings + Ai Companion filter rows: preset + dates stay near intrinsic width; flexible gap before Apply/Reset/Refresh.
  8205. private func configureResponsiveMeetingsStyleFilterRow(
  8206. filterRow: NSStackView,
  8207. categoryFilter: NSPopUpButton,
  8208. fromDatePicker: NSDatePicker,
  8209. toDatePicker: NSDatePicker,
  8210. applyFiltersButton: NSButton,
  8211. resetFiltersButton: NSButton,
  8212. refreshButton: NSButton
  8213. ) {
  8214. filterRow.distribution = .fill
  8215. filterRow.setContentHuggingPriority(.defaultLow, for: .horizontal)
  8216. let spacer = NSView()
  8217. spacer.translatesAutoresizingMaskIntoConstraints = false
  8218. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  8219. spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  8220. filterRow.insertArrangedSubview(spacer, at: 3)
  8221. categoryFilter.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8222. categoryFilter.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
  8223. fromDatePicker.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8224. fromDatePicker.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
  8225. toDatePicker.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8226. toDatePicker.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
  8227. applyFiltersButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8228. applyFiltersButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  8229. resetFiltersButton.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  8230. resetFiltersButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  8231. refreshButton.setContentHuggingPriority(.required, for: .horizontal)
  8232. refreshButton.setContentCompressionResistancePriority(.required, for: .horizontal)
  8233. NSLayoutConstraint.activate([
  8234. categoryFilter.widthAnchor.constraint(greaterThanOrEqualToConstant: 120),
  8235. fromDatePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 170),
  8236. toDatePicker.widthAnchor.constraint(greaterThanOrEqualToConstant: 170),
  8237. fromDatePicker.widthAnchor.constraint(equalTo: toDatePicker.widthAnchor),
  8238. applyFiltersButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
  8239. resetFiltersButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 76),
  8240. refreshButton.widthAnchor.constraint(equalToConstant: 44),
  8241. applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  8242. resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  8243. refreshButton.heightAnchor.constraint(equalToConstant: 34)
  8244. ])
  8245. }
  8246. private func makeMeetingsPageView() -> NSView {
  8247. let root = NSView()
  8248. root.wantsLayer = true
  8249. root.layer?.backgroundColor = NSColor.clear.cgColor
  8250. let container = NSView()
  8251. container.translatesAutoresizingMaskIntoConstraints = false
  8252. container.wantsLayer = true
  8253. container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  8254. container.layer?.cornerRadius = 16
  8255. container.layer?.borderWidth = 1
  8256. container.layer?.borderColor = palette.inputBorder.cgColor
  8257. root.addSubview(container)
  8258. let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
  8259. let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
  8260. let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
  8261. let titleStack = NSStackView(views: [title, subtitle, count])
  8262. titleStack.orientation = .vertical
  8263. titleStack.spacing = 4
  8264. titleStack.alignment = .leading
  8265. titleStack.translatesAutoresizingMaskIntoConstraints = false
  8266. let categoryFilter = NSPopUpButton()
  8267. categoryFilter.translatesAutoresizingMaskIntoConstraints = false
  8268. categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
  8269. categoryFilter.selectItem(withTitle: "All")
  8270. categoryFilter.target = self
  8271. categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
  8272. let fromDatePicker = NSDatePicker()
  8273. fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
  8274. fromDatePicker.datePickerStyle = .textFieldAndStepper
  8275. fromDatePicker.datePickerMode = .single
  8276. fromDatePicker.datePickerElements = [.yearMonthDay]
  8277. fromDatePicker.dateValue = Date()
  8278. fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  8279. let toDatePicker = NSDatePicker()
  8280. toDatePicker.translatesAutoresizingMaskIntoConstraints = false
  8281. toDatePicker.datePickerStyle = .textFieldAndStepper
  8282. toDatePicker.datePickerMode = .single
  8283. toDatePicker.datePickerElements = [.yearMonthDay]
  8284. toDatePicker.dateValue = Date()
  8285. toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  8286. let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
  8287. applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  8288. applyFiltersButton.isBordered = false
  8289. applyFiltersButton.wantsLayer = true
  8290. applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
  8291. applyFiltersButton.layer?.cornerRadius = 10
  8292. applyFiltersButton.contentTintColor = .white
  8293. applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8294. let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
  8295. resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  8296. resetFiltersButton.isBordered = false
  8297. resetFiltersButton.wantsLayer = true
  8298. resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  8299. resetFiltersButton.layer?.cornerRadius = 10
  8300. resetFiltersButton.layer?.borderWidth = 1
  8301. resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  8302. resetFiltersButton.contentTintColor = primaryText
  8303. resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8304. let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
  8305. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  8306. refreshButton.isBordered = false
  8307. refreshButton.wantsLayer = true
  8308. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  8309. refreshButton.layer?.cornerRadius = 10
  8310. refreshButton.layer?.borderWidth = 1
  8311. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  8312. refreshButton.contentTintColor = primaryText
  8313. refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8314. if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
  8315. refreshButton.image = image
  8316. refreshButton.imagePosition = .imageOnly
  8317. }
  8318. let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
  8319. filterRow.orientation = .horizontal
  8320. filterRow.spacing = 10
  8321. filterRow.alignment = .centerY
  8322. filterRow.translatesAutoresizingMaskIntoConstraints = false
  8323. configureResponsiveMeetingsStyleFilterRow(
  8324. filterRow: filterRow,
  8325. categoryFilter: categoryFilter,
  8326. fromDatePicker: fromDatePicker,
  8327. toDatePicker: toDatePicker,
  8328. applyFiltersButton: applyFiltersButton,
  8329. resetFiltersButton: resetFiltersButton,
  8330. refreshButton: refreshButton
  8331. )
  8332. let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
  8333. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  8334. let listScroll = NSScrollView()
  8335. listScroll.translatesAutoresizingMaskIntoConstraints = false
  8336. listScroll.drawsBackground = false
  8337. listScroll.hasVerticalScroller = true
  8338. listScroll.hasHorizontalScroller = false
  8339. listScroll.autohidesScrollers = true
  8340. let listDocument = FlippedView()
  8341. listDocument.translatesAutoresizingMaskIntoConstraints = false
  8342. let listStack = NSStackView()
  8343. listStack.translatesAutoresizingMaskIntoConstraints = false
  8344. listStack.orientation = .vertical
  8345. listStack.spacing = 12
  8346. listStack.alignment = .leading
  8347. listStack.setContentHuggingPriority(.required, for: .vertical)
  8348. listStack.setContentCompressionResistancePriority(.required, for: .vertical)
  8349. listScroll.documentView = listDocument
  8350. listDocument.addSubview(listStack)
  8351. let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
  8352. empty.translatesAutoresizingMaskIntoConstraints = false
  8353. empty.isHidden = true
  8354. let loadMoreButton = NSButton(title: "", target: nil, action: nil)
  8355. loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
  8356. loadMoreButton.isBordered = false
  8357. loadMoreButton.wantsLayer = true
  8358. loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
  8359. loadMoreButton.layer?.cornerRadius = 10
  8360. loadMoreButton.layer?.borderWidth = 1
  8361. loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
  8362. loadMoreButton.contentTintColor = primaryText
  8363. loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
  8364. loadMoreButton.isHidden = true
  8365. [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
  8366. NSLayoutConstraint.activate([
  8367. container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  8368. container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  8369. container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  8370. container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  8371. titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
  8372. titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  8373. titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
  8374. filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
  8375. filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  8376. filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
  8377. statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
  8378. statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
  8379. statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
  8380. listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
  8381. listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  8382. listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  8383. listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  8384. listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
  8385. listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
  8386. listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
  8387. listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
  8388. // Pin bottom edge so document height grows with arranged subviews.
  8389. listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
  8390. empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
  8391. empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
  8392. loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  8393. loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  8394. loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  8395. loadMoreButton.heightAnchor.constraint(equalToConstant: 1)
  8396. ])
  8397. meetingsPageCategoryFilter = categoryFilter
  8398. meetingsPageFromDatePicker = fromDatePicker
  8399. meetingsPageToDatePicker = toDatePicker
  8400. meetingsPageRefreshButton = refreshButton
  8401. meetingsPageCountLabel = count
  8402. meetingsPageStatusLabel = statusLabel
  8403. meetingsPageListStack = listStack
  8404. meetingsPageEmptyLabel = empty
  8405. meetingsPageScrollView = listScroll
  8406. meetingsPageLoadMoreButton = loadMoreButton
  8407. observeMeetingsPageInfiniteScroll(in: listScroll)
  8408. updateMeetingsDatePickersEnabledState()
  8409. return root
  8410. }
  8411. deinit {
  8412. premiumRatingPromptWorkItem?.cancel()
  8413. usageRatingPromptWorkItem?.cancel()
  8414. removeSearchFieldObserver()
  8415. removeMeetingSearchObserver()
  8416. clearMeetingsPageScrollObserver()
  8417. removeSearchShortcutMonitor()
  8418. for observer in windowFullScreenObservers {
  8419. NotificationCenter.default.removeObserver(observer)
  8420. }
  8421. windowFullScreenObservers.removeAll()
  8422. }
  8423. private func ensureUsageSessionStartDate() {
  8424. if UserDefaults.standard.object(forKey: usageSessionStartedAtKey) == nil {
  8425. UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  8426. }
  8427. }
  8428. private func usageSessionStartedAt() -> Date {
  8429. let storedValue = UserDefaults.standard.double(forKey: usageSessionStartedAtKey)
  8430. if storedValue > 0 {
  8431. return Date(timeIntervalSince1970: storedValue)
  8432. }
  8433. let now = Date()
  8434. UserDefaults.standard.set(now.timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  8435. return now
  8436. }
  8437. private func hasCompletedOrSeenRatingFlow() -> Bool {
  8438. UserDefaults.standard.bool(forKey: ratingCompletedKey)
  8439. }
  8440. private func scheduleUsageBasedRatingPromptIfNeeded() {
  8441. usageRatingPromptWorkItem?.cancel()
  8442. guard hasCompletedOrSeenRatingFlow() == false else { return }
  8443. let elapsed = Date().timeIntervalSince(usageSessionStartedAt())
  8444. let delay = max(0, usageRatingPromptDelay - elapsed)
  8445. let workItem = DispatchWorkItem { [weak self] in
  8446. self?.presentRatingPrompt(source: "usage")
  8447. }
  8448. usageRatingPromptWorkItem = workItem
  8449. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  8450. }
  8451. private func schedulePremiumRatingPromptIfNeeded() {
  8452. guard hasCompletedOrSeenRatingFlow() == false else { return }
  8453. guard premiumRatingPromptWorkItem == nil else { return }
  8454. let workItem = DispatchWorkItem { [weak self] in
  8455. self?.premiumRatingPromptWorkItem = nil
  8456. self?.presentRatingPrompt(source: "premium")
  8457. }
  8458. premiumRatingPromptWorkItem = workItem
  8459. DispatchQueue.main.asyncAfter(deadline: .now() + premiumRatingPromptDelay, execute: workItem)
  8460. }
  8461. private func presentRatingPrompt(source: String) {
  8462. guard hasCompletedOrSeenRatingFlow() == false else { return }
  8463. UserDefaults.standard.set(true, forKey: ratingPromptShownKey)
  8464. NSApp.activate(ignoringOtherApps: true)
  8465. let alert = NSAlert()
  8466. alert.alertStyle = .informational
  8467. alert.messageText = "Enjoying Zoom App?"
  8468. if source == "premium" {
  8469. alert.informativeText = "You unlocked Premium. If you're enjoying the app, please rate us."
  8470. } else {
  8471. alert.informativeText = "You've been using the app for a while. If it's helping you, please rate us."
  8472. }
  8473. alert.addButton(withTitle: "Rate Now")
  8474. alert.addButton(withTitle: "Later")
  8475. let response = alert.runModal()
  8476. if response == .alertFirstButtonReturn {
  8477. UserDefaults.standard.set(true, forKey: ratingCompletedKey)
  8478. SKStoreReviewController.requestReview()
  8479. }
  8480. }
  8481. private func startClock() {
  8482. clockTimer?.invalidate()
  8483. clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  8484. self?.updateClock()
  8485. }
  8486. updateClock()
  8487. }
  8488. private func updateClock() {
  8489. let now = Date()
  8490. let timeFormatter = DateFormatter()
  8491. timeFormatter.dateFormat = "h:mm a"
  8492. let dateFormatter = DateFormatter()
  8493. dateFormatter.dateFormat = "EEEE, d MMM"
  8494. timeLabel?.stringValue = timeFormatter.string(from: now)
  8495. dateLabel?.stringValue = dateFormatter.string(from: now)
  8496. }
  8497. // MARK: - Shared UI
  8498. private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .home) -> NSView {
  8499. let sidebar = NSView()
  8500. sidebar.wantsLayer = true
  8501. sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
  8502. let stack = NSStackView()
  8503. stack.orientation = .vertical
  8504. stack.spacing = style == .home ? 12 : 16
  8505. stack.alignment = .centerX
  8506. stack.distribution = .fill
  8507. // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
  8508. stack.setContentHuggingPriority(.required, for: .vertical)
  8509. stack.setContentCompressionResistancePriority(.required, for: .vertical)
  8510. stack.translatesAutoresizingMaskIntoConstraints = false
  8511. sidebar.addSubview(stack)
  8512. for item in items {
  8513. let row = NSView()
  8514. row.translatesAutoresizingMaskIntoConstraints = false
  8515. row.wantsLayer = true
  8516. let selectedRow = item == selected
  8517. row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  8518. row.layer?.cornerRadius = style == .home ? 12 : 10
  8519. row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
  8520. // Prevent rows from stretching/collapsing when the window resizes.
  8521. row.setContentHuggingPriority(.required, for: .vertical)
  8522. row.setContentCompressionResistancePriority(.required, for: .vertical)
  8523. if style == .home {
  8524. // Must be tall enough for icon (26) + paddings + label without clipping.
  8525. row.heightAnchor.constraint(equalToConstant: 66).isActive = true
  8526. }
  8527. if style == .home {
  8528. let iconContainer = NSView()
  8529. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  8530. row.addSubview(iconContainer)
  8531. let iconView = NSImageView()
  8532. iconView.translatesAutoresizingMaskIntoConstraints = false
  8533. iconView.contentTintColor = selectedRow ? primaryText : sidebarInactiveText
  8534. iconView.imageScaling = .scaleProportionallyUpOrDown
  8535. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  8536. let preferredSymbol = sidebarSymbolName(for: item, filled: selectedRow)
  8537. let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
  8538. iconView.image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
  8539. ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item)
  8540. iconContainer.addSubview(iconView)
  8541. let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
  8542. label.translatesAutoresizingMaskIntoConstraints = false
  8543. row.addSubview(label)
  8544. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  8545. hit.identifier = NSUserInterfaceItemIdentifier(item)
  8546. hit.isBordered = false
  8547. hit.bezelStyle = .shadowlessSquare
  8548. hit.focusRingType = .none
  8549. hit.translatesAutoresizingMaskIntoConstraints = false
  8550. row.addSubview(hit, positioned: .above, relativeTo: nil)
  8551. NSLayoutConstraint.activate([
  8552. iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
  8553. iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8554. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  8555. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  8556. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  8557. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  8558. iconView.widthAnchor.constraint(equalToConstant: 22),
  8559. iconView.heightAnchor.constraint(equalToConstant: 22),
  8560. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  8561. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8562. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
  8563. hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  8564. hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
  8565. hit.topAnchor.constraint(equalTo: row.topAnchor),
  8566. hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  8567. ])
  8568. homeSidebarRowViews[item] = row
  8569. homeSidebarIconViews[item] = iconView
  8570. homeSidebarLabels[item] = label
  8571. if item == "Hub" {
  8572. let badge = makeSidebarBadge(text: "1")
  8573. badge.translatesAutoresizingMaskIntoConstraints = false
  8574. iconContainer.addSubview(badge)
  8575. NSLayoutConstraint.activate([
  8576. badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
  8577. badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
  8578. ])
  8579. } else if item == "More" {
  8580. let dot = NSView()
  8581. dot.translatesAutoresizingMaskIntoConstraints = false
  8582. dot.wantsLayer = true
  8583. dot.layer?.backgroundColor = NSColor.systemRed.cgColor
  8584. dot.layer?.cornerRadius = 4
  8585. row.addSubview(dot)
  8586. NSLayoutConstraint.activate([
  8587. dot.widthAnchor.constraint(equalToConstant: 8),
  8588. dot.heightAnchor.constraint(equalToConstant: 8),
  8589. dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  8590. dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
  8591. ])
  8592. }
  8593. } else {
  8594. let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
  8595. let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
  8596. [icon, label].forEach {
  8597. $0.translatesAutoresizingMaskIntoConstraints = false
  8598. row.addSubview($0)
  8599. }
  8600. NSLayoutConstraint.activate([
  8601. icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  8602. icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8603. label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
  8604. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  8605. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  8606. ])
  8607. }
  8608. stack.addArrangedSubview(row)
  8609. }
  8610. NSLayoutConstraint.activate([
  8611. stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
  8612. stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
  8613. ])
  8614. if style == .home {
  8615. NSLayoutConstraint.activate([
  8616. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
  8617. ])
  8618. }
  8619. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  8620. return sidebar
  8621. }
  8622. @objc private func homeSidebarItemTapped(_ sender: NSButton) {
  8623. guard let item = sender.identifier?.rawValue else { return }
  8624. closeEmbeddedBrowserPanel()
  8625. selectedHomeSidebarItem = item
  8626. updateHomeSidebarHighlight()
  8627. updateSelectedHomeSectionUI()
  8628. }
  8629. @objc private func newMeetingTapped() {
  8630. menuBarTriggerNewMeeting()
  8631. }
  8632. func menuBarIsUserLoggedIn() -> Bool {
  8633. isUserLoggedIn()
  8634. }
  8635. func menuBarShowSection(_ section: HomeSection) {
  8636. closeEmbeddedBrowserPanel()
  8637. selectedHomeSidebarItem = section.rawValue
  8638. updateHomeSidebarHighlight()
  8639. updateSelectedHomeSectionUI()
  8640. }
  8641. func menuBarOpenTopMeeting(joinLink: String?) {
  8642. if let link = joinLink?.trimmingCharacters(in: .whitespacesAndNewlines),
  8643. !link.isEmpty,
  8644. let url = URL(string: link) {
  8645. openWebURLPreferringInApp(url, zoomMeetingDisplayName: nil, recordingConsentWindowTitle: "Join Meeting")
  8646. return
  8647. }
  8648. menuBarShowSection(.meetings)
  8649. }
  8650. func menuBarSyncTopMeetingsForStatusMenu() {
  8651. updateWidgetMeetingStoreFromCurrentMeetings()
  8652. }
  8653. func menuBarTriggerNewMeeting() {
  8654. guard isUserLoggedIn() else {
  8655. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
  8656. return
  8657. }
  8658. Task {
  8659. do {
  8660. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  8661. guard configured else { return }
  8662. let parentWindow = await MainActor.run { self.view.window }
  8663. let token = try await zoomOAuth.validAccessToken(
  8664. presentingWindow: parentWindow,
  8665. requiringAnyOf: ["meeting:write:meeting"]
  8666. )
  8667. let result = try await createZoomInstantMeeting(accessToken: token)
  8668. guard let joinURL = self.embeddedWebClientURLForZoomMeeting(
  8669. meetingId: result.id,
  8670. joinURLString: result.join_url,
  8671. startURLString: result.start_url
  8672. ) else {
  8673. await MainActor.run {
  8674. self.showSimpleAlert(
  8675. title: "Unable to start",
  8676. 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."
  8677. )
  8678. }
  8679. return
  8680. }
  8681. await MainActor.run {
  8682. self.openWebURLPreferringInApp(
  8683. joinURL,
  8684. zoomMeetingDisplayName: "Instant meeting",
  8685. recordingConsentWindowTitle: "New Meeting"
  8686. )
  8687. }
  8688. } catch {
  8689. await MainActor.run {
  8690. if case ZoomOAuthError.missingRequiredScope(_) = error {
  8691. self.zoomOAuth.clearSavedTokens()
  8692. self.showSimpleAlert(
  8693. title: "Zoom permissions",
  8694. 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."
  8695. )
  8696. } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
  8697. let seconds = max(retryAfter ?? 300, 30)
  8698. let minutes = Int(ceil(Double(seconds) / 60.0))
  8699. self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before starting another meeting. Try again in about \(minutes) min.")
  8700. } else if case ZoomOAuthError.authenticationCancelled = error {
  8701. return
  8702. } else {
  8703. self.showSimpleError("Could not start meeting", error: error)
  8704. }
  8705. }
  8706. }
  8707. }
  8708. }
  8709. func menuBarTriggerJoinMeeting() {
  8710. joinMeetingTapped()
  8711. }
  8712. func menuBarTriggerScheduleMeeting() {
  8713. scheduleMeetingTapped()
  8714. }
  8715. func menuBarTriggerAuthAction() {
  8716. if isUserLoggedIn() {
  8717. logoutTapped()
  8718. } else {
  8719. zoomPrimarySignInTapped()
  8720. }
  8721. }
  8722. @MainActor
  8723. private func updateHomeSidebarHighlight() {
  8724. for (item, row) in homeSidebarRowViews {
  8725. let selected = item == selectedHomeSidebarItem
  8726. row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  8727. homeSidebarLabels[item]?.textColor = selected ? primaryText : sidebarInactiveText
  8728. homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : sidebarInactiveText
  8729. let preferredSymbol = sidebarSymbolName(for: item, filled: selected)
  8730. let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
  8731. if let image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
  8732. ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item) {
  8733. homeSidebarIconViews[item]?.image = image
  8734. }
  8735. }
  8736. }
  8737. @MainActor
  8738. private func updateSelectedHomeSectionUI() {
  8739. let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
  8740. let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
  8741. let isSettings = selectedHomeSidebarItem == HomeSection.settings.rawValue
  8742. let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
  8743. let isWidgets = selectedHomeSidebarItem == HomeSection.widgets.rawValue
  8744. let isAiCompanion = selectedHomeSidebarItem == HomeSection.aiCompanion.rawValue
  8745. let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  8746. homeWelcomeLabel?.stringValue = "Home"
  8747. homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
  8748. let dashboardViews: [NSView?] = [
  8749. homeTimeLabelView,
  8750. homeDateLabelView,
  8751. homeActionsRow,
  8752. homeMeetingsPanel,
  8753. meetingsDayHeaderLabel,
  8754. meetingsStatusLabel,
  8755. meetingsPrevDayButton,
  8756. meetingsTodayButton,
  8757. meetingsNextDayButton,
  8758. meetingsScrollView,
  8759. refreshMeetingsButton
  8760. ]
  8761. let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler || isWidgets || isAiCompanion
  8762. dashboardViews.forEach { $0?.isHidden = hideDashboard }
  8763. // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
  8764. // and showed "No meetings…" on top of meeting cards when returning to Home.
  8765. if hideDashboard {
  8766. emptyMeetingLabel?.isHidden = true
  8767. } else {
  8768. let hasMeetingCards = (meetingsListStack?.arrangedSubviews.isEmpty == false)
  8769. emptyMeetingLabel?.isHidden = premiumLocked || hasMeetingCards
  8770. }
  8771. homeSettingsView?.isHidden = isSettings == false
  8772. meetingsPageRootView?.isHidden = isMeetings == false
  8773. schedulerRootView?.isHidden = isScheduler == false
  8774. widgetsPageRootView?.isHidden = isWidgets == false
  8775. aiCompanionPageRootView?.isHidden = isAiCompanion == false
  8776. if isHome {
  8777. homePlaceholderLabel?.isHidden = true
  8778. } else {
  8779. // Keep non-Home pages empty for now.
  8780. homePlaceholderLabel?.isHidden = true
  8781. }
  8782. if isScheduler {
  8783. updateSchedulerDateHeader()
  8784. applySchedulerMeetingsForSelectedDate()
  8785. renderSchedulerCalendarGrid()
  8786. }
  8787. if isMeetings {
  8788. applyMeetingsPageFilters(resetVisible: false)
  8789. }
  8790. if isAiCompanion {
  8791. recordedMeetings = MeetingRecordingManager.shared.recordings
  8792. renderAiCompanionList()
  8793. updateAiCompanionRecordingUI()
  8794. }
  8795. }
  8796. private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
  8797. switch item {
  8798. case "Home":
  8799. return filled ? "house.fill" : "house"
  8800. case "Meetings":
  8801. return filled ? "video.fill" : "video"
  8802. case "Chat":
  8803. return filled ? "message.fill" : "message"
  8804. case "Scheduler":
  8805. // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
  8806. return "calendar.badge.clock"
  8807. case "Widgets":
  8808. return filled ? "rectangle.grid.2x2.fill" : "rectangle.grid.2x2"
  8809. case "Ai Assistant":
  8810. return filled ? "sparkles" : "sparkles"
  8811. case "Settings":
  8812. // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
  8813. return filled ? "gearshape.fill" : "gearshape"
  8814. case "Hub":
  8815. return "square.grid.3x3"
  8816. case "More":
  8817. return "ellipsis"
  8818. default:
  8819. return "circle"
  8820. }
  8821. }
  8822. private func makeSidebarBadge(text: String) -> NSView {
  8823. let badge = NSView()
  8824. badge.wantsLayer = true
  8825. badge.layer?.backgroundColor = NSColor.systemRed.cgColor
  8826. badge.layer?.cornerRadius = 12 / 2
  8827. badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
  8828. badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
  8829. let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
  8830. label.translatesAutoresizingMaskIntoConstraints = false
  8831. badge.addSubview(label)
  8832. NSLayoutConstraint.activate([
  8833. label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
  8834. label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
  8835. ])
  8836. return badge
  8837. }
  8838. @MainActor
  8839. private func alignNativeTrafficLights() {
  8840. guard let window = view.window else { return }
  8841. guard let closeButton = window.standardWindowButton(.closeButton),
  8842. let miniButton = window.standardWindowButton(.miniaturizeButton),
  8843. let zoomButton = window.standardWindowButton(.zoomButton) else { return }
  8844. guard let buttonContainer = closeButton.superview else { return }
  8845. let buttons = [closeButton, miniButton, zoomButton]
  8846. // Compute from top inset so moving "down" is stable in titlebar coordinates.
  8847. let containerHeight = buttonContainer.bounds.height
  8848. let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
  8849. var nextX = nativeTrafficLightsLeading
  8850. for button in buttons {
  8851. button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
  8852. nextX += button.frame.width + 8
  8853. }
  8854. }
  8855. private func makeUpgradeToProButton(action: Selector?) -> NSButton {
  8856. let title = "Upgrade to Pro"
  8857. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  8858. button.isBordered = false
  8859. button.focusRingType = .none
  8860. button.wantsLayer = true
  8861. button.layer?.backgroundColor = accentBlue.cgColor
  8862. button.layer?.cornerRadius = 14
  8863. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  8864. button.attributedTitle = NSAttributedString(string: title, attributes: [
  8865. .foregroundColor: NSColor.white,
  8866. .font: font
  8867. ])
  8868. button.toolTip = title
  8869. button.translatesAutoresizingMaskIntoConstraints = false
  8870. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  8871. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 148).isActive = true
  8872. return button
  8873. }
  8874. /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
  8875. private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
  8876. let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
  8877. button.isBordered = false
  8878. button.bezelStyle = .shadowlessSquare
  8879. button.focusRingType = .none
  8880. button.contentTintColor = palette.isDarkMode
  8881. ? NSColor(calibratedWhite: 0.84, alpha: 1)
  8882. : NSColor(calibratedWhite: 0.22, alpha: 1)
  8883. if let toolTip {
  8884. button.toolTip = toolTip
  8885. }
  8886. let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
  8887. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
  8888. let image = base.withSymbolConfiguration(symbolConfig) {
  8889. image.isTemplate = true
  8890. button.image = image
  8891. }
  8892. button.imageScaling = .scaleProportionallyUpOrDown
  8893. button.imagePosition = .imageOnly
  8894. button.translatesAutoresizingMaskIntoConstraints = false
  8895. button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
  8896. button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
  8897. return button
  8898. }
  8899. private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
  8900. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  8901. button.isBordered = false
  8902. button.bezelStyle = .shadowlessSquare
  8903. button.focusRingType = .none
  8904. button.wantsLayer = true
  8905. button.layer?.backgroundColor = (palette.isDarkMode
  8906. ? NSColor.white.withAlphaComponent(0.06)
  8907. : NSColor.black.withAlphaComponent(0.06)
  8908. ).cgColor
  8909. button.layer?.cornerRadius = 10
  8910. button.layer?.borderWidth = 1
  8911. button.layer?.borderColor = (palette.isDarkMode
  8912. ? NSColor.white.withAlphaComponent(0.08)
  8913. : NSColor.black.withAlphaComponent(0.12)
  8914. ).cgColor
  8915. let tint = palette.isDarkMode
  8916. ? NSColor(calibratedWhite: 0.9, alpha: 1)
  8917. : NSColor(calibratedWhite: 0.18, alpha: 1)
  8918. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  8919. button.contentTintColor = tint
  8920. button.font = font
  8921. button.attributedTitle = NSAttributedString(string: title, attributes: [
  8922. .foregroundColor: tint,
  8923. .font: font
  8924. ])
  8925. if let symbol {
  8926. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  8927. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  8928. let image = base.withSymbolConfiguration(symbolConfig) {
  8929. image.isTemplate = true
  8930. button.image = image
  8931. button.imagePosition = .imageLeading
  8932. }
  8933. button.imageHugsTitle = true
  8934. button.imageScaling = .scaleNone
  8935. }
  8936. button.translatesAutoresizingMaskIntoConstraints = false
  8937. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  8938. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
  8939. return button
  8940. }
  8941. private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
  8942. let root = HoverTileView()
  8943. root.translatesAutoresizingMaskIntoConstraints = false
  8944. root.widthAnchor.constraint(equalToConstant: 88).isActive = true
  8945. // Give caption text a little more vertical room so descenders (e.g. "g") are never clipped.
  8946. root.heightAnchor.constraint(equalToConstant: 76).isActive = true
  8947. let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
  8948. iconButton.isBordered = false
  8949. iconButton.wantsLayer = true
  8950. iconButton.layer?.backgroundColor = color.cgColor
  8951. iconButton.layer?.cornerRadius = 15
  8952. iconButton.layer?.shadowOpacity = 0.2
  8953. iconButton.layer?.shadowRadius = 7
  8954. iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
  8955. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
  8956. if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  8957. let configured = baseImage.withSymbolConfiguration(symbolConfig) {
  8958. iconButton.image = configured
  8959. }
  8960. iconButton.contentTintColor = .white
  8961. iconButton.imageScaling = .scaleNone
  8962. let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
  8963. [iconButton, label].forEach {
  8964. $0.translatesAutoresizingMaskIntoConstraints = false
  8965. root.addSubview($0)
  8966. }
  8967. let iconTopConstraint = iconButton.topAnchor.constraint(equalTo: root.topAnchor)
  8968. NSLayoutConstraint.activate([
  8969. iconTopConstraint,
  8970. iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  8971. iconButton.widthAnchor.constraint(equalToConstant: 50),
  8972. iconButton.heightAnchor.constraint(equalToConstant: 50),
  8973. label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
  8974. label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  8975. label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  8976. ])
  8977. root.onHoverChanged = { [weak iconButton, weak label] hovering in
  8978. NSAnimationContext.runAnimationGroup { context in
  8979. context.duration = hovering ? 0.16 : 0.14
  8980. context.timingFunction = CAMediaTimingFunction(name: hovering ? .easeOut : .easeInEaseOut)
  8981. iconTopConstraint.animator().constant = hovering ? -3 : 0
  8982. label?.animator().alphaValue = hovering ? 1.0 : 0.96
  8983. }
  8984. guard let iconButton else { return }
  8985. iconButton.layer?.shadowOpacity = hovering ? 0.28 : 0.2
  8986. iconButton.layer?.shadowRadius = hovering ? 10 : 7
  8987. iconButton.layer?.shadowOffset = hovering ? NSSize(width: 0, height: -3) : NSSize(width: 0, height: -1)
  8988. }
  8989. return root
  8990. }
  8991. private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
  8992. let hoverBackground = palette.isDarkMode
  8993. ? NSColor.white.withAlphaComponent(0.075)
  8994. : NSColor.black.withAlphaComponent(0.10)
  8995. let hoverBorder = palette.isDarkMode
  8996. ? NSColor.white.withAlphaComponent(0.85)
  8997. : NSColor.black.withAlphaComponent(0.22)
  8998. let card = MeetingCardView(
  8999. url: meeting.webURL,
  9000. hoverBackground: hoverBackground,
  9001. hoverBorder: hoverBorder,
  9002. onTapURL: { [weak self] url in
  9003. self?.openWebURLPreferringInApp(url, zoomMeetingDisplayName: meeting.title, recordingConsentWindowTitle: "Join Meeting")
  9004. }
  9005. )
  9006. card.wantsLayer = true
  9007. card.layer?.backgroundColor = meetingCardBackground.cgColor
  9008. card.layer?.cornerRadius = 13
  9009. card.layer?.borderWidth = 1
  9010. card.layer?.borderColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.10)).cgColor
  9011. card.translatesAutoresizingMaskIntoConstraints = false
  9012. card.heightAnchor.constraint(equalToConstant: 116).isActive = true
  9013. let dateFormatter = DateFormatter()
  9014. dateFormatter.dateFormat = "EEE, MMM d"
  9015. let timeFormatter = DateFormatter()
  9016. timeFormatter.dateFormat = "h:mm a"
  9017. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  9018. dateFormatter.timeZone = displayTimeZone
  9019. timeFormatter.timeZone = displayTimeZone
  9020. let startText = timeFormatter.string(from: meeting.start)
  9021. let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
  9022. let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
  9023. let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
  9024. let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
  9025. detail.maximumNumberOfLines = 2
  9026. let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
  9027. [title, detail, host].forEach {
  9028. $0.translatesAutoresizingMaskIntoConstraints = false
  9029. card.addSubview($0)
  9030. }
  9031. NSLayoutConstraint.activate([
  9032. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
  9033. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  9034. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  9035. detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
  9036. detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  9037. detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  9038. host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
  9039. host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  9040. host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  9041. host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
  9042. ])
  9043. return card
  9044. }
  9045. private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
  9046. let label = NSTextField(labelWithString: text)
  9047. label.font = .systemFont(ofSize: size, weight: weight)
  9048. label.textColor = color
  9049. label.alignment = centered ? .center : .left
  9050. return label
  9051. }
  9052. private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
  9053. let wrapper = NSView()
  9054. let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
  9055. button.font = .systemFont(ofSize: 20, weight: .medium)
  9056. button.isBordered = false
  9057. button.wantsLayer = true
  9058. button.layer?.cornerRadius = 12
  9059. button.layer?.backgroundColor = cardBackground.cgColor
  9060. button.contentTintColor = primaryText
  9061. button.translatesAutoresizingMaskIntoConstraints = false
  9062. let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
  9063. label.translatesAutoresizingMaskIntoConstraints = false
  9064. wrapper.addSubview(button)
  9065. wrapper.addSubview(label)
  9066. NSLayoutConstraint.activate([
  9067. button.topAnchor.constraint(equalTo: wrapper.topAnchor),
  9068. button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  9069. button.widthAnchor.constraint(equalToConstant: 52),
  9070. button.heightAnchor.constraint(equalToConstant: 52),
  9071. label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
  9072. label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  9073. label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  9074. ])
  9075. return (wrapper, action == nil ? nil : button)
  9076. }
  9077. }
  9078. extension ViewController: EmbeddedOAuthPresenting {}
  9079. extension ViewController: WKScriptMessageHandler {
  9080. func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
  9081. guard message.name == Self.embeddedZoomMeetingLifecycleHandlerName else { return }
  9082. guard let href = message.body as? String, let url = URL(string: href) else { return }
  9083. Task { @MainActor in
  9084. let title = self.embeddedBrowserWebView?.title
  9085. self.updateEmbeddedMeetingRecordingPresence(for: url, webViewTitle: title)
  9086. }
  9087. }
  9088. }
  9089. extension ViewController: WKNavigationDelegate, WKUIDelegate {
  9090. func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  9091. guard webView === embeddedBrowserWebView else {
  9092. decisionHandler(.allow)
  9093. return
  9094. }
  9095. if let prefix = oauthSessionCallbackPrefix,
  9096. let targetURL = navigationAction.request.url,
  9097. targetURL.absoluteString.hasPrefix(prefix) {
  9098. oauthSessionContinuation?.resume(returning: targetURL)
  9099. oauthSessionContinuation = nil
  9100. oauthSessionCallbackPrefix = nil
  9101. decisionHandler(.cancel)
  9102. return
  9103. }
  9104. if let targetURL = navigationAction.request.url {
  9105. Task { @MainActor in
  9106. self.updateEmbeddedMeetingRecordingPresence(for: targetURL, webViewTitle: webView.title)
  9107. }
  9108. }
  9109. decisionHandler(.allow)
  9110. }
  9111. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
  9112. guard webView === embeddedBrowserWebView else { return }
  9113. updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
  9114. refreshEmbeddedBrowserNavButtons()
  9115. }
  9116. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  9117. guard webView === embeddedBrowserWebView else { return }
  9118. updateEmbeddedMeetingRecordingPresence(for: webView.url, webViewTitle: webView.title)
  9119. applyEmbeddedZoomWebAuthenticationIfNeeded(webView: webView)
  9120. if let pageURL = webView.url, isZoomEmbeddedWebClientInCallURL(pageURL) {
  9121. embeddedZoomPendingWebAuth = nil
  9122. }
  9123. refreshEmbeddedBrowserNavButtons()
  9124. }
  9125. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
  9126. guard webView === embeddedBrowserWebView else { return nil }
  9127. if navigationAction.targetFrame == nil {
  9128. webView.load(navigationAction.request)
  9129. }
  9130. return nil
  9131. }
  9132. func webView(
  9133. _ webView: WKWebView,
  9134. requestMediaCapturePermissionFor origin: WKSecurityOrigin,
  9135. initiatedByFrame frame: WKFrameInfo,
  9136. type: WKMediaCaptureType,
  9137. decisionHandler: @escaping (WKPermissionDecision) -> Void
  9138. ) {
  9139. guard webView === embeddedBrowserWebView else {
  9140. decisionHandler(.prompt)
  9141. return
  9142. }
  9143. Task {
  9144. let grant: Bool
  9145. switch type {
  9146. case .camera:
  9147. grant = await cameraAccessGrantedForWebCapture()
  9148. case .microphone:
  9149. grant = await microphoneAccessGrantedForWebCapture()
  9150. case .cameraAndMicrophone:
  9151. let camera = await cameraAccessGrantedForWebCapture()
  9152. let microphone = await microphoneAccessGrantedForWebCapture()
  9153. grant = camera && microphone
  9154. @unknown default:
  9155. grant = false
  9156. }
  9157. await MainActor.run {
  9158. decisionHandler(grant ? .grant : .deny)
  9159. }
  9160. }
  9161. }
  9162. }
  9163. extension ViewController: NSWindowDelegate {
  9164. func windowShouldClose(_ sender: NSWindow) -> Bool {
  9165. if let panel = joinMeetingWindow, sender === panel, let host = view.window {
  9166. host.endSheet(panel)
  9167. return false
  9168. }
  9169. if let panel = aiCompanionLocalTranscriptPanel, sender === panel, let host = view.window {
  9170. host.endSheet(panel)
  9171. return false
  9172. }
  9173. if let panel = aiCompanionLocalNotesPanel, sender === panel, let host = view.window {
  9174. host.endSheet(panel)
  9175. return false
  9176. }
  9177. return true
  9178. }
  9179. func windowWillClose(_ notification: Notification) {
  9180. guard let window = notification.object as? NSWindow else { return }
  9181. if window === joinMeetingWindow {
  9182. resetJoinMeetingPanelReferences()
  9183. }
  9184. if window === scheduleMeetingWindow {
  9185. resetScheduleMeetingPanelReferences()
  9186. }
  9187. }
  9188. }
  9189. private extension Array {
  9190. subscript(safe index: Int) -> Element? {
  9191. guard index >= 0, index < count else { return nil }
  9192. return self[index]
  9193. }
  9194. }
  9195. /// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
  9196. private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
  9197. private func lineHeight() -> CGFloat {
  9198. guard let font = font else { return 0 }
  9199. return ceil(font.ascender - font.descender + font.leading)
  9200. }
  9201. private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
  9202. let lh = lineHeight()
  9203. guard lh > 0, h > lh else { return 0 }
  9204. return max(0, floor((h - lh) / 2))
  9205. }
  9206. private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
  9207. let lh = lineHeight()
  9208. guard lh > 0 else { return cellFrame }
  9209. let m = verticalMargin(forBoundsHeight: cellFrame.height)
  9210. var r = cellFrame
  9211. r.origin.y += m
  9212. r.size.height = lh
  9213. return r
  9214. }
  9215. override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
  9216. super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
  9217. }
  9218. override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
  9219. let text = super.setUpFieldEditorAttributes(textObj)
  9220. guard let tv = text as? NSTextView else { return text }
  9221. // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
  9222. let h = max(controlView?.bounds.height ?? 0, 42)
  9223. let margin = verticalMargin(forBoundsHeight: h)
  9224. if margin > 0 {
  9225. // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
  9226. tv.textContainerInset = NSSize(width: 0, height: margin)
  9227. }
  9228. return text
  9229. }
  9230. }
  9231. /// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
  9232. private final class JoinPanelFieldContainer: NSView {
  9233. let textField: NSTextField
  9234. private let normalBorder: NSColor
  9235. private let focusBorder: NSColor
  9236. private let fill: NSColor
  9237. private var beginObserver: NSObjectProtocol?
  9238. private var endObserver: NSObjectProtocol?
  9239. init(
  9240. placeholder: String,
  9241. normalBorder: NSColor,
  9242. focusBorder: NSColor,
  9243. fill: NSColor,
  9244. primaryText: NSColor,
  9245. mutedText: NSColor
  9246. ) {
  9247. self.normalBorder = normalBorder
  9248. self.focusBorder = focusBorder
  9249. self.fill = fill
  9250. textField = NSTextField()
  9251. textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
  9252. super.init(frame: .zero)
  9253. translatesAutoresizingMaskIntoConstraints = false
  9254. wantsLayer = true
  9255. // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
  9256. layer?.masksToBounds = false
  9257. layer?.cornerRadius = 23
  9258. layer?.backgroundColor = fill.cgColor
  9259. applyBorder(focused: false)
  9260. textField.font = .systemFont(ofSize: 14, weight: .regular)
  9261. textField.textColor = primaryText
  9262. textField.alignment = .center
  9263. textField.isEditable = true
  9264. textField.isSelectable = true
  9265. textField.refusesFirstResponder = false
  9266. textField.focusRingType = .none
  9267. textField.isBordered = false
  9268. textField.drawsBackground = false
  9269. textField.translatesAutoresizingMaskIntoConstraints = false
  9270. if let cell = textField.cell as? NSTextFieldCell {
  9271. cell.isBezeled = false
  9272. cell.drawsBackground = false
  9273. cell.alignment = .center
  9274. cell.usesSingleLineMode = true
  9275. cell.lineBreakMode = .byTruncatingTail
  9276. let placeholderParagraph = NSMutableParagraphStyle()
  9277. placeholderParagraph.alignment = .center
  9278. cell.placeholderAttributedString = NSAttributedString(
  9279. string: placeholder,
  9280. attributes: [
  9281. .foregroundColor: mutedText.withAlphaComponent(0.88),
  9282. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  9283. .paragraphStyle: placeholderParagraph
  9284. ]
  9285. )
  9286. }
  9287. addSubview(textField)
  9288. NSLayoutConstraint.activate([
  9289. textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  9290. textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  9291. textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
  9292. textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
  9293. heightAnchor.constraint(equalToConstant: 46)
  9294. ])
  9295. beginObserver = NotificationCenter.default.addObserver(
  9296. forName: NSControl.textDidBeginEditingNotification,
  9297. object: textField,
  9298. queue: .main
  9299. ) { [weak self] _ in
  9300. self?.applyBorder(focused: true)
  9301. }
  9302. endObserver = NotificationCenter.default.addObserver(
  9303. forName: NSControl.textDidEndEditingNotification,
  9304. object: textField,
  9305. queue: .main
  9306. ) { [weak self] _ in
  9307. self?.applyBorder(focused: false)
  9308. }
  9309. }
  9310. required init?(coder: NSCoder) {
  9311. fatalError("init(coder:) has not been implemented")
  9312. }
  9313. deinit {
  9314. if let beginObserver {
  9315. NotificationCenter.default.removeObserver(beginObserver)
  9316. }
  9317. if let endObserver {
  9318. NotificationCenter.default.removeObserver(endObserver)
  9319. }
  9320. }
  9321. private func applyBorder(focused: Bool) {
  9322. layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
  9323. layer?.borderWidth = focused ? 1.5 : 1
  9324. }
  9325. override func viewDidMoveToWindow() {
  9326. super.viewDidMoveToWindow()
  9327. if window == nil {
  9328. applyBorder(focused: false)
  9329. }
  9330. }
  9331. }
  9332. private final class SearchPillTextField: NSTextField {
  9333. var onFocusChange: ((Bool) -> Void)?
  9334. private(set) var isSearchFocused = false
  9335. func forceClearFocusState() {
  9336. isSearchFocused = false
  9337. onFocusChange?(false)
  9338. }
  9339. override func becomeFirstResponder() -> Bool {
  9340. let ok = super.becomeFirstResponder()
  9341. if ok {
  9342. isSearchFocused = true
  9343. onFocusChange?(true)
  9344. }
  9345. return ok
  9346. }
  9347. override func resignFirstResponder() -> Bool {
  9348. let ok = super.resignFirstResponder()
  9349. if ok {
  9350. isSearchFocused = false
  9351. onFocusChange?(false)
  9352. }
  9353. return ok
  9354. }
  9355. }
  9356. private final class FlippedView: NSView {
  9357. override var isFlipped: Bool { true }
  9358. }
  9359. private final class MeetingCardView: NSView {
  9360. private let url: URL?
  9361. private let hoverBackground: NSColor
  9362. private let hoverBorder: NSColor
  9363. private let onTapURL: ((URL) -> Void)?
  9364. private var tracking: NSTrackingArea?
  9365. private var isHovering = false
  9366. private var normalBackgroundColor: CGColor?
  9367. private var normalBorderColor: CGColor?
  9368. private var normalBorderWidth: CGFloat?
  9369. init(url: URL?, hoverBackground: NSColor, hoverBorder: NSColor, onTapURL: ((URL) -> Void)? = nil) {
  9370. self.url = url
  9371. self.hoverBackground = hoverBackground
  9372. self.hoverBorder = hoverBorder
  9373. self.onTapURL = onTapURL
  9374. super.init(frame: .zero)
  9375. }
  9376. required init?(coder: NSCoder) {
  9377. self.url = nil
  9378. self.hoverBackground = NSColor.white.withAlphaComponent(0.075)
  9379. self.hoverBorder = NSColor.white.withAlphaComponent(0.85)
  9380. self.onTapURL = nil
  9381. super.init(coder: coder)
  9382. }
  9383. override func updateTrackingAreas() {
  9384. super.updateTrackingAreas()
  9385. if let tracking { removeTrackingArea(tracking) }
  9386. let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
  9387. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  9388. addTrackingArea(area)
  9389. tracking = area
  9390. }
  9391. override func resetCursorRects() {
  9392. super.resetCursorRects()
  9393. if url != nil {
  9394. addCursorRect(bounds, cursor: .pointingHand)
  9395. }
  9396. }
  9397. override func mouseEntered(with event: NSEvent) {
  9398. super.mouseEntered(with: event)
  9399. guard url != nil else { return }
  9400. isHovering = true
  9401. applyHoverState()
  9402. }
  9403. override func mouseExited(with event: NSEvent) {
  9404. super.mouseExited(with: event)
  9405. guard url != nil else { return }
  9406. isHovering = false
  9407. applyHoverState()
  9408. }
  9409. override func mouseUp(with event: NSEvent) {
  9410. super.mouseUp(with: event)
  9411. guard let url else { return }
  9412. if let onTapURL {
  9413. onTapURL(url)
  9414. } else {
  9415. NSWorkspace.shared.open(url)
  9416. }
  9417. }
  9418. private func applyHoverState() {
  9419. guard let layer else { return }
  9420. if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
  9421. if normalBorderColor == nil { normalBorderColor = layer.borderColor }
  9422. if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
  9423. if isHovering {
  9424. layer.backgroundColor = hoverBackground.cgColor
  9425. layer.borderColor = hoverBorder.cgColor
  9426. layer.borderWidth = max(1.5, layer.borderWidth)
  9427. } else {
  9428. layer.backgroundColor = normalBackgroundColor
  9429. layer.borderColor = normalBorderColor
  9430. if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
  9431. }
  9432. }
  9433. }
  9434. struct GoogleOAuthTokens: Codable, Equatable {
  9435. var accessToken: String
  9436. var refreshToken: String?
  9437. var expiresAt: Date
  9438. var scope: String?
  9439. var tokenType: String?
  9440. }
  9441. struct GoogleUserProfile: Codable, Equatable {
  9442. var name: String?
  9443. var email: String?
  9444. var picture: String?
  9445. }
  9446. struct ZoomOAuthTokens: Codable, Equatable {
  9447. var accessToken: String
  9448. var refreshToken: String?
  9449. var expiresAt: Date
  9450. var scope: String?
  9451. var tokenType: String?
  9452. }
  9453. enum ZoomOAuthError: Error {
  9454. case missingClientId
  9455. case missingClientSecret
  9456. case invalidCallbackURL
  9457. case missingAuthorizationCode
  9458. case tokenExchangeFailed(String)
  9459. case missingRequiredScope(String)
  9460. case rateLimited(retryAfterSeconds: Int?)
  9461. case unableToOpenBrowser
  9462. case authenticationTimedOut
  9463. case authenticationCancelled
  9464. }
  9465. final class ZoomOAuthTokenStore {
  9466. private let defaultsKey: String
  9467. private let defaults: UserDefaults
  9468. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  9469. account: String = "zoomOAuthTokens",
  9470. defaults: UserDefaults = .standard) {
  9471. self.defaultsKey = "\(service).\(account)"
  9472. self.defaults = defaults
  9473. }
  9474. func readTokens() throws -> ZoomOAuthTokens? {
  9475. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  9476. return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
  9477. }
  9478. func writeTokens(_ tokens: ZoomOAuthTokens) throws {
  9479. let data = try JSONEncoder().encode(tokens)
  9480. defaults.set(data, forKey: defaultsKey)
  9481. }
  9482. func clearTokens() {
  9483. defaults.removeObject(forKey: defaultsKey)
  9484. }
  9485. }
  9486. final class ZoomOAuthService: NSObject {
  9487. static let shared = ZoomOAuthService()
  9488. private var inAppOAuthWindowController: InAppOAuthWindowController?
  9489. /// When set (by `ViewController`), Zoom OAuth runs in the right-panel web view so cookies match embedded meetings.
  9490. @MainActor
  9491. var embeddedOAuthSession: (@MainActor (URL, String, Double) async throws -> URL)?
  9492. private let tokenStore = ZoomOAuthTokenStore()
  9493. private let clientIdDefaultsKey = "zoom.oauth.clientId"
  9494. private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
  9495. private let infoPlistClientIdKey = "ZoomOAuthClientId"
  9496. private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
  9497. // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
  9498. /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
  9499. private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
  9500. /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
  9501. private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
  9502. func setClientCredentials(clientId: String, clientSecret: String) {
  9503. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  9504. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  9505. }
  9506. func configuredClientId() -> String? {
  9507. if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
  9508. let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
  9509. if trimmed.isEmpty == false { return trimmed }
  9510. }
  9511. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
  9512. .trimmingCharacters(in: .whitespacesAndNewlines)
  9513. if let value, value.isEmpty == false { return value }
  9514. return bundledClientId.isEmpty ? nil : bundledClientId
  9515. }
  9516. func configuredClientSecret() -> String? {
  9517. if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
  9518. let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
  9519. if trimmed.isEmpty == false { return trimmed }
  9520. }
  9521. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
  9522. .trimmingCharacters(in: .whitespacesAndNewlines)
  9523. if let value, value.isEmpty == false { return value }
  9524. return bundledClientSecret.isEmpty ? nil : bundledClientSecret
  9525. }
  9526. func clearSavedTokens() {
  9527. tokenStore.clearTokens()
  9528. }
  9529. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  9530. // Default: baseline meeting access used throughout the app.
  9531. try await validAccessToken(presentingWindow: presentingWindow, requiringAnyOf: [
  9532. // Zoom's newer granular scopes for listing and scheduling meetings.
  9533. "meeting:read:list_meetings",
  9534. "meeting:write:meeting"
  9535. ])
  9536. }
  9537. func validAccessToken(presentingWindow: NSWindow?, requiringAnyOf requiredScopes: [String]) async throws -> String {
  9538. if let tokens = try tokenStore.readTokens(),
  9539. tokens.expiresAt.timeIntervalSinceNow > 60,
  9540. tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
  9541. return tokens.accessToken
  9542. } else if var tokens = try tokenStore.readTokens(),
  9543. let refreshed = try await refreshTokens(tokens) {
  9544. tokens = refreshed
  9545. try tokenStore.writeTokens(tokens)
  9546. if tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
  9547. return tokens.accessToken
  9548. }
  9549. }
  9550. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  9551. try tokenStore.writeTokens(tokens)
  9552. guard tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) else {
  9553. throw ZoomOAuthError.missingRequiredScope(tokens.scope ?? "Missing required scope(s).")
  9554. }
  9555. return tokens.accessToken
  9556. }
  9557. /// User-managed OAuth apps often **cannot** add `meeting:read:list_summaries` (admin/master only).
  9558. /// In that case we load AI summaries by listing past meetings (`meeting:read:list_meetings`) and
  9559. /// calling `meeting:read:summary` per meeting.
  9560. func validAccessTokenForMeetingSummaries(presentingWindow: NSWindow?) async throws -> String {
  9561. func canLoadSummaries(_ scope: String?) -> Bool {
  9562. tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_summaries"])
  9563. || (tokenHasScopes(scope, requiringAnyOf: ["meeting:read:summary"])
  9564. && tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_meetings"]))
  9565. }
  9566. if let tokens = try tokenStore.readTokens(),
  9567. tokens.expiresAt.timeIntervalSinceNow > 60,
  9568. canLoadSummaries(tokens.scope) {
  9569. return tokens.accessToken
  9570. } else if var tokens = try tokenStore.readTokens(),
  9571. let refreshed = try await refreshTokens(tokens) {
  9572. tokens = refreshed
  9573. try tokenStore.writeTokens(tokens)
  9574. if canLoadSummaries(tokens.scope) {
  9575. return tokens.accessToken
  9576. }
  9577. }
  9578. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  9579. try tokenStore.writeTokens(tokens)
  9580. guard canLoadSummaries(tokens.scope) else {
  9581. let hint = " Grant meeting:read:list_summaries (if available), or both meeting:read:list_meetings and meeting:read:summary."
  9582. throw ZoomOAuthError.missingRequiredScope((tokens.scope ?? "Missing required scope(s).") + hint)
  9583. }
  9584. return tokens.accessToken
  9585. }
  9586. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
  9587. guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
  9588. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  9589. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  9590. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  9591. let state = UUID().uuidString
  9592. // Explicitly request the scopes we rely on.
  9593. // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
  9594. // Note: `meeting:read:list_summaries` is often unavailable on user-managed General apps; we
  9595. // fall back to per-meeting summary fetches using `meeting:read:summary` + past meeting list.
  9596. let requestedScopes = [
  9597. "meeting:read:list_meetings",
  9598. "meeting:write:meeting",
  9599. "meeting:read:summary",
  9600. "zoomapp:inmeeting",
  9601. "user_zak:read"
  9602. ]
  9603. var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
  9604. components.queryItems = [
  9605. URLQueryItem(name: "response_type", value: "code"),
  9606. URLQueryItem(name: "client_id", value: clientId),
  9607. URLQueryItem(name: "redirect_uri", value: redirectURI),
  9608. URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
  9609. // Force consent so updated scopes are actually granted.
  9610. URLQueryItem(name: "prompt", value: "consent"),
  9611. // Zoom-specific: forces interactive verification even if already signed in.
  9612. URLQueryItem(name: "force_verify", value: "true"),
  9613. URLQueryItem(name: "state", value: state)
  9614. ]
  9615. guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
  9616. do {
  9617. let callbackURL: URL
  9618. if let runEmbeddedOAuth = await MainActor.run(body: { embeddedOAuthSession }) {
  9619. callbackURL = try await runEmbeddedOAuth(authURL, redirectURI, 180)
  9620. } else {
  9621. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  9622. callbackURL = try await oauthWindow.authenticate(
  9623. startURL: authURL,
  9624. callbackURLPrefix: redirectURI,
  9625. presentingWindow: presentingWindow,
  9626. timeoutSeconds: 180
  9627. )
  9628. }
  9629. let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  9630. guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
  9631. guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  9632. throw ZoomOAuthError.missingAuthorizationCode
  9633. }
  9634. return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
  9635. } catch GoogleOAuthError.authenticationTimedOut {
  9636. throw ZoomOAuthError.authenticationTimedOut
  9637. } catch GoogleOAuthError.authenticationCancelled {
  9638. throw ZoomOAuthError.authenticationCancelled
  9639. } catch {
  9640. throw error
  9641. }
  9642. }
  9643. @MainActor
  9644. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  9645. if let existing = inAppOAuthWindowController { return existing }
  9646. let controller = InAppOAuthWindowController(
  9647. title: "Zoom Sign-In",
  9648. websiteDataStore: zoomEmbeddedWebsiteDataStore,
  9649. processPool: zoomEmbeddedWebProcessPool
  9650. )
  9651. inAppOAuthWindowController = controller
  9652. return controller
  9653. }
  9654. private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
  9655. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  9656. request.httpMethod = "POST"
  9657. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  9658. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  9659. request.httpBody = Self.formURLEncoded([
  9660. "grant_type": "authorization_code",
  9661. "code": code,
  9662. "redirect_uri": redirectURI
  9663. ])
  9664. let (data, response) = try await URLSession.shared.data(for: request)
  9665. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  9666. throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  9667. }
  9668. struct TokenResponse: Decodable {
  9669. let access_token: String
  9670. let refresh_token: String?
  9671. let expires_in: Double
  9672. let scope: String?
  9673. let token_type: String?
  9674. }
  9675. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  9676. return ZoomOAuthTokens(
  9677. accessToken: decoded.access_token,
  9678. refreshToken: decoded.refresh_token,
  9679. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  9680. scope: decoded.scope,
  9681. tokenType: decoded.token_type
  9682. )
  9683. }
  9684. private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
  9685. guard let refreshToken = tokens.refreshToken else { return nil }
  9686. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  9687. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  9688. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  9689. request.httpMethod = "POST"
  9690. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  9691. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  9692. request.httpBody = Self.formURLEncoded([
  9693. "grant_type": "refresh_token",
  9694. "refresh_token": refreshToken
  9695. ])
  9696. let (data, response) = try await URLSession.shared.data(for: request)
  9697. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  9698. return nil
  9699. }
  9700. struct RefreshResponse: Decodable {
  9701. let access_token: String
  9702. let refresh_token: String?
  9703. let expires_in: Double
  9704. let scope: String?
  9705. let token_type: String?
  9706. }
  9707. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  9708. return ZoomOAuthTokens(
  9709. accessToken: decoded.access_token,
  9710. refreshToken: decoded.refresh_token ?? refreshToken,
  9711. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  9712. scope: decoded.scope ?? tokens.scope,
  9713. tokenType: decoded.token_type ?? tokens.tokenType
  9714. )
  9715. }
  9716. private func normalizedGrantedScopes(_ scopeValue: String?) -> Set<String> {
  9717. guard let scopeValue, scopeValue.isEmpty == false else { return [] }
  9718. let parts = scopeValue.split { $0 == " " || $0 == "," }
  9719. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  9720. .filter { $0.isEmpty == false }
  9721. return Set(parts)
  9722. }
  9723. /// Whether `granted` includes the Zoom scope `req` (granular exact match, admin/master variants,
  9724. /// or legacy broad scopes Zoom still returns on some tokens).
  9725. private func grantedScopeSatisfies(_ granted: Set<String>, requirement req: String) -> Bool {
  9726. if granted.contains(req)
  9727. || granted.contains("\(req):admin")
  9728. || granted.contains("\(req):master") {
  9729. return true
  9730. }
  9731. // Classic scopes: listing / reading meetings and summaries often appear as `meeting:read`
  9732. // instead of `meeting:read:list_meetings` / `meeting:read:summary` on the token string.
  9733. switch req {
  9734. case "meeting:read:list_meetings", "meeting:read:summary":
  9735. return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
  9736. case "meeting:write:meeting":
  9737. return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
  9738. case "user_zak:read":
  9739. return granted.contains("user_zak") || granted.contains("user:read:zak")
  9740. default:
  9741. return false
  9742. }
  9743. }
  9744. private func tokenHasScopes(_ scopeValue: String?, requiringAnyOf requiredScopes: [String]) -> Bool {
  9745. let granted = normalizedGrantedScopes(scopeValue)
  9746. if granted.isEmpty { return false }
  9747. return requiredScopes.contains { grantedScopeSatisfies(granted, requirement: $0) }
  9748. }
  9749. private static func basicAuth(clientId: String, clientSecret: String) -> String {
  9750. let joined = "\(clientId):\(clientSecret)"
  9751. return Data(joined.utf8).base64EncodedString()
  9752. }
  9753. private static func formURLEncoded(_ params: [String: String]) -> Data {
  9754. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  9755. let pairs = params.map { key, value in
  9756. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  9757. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  9758. return "\(k)=\(v)"
  9759. }.joined(separator: "&")
  9760. return Data(pairs.utf8)
  9761. }
  9762. }
  9763. enum GoogleOAuthError: Error {
  9764. case missingClientId
  9765. case missingClientSecret
  9766. case invalidCallbackURL
  9767. case missingAuthorizationCode
  9768. case tokenExchangeFailed(String)
  9769. case unableToOpenBrowser
  9770. case authenticationTimedOut
  9771. case authenticationCancelled
  9772. }
  9773. final class GoogleOAuthService: NSObject {
  9774. static let shared = GoogleOAuthService()
  9775. private var inAppOAuthWindowController: InAppOAuthWindowController?
  9776. private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
  9777. private let clientSecret = "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"
  9778. private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
  9779. private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
  9780. private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
  9781. func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
  9782. func clearSavedTokens() {
  9783. tokenStore.clearTokens()
  9784. }
  9785. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  9786. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  9787. request.httpMethod = "GET"
  9788. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  9789. let (data, response) = try await URLSession.shared.data(for: request)
  9790. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  9791. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  9792. }
  9793. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  9794. }
  9795. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  9796. if let tokens = try tokenStore.readTokens(),
  9797. tokens.expiresAt.timeIntervalSinceNow > 60,
  9798. tokenHasCalendarScope(tokens.scope) {
  9799. return tokens.accessToken
  9800. }
  9801. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  9802. try tokenStore.writeTokens(tokens)
  9803. return tokens.accessToken
  9804. }
  9805. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  9806. guard let presentingWindow else { throw GoogleOAuthError.unableToOpenBrowser }
  9807. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  9808. let codeVerifier = Self.randomURLSafeString(length: 64)
  9809. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  9810. let state = Self.randomURLSafeString(length: 32)
  9811. // Google OAuth app settings often already include this callback from prior loopback setup.
  9812. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  9813. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  9814. components.queryItems = [
  9815. URLQueryItem(name: "client_id", value: clientId),
  9816. URLQueryItem(name: "redirect_uri", value: redirectURI),
  9817. URLQueryItem(name: "response_type", value: "code"),
  9818. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  9819. URLQueryItem(name: "state", value: state),
  9820. URLQueryItem(name: "code_challenge", value: codeChallenge),
  9821. URLQueryItem(name: "code_challenge_method", value: "S256"),
  9822. URLQueryItem(name: "access_type", value: "offline"),
  9823. URLQueryItem(name: "prompt", value: "consent")
  9824. ]
  9825. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  9826. let callbackURL = try await oauthWindow.authenticate(
  9827. startURL: authURL,
  9828. callbackURLPrefix: redirectURI,
  9829. presentingWindow: presentingWindow
  9830. )
  9831. let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  9832. guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
  9833. guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  9834. throw GoogleOAuthError.missingAuthorizationCode
  9835. }
  9836. return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
  9837. }
  9838. @MainActor
  9839. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  9840. if let existing = inAppOAuthWindowController { return existing }
  9841. let controller = InAppOAuthWindowController(title: "Google Sign-In")
  9842. inAppOAuthWindowController = controller
  9843. return controller
  9844. }
  9845. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
  9846. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  9847. request.httpMethod = "POST"
  9848. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  9849. request.httpBody = Self.formURLEncoded([
  9850. "client_id": clientId,
  9851. "client_secret": clientSecret,
  9852. "code": code,
  9853. "code_verifier": codeVerifier,
  9854. "redirect_uri": redirectURI,
  9855. "grant_type": "authorization_code"
  9856. ])
  9857. let (data, response) = try await URLSession.shared.data(for: request)
  9858. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  9859. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  9860. }
  9861. struct TokenResponse: Decodable {
  9862. let access_token: String
  9863. let expires_in: Double
  9864. let refresh_token: String?
  9865. let scope: String?
  9866. let token_type: String?
  9867. }
  9868. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  9869. return GoogleOAuthTokens(
  9870. accessToken: decoded.access_token,
  9871. refreshToken: decoded.refresh_token,
  9872. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  9873. scope: decoded.scope,
  9874. tokenType: decoded.token_type
  9875. )
  9876. }
  9877. private static func pkceChallenge(for verifier: String) -> String {
  9878. let digest = SHA256.hash(data: Data(verifier.utf8))
  9879. return Data(digest).base64URLEncodedString()
  9880. }
  9881. private static func randomURLSafeString(length: Int) -> String {
  9882. var bytes = [UInt8](repeating: 0, count: length)
  9883. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  9884. return Data(bytes).base64URLEncodedString()
  9885. }
  9886. private static func formURLEncoded(_ params: [String: String]) -> Data {
  9887. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  9888. let pairs = params.map { key, value in
  9889. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  9890. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  9891. return "\(k)=\(v)"
  9892. }.joined(separator: "&")
  9893. return Data(pairs.utf8)
  9894. }
  9895. private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
  9896. guard let scopeValue else { return false }
  9897. return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
  9898. }
  9899. }
  9900. private extension Data {
  9901. func base64URLEncodedString() -> String {
  9902. base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
  9903. }
  9904. }
  9905. final class KeychainTokenStore {
  9906. private let defaultsKey: String
  9907. private let defaults: UserDefaults
  9908. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  9909. account: String = "googleOAuthTokens",
  9910. defaults: UserDefaults = .standard) {
  9911. self.defaultsKey = "\(service).\(account)"
  9912. self.defaults = defaults
  9913. }
  9914. func readTokens() throws -> GoogleOAuthTokens? {
  9915. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  9916. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  9917. }
  9918. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  9919. let data = try JSONEncoder().encode(tokens)
  9920. defaults.set(data, forKey: defaultsKey)
  9921. }
  9922. func clearTokens() {
  9923. defaults.removeObject(forKey: defaultsKey)
  9924. }
  9925. }
  9926. // MARK: - OAuth window (previous sign-in behavior)
  9927. @MainActor
  9928. private final class OAuthWebViewContainerView: NSView {
  9929. private let webView: WKWebView
  9930. init(webView: WKWebView) {
  9931. self.webView = webView
  9932. super.init(frame: .zero)
  9933. addSubview(webView)
  9934. }
  9935. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  9936. override func layout() {
  9937. super.layout()
  9938. webView.frame = bounds
  9939. }
  9940. }
  9941. /// 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.
  9942. @MainActor
  9943. private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
  9944. private let webView: WKWebView
  9945. private var callbackPrefix: String?
  9946. private var callbackContinuation: CheckedContinuation<URL, Error>?
  9947. private weak var parentWindowForChild: NSWindow?
  9948. /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
  9949. private var suppressUserCloseCancellation = false
  9950. init(
  9951. title: String,
  9952. websiteDataStore: WKWebsiteDataStore = .nonPersistent(),
  9953. processPool: WKProcessPool? = nil
  9954. ) {
  9955. let configuration = WKWebViewConfiguration()
  9956. configuration.websiteDataStore = websiteDataStore
  9957. if let processPool {
  9958. configuration.processPool = processPool
  9959. }
  9960. self.webView = WKWebView(frame: .zero, configuration: configuration)
  9961. let container = OAuthWebViewContainerView(webView: webView)
  9962. let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  9963. let window = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
  9964. window.title = title
  9965. window.contentView = container
  9966. window.contentMinSize = contentSize
  9967. window.contentMaxSize = contentSize
  9968. window.isReleasedWhenClosed = false
  9969. super.init(window: window)
  9970. window.delegate = self
  9971. webView.navigationDelegate = self
  9972. }
  9973. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  9974. func windowWillClose(_ notification: Notification) {
  9975. guard suppressUserCloseCancellation == false else { return }
  9976. removeFromParentIfNeeded()
  9977. webView.stopLoading()
  9978. if let continuation = callbackContinuation {
  9979. callbackContinuation = nil
  9980. continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
  9981. }
  9982. }
  9983. func authenticate(startURL: URL, callbackURLPrefix: String, presentingWindow: NSWindow?, timeoutSeconds: Double = 120) async throws -> URL {
  9984. self.callbackPrefix = callbackURLPrefix
  9985. parentWindowForChild = presentingWindow
  9986. applyFixedOAuthFrame(presentingWindow: presentingWindow)
  9987. if let parent = presentingWindow, let w = window {
  9988. parent.addChildWindow(w, ordered: .above)
  9989. }
  9990. showWindow(nil)
  9991. window?.makeKeyAndOrderFront(nil)
  9992. webView.load(URLRequest(url: startURL))
  9993. do {
  9994. return try await withThrowingTaskGroup(of: URL.self) { group in
  9995. group.addTask { [weak self] in
  9996. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  9997. return try await self.awaitCallback()
  9998. }
  9999. group.addTask {
  10000. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  10001. throw GoogleOAuthError.authenticationTimedOut
  10002. }
  10003. let url = try await group.next()!
  10004. group.cancelAll()
  10005. self.closeOAuthWindowAfterFlow()
  10006. return url
  10007. }
  10008. } catch {
  10009. self.closeOAuthWindowAfterFlow()
  10010. throw error
  10011. }
  10012. }
  10013. private func closeOAuthWindowAfterFlow() {
  10014. suppressUserCloseCancellation = true
  10015. webView.stopLoading()
  10016. removeFromParentIfNeeded()
  10017. close()
  10018. suppressUserCloseCancellation = false
  10019. }
  10020. private func awaitCallback() async throws -> URL {
  10021. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  10022. callbackContinuation = continuation
  10023. }
  10024. }
  10025. private func removeFromParentIfNeeded() {
  10026. if let parent = parentWindowForChild, let w = window {
  10027. parent.removeChildWindow(w)
  10028. }
  10029. parentWindowForChild = nil
  10030. }
  10031. private func applyFixedOAuthFrame(presentingWindow: NSWindow?) {
  10032. guard let window else { return }
  10033. if let parent = presentingWindow {
  10034. let parentContent = parent.contentRect(forFrameRect: parent.frame)
  10035. let oauthFrame = window.frameRect(forContentRect: parentContent)
  10036. window.setFrame(oauthFrame, display: true)
  10037. } else {
  10038. let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  10039. window.setContentSize(contentSize)
  10040. window.center()
  10041. }
  10042. let lockedContentSize = window.contentRect(forFrameRect: window.frame).size
  10043. window.contentMinSize = lockedContentSize
  10044. window.contentMaxSize = lockedContentSize
  10045. }
  10046. func webView(_ webView: WKWebView,
  10047. decidePolicyFor navigationAction: WKNavigationAction,
  10048. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  10049. guard let targetURL = navigationAction.request.url,
  10050. let callbackPrefix,
  10051. targetURL.absoluteString.hasPrefix(callbackPrefix) else {
  10052. decisionHandler(.allow)
  10053. return
  10054. }
  10055. callbackContinuation?.resume(returning: targetURL)
  10056. callbackContinuation = nil
  10057. decisionHandler(.cancel)
  10058. }
  10059. }
  10060. extension GoogleOAuthError: LocalizedError {
  10061. var errorDescription: String? {
  10062. switch self {
  10063. case .missingClientId: return "Missing Google OAuth Client ID."
  10064. case .missingClientSecret: return "Missing Google OAuth Client Secret."
  10065. case .invalidCallbackURL: return "Invalid OAuth callback URL."
  10066. case .missingAuthorizationCode: return "Google did not return an authorization code."
  10067. case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
  10068. case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
  10069. case .authenticationTimedOut: return "Google sign-in timed out."
  10070. case .authenticationCancelled: return "Sign-in was cancelled."
  10071. }
  10072. }
  10073. }
  10074. extension ZoomOAuthError: LocalizedError {
  10075. var errorDescription: String? {
  10076. switch self {
  10077. case .missingClientId:
  10078. return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
  10079. case .missingClientSecret:
  10080. return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
  10081. case .invalidCallbackURL:
  10082. return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
  10083. case .missingAuthorizationCode:
  10084. return "Zoom did not return an authorization code."
  10085. case .tokenExchangeFailed(let details):
  10086. return details
  10087. case .missingRequiredScope(let details):
  10088. return "The Zoom access token is missing required scopes. \(details)"
  10089. case .rateLimited(let retryAfterSeconds):
  10090. if let retryAfterSeconds {
  10091. return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
  10092. }
  10093. return "Zoom rate limit reached. Try again later."
  10094. case .unableToOpenBrowser:
  10095. return "Could not open Zoom sign-in (no window to present from)."
  10096. case .authenticationTimedOut:
  10097. return "Zoom sign-in timed out waiting for the browser redirect."
  10098. case .authenticationCancelled:
  10099. return "Zoom sign-in was cancelled."
  10100. }
  10101. }
  10102. }
  10103. // MARK: - Desktop Widgets (Zoom)
  10104. private struct WidgetQuickAction: Identifiable, Hashable {
  10105. enum Destination: String, Hashable {
  10106. case home
  10107. case meetings
  10108. case scheduler
  10109. case widgets
  10110. case settings
  10111. case newMeeting
  10112. case joinMeeting
  10113. case scheduleMeeting
  10114. case refreshMeetings
  10115. }
  10116. let id: String
  10117. let title: String
  10118. let systemImage: String
  10119. let destination: Destination
  10120. }
  10121. private struct WidgetVariant: Identifiable, Hashable {
  10122. let id: String
  10123. let title: String
  10124. let subtitle: String
  10125. let size: WidgetSize
  10126. let quickActions: [WidgetQuickAction]
  10127. }
  10128. private enum WidgetTemplates {
  10129. static let zoomVariants: [WidgetVariant] = [
  10130. WidgetVariant(
  10131. id: "zoom_small",
  10132. title: "Zoom",
  10133. subtitle: "Quick actions",
  10134. size: .small,
  10135. quickActions: [
  10136. WidgetQuickAction(id: "new", title: "New meeting", systemImage: "video.fill", destination: .newMeeting),
  10137. WidgetQuickAction(id: "join", title: "Join", systemImage: "plus", destination: .joinMeeting),
  10138. WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "calendar", destination: .scheduleMeeting),
  10139. WidgetQuickAction(id: "open", title: "Meetings", systemImage: "list.bullet.rectangle", destination: .meetings)
  10140. ]
  10141. ),
  10142. WidgetVariant(
  10143. id: "zoom_medium",
  10144. title: "Zoom",
  10145. subtitle: "Upcoming meetings",
  10146. size: .medium,
  10147. quickActions: [
  10148. WidgetQuickAction(id: "meetings", title: "Meetings", systemImage: "video", destination: .meetings),
  10149. WidgetQuickAction(id: "scheduler", title: "Scheduler", systemImage: "calendar.badge.clock", destination: .scheduler),
  10150. WidgetQuickAction(id: "widgets", title: "Widgets", systemImage: "rectangle.grid.2x2", destination: .widgets),
  10151. WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
  10152. ]
  10153. )
  10154. ]
  10155. static func variant(for variantID: String) -> WidgetVariant {
  10156. zoomVariants.first(where: { $0.id == variantID }) ?? zoomVariants[0]
  10157. }
  10158. }
  10159. private extension WidgetVariant {
  10160. var previewCardSize: CGSize {
  10161. switch size {
  10162. case .small:
  10163. return CGSize(width: 196, height: 196)
  10164. case .medium:
  10165. return CGSize(width: 340, height: 288)
  10166. }
  10167. }
  10168. // Simple Zoom-blue palette shared by all widget sizes.
  10169. var gradientStops: [Color] {
  10170. [
  10171. Color(red: 0.04, green: 0.32, blue: 0.86),
  10172. Color(red: 0.10, green: 0.50, blue: 0.96)
  10173. ]
  10174. }
  10175. // Neutral highlight keeps the blue clean without washing out the logo.
  10176. var highlightTint: Color {
  10177. Color.white.opacity(0.16)
  10178. }
  10179. }
  10180. private enum WidgetSize: String, CaseIterable, Codable, Hashable {
  10181. case small
  10182. case medium
  10183. var title: String {
  10184. switch self {
  10185. case .small: return "Small"
  10186. case .medium: return "Medium"
  10187. }
  10188. }
  10189. }
  10190. private struct WidgetInstance: Identifiable, Codable, Hashable {
  10191. struct Origin: Codable, Hashable {
  10192. var x: Double
  10193. var y: Double
  10194. }
  10195. let id: UUID
  10196. let variantID: String
  10197. var size: WidgetSize
  10198. var origin: Origin?
  10199. init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
  10200. self.id = id
  10201. self.variantID = variantID
  10202. self.size = size
  10203. self.origin = origin
  10204. }
  10205. }
  10206. private enum WidgetAppNavigator {
  10207. static func open(target: WidgetQuickAction.Destination) {
  10208. DispatchQueue.main.async {
  10209. bringAppToFront()
  10210. DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
  10211. NotificationCenter.default.post(name: notificationName(for: target), object: nil)
  10212. }
  10213. }
  10214. }
  10215. static func openMeetingLink(_ link: String) {
  10216. DispatchQueue.main.async {
  10217. bringAppToFront()
  10218. DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
  10219. NotificationCenter.default.post(
  10220. name: .widgetOpenMeetingLinkRequested,
  10221. object: nil,
  10222. userInfo: ["link": link]
  10223. )
  10224. }
  10225. }
  10226. }
  10227. private static func notificationName(for target: WidgetQuickAction.Destination) -> Notification.Name {
  10228. switch target {
  10229. case .home: return .widgetOpenHomePage
  10230. case .meetings: return .widgetOpenMeetingsPage
  10231. case .scheduler: return .widgetOpenSchedulerPage
  10232. case .widgets: return .widgetOpenWidgetsPage
  10233. case .settings: return .widgetOpenSettingsPage
  10234. case .newMeeting: return .widgetStartNewMeetingRequested
  10235. case .joinMeeting: return .widgetJoinMeetingRequested
  10236. case .scheduleMeeting: return .widgetScheduleMeetingRequested
  10237. case .refreshMeetings: return .widgetRefreshRequested
  10238. }
  10239. }
  10240. private static func bringAppToFront() {
  10241. NSApp.unhide(nil)
  10242. NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
  10243. NSApp.activate(ignoringOtherApps: true)
  10244. NSApp.arrangeInFront(nil)
  10245. for window in NSApp.windows where window.contentViewController is ViewController {
  10246. if window.isMiniaturized {
  10247. window.deminiaturize(nil)
  10248. }
  10249. window.orderFrontRegardless()
  10250. window.makeKeyAndOrderFront(nil)
  10251. }
  10252. }
  10253. }
  10254. private extension Notification.Name {
  10255. static let widgetOpenHomePage = Notification.Name("widgetOpenHomePage")
  10256. static let widgetOpenMeetingsPage = Notification.Name("widgetOpenMeetingsPage")
  10257. static let widgetOpenSchedulerPage = Notification.Name("widgetOpenSchedulerPage")
  10258. static let widgetOpenWidgetsPage = Notification.Name("widgetOpenWidgetsPage")
  10259. static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
  10260. static let widgetStartNewMeetingRequested = Notification.Name("widgetStartNewMeetingRequested")
  10261. static let widgetJoinMeetingRequested = Notification.Name("widgetJoinMeetingRequested")
  10262. static let widgetScheduleMeetingRequested = Notification.Name("widgetScheduleMeetingRequested")
  10263. static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
  10264. static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
  10265. }
  10266. private final class DesktopWidgetWindowManager: ObservableObject {
  10267. static let shared = DesktopWidgetWindowManager()
  10268. private let storageKey = "zoom_app.widget.instancesData"
  10269. private var windowsByInstanceID: [UUID: NSPanel] = [:]
  10270. @Published private(set) var instancesChangeToken: Int = 0
  10271. @Published private(set) var meetingsRefreshToken: Int = 0
  10272. private init() {}
  10273. func hasInstance(variantID: String) -> Bool {
  10274. loadInstances().contains { $0.variantID == variantID }
  10275. }
  10276. func show(instance: WidgetInstance) {
  10277. let variant = WidgetTemplates.variant(for: instance.variantID)
  10278. var resolved = instance
  10279. if resolved.origin == nil {
  10280. resolved.origin = nextAutoPlacement(for: variant)
  10281. }
  10282. let panel = makePanel(for: resolved, variant: variant)
  10283. windowsByInstanceID[resolved.id] = panel
  10284. persistUpsert(instance: resolved)
  10285. if let origin = resolved.origin {
  10286. panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
  10287. persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
  10288. } else {
  10289. panel.center()
  10290. persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
  10291. }
  10292. panel.orderFront(nil)
  10293. panel.orderFrontRegardless()
  10294. instancesChangeToken += 1
  10295. }
  10296. @discardableResult
  10297. func removeInstances(variantID: String) -> Int {
  10298. let instances = loadInstances()
  10299. let removed = instances.filter { $0.variantID == variantID }
  10300. let remaining = instances.filter { $0.variantID != variantID }
  10301. for instance in removed {
  10302. if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
  10303. panel.close()
  10304. }
  10305. }
  10306. saveInstances(remaining)
  10307. instancesChangeToken += 1
  10308. return removed.count
  10309. }
  10310. @discardableResult
  10311. func removeAllWidgets() -> Int {
  10312. let removedCount = loadInstances().count
  10313. for panel in windowsByInstanceID.values {
  10314. panel.close()
  10315. }
  10316. windowsByInstanceID.removeAll()
  10317. saveInstances([])
  10318. instancesChangeToken += 1
  10319. return removedCount
  10320. }
  10321. func restore() {
  10322. for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
  10323. show(instance: instance)
  10324. }
  10325. }
  10326. func refreshForMeetingsChange() {
  10327. resizeVisiblePanelsForCurrentContent()
  10328. meetingsRefreshToken += 1
  10329. }
  10330. private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
  10331. let size = panelSize(for: variant.size)
  10332. let panel = DesktopWidgetPanel(
  10333. contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
  10334. styleMask: [.nonactivatingPanel, .borderless],
  10335. backing: .buffered,
  10336. defer: false
  10337. )
  10338. panel.becomesKeyOnlyIfNeeded = false
  10339. panel.isReleasedWhenClosed = false
  10340. panel.isFloatingPanel = false
  10341. panel.isMovable = true
  10342. panel.hasShadow = false
  10343. panel.isOpaque = false
  10344. panel.backgroundColor = .clear
  10345. panel.isMovableByWindowBackground = true
  10346. panel.hidesOnDeactivate = false
  10347. panel.ignoresMouseEvents = false
  10348. let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
  10349. panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
  10350. panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
  10351. let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
  10352. host.translatesAutoresizingMaskIntoConstraints = false
  10353. let container = NSView()
  10354. container.wantsLayer = true
  10355. container.layer?.backgroundColor = NSColor.clear.cgColor
  10356. container.layer?.cornerRadius = 22
  10357. container.layer?.masksToBounds = true
  10358. container.addSubview(host)
  10359. NSLayoutConstraint.activate([
  10360. host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  10361. host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  10362. host.topAnchor.constraint(equalTo: container.topAnchor),
  10363. host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  10364. ])
  10365. panel.contentView = container
  10366. NotificationCenter.default.addObserver(
  10367. forName: NSWindow.didMoveNotification,
  10368. object: panel,
  10369. queue: .main
  10370. ) { [weak self, weak panel] _ in
  10371. guard let self, let panel else { return }
  10372. self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
  10373. }
  10374. return panel
  10375. }
  10376. private func panelSize(for size: WidgetSize) -> CGSize {
  10377. switch size {
  10378. case .small:
  10379. return CGSize(width: 220, height: 204)
  10380. case .medium:
  10381. return CGSize(width: 390, height: mediumPanelHeight())
  10382. }
  10383. }
  10384. private func mediumPanelHeight() -> CGFloat {
  10385. let visibleMeetingCount = min(WidgetMeetingStore.load().count, 2)
  10386. switch visibleMeetingCount {
  10387. case 0:
  10388. return 252
  10389. case 1:
  10390. return 270
  10391. default:
  10392. return 288
  10393. }
  10394. }
  10395. private func resizeVisiblePanelsForCurrentContent() {
  10396. let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
  10397. for (instanceID, panel) in windowsByInstanceID {
  10398. guard let instance = instancesByID[instanceID] else { continue }
  10399. let variant = WidgetTemplates.variant(for: instance.variantID)
  10400. let targetSize = panelSize(for: variant.size)
  10401. let currentSize = panel.frame.size
  10402. guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
  10403. continue
  10404. }
  10405. var frame = panel.frame
  10406. let oldMaxY = frame.maxY
  10407. frame.size = targetSize
  10408. frame.origin.y = oldMaxY - targetSize.height
  10409. panel.setFrame(frame, display: true)
  10410. persistOrigin(instanceID: instanceID, origin: frame.origin)
  10411. }
  10412. }
  10413. private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
  10414. let size = panelSize(for: variant.size)
  10415. let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
  10416. let margin: CGFloat = 18
  10417. let gapX: CGFloat = 22
  10418. let gapY: CGFloat = 18
  10419. let cellW = size.width + gapX
  10420. let cellH = size.height + gapY
  10421. let existingRects = loadInstances().compactMap { instance -> NSRect? in
  10422. guard let origin = instance.origin else { return nil }
  10423. let instanceSize = panelSize(for: instance.size)
  10424. return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
  10425. }
  10426. let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
  10427. let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
  10428. let maxSlots = max(120, cols * rows)
  10429. for slot in 0..<maxSlots {
  10430. let row = slot / cols
  10431. let col = cols - 1 - (slot % cols)
  10432. let x = frame.minX + margin + CGFloat(col) * cellW
  10433. let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
  10434. let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
  10435. guard candidate.minX >= frame.minX + margin,
  10436. candidate.maxX <= frame.maxX - margin,
  10437. candidate.minY >= frame.minY + margin else {
  10438. continue
  10439. }
  10440. if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
  10441. return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
  10442. }
  10443. }
  10444. return WidgetInstance.Origin(
  10445. x: frame.midX - size.width / 2,
  10446. y: frame.midY - size.height / 2
  10447. )
  10448. }
  10449. private func close(instanceID: UUID) {
  10450. if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
  10451. panel.close()
  10452. }
  10453. let remaining = loadInstances().filter { $0.id != instanceID }
  10454. saveInstances(remaining)
  10455. instancesChangeToken += 1
  10456. }
  10457. private func loadInstances() -> [WidgetInstance] {
  10458. guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
  10459. let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
  10460. let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.zoomVariants.map { ($0.id, $0.size) })
  10461. let sanitized = decoded.compactMap { instance -> WidgetInstance? in
  10462. guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
  10463. var normalized = instance
  10464. normalized.size = expectedSize
  10465. return normalized
  10466. }
  10467. if sanitized != decoded {
  10468. saveInstances(sanitized)
  10469. }
  10470. return sanitized
  10471. }
  10472. private func saveInstances(_ instances: [WidgetInstance]) {
  10473. guard let data = try? JSONEncoder().encode(instances) else { return }
  10474. UserDefaults.standard.set(data, forKey: storageKey)
  10475. }
  10476. private func persistUpsert(instance: WidgetInstance) {
  10477. var instances = loadInstances()
  10478. if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
  10479. instances[idx] = instance
  10480. } else {
  10481. instances.append(instance)
  10482. }
  10483. saveInstances(instances)
  10484. }
  10485. private func persistOrigin(instanceID: UUID, origin: NSPoint) {
  10486. var instances = loadInstances()
  10487. guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
  10488. instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
  10489. saveInstances(instances)
  10490. }
  10491. private struct DesktopWidgetHostView: View {
  10492. let instanceID: UUID
  10493. let variant: WidgetVariant
  10494. @ObservedObject private var manager = DesktopWidgetWindowManager.shared
  10495. var body: some View {
  10496. DesktopWidgetView(variant: variant, isPreview: false)
  10497. .id(manager.meetingsRefreshToken)
  10498. .contextMenu {
  10499. Button("Remove Widget", role: .destructive) {
  10500. DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
  10501. }
  10502. }
  10503. }
  10504. }
  10505. }
  10506. private final class DesktopWidgetPanel: NSPanel {
  10507. override var canBecomeKey: Bool { true }
  10508. override var canBecomeMain: Bool { false }
  10509. override func mouseDown(with event: NSEvent) {
  10510. NSApp.activate(ignoringOtherApps: true)
  10511. makeKeyAndOrderFront(nil)
  10512. super.mouseDown(with: event)
  10513. }
  10514. }
  10515. private struct WidgetsRootView: View {
  10516. @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
  10517. @Environment(\.colorScheme) private var colorScheme
  10518. @State private var toastMessage: String?
  10519. @State private var toastTask: Task<Void, Never>?
  10520. let canAddWidgets: Bool
  10521. let onAddBlocked: (() -> Void)?
  10522. private let variants = WidgetTemplates.zoomVariants
  10523. init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
  10524. self.canAddWidgets = canAddWidgets
  10525. self.onAddBlocked = onAddBlocked
  10526. }
  10527. var body: some View {
  10528. VStack(alignment: .leading, spacing: 18) {
  10529. HStack {
  10530. VStack(alignment: .leading, spacing: 3) {
  10531. Text("Add Widget to Desktop")
  10532. .font(.system(size: 20, weight: .bold))
  10533. Text("Choose a size and add your Zoom desktop widget.")
  10534. .font(.system(size: 13, weight: .medium))
  10535. .foregroundStyle(secondaryTextColor)
  10536. }
  10537. Spacer()
  10538. }
  10539. GeometryReader { geo in
  10540. let count = max(variants.count, 1)
  10541. let spacing: CGFloat = 24
  10542. let baseRowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width }
  10543. let usableWidth = max(1, geo.size.width)
  10544. let usableHeight = max(1, geo.size.height)
  10545. let maxPreviewHeight = variants.map(\.previewCardSize.height).max() ?? 320
  10546. let widthScale = (usableWidth - CGFloat(count - 1) * spacing) / max(1, baseRowWidth)
  10547. let heightScale = usableHeight / max(1, maxPreviewHeight)
  10548. let scale = max(0.45, min(1.0, min(widthScale, heightScale)))
  10549. HStack(alignment: .top, spacing: spacing) {
  10550. ForEach(variants) { variant in
  10551. WidgetPreviewAddCard(
  10552. variant: variant,
  10553. layoutScale: scale,
  10554. isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
  10555. isLocked: canAddWidgets == false,
  10556. onToggle: { toggleWidget(variant: variant) }
  10557. )
  10558. }
  10559. Spacer(minLength: 0)
  10560. }
  10561. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  10562. }
  10563. .frame(maxWidth: .infinity, maxHeight: .infinity)
  10564. Text("Tip: Right-click a desktop widget to remove it.")
  10565. .font(.system(size: 12, weight: .medium))
  10566. .foregroundStyle(secondaryTextColor)
  10567. }
  10568. .padding(22)
  10569. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  10570. .background(
  10571. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10572. .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
  10573. )
  10574. .overlay(
  10575. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10576. .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
  10577. )
  10578. .padding(.horizontal, 12)
  10579. .padding(.vertical, 12)
  10580. .overlay(alignment: .top) {
  10581. if let toastMessage {
  10582. Text(toastMessage)
  10583. .font(.system(size: 13, weight: .semibold))
  10584. .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
  10585. .padding(.horizontal, 14)
  10586. .padding(.vertical, 9)
  10587. .background(
  10588. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10589. .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
  10590. )
  10591. .overlay(
  10592. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10593. .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
  10594. )
  10595. .padding(.top, 8)
  10596. }
  10597. }
  10598. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  10599. }
  10600. private var secondaryTextColor: Color {
  10601. colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
  10602. }
  10603. private func toggleWidget(variant: WidgetVariant) {
  10604. guard canAddWidgets else {
  10605. showToast("Widgets are Premium only. Upgrade to add widgets.")
  10606. onAddBlocked?()
  10607. return
  10608. }
  10609. if desktopWidgetManager.hasInstance(variantID: variant.id) {
  10610. let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
  10611. guard removed > 0 else { return }
  10612. showToast("Removed \(variant.size.title) widget from desktop.")
  10613. } else {
  10614. desktopWidgetManager.show(
  10615. instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
  10616. )
  10617. showToast("Added \(variant.size.title) widget to desktop.")
  10618. }
  10619. }
  10620. private func showToast(_ message: String) {
  10621. toastTask?.cancel()
  10622. toastMessage = message
  10623. toastTask = Task { @MainActor in
  10624. try? await Task.sleep(nanoseconds: 1_800_000_000)
  10625. guard !Task.isCancelled else { return }
  10626. toastMessage = nil
  10627. }
  10628. }
  10629. }
  10630. private struct WidgetPreviewAddCard: View {
  10631. let variant: WidgetVariant
  10632. var layoutScale: CGFloat = 1
  10633. let isAdded: Bool
  10634. let isLocked: Bool
  10635. let onToggle: () -> Void
  10636. @State private var hovering = false
  10637. var body: some View {
  10638. ZStack(alignment: .topTrailing) {
  10639. Button(action: onToggle) {
  10640. WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
  10641. .opacity(isLocked ? 0.52 : 1)
  10642. .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
  10643. .overlay {
  10644. if isLocked {
  10645. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10646. .fill(Color.black.opacity(0.28))
  10647. .frame(
  10648. width: variant.previewCardSize.width * max(0.01, layoutScale),
  10649. height: variant.previewCardSize.height * max(0.01, layoutScale)
  10650. )
  10651. }
  10652. }
  10653. .overlay {
  10654. if isLocked {
  10655. VStack(spacing: 6) {
  10656. Image(systemName: "lock.fill")
  10657. .font(.system(size: 18, weight: .bold))
  10658. Text("Premium")
  10659. .font(.system(size: 13, weight: .bold))
  10660. }
  10661. .foregroundStyle(.white.opacity(0.96))
  10662. .padding(.horizontal, 14)
  10663. .padding(.vertical, 10)
  10664. .background(
  10665. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10666. .fill(Color.black.opacity(0.46))
  10667. )
  10668. .frame(
  10669. width: variant.previewCardSize.width * max(0.01, layoutScale),
  10670. height: variant.previewCardSize.height * max(0.01, layoutScale)
  10671. )
  10672. }
  10673. }
  10674. }
  10675. .buttonStyle(.plain)
  10676. Button(action: onToggle) {
  10677. Image(systemName: isLocked ? "lock.fill" : (isAdded ? "minus" : "plus"))
  10678. .font(.system(size: 12, weight: .bold))
  10679. .foregroundStyle(.white.opacity(0.95))
  10680. .frame(width: 26, height: 26)
  10681. .background(
  10682. Circle().fill(
  10683. isLocked
  10684. ? Color.orange.opacity(0.95)
  10685. : (isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95))
  10686. )
  10687. )
  10688. .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
  10689. }
  10690. .buttonStyle(.plain)
  10691. .offset(x: 10 * layoutScale, y: -10 * layoutScale)
  10692. .opacity((isAdded || isLocked) ? 1 : (hovering ? 1 : 0))
  10693. .animation(.easeOut(duration: 0.12), value: hovering)
  10694. }
  10695. .onHover { hovering = $0 }
  10696. }
  10697. }
  10698. private struct WidgetPreviewCard: View {
  10699. let variant: WidgetVariant
  10700. var layoutScale: CGFloat = 1
  10701. var body: some View {
  10702. let size = variant.previewCardSize
  10703. let scale = max(0.01, layoutScale)
  10704. DesktopWidgetView(variant: variant, isPreview: true)
  10705. .frame(width: size.width, height: size.height)
  10706. .scaleEffect(scale)
  10707. .frame(width: size.width * scale, height: size.height * scale)
  10708. .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
  10709. .overlay(
  10710. RoundedRectangle(cornerRadius: 18, style: .continuous)
  10711. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  10712. )
  10713. }
  10714. }
  10715. private struct DesktopWidgetView: View {
  10716. let variant: WidgetVariant
  10717. var isPreview: Bool
  10718. private var topMeetings: [WidgetMeetingSnapshot] {
  10719. WidgetMeetingStore.load().prefix(3).map { $0 }
  10720. }
  10721. var body: some View {
  10722. ZStack {
  10723. RoundedRectangle(cornerRadius: 22, style: .continuous)
  10724. .fill(
  10725. LinearGradient(
  10726. colors: variant.gradientStops,
  10727. startPoint: .topLeading,
  10728. endPoint: .bottomTrailing
  10729. )
  10730. )
  10731. .overlay(
  10732. RoundedRectangle(cornerRadius: 22, style: .continuous)
  10733. .fill(
  10734. RadialGradient(
  10735. colors: [variant.highlightTint, Color.clear],
  10736. center: .topLeading,
  10737. startRadius: 4,
  10738. endRadius: 200
  10739. )
  10740. )
  10741. .blendMode(.plusLighter)
  10742. .allowsHitTesting(false)
  10743. )
  10744. .overlay(
  10745. RoundedRectangle(cornerRadius: 22, style: .continuous)
  10746. .fill(
  10747. LinearGradient(
  10748. colors: [Color.clear, Color.black.opacity(0.18)],
  10749. startPoint: .top,
  10750. endPoint: .bottom
  10751. )
  10752. )
  10753. .allowsHitTesting(false)
  10754. )
  10755. .overlay(
  10756. RoundedRectangle(cornerRadius: 22, style: .continuous)
  10757. .stroke(Color.white.opacity(0.16), lineWidth: 1)
  10758. )
  10759. VStack(alignment: .leading, spacing: 12) {
  10760. widgetHeader
  10761. contentBody
  10762. if variant.size != .small {
  10763. Spacer(minLength: 0)
  10764. }
  10765. }
  10766. .padding(.horizontal, 14)
  10767. .padding(.bottom, variant.size == .medium ? 16 : 14)
  10768. .padding(.top, variant.size == .medium ? 18 : 10)
  10769. }
  10770. }
  10771. @ViewBuilder
  10772. private var contentBody: some View {
  10773. switch variant.size {
  10774. case .small:
  10775. VStack(alignment: .leading, spacing: 10) {
  10776. Text("Quick actions")
  10777. .font(.system(size: 11.5, weight: .semibold))
  10778. .foregroundStyle(.white.opacity(0.78))
  10779. compactActionButton(title: "New meeting", icon: "video.fill", destination: .newMeeting)
  10780. HStack(spacing: 8) {
  10781. compactActionButton(title: "Join", icon: "plus", destination: .joinMeeting)
  10782. compactActionButton(title: "Schedule", icon: "calendar", destination: .scheduleMeeting)
  10783. }
  10784. }
  10785. case .medium:
  10786. meetingBlock(maxRows: 2)
  10787. LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
  10788. ForEach(variant.quickActions.prefix(4)) { action in
  10789. actionTile(action)
  10790. }
  10791. }
  10792. }
  10793. }
  10794. private var widgetHeader: some View {
  10795. HStack(spacing: 10) {
  10796. Image(systemName: "video.fill")
  10797. .font(.system(size: 16, weight: .bold))
  10798. .foregroundStyle(.white.opacity(0.95))
  10799. .frame(width: 30, height: 30)
  10800. .background(
  10801. RoundedRectangle(cornerRadius: 8, style: .continuous)
  10802. .fill(
  10803. LinearGradient(
  10804. colors: [Color.white.opacity(0.22), Color.black.opacity(0.18)],
  10805. startPoint: .top,
  10806. endPoint: .bottom
  10807. )
  10808. )
  10809. )
  10810. .overlay(
  10811. RoundedRectangle(cornerRadius: 8, style: .continuous)
  10812. .stroke(Color.white.opacity(0.18), lineWidth: 1)
  10813. )
  10814. VStack(alignment: .leading, spacing: 2) {
  10815. Text(variant.title)
  10816. .font(.system(size: 16, weight: .bold))
  10817. .foregroundStyle(.white.opacity(0.95))
  10818. Text(variant.subtitle)
  10819. .font(.system(size: 12, weight: .medium))
  10820. .foregroundStyle(.white.opacity(0.8))
  10821. }
  10822. Spacer(minLength: 0)
  10823. }
  10824. }
  10825. private func meetingBlock(maxRows: Int) -> some View {
  10826. VStack(alignment: .leading, spacing: 9) {
  10827. Text("Upcoming meetings")
  10828. .font(.system(size: 11.5, weight: .semibold))
  10829. .foregroundStyle(.white.opacity(0.80))
  10830. if topMeetings.isEmpty {
  10831. Text("No upcoming meetings")
  10832. .font(.system(size: 12, weight: .medium))
  10833. .foregroundStyle(.white.opacity(0.82))
  10834. } else {
  10835. VStack(alignment: .leading, spacing: 8) {
  10836. ForEach(topMeetings.prefix(maxRows)) { meeting in
  10837. meetingRow(meeting)
  10838. }
  10839. }
  10840. }
  10841. }
  10842. }
  10843. private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
  10844. Button(action: {
  10845. if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
  10846. WidgetAppNavigator.openMeetingLink(link)
  10847. } else {
  10848. WidgetAppNavigator.open(target: .meetings)
  10849. }
  10850. }) {
  10851. HStack(spacing: 8) {
  10852. Image(systemName: "video.fill")
  10853. .font(.system(size: 11, weight: .semibold))
  10854. .foregroundStyle(.white.opacity(0.88))
  10855. VStack(alignment: .leading, spacing: 1) {
  10856. Text(meeting.title)
  10857. .font(.system(size: 11.5, weight: .semibold))
  10858. .foregroundStyle(.white.opacity(0.93))
  10859. .lineLimit(1)
  10860. Text(meeting.timeText)
  10861. .font(.system(size: 10.5, weight: .medium))
  10862. .foregroundStyle(.white.opacity(0.74))
  10863. .lineLimit(1)
  10864. }
  10865. Spacer(minLength: 0)
  10866. }
  10867. .padding(.horizontal, 10)
  10868. .padding(.vertical, 7)
  10869. .background(
  10870. RoundedRectangle(cornerRadius: 10, style: .continuous)
  10871. .fill(
  10872. LinearGradient(
  10873. colors: [Color.white.opacity(0.14), Color.black.opacity(0.22)],
  10874. startPoint: .top,
  10875. endPoint: .bottom
  10876. )
  10877. )
  10878. )
  10879. .overlay(
  10880. RoundedRectangle(cornerRadius: 10, style: .continuous)
  10881. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  10882. )
  10883. }
  10884. .buttonStyle(.plain)
  10885. .disabled(isPreview)
  10886. .allowsHitTesting(!isPreview)
  10887. }
  10888. private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
  10889. Button(action: { WidgetAppNavigator.open(target: destination) }) {
  10890. HStack(spacing: 5) {
  10891. Image(systemName: icon)
  10892. .font(.system(size: 10.5, weight: .semibold))
  10893. .frame(width: 14)
  10894. Text(title)
  10895. .font(.system(size: 11.5, weight: .bold))
  10896. .lineLimit(1)
  10897. .minimumScaleFactor(0.82)
  10898. .layoutPriority(1)
  10899. }
  10900. .foregroundStyle(.white.opacity(0.97))
  10901. .padding(.horizontal, 10)
  10902. .padding(.vertical, 8)
  10903. .frame(maxWidth: .infinity)
  10904. .background(
  10905. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10906. .fill(
  10907. LinearGradient(
  10908. colors: [Color.white.opacity(0.20), Color.black.opacity(0.28)],
  10909. startPoint: .top,
  10910. endPoint: .bottom
  10911. )
  10912. )
  10913. )
  10914. .overlay(
  10915. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10916. .stroke(Color.white.opacity(0.26), lineWidth: 1)
  10917. )
  10918. }
  10919. .buttonStyle(.plain)
  10920. .disabled(isPreview)
  10921. .allowsHitTesting(!isPreview)
  10922. }
  10923. private func actionTile(_ action: WidgetQuickAction) -> some View {
  10924. Button(action: { open(action: action) }) {
  10925. HStack(spacing: 8) {
  10926. Image(systemName: action.systemImage)
  10927. .font(.system(size: 11, weight: .semibold))
  10928. Text(action.title)
  10929. .font(.system(size: 12, weight: .bold))
  10930. .lineLimit(1)
  10931. Spacer(minLength: 0)
  10932. }
  10933. .foregroundStyle(.white.opacity(0.95))
  10934. .padding(.horizontal, 10)
  10935. .padding(.vertical, 9)
  10936. .background(
  10937. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10938. .fill(
  10939. LinearGradient(
  10940. colors: [Color.white.opacity(0.16), Color.black.opacity(0.22)],
  10941. startPoint: .top,
  10942. endPoint: .bottom
  10943. )
  10944. )
  10945. )
  10946. .overlay(
  10947. RoundedRectangle(cornerRadius: 12, style: .continuous)
  10948. .stroke(Color.white.opacity(0.16), lineWidth: 1)
  10949. )
  10950. }
  10951. .buttonStyle(.plain)
  10952. .disabled(isPreview)
  10953. .allowsHitTesting(!isPreview)
  10954. }
  10955. private func open(action: WidgetQuickAction) {
  10956. guard !isPreview else { return }
  10957. WidgetAppNavigator.open(target: action.destination)
  10958. }
  10959. }