ViewController.swift 456 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257425842594260426142624263426442654266426742684269427042714272427342744275427642774278427942804281428242834284428542864287428842894290429142924293429442954296429742984299430043014302430343044305430643074308430943104311431243134314431543164317431843194320432143224323432443254326432743284329433043314332433343344335433643374338433943404341434243434344434543464347434843494350435143524353435443554356435743584359436043614362436343644365436643674368436943704371437243734374437543764377437843794380438143824383438443854386438743884389439043914392439343944395439643974398439944004401440244034404440544064407440844094410441144124413441444154416441744184419442044214422442344244425442644274428442944304431443244334434443544364437443844394440444144424443444444454446444744484449445044514452445344544455445644574458445944604461446244634464446544664467446844694470447144724473447444754476447744784479448044814482448344844485448644874488448944904491449244934494449544964497449844994500450145024503450445054506450745084509451045114512451345144515451645174518451945204521452245234524452545264527452845294530453145324533453445354536453745384539454045414542454345444545454645474548454945504551455245534554455545564557455845594560456145624563456445654566456745684569457045714572457345744575457645774578457945804581458245834584458545864587458845894590459145924593459445954596459745984599460046014602460346044605460646074608460946104611461246134614461546164617461846194620462146224623462446254626462746284629463046314632463346344635463646374638463946404641464246434644464546464647464846494650465146524653465446554656465746584659466046614662466346644665466646674668466946704671467246734674467546764677467846794680468146824683468446854686468746884689469046914692469346944695469646974698469947004701470247034704470547064707470847094710471147124713471447154716471747184719472047214722472347244725472647274728472947304731473247334734473547364737473847394740474147424743474447454746474747484749475047514752475347544755475647574758475947604761476247634764476547664767476847694770477147724773477447754776477747784779478047814782478347844785478647874788478947904791479247934794479547964797479847994800480148024803480448054806480748084809481048114812481348144815481648174818481948204821482248234824482548264827482848294830483148324833483448354836483748384839484048414842484348444845484648474848484948504851485248534854485548564857485848594860486148624863486448654866486748684869487048714872487348744875487648774878487948804881488248834884488548864887488848894890489148924893489448954896489748984899490049014902490349044905490649074908490949104911491249134914491549164917491849194920492149224923492449254926492749284929493049314932493349344935493649374938493949404941494249434944494549464947494849494950495149524953495449554956495749584959496049614962496349644965496649674968496949704971497249734974497549764977497849794980498149824983498449854986498749884989499049914992499349944995499649974998499950005001500250035004500550065007500850095010501150125013501450155016501750185019502050215022502350245025502650275028502950305031503250335034503550365037503850395040504150425043504450455046504750485049505050515052505350545055505650575058505950605061506250635064506550665067506850695070507150725073507450755076507750785079508050815082508350845085508650875088508950905091509250935094509550965097509850995100510151025103510451055106510751085109511051115112511351145115511651175118511951205121512251235124512551265127512851295130513151325133513451355136513751385139514051415142514351445145514651475148514951505151515251535154515551565157515851595160516151625163516451655166516751685169517051715172517351745175517651775178517951805181518251835184518551865187518851895190519151925193519451955196519751985199520052015202520352045205520652075208520952105211521252135214521552165217521852195220522152225223522452255226522752285229523052315232523352345235523652375238523952405241524252435244524552465247524852495250525152525253525452555256525752585259526052615262526352645265526652675268526952705271527252735274527552765277527852795280528152825283528452855286528752885289529052915292529352945295529652975298529953005301530253035304530553065307530853095310531153125313531453155316531753185319532053215322532353245325532653275328532953305331533253335334533553365337533853395340534153425343534453455346534753485349535053515352535353545355535653575358535953605361536253635364536553665367536853695370537153725373537453755376537753785379538053815382538353845385538653875388538953905391539253935394539553965397539853995400540154025403540454055406540754085409541054115412541354145415541654175418541954205421542254235424542554265427542854295430543154325433543454355436543754385439544054415442544354445445544654475448544954505451545254535454545554565457545854595460546154625463546454655466546754685469547054715472547354745475547654775478547954805481548254835484548554865487548854895490549154925493549454955496549754985499550055015502550355045505550655075508550955105511551255135514551555165517551855195520552155225523552455255526552755285529553055315532553355345535553655375538553955405541554255435544554555465547554855495550555155525553555455555556555755585559556055615562556355645565556655675568556955705571557255735574557555765577557855795580558155825583558455855586558755885589559055915592559355945595559655975598559956005601560256035604560556065607560856095610561156125613561456155616561756185619562056215622562356245625562656275628562956305631563256335634563556365637563856395640564156425643564456455646564756485649565056515652565356545655565656575658565956605661566256635664566556665667566856695670567156725673567456755676567756785679568056815682568356845685568656875688568956905691569256935694569556965697569856995700570157025703570457055706570757085709571057115712571357145715571657175718571957205721572257235724572557265727572857295730573157325733573457355736573757385739574057415742574357445745574657475748574957505751575257535754575557565757575857595760576157625763576457655766576757685769577057715772577357745775577657775778577957805781578257835784578557865787578857895790579157925793579457955796579757985799580058015802580358045805580658075808580958105811581258135814581558165817581858195820582158225823582458255826582758285829583058315832583358345835583658375838583958405841584258435844584558465847584858495850585158525853585458555856585758585859586058615862586358645865586658675868586958705871587258735874587558765877587858795880588158825883588458855886588758885889589058915892589358945895589658975898589959005901590259035904590559065907590859095910591159125913591459155916591759185919592059215922592359245925592659275928592959305931593259335934593559365937593859395940594159425943594459455946594759485949595059515952595359545955595659575958595959605961596259635964596559665967596859695970597159725973597459755976597759785979598059815982598359845985598659875988598959905991599259935994599559965997599859996000600160026003600460056006600760086009601060116012601360146015601660176018601960206021602260236024602560266027602860296030603160326033603460356036603760386039604060416042604360446045604660476048604960506051605260536054605560566057605860596060606160626063606460656066606760686069607060716072607360746075607660776078607960806081608260836084608560866087608860896090609160926093609460956096609760986099610061016102610361046105610661076108610961106111611261136114611561166117611861196120612161226123612461256126612761286129613061316132613361346135613661376138613961406141614261436144614561466147614861496150615161526153615461556156615761586159616061616162616361646165616661676168616961706171617261736174617561766177617861796180618161826183618461856186618761886189619061916192619361946195619661976198619962006201620262036204620562066207620862096210621162126213621462156216621762186219622062216222622362246225622662276228622962306231623262336234623562366237623862396240624162426243624462456246624762486249625062516252625362546255625662576258625962606261626262636264626562666267626862696270627162726273627462756276627762786279628062816282628362846285628662876288628962906291629262936294629562966297629862996300630163026303630463056306630763086309631063116312631363146315631663176318631963206321632263236324632563266327632863296330633163326333633463356336633763386339634063416342634363446345634663476348634963506351635263536354635563566357635863596360636163626363636463656366636763686369637063716372637363746375637663776378637963806381638263836384638563866387638863896390639163926393639463956396639763986399640064016402640364046405640664076408640964106411641264136414641564166417641864196420642164226423642464256426642764286429643064316432643364346435643664376438643964406441644264436444644564466447644864496450645164526453645464556456645764586459646064616462646364646465646664676468646964706471647264736474647564766477647864796480648164826483648464856486648764886489649064916492649364946495649664976498649965006501650265036504650565066507650865096510651165126513651465156516651765186519652065216522652365246525652665276528652965306531653265336534653565366537653865396540654165426543654465456546654765486549655065516552655365546555655665576558655965606561656265636564656565666567656865696570657165726573657465756576657765786579658065816582658365846585658665876588658965906591659265936594659565966597659865996600660166026603660466056606660766086609661066116612661366146615661666176618661966206621662266236624662566266627662866296630663166326633663466356636663766386639664066416642664366446645664666476648664966506651665266536654665566566657665866596660666166626663666466656666666766686669667066716672667366746675667666776678667966806681668266836684668566866687668866896690669166926693669466956696669766986699670067016702670367046705670667076708670967106711671267136714671567166717671867196720672167226723672467256726672767286729673067316732673367346735673667376738673967406741674267436744674567466747674867496750675167526753675467556756675767586759676067616762676367646765676667676768676967706771677267736774677567766777677867796780678167826783678467856786678767886789679067916792679367946795679667976798679968006801680268036804680568066807680868096810681168126813681468156816681768186819682068216822682368246825682668276828682968306831683268336834683568366837683868396840684168426843684468456846684768486849685068516852685368546855685668576858685968606861686268636864686568666867686868696870687168726873687468756876687768786879688068816882688368846885688668876888688968906891689268936894689568966897689868996900690169026903690469056906690769086909691069116912691369146915691669176918691969206921692269236924692569266927692869296930693169326933693469356936693769386939694069416942694369446945694669476948694969506951695269536954695569566957695869596960696169626963696469656966696769686969697069716972697369746975697669776978697969806981698269836984698569866987698869896990699169926993699469956996699769986999700070017002700370047005700670077008700970107011701270137014701570167017701870197020702170227023702470257026702770287029703070317032703370347035703670377038703970407041704270437044704570467047704870497050705170527053705470557056705770587059706070617062706370647065706670677068706970707071707270737074707570767077707870797080708170827083708470857086708770887089709070917092709370947095709670977098709971007101710271037104710571067107710871097110711171127113711471157116711771187119712071217122712371247125712671277128712971307131713271337134713571367137713871397140714171427143714471457146714771487149715071517152715371547155715671577158715971607161716271637164716571667167716871697170717171727173717471757176717771787179718071817182718371847185718671877188718971907191719271937194719571967197719871997200720172027203720472057206720772087209721072117212721372147215721672177218721972207221722272237224722572267227722872297230723172327233723472357236723772387239724072417242724372447245724672477248724972507251725272537254725572567257725872597260726172627263726472657266726772687269727072717272727372747275727672777278727972807281728272837284728572867287728872897290729172927293729472957296729772987299730073017302730373047305730673077308730973107311731273137314731573167317731873197320732173227323732473257326732773287329733073317332733373347335733673377338733973407341734273437344734573467347734873497350735173527353735473557356735773587359736073617362736373647365736673677368736973707371737273737374737573767377737873797380738173827383738473857386738773887389739073917392739373947395739673977398739974007401740274037404740574067407740874097410741174127413741474157416741774187419742074217422742374247425742674277428742974307431743274337434743574367437743874397440744174427443744474457446744774487449745074517452745374547455745674577458745974607461746274637464746574667467746874697470747174727473747474757476747774787479748074817482748374847485748674877488748974907491749274937494749574967497749874997500750175027503750475057506750775087509751075117512751375147515751675177518751975207521752275237524752575267527752875297530753175327533753475357536753775387539754075417542754375447545754675477548754975507551755275537554755575567557755875597560756175627563756475657566756775687569757075717572757375747575757675777578757975807581758275837584758575867587758875897590759175927593759475957596759775987599760076017602760376047605760676077608760976107611761276137614761576167617761876197620762176227623762476257626762776287629763076317632763376347635763676377638763976407641764276437644764576467647764876497650765176527653765476557656765776587659766076617662766376647665766676677668766976707671767276737674767576767677767876797680768176827683768476857686768776887689769076917692769376947695769676977698769977007701770277037704770577067707770877097710771177127713771477157716771777187719772077217722772377247725772677277728772977307731773277337734773577367737773877397740774177427743774477457746774777487749775077517752775377547755775677577758775977607761776277637764776577667767776877697770777177727773777477757776777777787779778077817782778377847785778677877788778977907791779277937794779577967797779877997800780178027803780478057806780778087809781078117812781378147815781678177818781978207821782278237824782578267827782878297830783178327833783478357836783778387839784078417842784378447845784678477848784978507851785278537854785578567857785878597860786178627863786478657866786778687869787078717872787378747875787678777878787978807881788278837884788578867887788878897890789178927893789478957896789778987899790079017902790379047905790679077908790979107911791279137914791579167917791879197920792179227923792479257926792779287929793079317932793379347935793679377938793979407941794279437944794579467947794879497950795179527953795479557956795779587959796079617962796379647965796679677968796979707971797279737974797579767977797879797980798179827983798479857986798779887989799079917992799379947995799679977998799980008001800280038004800580068007800880098010801180128013801480158016801780188019802080218022802380248025802680278028802980308031803280338034803580368037803880398040804180428043804480458046804780488049805080518052805380548055805680578058805980608061806280638064806580668067806880698070807180728073807480758076807780788079808080818082808380848085808680878088808980908091809280938094809580968097809880998100810181028103810481058106810781088109811081118112811381148115811681178118811981208121812281238124812581268127812881298130813181328133813481358136813781388139814081418142814381448145814681478148814981508151815281538154815581568157815881598160816181628163816481658166816781688169817081718172817381748175817681778178817981808181818281838184818581868187818881898190819181928193819481958196819781988199820082018202820382048205820682078208820982108211821282138214821582168217821882198220822182228223822482258226822782288229823082318232823382348235823682378238823982408241824282438244824582468247824882498250825182528253825482558256825782588259826082618262826382648265826682678268826982708271827282738274827582768277827882798280828182828283828482858286828782888289829082918292829382948295829682978298829983008301830283038304830583068307830883098310831183128313831483158316831783188319832083218322832383248325832683278328832983308331833283338334833583368337833883398340834183428343834483458346834783488349835083518352835383548355835683578358835983608361836283638364836583668367836883698370837183728373837483758376837783788379838083818382838383848385838683878388838983908391839283938394839583968397839883998400840184028403840484058406840784088409841084118412841384148415841684178418841984208421842284238424842584268427842884298430843184328433843484358436843784388439844084418442844384448445844684478448844984508451845284538454845584568457845884598460846184628463846484658466846784688469847084718472847384748475847684778478847984808481848284838484848584868487848884898490849184928493849484958496849784988499850085018502850385048505850685078508850985108511851285138514851585168517851885198520852185228523852485258526852785288529853085318532853385348535853685378538853985408541854285438544854585468547854885498550855185528553855485558556855785588559856085618562856385648565856685678568856985708571857285738574857585768577857885798580858185828583858485858586858785888589859085918592859385948595859685978598859986008601860286038604860586068607860886098610861186128613861486158616861786188619862086218622862386248625862686278628862986308631863286338634863586368637863886398640864186428643864486458646864786488649865086518652865386548655865686578658865986608661866286638664866586668667866886698670867186728673867486758676867786788679868086818682868386848685868686878688868986908691869286938694869586968697869886998700870187028703870487058706870787088709871087118712871387148715871687178718871987208721872287238724872587268727872887298730873187328733873487358736873787388739874087418742874387448745874687478748874987508751875287538754875587568757875887598760876187628763876487658766876787688769877087718772877387748775877687778778877987808781878287838784878587868787878887898790879187928793879487958796879787988799880088018802880388048805880688078808880988108811881288138814881588168817881888198820882188228823882488258826882788288829883088318832883388348835883688378838883988408841884288438844884588468847884888498850885188528853885488558856885788588859886088618862886388648865886688678868886988708871887288738874887588768877887888798880888188828883888488858886888788888889889088918892889388948895889688978898889989008901890289038904890589068907890889098910891189128913891489158916891789188919892089218922892389248925892689278928892989308931893289338934893589368937893889398940894189428943894489458946894789488949895089518952895389548955895689578958895989608961896289638964896589668967896889698970897189728973897489758976897789788979898089818982898389848985898689878988898989908991899289938994899589968997899889999000900190029003900490059006900790089009901090119012901390149015901690179018901990209021902290239024902590269027902890299030903190329033903490359036903790389039904090419042904390449045904690479048904990509051905290539054905590569057905890599060906190629063906490659066906790689069907090719072907390749075907690779078907990809081908290839084908590869087908890899090909190929093909490959096909790989099910091019102910391049105910691079108910991109111911291139114911591169117911891199120912191229123912491259126912791289129913091319132913391349135913691379138913991409141914291439144914591469147914891499150915191529153915491559156915791589159916091619162916391649165916691679168916991709171917291739174917591769177917891799180918191829183918491859186918791889189919091919192919391949195919691979198919992009201920292039204920592069207920892099210921192129213921492159216921792189219922092219222922392249225922692279228922992309231923292339234923592369237923892399240924192429243924492459246924792489249925092519252925392549255925692579258925992609261926292639264926592669267926892699270927192729273927492759276927792789279928092819282928392849285928692879288928992909291929292939294929592969297929892999300930193029303930493059306930793089309931093119312931393149315931693179318931993209321932293239324932593269327932893299330933193329333933493359336933793389339934093419342934393449345934693479348934993509351935293539354935593569357935893599360936193629363936493659366936793689369937093719372937393749375937693779378937993809381938293839384938593869387938893899390939193929393939493959396939793989399940094019402940394049405940694079408940994109411941294139414941594169417941894199420942194229423942494259426942794289429943094319432943394349435943694379438943994409441944294439444944594469447944894499450945194529453945494559456945794589459946094619462946394649465946694679468946994709471947294739474947594769477947894799480948194829483948494859486948794889489949094919492949394949495949694979498949995009501950295039504950595069507950895099510951195129513951495159516951795189519952095219522952395249525952695279528952995309531953295339534953595369537953895399540954195429543954495459546954795489549955095519552955395549555955695579558955995609561956295639564956595669567956895699570957195729573957495759576957795789579958095819582958395849585958695879588958995909591959295939594959595969597959895999600960196029603960496059606960796089609961096119612961396149615961696179618961996209621962296239624962596269627962896299630963196329633963496359636963796389639964096419642964396449645964696479648964996509651965296539654965596569657965896599660966196629663966496659666966796689669967096719672967396749675967696779678967996809681968296839684968596869687968896899690969196929693969496959696969796989699970097019702970397049705970697079708970997109711971297139714971597169717971897199720972197229723972497259726972797289729973097319732973397349735973697379738973997409741974297439744974597469747974897499750975197529753975497559756975797589759976097619762976397649765976697679768976997709771977297739774977597769777977897799780978197829783978497859786978797889789979097919792979397949795979697979798979998009801980298039804980598069807980898099810981198129813981498159816981798189819982098219822982398249825982698279828982998309831983298339834983598369837983898399840984198429843984498459846984798489849985098519852985398549855985698579858985998609861986298639864986598669867986898699870987198729873987498759876987798789879988098819882988398849885988698879888988998909891989298939894989598969897989898999900990199029903990499059906990799089909991099119912991399149915991699179918991999209921992299239924992599269927992899299930993199329933993499359936993799389939994099419942994399449945994699479948994999509951995299539954995599569957995899599960996199629963996499659966996799689969997099719972997399749975997699779978997999809981998299839984998599869987998899899990999199929993999499959996999799989999100001000110002100031000410005100061000710008100091001010011100121001310014100151001610017100181001910020100211002210023100241002510026100271002810029100301003110032100331003410035100361003710038100391004010041100421004310044100451004610047100481004910050100511005210053100541005510056100571005810059100601006110062100631006410065100661006710068100691007010071100721007310074100751007610077100781007910080
  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 StoreKit
  11. import SwiftUI
  12. import UserNotifications
  13. import WebKit
  14. /// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
  15. private enum AppWindowMetrics {
  16. static let defaultContentWidth: CGFloat = 1020
  17. static let defaultContentHeight: CGFloat = 690
  18. }
  19. /// Errors from the main-window embedded OAuth web session (right panel).
  20. private enum EmbeddedOAuthFlowError: Error {
  21. case webViewUnavailable
  22. case userCancelled
  23. case timedOut
  24. }
  25. @MainActor
  26. protocol EmbeddedOAuthPresenting: AnyObject {
  27. func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL
  28. }
  29. class ViewController: NSViewController {
  30. enum HomeSection: String {
  31. case home = "Home"
  32. case meetings = "Meetings"
  33. case scheduler = "Scheduler"
  34. case widgets = "Widgets"
  35. case aiCompanion = "Ai Companion"
  36. case settings = "Settings"
  37. }
  38. private let googleOAuth = GoogleOAuthService.shared
  39. private let zoomOAuth = ZoomOAuthService.shared
  40. private let loginStateKey = "zoom_app.isLoggedIn"
  41. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  42. private let legacyNotificationsEnabledDefaultsKey = "settings.notificationsEnabled"
  43. private let remindersEnabledDefaultsKey = "reminders.enabled"
  44. private let remind1DayDefaultsKey = "reminders.1day"
  45. private let remind12HoursDefaultsKey = "reminders.12hours"
  46. private let remind1HourDefaultsKey = "reminders.1hour"
  47. private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
  48. private let ratingCompletedKey = "zoom_app.ratingCompleted"
  49. private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
  50. private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
  51. private struct Palette {
  52. let isDarkMode: Bool
  53. var appBackground: NSColor {
  54. isDarkMode
  55. ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
  56. : NSColor(calibratedWhite: 0.96, alpha: 1)
  57. }
  58. var sidebarBackground: NSColor {
  59. isDarkMode
  60. ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
  61. : NSColor(calibratedWhite: 0.94, alpha: 1)
  62. }
  63. var sidebarActiveBackground: NSColor {
  64. isDarkMode
  65. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  66. : NSColor(calibratedWhite: 0.86, alpha: 1)
  67. }
  68. var cardBackground: NSColor {
  69. isDarkMode
  70. ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
  71. : NSColor.white
  72. }
  73. var secondaryCardBackground: NSColor {
  74. isDarkMode
  75. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  76. // Slightly darker than pure white so cards are visible on Light background.
  77. : NSColor(calibratedWhite: 0.93, alpha: 1)
  78. }
  79. var appShellBackground: NSColor { appBackground }
  80. var contentShellBackground: NSColor { appBackground }
  81. var topStripBackground: NSColor { chromeUnifiedBackground }
  82. var chromeUnifiedBackground: NSColor {
  83. isDarkMode
  84. ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
  85. : NSColor(calibratedWhite: 0.92, alpha: 1)
  86. }
  87. var searchPillBackground: NSColor {
  88. isDarkMode
  89. ? NSColor.white.withAlphaComponent(0.06)
  90. : NSColor.black.withAlphaComponent(0.06)
  91. }
  92. var meetingCardBackground: NSColor {
  93. isDarkMode
  94. ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
  95. : NSColor(calibratedWhite: 0.98, alpha: 1)
  96. }
  97. var sectionCard: NSColor {
  98. isDarkMode
  99. ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
  100. : NSColor.white
  101. }
  102. var inputBackground: NSColor {
  103. isDarkMode
  104. ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
  105. : NSColor(calibratedWhite: 0.97, alpha: 1)
  106. }
  107. var inputBorder: NSColor {
  108. isDarkMode
  109. ? NSColor.white.withAlphaComponent(0.08)
  110. : NSColor.black.withAlphaComponent(0.10)
  111. }
  112. var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
  113. var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
  114. var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
  115. var sidebarInactiveText: NSColor {
  116. isDarkMode ? secondaryText : NSColor(calibratedWhite: 0.12, alpha: 1)
  117. }
  118. }
  119. private final class TopAlignedClipView: NSClipView {
  120. override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
  121. var rect = super.constrainBoundsRect(proposedBounds)
  122. rect.origin.x = 0
  123. return rect
  124. }
  125. }
  126. private final class HoverButton: NSButton {
  127. var normalColor: NSColor = .clear { didSet { applyBackground() } }
  128. var hoverColor: NSColor = .clear
  129. var onHoverChanged: ((Bool) -> Void)?
  130. private var tracking: NSTrackingArea?
  131. private var hovering = false { didSet { applyBackground() } }
  132. override func updateTrackingAreas() {
  133. super.updateTrackingAreas()
  134. if let tracking { removeTrackingArea(tracking) }
  135. let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
  136. addTrackingArea(area)
  137. tracking = area
  138. }
  139. override func mouseEntered(with event: NSEvent) {
  140. hovering = true
  141. onHoverChanged?(true)
  142. }
  143. override func mouseExited(with event: NSEvent) {
  144. hovering = false
  145. onHoverChanged?(false)
  146. }
  147. private func applyBackground() {
  148. wantsLayer = true
  149. layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
  150. }
  151. }
  152. private final class HoverTileView: NSView {
  153. var onHoverChanged: ((Bool) -> Void)?
  154. private var tracking: NSTrackingArea?
  155. override func updateTrackingAreas() {
  156. super.updateTrackingAreas()
  157. if let tracking { removeTrackingArea(tracking) }
  158. let area = NSTrackingArea(
  159. rect: bounds,
  160. options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect],
  161. owner: self,
  162. userInfo: nil
  163. )
  164. addTrackingArea(area)
  165. tracking = area
  166. }
  167. override func mouseEntered(with event: NSEvent) {
  168. super.mouseEntered(with: event)
  169. onHoverChanged?(true)
  170. }
  171. override func mouseExited(with event: NSEvent) {
  172. super.mouseExited(with: event)
  173. onHoverChanged?(false)
  174. }
  175. }
  176. private var palette = Palette(isDarkMode: true)
  177. private var darkModeEnabled: Bool {
  178. get {
  179. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  180. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  181. }
  182. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  183. }
  184. private var remindersEnabled: Bool {
  185. get {
  186. let defaults = UserDefaults.standard
  187. if defaults.object(forKey: remindersEnabledDefaultsKey) != nil {
  188. return defaults.bool(forKey: remindersEnabledDefaultsKey)
  189. }
  190. // Migration: if the old single-toggle key exists, treat it as master reminders toggle.
  191. if defaults.object(forKey: legacyNotificationsEnabledDefaultsKey) != nil {
  192. return defaults.bool(forKey: legacyNotificationsEnabledDefaultsKey)
  193. }
  194. return true
  195. }
  196. set { UserDefaults.standard.set(newValue, forKey: remindersEnabledDefaultsKey) }
  197. }
  198. private var remind1Day: Bool {
  199. get {
  200. let defaults = UserDefaults.standard
  201. let stored = defaults.object(forKey: remind1DayDefaultsKey)
  202. return stored != nil ? defaults.bool(forKey: remind1DayDefaultsKey) : true
  203. }
  204. set { UserDefaults.standard.set(newValue, forKey: remind1DayDefaultsKey) }
  205. }
  206. private var remind12Hours: Bool {
  207. get {
  208. let defaults = UserDefaults.standard
  209. let stored = defaults.object(forKey: remind12HoursDefaultsKey)
  210. return stored != nil ? defaults.bool(forKey: remind12HoursDefaultsKey) : true
  211. }
  212. set { UserDefaults.standard.set(newValue, forKey: remind12HoursDefaultsKey) }
  213. }
  214. private var remind1Hour: Bool {
  215. get {
  216. let defaults = UserDefaults.standard
  217. let stored = defaults.object(forKey: remind1HourDefaultsKey)
  218. return stored != nil ? defaults.bool(forKey: remind1HourDefaultsKey) : true
  219. }
  220. set { UserDefaults.standard.set(newValue, forKey: remind1HourDefaultsKey) }
  221. }
  222. private let sidebarWidth: CGFloat = 78
  223. private var appBackground: NSColor { palette.appBackground }
  224. private var sidebarBackground: NSColor { palette.sidebarBackground }
  225. private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
  226. private var cardBackground: NSColor { palette.cardBackground }
  227. private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
  228. private var appShellBackground: NSColor { palette.appShellBackground }
  229. private var contentShellBackground: NSColor { palette.contentShellBackground }
  230. private var topStripBackground: NSColor { palette.topStripBackground }
  231. private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
  232. private var searchPillBackground: NSColor { palette.searchPillBackground }
  233. private var meetingCardBackground: NSColor { palette.meetingCardBackground }
  234. private let appShellCornerRadius: CGFloat = 20
  235. private let homeChromeHeaderHeight: CGFloat = 56
  236. private let nativeTrafficLightsLeading: CGFloat = 14
  237. private let nativeTrafficLightsTopInset: CGFloat = 20
  238. private let brandLeadingInset: CGFloat = 84
  239. private let brandFullScreenLeadingInset: CGFloat = 14
  240. private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
  241. private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
  242. private var primaryText: NSColor { palette.primaryText }
  243. private var secondaryText: NSColor { palette.secondaryText }
  244. private var mutedText: NSColor { palette.mutedText }
  245. private var sidebarInactiveText: NSColor { palette.sidebarInactiveText }
  246. private let rootContainer = NSView()
  247. private var homeView: NSView?
  248. private weak var googleButton: NSButton?
  249. private weak var timeLabel: NSTextField?
  250. private weak var dateLabel: NSTextField?
  251. private weak var meetingsDayHeaderLabel: NSTextField?
  252. private weak var emptyMeetingLabel: NSTextField?
  253. private weak var meetingsListStack: NSStackView?
  254. private weak var meetingsStatusLabel: NSTextField?
  255. private weak var meetingsScrollView: NSScrollView?
  256. private weak var meetingsPrevDayButton: NSButton?
  257. private weak var meetingsNextDayButton: NSButton?
  258. private weak var meetingsTodayButton: NSButton?
  259. private weak var refreshMeetingsButton: NSButton?
  260. private weak var homeWelcomeLabel: NSTextField?
  261. private weak var homeTimeLabelView: NSTextField?
  262. private weak var homeDateLabelView: NSTextField?
  263. private weak var homeActionsRow: NSView?
  264. private weak var homeMeetingsPanel: NSView?
  265. private weak var homePlaceholderLabel: NSTextField?
  266. private weak var homeSearchField: NSTextField?
  267. private weak var homeSearchPill: NSView?
  268. private weak var meetingSearchOverlayView: NSView?
  269. private weak var meetingSearchPanelView: NSView?
  270. private weak var meetingSearchField: NSTextField?
  271. private weak var meetingSearchResultsStack: NSStackView?
  272. private weak var meetingSearchEmptyLabel: NSTextField?
  273. private weak var homeSettingsView: NSView?
  274. private weak var homeSettingsHostColumn: NSView?
  275. private weak var schedulerRootView: NSView?
  276. private weak var schedulerMonthLabel: NSTextField?
  277. private weak var schedulerCalendarGridStack: NSStackView?
  278. private weak var schedulerSelectedDateLabel: NSTextField?
  279. private weak var schedulerMeetingsListStack: NSStackView?
  280. private weak var schedulerMeetingsEmptyLabel: NSTextField?
  281. private weak var schedulerTodayButton: NSButton?
  282. private weak var meetingsPageRootView: NSView?
  283. private weak var widgetsPageRootView: NSView?
  284. private weak var aiCompanionPageRootView: NSView?
  285. private weak var aiCompanionListStack: NSStackView?
  286. private weak var aiCompanionListScroll: NSScrollView?
  287. private weak var aiCompanionDetailScroll: NSScrollView?
  288. private weak var aiCompanionDetailStack: NSStackView?
  289. private weak var aiCompanionStatusLabel: NSTextField?
  290. private weak var aiCompanionCountLabel: NSTextField?
  291. private weak var aiCompanionRefreshButton: NSButton?
  292. private weak var aiCompanionRangePopup: NSPopUpButton?
  293. private weak var aiCompanionEmptyLabel: NSTextField?
  294. private weak var aiCompanionLoadingIndicator: NSProgressIndicator?
  295. private weak var aiCompanionDetailEmptyLabel: NSTextField?
  296. private weak var aiCompanionOpenDocButton: NSButton?
  297. private weak var aiCompanionCopyMarkdownButton: NSButton?
  298. private var aiCompanionRowButtons: [NSButton] = []
  299. private var aiCompanionSummaries: [AICompanionSummary] = []
  300. private var aiCompanionSelectedIndex: Int?
  301. private var aiCompanionIsLoading: Bool = false
  302. private var aiCompanionLastLoadedAt: Date?
  303. private var aiCompanionSelectedRange: String = "Last 30 days"
  304. private weak var meetingsPageCategoryFilter: NSPopUpButton?
  305. private weak var meetingsPageFromDatePicker: NSDatePicker?
  306. private weak var meetingsPageToDatePicker: NSDatePicker?
  307. private weak var meetingsPageRefreshButton: NSButton?
  308. private weak var meetingsPageCountLabel: NSTextField?
  309. private weak var meetingsPageStatusLabel: NSTextField?
  310. private weak var meetingsPageListStack: NSStackView?
  311. private weak var meetingsPageEmptyLabel: NSTextField?
  312. private weak var meetingsPageLoadMoreButton: NSButton?
  313. private weak var meetingsPageScrollView: NSScrollView?
  314. private weak var embeddedBrowserWebView: WKWebView?
  315. private weak var embeddedBrowserPanel: NSView?
  316. private weak var homeBodyRowView: NSView?
  317. private weak var homeMainContentColumnView: NSView?
  318. private weak var embeddedBrowserBackButton: NSButton?
  319. private weak var embeddedBrowserForwardButton: NSButton?
  320. private var embeddedBrowserPanelWidthConstraint: NSLayoutConstraint?
  321. private var embeddedBrowserMainColumnCollapsedWidthConstraint: NSLayoutConstraint?
  322. private var oauthSessionContinuation: CheckedContinuation<URL, Error>?
  323. private var oauthSessionCallbackPrefix: String?
  324. /// Gap between main column and right web panel (must match Auto Layout constant).
  325. private let homeEmbeddedBrowserColumnGap: CGFloat = 10
  326. private weak var homePremiumLockOverlay: NSButton?
  327. private weak var meetingsPremiumLockOverlay: NSButton?
  328. private weak var schedulerPremiumLockOverlay: NSButton?
  329. private var schedulerDayButtons: [Date: NSButton] = [:]
  330. private var schedulerDateByButtonTag: [Int: Date] = [:]
  331. private var nextSchedulerButtonTag: Int = 1
  332. private weak var settingsDarkModeSwitch: NSSwitch?
  333. private weak var settingsRemindersMasterSwitch: NSSwitch?
  334. private weak var settingsReminders1DaySwitch: NSSwitch?
  335. private weak var settingsReminders12HoursSwitch: NSSwitch?
  336. private weak var settingsReminders1HourSwitch: NSSwitch?
  337. private weak var settingsReminders1DayRow: NSView?
  338. private weak var settingsReminders12HoursRow: NSView?
  339. private weak var settingsReminders1HourRow: NSView?
  340. private weak var settingsUpgradeButton: NSButton?
  341. private weak var settingsUpgradeTitleLabel: NSTextField?
  342. private weak var settingsNotificationsBlock: NSStackView?
  343. private weak var settingsGoogleActionButton: NSButton?
  344. private weak var topBarPremiumButton: NSButton?
  345. private weak var topBarAuthButton: NSButton?
  346. private var topBarAuthButtonWidthConstraint: NSLayoutConstraint?
  347. private var profileMenuPopover: NSPopover?
  348. private weak var paywallOverlayView: NSView?
  349. private weak var paywallPanelView: NSView?
  350. private weak var paywallBodyStack: NSStackView?
  351. private weak var paywallPlansRowWide: NSStackView?
  352. private weak var paywallPlansGridNarrow: NSStackView?
  353. private var paywallIsDesktopLayout = false
  354. private var paywallPanelWidthConstraint: NSLayoutConstraint?
  355. private var paywallPanelHeightConstraint: NSLayoutConstraint?
  356. private var joinMeetingWindow: NSWindow?
  357. private var scheduleMeetingWindow: NSWindow?
  358. private weak var scheduleTopicField: NSTextField?
  359. private weak var scheduleDateField: NSTextField?
  360. private weak var scheduleTimeField: NSTextField?
  361. private weak var scheduleTimeZonePopup: NSPopUpButton?
  362. private weak var scheduleDurationField: NSTextField?
  363. private weak var scheduleSubmitButton: NSButton?
  364. private var scheduleTimeZoneDisplayToIdentifier: [String: String] = [:]
  365. private weak var joinURLField: NSTextField?
  366. private weak var joinMeetingIDField: NSTextField?
  367. private weak var joinPasscodeField: NSTextField?
  368. private weak var joinURLFieldsContainer: NSView?
  369. private weak var joinIDFieldsContainer: NSView?
  370. private weak var joinModeSegment: NSSegmentedControl?
  371. private let paywallContentWidth: CGFloat = 520
  372. private var selectedPremiumPlan: PremiumPlan = .yearly
  373. private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
  374. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  375. private var paywallPlanPriceLabels: [PremiumPlan: [NSTextField]] = [:]
  376. private var paywallPlanSubtitleLabels: [PremiumPlan: [NSTextField]] = [:]
  377. private var paywallPlanStrikeLabels: [PremiumPlan: [NSTextField]] = [:]
  378. private weak var paywallOfferLabel: NSTextField?
  379. private weak var paywallContinueLabel: NSTextField?
  380. private weak var paywallContinueButton: NSView?
  381. private var paywallPurchaseTask: Task<Void, Never>?
  382. private var paywallContinueEnabled = true
  383. private var lastKnownPremiumAccess = false
  384. private var allScheduledMeetings: [ScheduledMeeting] = []
  385. private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
  386. private var schedulerVisibleMonthStart: Date = {
  387. let calendar = Calendar.current
  388. let today = Date()
  389. let components = calendar.dateComponents([.year, .month], from: today)
  390. return calendar.date(from: components) ?? calendar.startOfDay(for: today)
  391. }()
  392. private var selectedHomeSidebarItem: String = "Home"
  393. private var homeSidebarRowViews: [String: NSView] = [:]
  394. private var homeSidebarIconViews: [String: NSImageView] = [:]
  395. private var homeSidebarLabels: [String: NSTextField] = [:]
  396. private var searchTextObserver: NSObjectProtocol?
  397. private var searchShortcutMonitor: Any?
  398. private var searchOutsideClickMonitor: Any?
  399. private var meetingSearchTextObserver: NSObjectProtocol?
  400. private var globalSearchResults: [ScheduledMeeting] = []
  401. private var topBarProfileInitial = "H"
  402. private var clockTimer: Timer?
  403. private var meetingsRefreshTimer: Timer?
  404. private var brandLeadingConstraint: NSLayoutConstraint?
  405. private var windowFullScreenObservers: [NSObjectProtocol] = []
  406. private var isSigningIn = false
  407. private var isPromptingZoomCredentials = false
  408. private var isLoadingMeetings = false
  409. private var meetingsScrollObserver: NSObjectProtocol?
  410. private var meetingsPageScrollObserver: NSObjectProtocol?
  411. private var lastMeetingsRefreshAt = Date.distantPast
  412. private var lastScrollEdgeRefreshAt = Date.distantPast
  413. private var lastMeetingsPageAutoLoadAt = Date.distantPast
  414. // Keep this conservative to avoid Zoom API rate limits.
  415. private let meetingsRefreshInterval: TimeInterval = 60
  416. private let scrollRefreshCooldown: TimeInterval = 3
  417. private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
  418. private var meetingsRateLimitedUntil: Date?
  419. private var zoomAccountTimeZone: TimeZone?
  420. private var meetingTimeZoneOverrides: [String: String] = [:]
  421. private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
  422. private var meetingsPageSelectedRangePreset: String = "All"
  423. private var meetingsPageFromDateFilter: Date?
  424. private var meetingsPageToDateFilter: Date?
  425. private var meetingsPageVisibleCount: Int = 0
  426. private let meetingsPageSize: Int = 8
  427. private var premiumRatingPromptWorkItem: DispatchWorkItem?
  428. private var usageRatingPromptWorkItem: DispatchWorkItem?
  429. private let usageRatingPromptDelay: TimeInterval = 30 * 60
  430. private let premiumRatingPromptDelay: TimeInterval = 10
  431. private enum SidebarStyle {
  432. case home
  433. }
  434. private enum PremiumPlan: String, CaseIterable {
  435. case weekly = "com.mqldev.zoomapp.premium.weekly"
  436. case monthly = "com.mqldev.zoomapp.premium.monthly"
  437. case yearly = "com.mqldev.zoomapp.premium.yearly"
  438. case lifetime = "com.mqldev.zoomapp.premium.lifetime"
  439. var displayName: String {
  440. switch self {
  441. case .weekly: return "Premium Weekly"
  442. case .monthly: return "Premium Monthly"
  443. case .yearly: return "Premium Yearly"
  444. case .lifetime: return "Premium Lifetime"
  445. }
  446. }
  447. }
  448. private final class StoreKitCoordinator {
  449. enum PurchaseOutcome {
  450. case success
  451. case pending
  452. case cancelled
  453. case failed(String)
  454. }
  455. private(set) var productsByID: [String: Product] = [:]
  456. private(set) var activeProductIDs = Set<String>()
  457. var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
  458. var hasLifetimePremiumAccess: Bool { activeProductIDs.contains(PremiumPlan.lifetime.rawValue) }
  459. private var transactionUpdatesTask: Task<Void, Never>?
  460. var onEntitlementsChanged: ((Bool) -> Void)?
  461. deinit {
  462. transactionUpdatesTask?.cancel()
  463. }
  464. func start() async {
  465. await refreshProducts()
  466. await refreshEntitlements()
  467. observeTransactionUpdatesIfNeeded()
  468. }
  469. func refreshProducts() async {
  470. let productIDs = PremiumPlan.allCases.map(\.rawValue)
  471. var loadedProducts: [Product] = []
  472. var attempts = 0
  473. let maxAttempts = 3
  474. while attempts < maxAttempts {
  475. attempts += 1
  476. do {
  477. loadedProducts = try await Product.products(for: productIDs)
  478. } catch {
  479. loadedProducts = []
  480. }
  481. if loadedProducts.isEmpty == false {
  482. break
  483. }
  484. if attempts < maxAttempts {
  485. // StoreKit can transiently return empty right after launch/network changes.
  486. try? await Task.sleep(nanoseconds: UInt64(attempts) * 500_000_000)
  487. }
  488. }
  489. if loadedProducts.isEmpty == false {
  490. productsByID = Dictionary(uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) })
  491. }
  492. }
  493. func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
  494. guard let product = productsByID[plan.rawValue] else {
  495. await refreshProducts()
  496. guard let refreshed = productsByID[plan.rawValue] else {
  497. return .failed("Product not available. Check StoreKit configuration and product IDs.")
  498. }
  499. return await purchase(product: refreshed)
  500. }
  501. return await purchase(product: product)
  502. }
  503. func restorePurchases() async -> String {
  504. do {
  505. try await AppStore.sync()
  506. await refreshEntitlements()
  507. return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
  508. } catch {
  509. return "Restore failed. \(error.localizedDescription)"
  510. }
  511. }
  512. private func purchase(product: Product) async -> PurchaseOutcome {
  513. do {
  514. let result = try await product.purchase()
  515. switch result {
  516. case .success(let verification):
  517. guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
  518. await transaction.finish()
  519. await refreshEntitlements()
  520. return .success
  521. case .pending:
  522. return .pending
  523. case .userCancelled:
  524. return .cancelled
  525. @unknown default:
  526. return .failed("Unknown purchase state.")
  527. }
  528. } catch {
  529. return .failed(error.localizedDescription)
  530. }
  531. }
  532. private func observeTransactionUpdatesIfNeeded() {
  533. guard transactionUpdatesTask == nil else { return }
  534. transactionUpdatesTask = Task { [weak self] in
  535. for await update in Transaction.updates {
  536. guard case .verified(let transaction) = update else { continue }
  537. guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
  538. await self?.refreshEntitlements()
  539. await transaction.finish()
  540. }
  541. }
  542. }
  543. @MainActor
  544. private func refreshEntitlements() async {
  545. let previousHasPremiumAccess = hasPremiumAccess
  546. let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
  547. var active = Set<String>()
  548. for await entitlement in Transaction.currentEntitlements {
  549. guard case .verified(let transaction) = entitlement else { continue }
  550. guard allIDs.contains(transaction.productID) else { continue }
  551. if Self.isTransactionActive(transaction) {
  552. active.insert(transaction.productID)
  553. }
  554. }
  555. // StoreKit testing can briefly report empty current entitlements even though a latest
  556. // verified transaction exists for a non-consumable. Merge in latest transactions.
  557. for productID in allIDs {
  558. guard let latest = await Transaction.latest(for: productID),
  559. case .verified(let transaction) = latest else { continue }
  560. if Self.isTransactionActive(transaction) {
  561. active.insert(productID)
  562. }
  563. }
  564. activeProductIDs = active
  565. let newHasPremiumAccess = hasPremiumAccess
  566. if newHasPremiumAccess != previousHasPremiumAccess {
  567. onEntitlementsChanged?(newHasPremiumAccess)
  568. }
  569. }
  570. private static func isTransactionActive(_ transaction: StoreKit.Transaction) -> Bool {
  571. if transaction.revocationDate != nil { return false }
  572. if let expirationDate = transaction.expirationDate {
  573. return expirationDate > Date()
  574. }
  575. return true
  576. }
  577. }
  578. private let storeKitCoordinator = StoreKitCoordinator()
  579. private var storeKitStartupTask: Task<Void, Never>?
  580. private var launchPaywallWorkItem: DispatchWorkItem?
  581. private let launchPaywallDelay: TimeInterval = 2
  582. private var hasPresentedLaunchPaywall = false
  583. override func viewDidLoad() {
  584. super.viewDidLoad()
  585. darkModeEnabled = systemPrefersDarkMode()
  586. palette = Palette(isDarkMode: darkModeEnabled)
  587. NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  588. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  589. meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
  590. ensureUsageSessionStartDate()
  591. scheduleUsageBasedRatingPromptIfNeeded()
  592. setupUI()
  593. setupWidgetBridge()
  594. startStoreKit()
  595. }
  596. override func viewDidAppear() {
  597. super.viewDidAppear()
  598. if let window = view.window {
  599. window.setContentSize(
  600. NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  601. )
  602. applyWindowBackgroundForCurrentTheme(window)
  603. // Use full-size content view so custom top chrome sits in the titlebar region.
  604. window.titleVisibility = .hidden
  605. window.titlebarAppearsTransparent = true
  606. window.isMovableByWindowBackground = true
  607. window.styleMask.insert(.fullSizeContentView)
  608. installWindowFullScreenObservers(for: window)
  609. }
  610. alignNativeTrafficLights()
  611. showHomeView(profile: nil)
  612. syncBrandPositionForCurrentWindowState(animated: false)
  613. scheduleLaunchPaywallIfNeeded()
  614. if storeKitCoordinator.hasPremiumAccess {
  615. DesktopWidgetWindowManager.shared.restore()
  616. }
  617. }
  618. override func viewDidLayout() {
  619. super.viewDidLayout()
  620. alignNativeTrafficLights()
  621. if paywallOverlayView != nil {
  622. updatePaywallPanelSize()
  623. updatePaywallResponsiveLayout()
  624. }
  625. if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
  626. updateEmbeddedRightPanelWidthForOpenState()
  627. }
  628. }
  629. private func setupUI() {
  630. view.wantsLayer = true
  631. view.layer?.backgroundColor = appBackground.cgColor
  632. rootContainer.translatesAutoresizingMaskIntoConstraints = false
  633. view.addSubview(rootContainer)
  634. NSLayoutConstraint.activate([
  635. rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  636. rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  637. rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
  638. rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  639. ])
  640. }
  641. private func installWindowFullScreenObservers(for window: NSWindow) {
  642. guard windowFullScreenObservers.isEmpty else { return }
  643. let center = NotificationCenter.default
  644. let enterObserver = center.addObserver(
  645. forName: NSWindow.didEnterFullScreenNotification,
  646. object: window,
  647. queue: .main
  648. ) { [weak self] _ in
  649. self?.syncBrandPositionForCurrentWindowState(animated: true)
  650. }
  651. let exitObserver = center.addObserver(
  652. forName: NSWindow.didExitFullScreenNotification,
  653. object: window,
  654. queue: .main
  655. ) { [weak self] _ in
  656. self?.syncBrandPositionForCurrentWindowState(animated: true)
  657. }
  658. windowFullScreenObservers = [enterObserver, exitObserver]
  659. }
  660. private func syncBrandPositionForCurrentWindowState(animated: Bool) {
  661. guard let constraint = brandLeadingConstraint else { return }
  662. let isFullScreen = view.window?.styleMask.contains(.fullScreen) == true
  663. let targetInset = isFullScreen ? brandFullScreenLeadingInset : brandLeadingInset
  664. guard abs(constraint.constant - targetInset) > 0.5 else { return }
  665. if animated {
  666. NSAnimationContext.runAnimationGroup { context in
  667. context.duration = 0.2
  668. constraint.animator().constant = targetInset
  669. view.layoutSubtreeIfNeeded()
  670. }
  671. } else {
  672. constraint.constant = targetInset
  673. }
  674. }
  675. private func showHomeView(profile: GoogleUserProfile?) {
  676. clearMeetingsScrollObserver()
  677. clearMeetingsPageScrollObserver()
  678. removeSearchFieldObserver()
  679. removeMeetingSearchObserver()
  680. removeSearchShortcutMonitor()
  681. homeSearchField = nil
  682. homeSearchPill = nil
  683. meetingSearchOverlayView = nil
  684. meetingSearchPanelView = nil
  685. meetingSearchField = nil
  686. meetingSearchResultsStack = nil
  687. meetingSearchEmptyLabel = nil
  688. meetingsPageRootView = nil
  689. widgetsPageRootView = nil
  690. aiCompanionPageRootView = nil
  691. aiCompanionListStack = nil
  692. aiCompanionListScroll = nil
  693. aiCompanionDetailScroll = nil
  694. aiCompanionDetailStack = nil
  695. aiCompanionStatusLabel = nil
  696. aiCompanionCountLabel = nil
  697. aiCompanionRefreshButton = nil
  698. aiCompanionRangePopup = nil
  699. aiCompanionEmptyLabel = nil
  700. aiCompanionLoadingIndicator = nil
  701. aiCompanionDetailEmptyLabel = nil
  702. aiCompanionOpenDocButton = nil
  703. aiCompanionCopyMarkdownButton = nil
  704. aiCompanionRowButtons.removeAll()
  705. aiCompanionLastLoadedAt = nil
  706. homeView?.removeFromSuperview()
  707. if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
  708. topBarProfileInitial = String(profileName.prefix(1)).uppercased()
  709. }
  710. selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
  711. homeView = makeHomeView(profile: profile)
  712. if let homeView { attachToRoot(homeView) }
  713. installSearchShortcutMonitor()
  714. startClock()
  715. applyAuthenticationUIState(triggerInitialRefresh: true)
  716. updateHomeSidebarHighlight()
  717. updateSelectedHomeSectionUI()
  718. }
  719. private func isUserLoggedIn() -> Bool {
  720. UserDefaults.standard.bool(forKey: loginStateKey)
  721. }
  722. private func persistLoggedInState(_ loggedIn: Bool) {
  723. UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
  724. }
  725. private func attachToRoot(_ subview: NSView) {
  726. subview.translatesAutoresizingMaskIntoConstraints = false
  727. if subview.superview != rootContainer {
  728. rootContainer.addSubview(subview)
  729. }
  730. NSLayoutConstraint.activate([
  731. subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
  732. subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
  733. subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
  734. subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
  735. ])
  736. }
  737. @objc private func googleLoginTapped() {
  738. guard isSigningIn == false else { return }
  739. isSigningIn = true
  740. googleButton?.title = "..."
  741. googleButton?.isEnabled = false
  742. Task {
  743. do {
  744. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  745. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  746. await MainActor.run {
  747. self.googleButton?.title = "G"
  748. self.resetLoginSigningInState()
  749. self.completeLoginAndRestart(profile: profile)
  750. }
  751. } catch {
  752. await MainActor.run {
  753. self.resetLoginSigningInState()
  754. if case GoogleOAuthError.authenticationCancelled = error { return }
  755. self.showSimpleError("Google login failed", error: error)
  756. }
  757. }
  758. }
  759. }
  760. /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
  761. @objc private func zoomPrimarySignInTapped() {
  762. guard isSigningIn == false else { return }
  763. isSigningIn = true
  764. topBarAuthButton?.title = "Signing in..."
  765. topBarAuthButton?.isEnabled = false
  766. googleButton?.isEnabled = false
  767. Task {
  768. do {
  769. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  770. guard configured else {
  771. await MainActor.run { self.resetLoginSigningInState() }
  772. return
  773. }
  774. let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
  775. let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
  776. let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
  777. let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
  778. await MainActor.run {
  779. self.resetLoginSigningInState()
  780. self.zoomAccountTimeZone = profileTimeZone
  781. self.completeLoginAndRestart(profile: profile)
  782. }
  783. } catch {
  784. await MainActor.run {
  785. self.resetLoginSigningInState()
  786. if case ZoomOAuthError.authenticationCancelled = error { return }
  787. self.showSimpleError("Zoom sign-in failed", error: error)
  788. }
  789. }
  790. }
  791. }
  792. @objc private func scheduleMeetingTapped() {
  793. guard isUserLoggedIn() else {
  794. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to schedule a meeting.")
  795. return
  796. }
  797. guard ensurePremiumAccess() else { return }
  798. if let existing = scheduleMeetingWindow {
  799. existing.makeKeyAndOrderFront(nil)
  800. NSApp.activate(ignoringOtherApps: true)
  801. DispatchQueue.main.async { [weak self] in
  802. _ = self?.scheduleTopicField?.becomeFirstResponder()
  803. }
  804. return
  805. }
  806. let content = makeScheduleMeetingPanelContent()
  807. let controller = NSViewController()
  808. controller.view = content
  809. let panel = NSPanel(
  810. contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
  811. styleMask: [.titled, .closable, .fullSizeContentView],
  812. backing: .buffered,
  813. defer: false
  814. )
  815. panel.title = "Schedule a meeting"
  816. panel.titleVisibility = .hidden
  817. panel.titlebarAppearsTransparent = true
  818. panel.hidesOnDeactivate = true
  819. panel.isReleasedWhenClosed = false
  820. panel.standardWindowButton(.closeButton)?.isHidden = true
  821. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  822. panel.standardWindowButton(.zoomButton)?.isHidden = true
  823. panel.isMovableByWindowBackground = true
  824. panel.becomesKeyOnlyIfNeeded = false
  825. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  826. panel.center()
  827. panel.contentViewController = controller
  828. panel.delegate = self
  829. applyWindowBackgroundForCurrentTheme(panel)
  830. panel.makeKeyAndOrderFront(nil)
  831. NSApp.activate(ignoringOtherApps: true)
  832. scheduleMeetingWindow = panel
  833. DispatchQueue.main.async { [weak self] in
  834. _ = self?.scheduleTopicField?.becomeFirstResponder()
  835. }
  836. }
  837. private func makeScheduleMeetingPanelContent() -> NSView {
  838. let root = NSView()
  839. root.translatesAutoresizingMaskIntoConstraints = false
  840. root.wantsLayer = true
  841. root.layer?.backgroundColor = appBackground.cgColor
  842. let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  843. let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
  844. let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
  845. closeButton.translatesAutoresizingMaskIntoConstraints = false
  846. closeButton.isBordered = false
  847. closeButton.bezelStyle = .regularSquare
  848. closeButton.wantsLayer = true
  849. closeButton.layer?.cornerRadius = 14
  850. closeButton.normalColor = palette.inputBackground
  851. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  852. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  853. closeButton.layer?.borderWidth = 1
  854. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  855. closeButton.contentTintColor = secondaryText
  856. closeButton.toolTip = "Close"
  857. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  858. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  859. let titleRow = NSStackView()
  860. titleRow.orientation = .horizontal
  861. titleRow.alignment = .centerY
  862. titleRow.spacing = 10
  863. titleRow.translatesAutoresizingMaskIntoConstraints = false
  864. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  865. titleStack.orientation = .vertical
  866. titleStack.spacing = 2
  867. titleStack.alignment = .leading
  868. let titleSpacer = NSView()
  869. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  870. titleRow.addArrangedSubview(titleStack)
  871. titleRow.addArrangedSubview(titleSpacer)
  872. titleRow.addArrangedSubview(closeButton)
  873. let headerDivider = NSView()
  874. headerDivider.wantsLayer = true
  875. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  876. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  877. let topicBox = makeJoinFormField(placeholder: "Meeting topic")
  878. scheduleTopicField = topicBox.textField
  879. topicBox.textField.stringValue = "Zoom meeting"
  880. let defaultStart = Date().addingTimeInterval(3600)
  881. let dateOnlyFormatter = DateFormatter()
  882. dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
  883. dateOnlyFormatter.timeZone = TimeZone.current
  884. dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
  885. let time12Formatter = DateFormatter()
  886. time12Formatter.locale = Locale(identifier: "en_US_POSIX")
  887. time12Formatter.timeZone = TimeZone.current
  888. time12Formatter.dateFormat = "h:mm a"
  889. let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
  890. scheduleDateField = dateBox.textField
  891. dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
  892. let timeBox = makeJoinFormField(placeholder: "2:30 PM")
  893. scheduleTimeField = timeBox.textField
  894. timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
  895. let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
  896. let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
  897. let tzCombo = NSPopUpButton()
  898. tzCombo.translatesAutoresizingMaskIntoConstraints = false
  899. tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
  900. tzCombo.wantsLayer = true
  901. tzCombo.layer?.cornerRadius = 10
  902. tzCombo.layer?.borderWidth = 1
  903. tzCombo.layer?.borderColor = palette.inputBorder.cgColor
  904. tzCombo.layer?.backgroundColor = palette.inputBackground.cgColor
  905. let tzOptions = makeTimeZoneDisplayOptions(referenceDate: defaultStart)
  906. scheduleTimeZoneDisplayToIdentifier = Dictionary(uniqueKeysWithValues: tzOptions.map { ($0.display, $0.identifier) })
  907. tzCombo.removeAllItems()
  908. tzCombo.addItems(withTitles: tzOptions.map(\.display))
  909. if let selected = tzOptions.first(where: { $0.identifier == TimeZone.current.identifier }) {
  910. tzCombo.selectItem(withTitle: selected.display)
  911. } else {
  912. tzCombo.selectItem(at: 0)
  913. }
  914. tzCombo.toolTip = "Timezone with GMT offset. Meeting start is interpreted in this zone."
  915. scheduleTimeZonePopup = tzCombo
  916. let tzHint = makeLabel("Includes GMT offset, e.g. GMT+05:00 - Asia/Karachi", size: 11, color: mutedText, weight: .regular, centered: false)
  917. let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
  918. tzStack.orientation = .vertical
  919. tzStack.spacing = 8
  920. tzStack.alignment = .leading
  921. tzStack.translatesAutoresizingMaskIntoConstraints = false
  922. let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
  923. durationBox.textField.stringValue = "60"
  924. scheduleDurationField = durationBox.textField
  925. let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
  926. let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
  927. let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
  928. let topicStack = NSStackView(views: [topicLabel, topicBox])
  929. topicStack.orientation = .vertical
  930. topicStack.spacing = 8
  931. topicStack.alignment = .leading
  932. topicStack.translatesAutoresizingMaskIntoConstraints = false
  933. let dateTimeRow = NSStackView(views: [dateBox, timeBox])
  934. dateTimeRow.orientation = .horizontal
  935. dateTimeRow.spacing = 10
  936. dateTimeRow.alignment = .top
  937. dateTimeRow.distribution = .fillEqually
  938. dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
  939. let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
  940. startStack.orientation = .vertical
  941. startStack.spacing = 8
  942. startStack.alignment = .leading
  943. startStack.translatesAutoresizingMaskIntoConstraints = false
  944. let durationStack = NSStackView(views: [durationLabel, durationBox])
  945. durationStack.orientation = .vertical
  946. durationStack.spacing = 8
  947. durationStack.alignment = .leading
  948. durationStack.translatesAutoresizingMaskIntoConstraints = false
  949. let formCard = NSView()
  950. formCard.translatesAutoresizingMaskIntoConstraints = false
  951. formCard.wantsLayer = true
  952. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  953. formCard.layer?.cornerRadius = 14
  954. formCard.layer?.borderWidth = 1
  955. formCard.layer?.borderColor = palette.inputBorder.cgColor
  956. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
  957. cancelButton.isBordered = false
  958. cancelButton.wantsLayer = true
  959. cancelButton.layer?.cornerRadius = 10
  960. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  961. cancelButton.layer?.borderWidth = 1
  962. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  963. cancelButton.contentTintColor = primaryText
  964. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  965. let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
  966. submitButton.isBordered = false
  967. submitButton.wantsLayer = true
  968. submitButton.layer?.cornerRadius = 10
  969. submitButton.normalColor = accentBlue
  970. submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  971. submitButton.contentTintColor = .white
  972. submitButton.font = .systemFont(ofSize: 13, weight: .bold)
  973. submitButton.keyEquivalent = "\r"
  974. scheduleSubmitButton = submitButton
  975. let buttons = NSStackView(views: [cancelButton, submitButton])
  976. buttons.orientation = .horizontal
  977. buttons.spacing = 12
  978. buttons.alignment = .centerY
  979. buttons.distribution = .fillEqually
  980. let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
  981. innerStack.orientation = .vertical
  982. innerStack.spacing = 18
  983. innerStack.alignment = .leading
  984. innerStack.translatesAutoresizingMaskIntoConstraints = false
  985. formCard.addSubview(innerStack)
  986. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  987. NSLayoutConstraint.activate([
  988. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  989. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  990. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  991. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  992. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  993. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  994. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  995. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  996. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  997. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  998. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  999. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1000. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1001. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1002. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1003. topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1004. tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1005. tzCombo.heightAnchor.constraint(equalToConstant: 34),
  1006. dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1007. durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1008. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1009. submitButton.heightAnchor.constraint(equalToConstant: 40),
  1010. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1011. ])
  1012. return root
  1013. }
  1014. @objc private func scheduleMeetingCancelTapped() {
  1015. scheduleMeetingWindow?.performClose(nil)
  1016. }
  1017. @objc private func scheduleMeetingSubmitTapped() {
  1018. let topicRaw = scheduleTopicField?.stringValue ?? ""
  1019. let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
  1020. guard topic.isEmpty == false else {
  1021. showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
  1022. return
  1023. }
  1024. let tzId = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1025. if tzId.isEmpty == false,
  1026. scheduleTimeZoneDisplayToIdentifier[tzId] == nil,
  1027. TimeZone(identifier: tzId) == nil {
  1028. showSimpleAlert(
  1029. title: "Timezone",
  1030. message: "Choose a timezone from the list (with GMT offset), for example GMT+05:00 - Asia/Karachi."
  1031. )
  1032. return
  1033. }
  1034. guard let draft = scheduleMeetingDraftFromFormFields() else {
  1035. showSimpleAlert(
  1036. title: "Date & time",
  1037. 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."
  1038. )
  1039. return
  1040. }
  1041. if draft.startDate < Date().addingTimeInterval(-30) {
  1042. showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
  1043. return
  1044. }
  1045. let durationRaw = scheduleDurationField?.stringValue ?? ""
  1046. let digits = durationRaw.filter(\.isNumber)
  1047. guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
  1048. showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
  1049. return
  1050. }
  1051. scheduleSubmitButton?.isEnabled = false
  1052. let tz = draft.timeZone
  1053. Task {
  1054. do {
  1055. let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
  1056. guard configured else {
  1057. await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
  1058. return
  1059. }
  1060. let oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
  1061. let token = try await zoomOAuth.validAccessToken(
  1062. presentingWindow: oauthParent,
  1063. requiringAnyOf: ["meeting:write:meeting"]
  1064. )
  1065. let result = try await createZoomMeeting(
  1066. accessToken: token,
  1067. topic: topic,
  1068. startTimeUTC: draft.startTimeUTC,
  1069. durationMinutes: duration,
  1070. timeZone: tz
  1071. )
  1072. await MainActor.run {
  1073. self.saveMeetingTimeZoneOverride(meetingID: result.id.map(String.init), timeZone: tz)
  1074. }
  1075. await MainActor.run {
  1076. self.scheduleSubmitButton?.isEnabled = true
  1077. self.scheduleMeetingWindow?.performClose(nil)
  1078. self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
  1079. self.triggerMeetingsRefresh(force: true)
  1080. if let join = result.join_url, join.isEmpty == false {
  1081. NSPasteboard.general.clearContents()
  1082. NSPasteboard.general.setString(join, forType: .string)
  1083. self.showSimpleAlert(
  1084. title: "Meeting scheduled",
  1085. message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
  1086. )
  1087. } else {
  1088. self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
  1089. }
  1090. }
  1091. } catch {
  1092. await MainActor.run {
  1093. self.scheduleSubmitButton?.isEnabled = true
  1094. if case ZoomOAuthError.missingRequiredScope(_) = error {
  1095. self.zoomOAuth.clearSavedTokens()
  1096. self.showSimpleAlert(
  1097. title: "Zoom permissions",
  1098. message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
  1099. )
  1100. } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
  1101. let seconds = max(retryAfter ?? 300, 30)
  1102. let minutes = Int(ceil(Double(seconds) / 60.0))
  1103. self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
  1104. } else if case ZoomOAuthError.authenticationCancelled = error {
  1105. // User closed the sign-in window; no alert.
  1106. } else {
  1107. self.showSimpleError("Could not schedule", error: error)
  1108. }
  1109. }
  1110. }
  1111. }
  1112. }
  1113. private func resetScheduleMeetingPanelReferences() {
  1114. scheduleMeetingWindow = nil
  1115. scheduleTopicField = nil
  1116. scheduleDateField = nil
  1117. scheduleTimeField = nil
  1118. scheduleTimeZonePopup = nil
  1119. scheduleDurationField = nil
  1120. scheduleSubmitButton = nil
  1121. scheduleTimeZoneDisplayToIdentifier = [:]
  1122. }
  1123. /// Resolves the schedule panel timezone (IANA id from combo, or system default).
  1124. private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
  1125. let raw = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1126. if let identifier = scheduleTimeZoneDisplayToIdentifier[raw],
  1127. let mapped = TimeZone(identifier: identifier) {
  1128. return mapped
  1129. }
  1130. guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
  1131. return tz
  1132. }
  1133. private struct TimeZoneDisplayOption {
  1134. let identifier: String
  1135. let display: String
  1136. }
  1137. private func makeTimeZoneDisplayOptions(referenceDate: Date) -> [TimeZoneDisplayOption] {
  1138. TimeZone.knownTimeZoneIdentifiers.compactMap { identifier in
  1139. guard let tz = TimeZone(identifier: identifier) else { return nil }
  1140. let seconds = tz.secondsFromGMT(for: referenceDate)
  1141. let sign = seconds >= 0 ? "+" : "-"
  1142. let absSeconds = abs(seconds)
  1143. let hours = absSeconds / 3600
  1144. let minutes = (absSeconds % 3600) / 60
  1145. let offset = String(format: "GMT%@%02d:%02d", sign, hours, minutes)
  1146. return TimeZoneDisplayOption(identifier: identifier, display: "\(offset) - \(identifier)")
  1147. }
  1148. .sorted { lhs, rhs in
  1149. if lhs.display == rhs.display { return lhs.identifier < rhs.identifier }
  1150. return lhs.display < rhs.display
  1151. }
  1152. }
  1153. /// Parses zoom timezone values like `Asia/Karachi`, `GMT+05:00`, `UTC+5`, `+0500`, or `Z`.
  1154. private func parseZoomTimeZone(_ raw: String?) -> TimeZone? {
  1155. guard let raw else { return nil }
  1156. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1157. guard trimmed.isEmpty == false else { return nil }
  1158. if let named = TimeZone(identifier: trimmed) { return named }
  1159. if trimmed.caseInsensitiveCompare("Z") == .orderedSame { return TimeZone(secondsFromGMT: 0) }
  1160. let normalized = trimmed.uppercased()
  1161. let pattern = #"^(?:GMT|UTC)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$"#
  1162. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1163. let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
  1164. guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
  1165. match.numberOfRanges == 4,
  1166. let signRange = Range(match.range(at: 1), in: normalized),
  1167. let hourRange = Range(match.range(at: 2), in: normalized) else {
  1168. return nil
  1169. }
  1170. let sign = normalized[signRange] == "-" ? -1 : 1
  1171. let hours = Int(normalized[hourRange]) ?? -1
  1172. let minutes: Int = {
  1173. if let minuteRange = Range(match.range(at: 3), in: normalized) {
  1174. return Int(normalized[minuteRange]) ?? -1
  1175. }
  1176. return 0
  1177. }()
  1178. guard (0...23).contains(hours), (0...59).contains(minutes) else { return nil }
  1179. return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60)))
  1180. }
  1181. private func saveMeetingTimeZoneOverride(meetingID: String?, timeZone: TimeZone) {
  1182. guard let meetingID, meetingID.isEmpty == false else { return }
  1183. meetingTimeZoneOverrides[meetingID] = timeZone.identifier
  1184. UserDefaults.standard.set(meetingTimeZoneOverrides, forKey: meetingTimeZoneOverrideKey)
  1185. }
  1186. private struct ScheduleMeetingDraft {
  1187. let startDate: Date
  1188. let startTimeUTC: String
  1189. let timeZone: TimeZone
  1190. }
  1191. /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
  1192. private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
  1193. let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1194. let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  1195. guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
  1196. let tz = resolvedScheduleTimeZoneForMeeting()
  1197. let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
  1198. // Parse date strictly as yyyy-MM-dd.
  1199. let dateComponentsRaw = datePart.split(separator: "-")
  1200. guard dateComponentsRaw.count == 3,
  1201. let year = Int(dateComponentsRaw[0]),
  1202. let month = Int(dateComponentsRaw[1]),
  1203. let day = Int(dateComponentsRaw[2]) else {
  1204. return nil
  1205. }
  1206. // Parse time strictly as h:mm AM/PM (optional :ss).
  1207. // This avoids any locale/date formatter ambiguity that can flip AM/PM.
  1208. let pattern = #"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])\s*$"#
  1209. guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
  1210. let nsRange = NSRange(normalizedTime.startIndex..<normalizedTime.endIndex, in: normalizedTime)
  1211. guard let match = regex.firstMatch(in: normalizedTime, options: [], range: nsRange),
  1212. match.numberOfRanges == 5,
  1213. let hourRange = Range(match.range(at: 1), in: normalizedTime),
  1214. let minuteRange = Range(match.range(at: 2), in: normalizedTime),
  1215. let meridiemRange = Range(match.range(at: 4), in: normalizedTime) else {
  1216. return nil
  1217. }
  1218. let rawHour = Int(normalizedTime[hourRange]) ?? -1
  1219. let minute = Int(normalizedTime[minuteRange]) ?? -1
  1220. let second: Int = {
  1221. if let secondRange = Range(match.range(at: 3), in: normalizedTime) {
  1222. return Int(normalizedTime[secondRange]) ?? -1
  1223. }
  1224. return 0
  1225. }()
  1226. let meridiem = normalizedTime[meridiemRange].uppercased()
  1227. guard (1...12).contains(rawHour),
  1228. (0...59).contains(minute),
  1229. (0...59).contains(second) else {
  1230. return nil
  1231. }
  1232. var hour24 = rawHour % 12
  1233. if meridiem == "PM" { hour24 += 12 }
  1234. var calendar = Calendar(identifier: .gregorian)
  1235. calendar.timeZone = tz
  1236. var components = DateComponents()
  1237. components.year = year
  1238. components.month = month
  1239. components.day = day
  1240. components.hour = hour24
  1241. components.minute = minute
  1242. components.second = second
  1243. components.timeZone = tz
  1244. guard let startDate = calendar.date(from: components) else { return nil }
  1245. let utcFormatter = DateFormatter()
  1246. utcFormatter.locale = Locale(identifier: "en_US_POSIX")
  1247. utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
  1248. utcFormatter.calendar = Calendar(identifier: .gregorian)
  1249. // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
  1250. utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
  1251. return ScheduleMeetingDraft(
  1252. startDate: startDate,
  1253. startTimeUTC: utcFormatter.string(from: startDate),
  1254. timeZone: tz
  1255. )
  1256. }
  1257. @objc private func joinMeetingTapped() {
  1258. if let existing = joinMeetingWindow {
  1259. existing.makeKeyAndOrderFront(nil)
  1260. NSApp.activate(ignoringOtherApps: true)
  1261. DispatchQueue.main.async { [weak self] in
  1262. _ = self?.joinURLField?.becomeFirstResponder()
  1263. }
  1264. return
  1265. }
  1266. let content = makeJoinMeetingPanelContent()
  1267. let controller = NSViewController()
  1268. controller.view = content
  1269. let panel = NSPanel(
  1270. contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
  1271. styleMask: [.titled, .closable, .fullSizeContentView],
  1272. backing: .buffered,
  1273. defer: false
  1274. )
  1275. panel.title = "Join a meeting"
  1276. panel.titleVisibility = .hidden
  1277. panel.titlebarAppearsTransparent = true
  1278. panel.hidesOnDeactivate = true
  1279. panel.isReleasedWhenClosed = false
  1280. panel.standardWindowButton(.closeButton)?.isHidden = true
  1281. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  1282. panel.standardWindowButton(.zoomButton)?.isHidden = true
  1283. panel.isMovableByWindowBackground = true
  1284. // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
  1285. panel.becomesKeyOnlyIfNeeded = false
  1286. panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
  1287. panel.center()
  1288. panel.contentViewController = controller
  1289. panel.delegate = self
  1290. applyWindowBackgroundForCurrentTheme(panel)
  1291. panel.makeKeyAndOrderFront(nil)
  1292. NSApp.activate(ignoringOtherApps: true)
  1293. joinMeetingWindow = panel
  1294. DispatchQueue.main.async { [weak self] in
  1295. guard let self else { return }
  1296. _ = self.joinURLField?.becomeFirstResponder()
  1297. }
  1298. }
  1299. private func makeJoinMeetingPanelContent() -> NSView {
  1300. let root = NSView()
  1301. root.translatesAutoresizingMaskIntoConstraints = false
  1302. root.wantsLayer = true
  1303. root.layer?.backgroundColor = appBackground.cgColor
  1304. let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
  1305. let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
  1306. let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
  1307. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1308. closeButton.isBordered = false
  1309. closeButton.bezelStyle = .regularSquare
  1310. closeButton.wantsLayer = true
  1311. closeButton.layer?.cornerRadius = 14
  1312. closeButton.normalColor = palette.inputBackground
  1313. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  1314. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1315. closeButton.layer?.borderWidth = 1
  1316. closeButton.font = .systemFont(ofSize: 13, weight: .bold)
  1317. closeButton.contentTintColor = secondaryText
  1318. closeButton.toolTip = "Close"
  1319. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1320. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1321. let titleRow = NSStackView()
  1322. titleRow.orientation = .horizontal
  1323. titleRow.alignment = .centerY
  1324. titleRow.spacing = 10
  1325. titleRow.translatesAutoresizingMaskIntoConstraints = false
  1326. let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
  1327. titleStack.orientation = .vertical
  1328. titleStack.spacing = 2
  1329. titleStack.alignment = .leading
  1330. let titleSpacer = NSView()
  1331. titleSpacer.translatesAutoresizingMaskIntoConstraints = false
  1332. titleRow.addArrangedSubview(titleStack)
  1333. titleRow.addArrangedSubview(titleSpacer)
  1334. titleRow.addArrangedSubview(closeButton)
  1335. let headerDivider = NSView()
  1336. headerDivider.wantsLayer = true
  1337. headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
  1338. headerDivider.translatesAutoresizingMaskIntoConstraints = false
  1339. let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
  1340. mode.segmentStyle = .rounded
  1341. mode.selectedSegment = 0
  1342. mode.translatesAutoresizingMaskIntoConstraints = false
  1343. mode.font = .systemFont(ofSize: 12, weight: .semibold)
  1344. mode.controlSize = .large
  1345. if #available(macOS 11.0, *) {
  1346. mode.selectedSegmentBezelColor = accentBlue
  1347. }
  1348. joinModeSegment = mode
  1349. let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
  1350. joinURLField = urlBox.textField
  1351. let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
  1352. let urlStack = NSStackView(views: [urlLabel, urlBox])
  1353. urlStack.orientation = .vertical
  1354. urlStack.spacing = 8
  1355. urlStack.alignment = .leading
  1356. urlStack.translatesAutoresizingMaskIntoConstraints = false
  1357. joinURLFieldsContainer = urlStack
  1358. let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
  1359. let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
  1360. joinMeetingIDField = idBox.textField
  1361. joinPasscodeField = passBox.textField
  1362. let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
  1363. let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
  1364. let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
  1365. idStack.orientation = .vertical
  1366. idStack.spacing = 8
  1367. idStack.alignment = .leading
  1368. idStack.translatesAutoresizingMaskIntoConstraints = false
  1369. idStack.isHidden = true
  1370. joinIDFieldsContainer = idStack
  1371. let formCard = NSView()
  1372. formCard.translatesAutoresizingMaskIntoConstraints = false
  1373. formCard.wantsLayer = true
  1374. formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
  1375. formCard.layer?.cornerRadius = 14
  1376. formCard.layer?.borderWidth = 1
  1377. formCard.layer?.borderColor = palette.inputBorder.cgColor
  1378. let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
  1379. cancelButton.isBordered = false
  1380. cancelButton.wantsLayer = true
  1381. cancelButton.layer?.cornerRadius = 10
  1382. cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1383. cancelButton.layer?.borderWidth = 1
  1384. cancelButton.layer?.borderColor = palette.inputBorder.cgColor
  1385. cancelButton.contentTintColor = primaryText
  1386. cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1387. let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
  1388. joinButton.isBordered = false
  1389. joinButton.wantsLayer = true
  1390. joinButton.layer?.cornerRadius = 10
  1391. joinButton.normalColor = accentBlue
  1392. joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
  1393. joinButton.contentTintColor = .white
  1394. joinButton.font = .systemFont(ofSize: 13, weight: .bold)
  1395. joinButton.keyEquivalent = "\r"
  1396. let buttons = NSStackView(views: [cancelButton, joinButton])
  1397. buttons.orientation = .horizontal
  1398. buttons.spacing = 12
  1399. buttons.alignment = .centerY
  1400. buttons.distribution = .fillEqually
  1401. let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
  1402. innerStack.orientation = .vertical
  1403. innerStack.spacing = 18
  1404. innerStack.alignment = .leading
  1405. innerStack.translatesAutoresizingMaskIntoConstraints = false
  1406. formCard.addSubview(innerStack)
  1407. [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
  1408. NSLayoutConstraint.activate([
  1409. titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1410. titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
  1411. titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
  1412. headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
  1413. headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1414. headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1415. headerDivider.heightAnchor.constraint(equalToConstant: 1),
  1416. formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
  1417. formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
  1418. formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
  1419. formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
  1420. innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
  1421. innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
  1422. innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
  1423. innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
  1424. mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1425. urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1426. idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1427. passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1428. buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
  1429. joinButton.heightAnchor.constraint(equalToConstant: 40),
  1430. cancelButton.heightAnchor.constraint(equalToConstant: 40)
  1431. ])
  1432. return root
  1433. }
  1434. private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
  1435. JoinPanelFieldContainer(
  1436. placeholder: placeholder,
  1437. normalBorder: palette.inputBorder,
  1438. focusBorder: accentBlue.withAlphaComponent(0.9),
  1439. fill: palette.inputBackground,
  1440. primaryText: primaryText,
  1441. mutedText: mutedText
  1442. )
  1443. }
  1444. @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
  1445. let urlMode = sender.selectedSegment == 0
  1446. joinURLFieldsContainer?.isHidden = urlMode == false
  1447. joinIDFieldsContainer?.isHidden = urlMode
  1448. }
  1449. @objc private func joinMeetingCancelTapped() {
  1450. joinMeetingWindow?.performClose(nil)
  1451. }
  1452. @objc private func joinMeetingSubmitTapped() {
  1453. guard let segment = joinModeSegment else { return }
  1454. if segment.selectedSegment == 0 {
  1455. let raw = joinURLField?.stringValue ?? ""
  1456. guard let url = parseZoomJoinURLFromUserInput(raw) else {
  1457. showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
  1458. return
  1459. }
  1460. openZoomMeetingInDefaultBrowser(url)
  1461. } else {
  1462. let idRaw = joinMeetingIDField?.stringValue ?? ""
  1463. let digits = idRaw.filter(\.isNumber)
  1464. guard digits.isEmpty == false else {
  1465. showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
  1466. return
  1467. }
  1468. let pass = joinPasscodeField?.stringValue ?? ""
  1469. guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
  1470. showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
  1471. return
  1472. }
  1473. openZoomMeetingInDefaultBrowser(url)
  1474. }
  1475. }
  1476. /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
  1477. private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
  1478. guard meetingIdDigits.isEmpty == false else { return nil }
  1479. var components = URLComponents()
  1480. components.scheme = "https"
  1481. components.host = "zoom.us"
  1482. components.path = "/wc/join/\(meetingIdDigits)"
  1483. let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
  1484. if trimmed.isEmpty == false {
  1485. components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
  1486. }
  1487. return components.url
  1488. }
  1489. private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
  1490. let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  1491. guard trimmed.isEmpty == false else { return nil }
  1492. let normalized: String
  1493. if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
  1494. normalized = trimmed
  1495. } else {
  1496. normalized = "https://\(trimmed)"
  1497. }
  1498. guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
  1499. let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
  1500. || host == "zoom.com" || host.hasSuffix(".zoom.com")
  1501. guard isZoom else { return nil }
  1502. return url
  1503. }
  1504. /// Opens non-sign-in web content in the system default browser.
  1505. @MainActor
  1506. private func openWebURLPreferringInApp(_ url: URL) {
  1507. _ = openURLInDefaultBrowser(url)
  1508. }
  1509. private func openZoomMeetingInDefaultBrowser(_ url: URL) {
  1510. let opened = openURLInDefaultBrowser(url)
  1511. if opened {
  1512. joinMeetingWindow?.performClose(nil)
  1513. } else {
  1514. showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
  1515. }
  1516. }
  1517. @discardableResult
  1518. private func openURLInDefaultBrowser(_ url: URL) -> Bool {
  1519. NSWorkspace.shared.open(url)
  1520. }
  1521. @MainActor
  1522. private func presentURLInEmbeddedRightPanel(_ url: URL) {
  1523. guard let webView = embeddedBrowserWebView else {
  1524. NSWorkspace.shared.open(url)
  1525. return
  1526. }
  1527. updateEmbeddedRightPanelWidthForOpenState()
  1528. webView.load(URLRequest(url: url))
  1529. refreshEmbeddedBrowserNavButtons()
  1530. view.window?.contentView?.layoutSubtreeIfNeeded()
  1531. }
  1532. /// Makes the embedded web panel fill the entire right-side content area (to match the reference behavior).
  1533. @MainActor
  1534. private func updateEmbeddedRightPanelWidthForOpenState() {
  1535. guard let widthConstraint = embeddedBrowserPanelWidthConstraint else { return }
  1536. view.layoutSubtreeIfNeeded()
  1537. view.window?.contentView?.layoutSubtreeIfNeeded()
  1538. var rowW = homeBodyRowView?.bounds.width ?? 0
  1539. if rowW < 1 {
  1540. rowW = view.bounds.width
  1541. }
  1542. // Fill the entire body row so the embedded web experience matches a dedicated right-side panel.
  1543. widthConstraint.constant = max(320, floor(rowW))
  1544. // Collapse the left main content column while web is visible.
  1545. homeMainContentColumnView?.isHidden = true
  1546. embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = true
  1547. }
  1548. @MainActor
  1549. private func refreshEmbeddedBrowserNavButtons() {
  1550. embeddedBrowserBackButton?.isEnabled = embeddedBrowserWebView?.canGoBack == true
  1551. embeddedBrowserForwardButton?.isEnabled = embeddedBrowserWebView?.canGoForward == true
  1552. }
  1553. @objc private func embeddedBrowserBackTapped() {
  1554. embeddedBrowserWebView?.goBack()
  1555. }
  1556. @objc private func embeddedBrowserForwardTapped() {
  1557. embeddedBrowserWebView?.goForward()
  1558. }
  1559. @objc private func embeddedBrowserReloadTapped() {
  1560. embeddedBrowserWebView?.reload()
  1561. }
  1562. @objc private func embeddedBrowserClosePanelTapped() {
  1563. closeEmbeddedBrowserPanel()
  1564. }
  1565. @MainActor
  1566. private func closeEmbeddedBrowserPanel() {
  1567. if let cont = oauthSessionContinuation {
  1568. oauthSessionContinuation = nil
  1569. oauthSessionCallbackPrefix = nil
  1570. cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
  1571. }
  1572. embeddedBrowserWebView?.stopLoading()
  1573. embeddedBrowserPanelWidthConstraint?.constant = 0
  1574. embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
  1575. homeMainContentColumnView?.isHidden = false
  1576. refreshEmbeddedBrowserNavButtons()
  1577. view.window?.contentView?.layoutSubtreeIfNeeded()
  1578. }
  1579. func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL {
  1580. guard let webView = embeddedBrowserWebView else {
  1581. throw EmbeddedOAuthFlowError.webViewUnavailable
  1582. }
  1583. do {
  1584. return try await withThrowingTaskGroup(of: URL.self) { group in
  1585. group.addTask { @MainActor in
  1586. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  1587. self.oauthSessionContinuation = continuation
  1588. self.oauthSessionCallbackPrefix = callbackURLPrefix
  1589. self.updateEmbeddedRightPanelWidthForOpenState()
  1590. webView.load(URLRequest(url: startURL))
  1591. self.refreshEmbeddedBrowserNavButtons()
  1592. }
  1593. }
  1594. group.addTask {
  1595. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  1596. await MainActor.run {
  1597. if let cont = self.oauthSessionContinuation {
  1598. self.oauthSessionContinuation = nil
  1599. self.oauthSessionCallbackPrefix = nil
  1600. webView.stopLoading()
  1601. cont.resume(throwing: EmbeddedOAuthFlowError.timedOut)
  1602. }
  1603. }
  1604. throw EmbeddedOAuthFlowError.timedOut
  1605. }
  1606. let url = try await group.next()!
  1607. group.cancelAll()
  1608. await MainActor.run {
  1609. self.oauthSessionContinuation = nil
  1610. self.oauthSessionCallbackPrefix = nil
  1611. self.closeEmbeddedBrowserPanel()
  1612. webView.load(URLRequest(url: URL(string: "about:blank")!))
  1613. }
  1614. return url
  1615. }
  1616. } catch {
  1617. await MainActor.run {
  1618. self.oauthSessionContinuation = nil
  1619. self.oauthSessionCallbackPrefix = nil
  1620. webView.stopLoading()
  1621. self.embeddedBrowserPanelWidthConstraint?.constant = 0
  1622. self.refreshEmbeddedBrowserNavButtons()
  1623. webView.load(URLRequest(url: URL(string: "about:blank")!))
  1624. self.view.window?.contentView?.layoutSubtreeIfNeeded()
  1625. }
  1626. throw error
  1627. }
  1628. }
  1629. private func resetJoinMeetingPanelReferences() {
  1630. joinMeetingWindow = nil
  1631. joinURLField = nil
  1632. joinMeetingIDField = nil
  1633. joinPasscodeField = nil
  1634. joinURLFieldsContainer = nil
  1635. joinIDFieldsContainer = nil
  1636. joinModeSegment = nil
  1637. }
  1638. @objc private func logoutTapped() {
  1639. meetingsRefreshTimer?.invalidate()
  1640. meetingsRefreshTimer = nil
  1641. clearMeetingsScrollObserver()
  1642. googleOAuth.clearSavedTokens()
  1643. zoomOAuth.clearSavedTokens()
  1644. persistLoggedInState(false)
  1645. allScheduledMeetings = []
  1646. aiCompanionSummaries = []
  1647. aiCompanionSelectedIndex = nil
  1648. aiCompanionLastLoadedAt = nil
  1649. applyAuthenticationUIState(triggerInitialRefresh: false)
  1650. Task { @MainActor in
  1651. self.applyMeetings([])
  1652. self.renderAiCompanionList()
  1653. self.renderAiCompanionDetail()
  1654. self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
  1655. }
  1656. }
  1657. @objc private func topBarAuthButtonTapped() {
  1658. if isUserLoggedIn() {
  1659. showProfileMenu()
  1660. } else {
  1661. zoomPrimarySignInTapped()
  1662. }
  1663. }
  1664. @objc private func profileMenuLogoutTapped() {
  1665. profileMenuPopover?.performClose(nil)
  1666. logoutTapped()
  1667. }
  1668. private func showProfileMenu() {
  1669. guard let button = topBarAuthButton else { return }
  1670. // Recreate so the popover always matches current light/dark mode.
  1671. profileMenuPopover = makeProfileMenuPopover()
  1672. guard let popover = profileMenuPopover else { return }
  1673. if popover.isShown {
  1674. popover.performClose(nil)
  1675. return
  1676. }
  1677. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
  1678. }
  1679. private func makeProfileMenuPopover() -> NSPopover {
  1680. let popover = NSPopover()
  1681. popover.behavior = .transient
  1682. popover.animates = true
  1683. let contentController = NSViewController()
  1684. let root = NSView(frame: NSRect(x: 0, y: 0, width: 198, height: 86))
  1685. root.wantsLayer = true
  1686. root.layer?.cornerRadius = 12
  1687. root.layer?.backgroundColor = (palette.isDarkMode
  1688. ? NSColor(calibratedRed: 30 / 255, green: 33 / 255, blue: 40 / 255, alpha: 1)
  1689. : NSColor(calibratedRed: 246 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
  1690. ).cgColor
  1691. root.layer?.borderWidth = 1
  1692. root.layer?.borderColor = (palette.isDarkMode
  1693. ? NSColor.white.withAlphaComponent(0.08)
  1694. : NSColor.black.withAlphaComponent(0.08)
  1695. ).cgColor
  1696. let logoutButton = NSButton(title: "Logout", target: self, action: #selector(profileMenuLogoutTapped))
  1697. logoutButton.translatesAutoresizingMaskIntoConstraints = false
  1698. logoutButton.isBordered = false
  1699. logoutButton.wantsLayer = true
  1700. logoutButton.layer?.cornerRadius = 9
  1701. logoutButton.layer?.backgroundColor = (palette.isDarkMode
  1702. ? NSColor.white.withAlphaComponent(0.08)
  1703. : NSColor(calibratedWhite: 0.0, alpha: 0.06)
  1704. ).cgColor
  1705. logoutButton.contentTintColor = (palette.isDarkMode
  1706. ? .white
  1707. : NSColor(calibratedRed: 22 / 255, green: 24 / 255, blue: 29 / 255, alpha: 1)
  1708. )
  1709. logoutButton.font = .systemFont(ofSize: 13, weight: .semibold)
  1710. logoutButton.alignment = .left
  1711. logoutButton.imagePosition = .imageLeading
  1712. logoutButton.imageHugsTitle = true
  1713. logoutButton.bezelStyle = .shadowlessSquare
  1714. logoutButton.title = " Logout"
  1715. if let symbol = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: "Logout") {
  1716. logoutButton.image = symbol.withSymbolConfiguration(.init(pointSize: 13, weight: .semibold))
  1717. }
  1718. let subtitle = makeLabel(
  1719. "Account",
  1720. size: 11,
  1721. color: palette.isDarkMode
  1722. ? NSColor.white.withAlphaComponent(0.72)
  1723. : NSColor(calibratedRed: 88 / 255, green: 92 / 255, blue: 102 / 255, alpha: 1),
  1724. weight: .medium,
  1725. centered: false
  1726. )
  1727. subtitle.translatesAutoresizingMaskIntoConstraints = false
  1728. root.addSubview(subtitle)
  1729. root.addSubview(logoutButton)
  1730. NSLayoutConstraint.activate([
  1731. subtitle.topAnchor.constraint(equalTo: root.topAnchor, constant: 10),
  1732. subtitle.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12),
  1733. subtitle.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
  1734. logoutButton.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 8),
  1735. logoutButton.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 10),
  1736. logoutButton.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -10),
  1737. logoutButton.heightAnchor.constraint(equalToConstant: 34)
  1738. ])
  1739. contentController.view = root
  1740. popover.contentViewController = contentController
  1741. popover.contentSize = root.frame.size
  1742. return popover
  1743. }
  1744. @objc private func topBarPlaceholderTapped() {
  1745. // Reserved for future titlebar control actions.
  1746. }
  1747. @objc private func upgradeToProTapped() {
  1748. if storeKitCoordinator.hasLifetimePremiumAccess {
  1749. openManageSubscriptions()
  1750. } else {
  1751. showPaywall()
  1752. }
  1753. }
  1754. private func openManageSubscriptions() {
  1755. if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
  1756. openWebURLPreferringInApp(url)
  1757. }
  1758. }
  1759. @objc private func refreshMeetingsTapped() {
  1760. guard isUserLoggedIn() else {
  1761. applyGuestLoginMessages()
  1762. return
  1763. }
  1764. guard ensurePremiumAccess() else {
  1765. applyPremiumLockMessages()
  1766. return
  1767. }
  1768. Task { @MainActor in
  1769. self.animateMeetingsRefresh()
  1770. self.setMeetingsLoadingUI(true)
  1771. }
  1772. triggerMeetingsRefresh(force: true)
  1773. }
  1774. @MainActor
  1775. private func setMeetingsLoadingUI(_ loading: Bool) {
  1776. refreshMeetingsButton?.isEnabled = loading == false
  1777. meetingsPageRefreshButton?.isEnabled = loading == false
  1778. if loading {
  1779. meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
  1780. }
  1781. }
  1782. @MainActor
  1783. private func animateMeetingsRefresh() {
  1784. guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
  1785. let views = stack.arrangedSubviews
  1786. let dimmed: CGFloat = 0.86
  1787. NSAnimationContext.runAnimationGroup { context in
  1788. context.duration = 0.07
  1789. context.timingFunction = CAMediaTimingFunction(name: .linear)
  1790. views.forEach { $0.animator().alphaValue = dimmed }
  1791. } completionHandler: {
  1792. NSAnimationContext.runAnimationGroup { context in
  1793. context.duration = 0.08
  1794. context.timingFunction = CAMediaTimingFunction(name: .linear)
  1795. views.forEach { $0.animator().alphaValue = 1.0 }
  1796. }
  1797. }
  1798. }
  1799. private func startMeetingsAutoRefresh() {
  1800. guard isUserLoggedIn() else { return }
  1801. meetingsRefreshTimer?.invalidate()
  1802. // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
  1803. meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
  1804. guard let self else { return }
  1805. self.triggerMeetingsRefresh()
  1806. }
  1807. }
  1808. private func triggerMeetingsRefresh(force: Bool = false) {
  1809. guard isUserLoggedIn() else { return }
  1810. guard storeKitCoordinator.hasPremiumAccess else { return }
  1811. let now = Date()
  1812. if let until = meetingsRateLimitedUntil, now < until {
  1813. return
  1814. }
  1815. if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
  1816. return
  1817. }
  1818. lastMeetingsRefreshAt = now
  1819. Task { await self.loadScheduledMeetings() }
  1820. }
  1821. private func clearMeetingsScrollObserver() {
  1822. if let meetingsScrollObserver {
  1823. NotificationCenter.default.removeObserver(meetingsScrollObserver)
  1824. }
  1825. meetingsScrollObserver = nil
  1826. meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
  1827. meetingsScrollView = nil
  1828. }
  1829. private func clearMeetingsPageScrollObserver() {
  1830. if let meetingsPageScrollObserver {
  1831. NotificationCenter.default.removeObserver(meetingsPageScrollObserver)
  1832. }
  1833. meetingsPageScrollObserver = nil
  1834. meetingsPageScrollView?.contentView.postsBoundsChangedNotifications = false
  1835. meetingsPageScrollView = nil
  1836. }
  1837. private func removeSearchFieldObserver() {
  1838. if let searchTextObserver {
  1839. NotificationCenter.default.removeObserver(searchTextObserver)
  1840. }
  1841. searchTextObserver = nil
  1842. }
  1843. private func removeMeetingSearchObserver() {
  1844. if let meetingSearchTextObserver {
  1845. NotificationCenter.default.removeObserver(meetingSearchTextObserver)
  1846. }
  1847. meetingSearchTextObserver = nil
  1848. }
  1849. private func installSearchShortcutMonitor() {
  1850. removeSearchShortcutMonitor()
  1851. searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
  1852. guard let self else { return event }
  1853. if event.keyCode == 53, self.meetingSearchOverlayView?.isHidden == false {
  1854. DispatchQueue.main.async {
  1855. self.hideMeetingSearchPopup()
  1856. }
  1857. return nil
  1858. }
  1859. guard event.modifierFlags.contains(.command),
  1860. event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
  1861. guard self.homeSearchPill != nil else { return event }
  1862. DispatchQueue.main.async {
  1863. self.showMeetingSearchPopup(seedQuery: self.meetingSearchField?.stringValue ?? "")
  1864. }
  1865. return nil
  1866. }
  1867. searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
  1868. guard let self else { return event }
  1869. guard let window = self.view.window, event.window === window else { return event }
  1870. guard self.meetingSearchOverlayView?.isHidden == false else { return event }
  1871. guard let panel = self.meetingSearchPanelView else { return event }
  1872. let rectInWindow = panel.convert(panel.bounds, to: nil)
  1873. if rectInWindow.contains(event.locationInWindow) == false {
  1874. DispatchQueue.main.async {
  1875. self.hideMeetingSearchPopup()
  1876. }
  1877. }
  1878. return event
  1879. }
  1880. }
  1881. private func removeSearchShortcutMonitor() {
  1882. if let searchShortcutMonitor {
  1883. NSEvent.removeMonitor(searchShortcutMonitor)
  1884. }
  1885. searchShortcutMonitor = nil
  1886. if let searchOutsideClickMonitor {
  1887. NSEvent.removeMonitor(searchOutsideClickMonitor)
  1888. }
  1889. searchOutsideClickMonitor = nil
  1890. }
  1891. private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
  1892. guard let fr = window.firstResponder else { return false }
  1893. if fr === field { return true }
  1894. if let editor = field.currentEditor(), fr === editor { return true }
  1895. return false
  1896. }
  1897. @MainActor
  1898. private func applySearchPillFocusBorder(focused: Bool) {
  1899. homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
  1900. homeSearchPill?.layer?.borderColor = accentBlue.cgColor
  1901. }
  1902. @MainActor
  1903. private func focusHomeSearchField() {
  1904. guard let field = homeSearchField else { return }
  1905. view.window?.makeFirstResponder(field)
  1906. }
  1907. @MainActor
  1908. private func showMeetingSearchPopup(seedQuery: String) {
  1909. guard let overlay = meetingSearchOverlayView else { return }
  1910. overlay.isHidden = false
  1911. overlay.alphaValue = 0
  1912. NSAnimationContext.runAnimationGroup { context in
  1913. context.duration = 0.16
  1914. overlay.animator().alphaValue = 1
  1915. }
  1916. if let field = meetingSearchField {
  1917. field.stringValue = seedQuery
  1918. view.window?.makeFirstResponder(field)
  1919. }
  1920. applyGlobalMeetingSearch(query: seedQuery)
  1921. }
  1922. @MainActor
  1923. private func hideMeetingSearchPopup() {
  1924. guard let overlay = meetingSearchOverlayView else { return }
  1925. overlay.isHidden = true
  1926. overlay.alphaValue = 1
  1927. view.window?.makeFirstResponder(nil)
  1928. }
  1929. private func applyGlobalMeetingSearch(query: String) {
  1930. let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  1931. let now = Date()
  1932. let dateFormatter = DateFormatter()
  1933. dateFormatter.dateStyle = .medium
  1934. dateFormatter.timeStyle = .short
  1935. let filtered: [ScheduledMeeting]
  1936. if trimmed.isEmpty {
  1937. filtered = allScheduledMeetings
  1938. } else {
  1939. filtered = allScheduledMeetings.filter { meeting in
  1940. let dateText = dateFormatter.string(from: meeting.start).lowercased()
  1941. return meeting.title.lowercased().contains(trimmed)
  1942. || meeting.host.lowercased().contains(trimmed)
  1943. || meeting.source.lowercased().contains(trimmed)
  1944. || (meeting.meetingID?.lowercased().contains(trimmed) ?? false)
  1945. || dateText.contains(trimmed)
  1946. }
  1947. }
  1948. globalSearchResults = filtered.sorted { lhs, rhs in
  1949. let lhsIsUpcoming = lhs.start >= now
  1950. let rhsIsUpcoming = rhs.start >= now
  1951. if lhsIsUpcoming != rhsIsUpcoming {
  1952. return lhsIsUpcoming && !rhsIsUpcoming
  1953. }
  1954. if lhsIsUpcoming {
  1955. return lhs.start < rhs.start
  1956. }
  1957. return lhs.start > rhs.start
  1958. }
  1959. renderMeetingSearchResults()
  1960. }
  1961. private func renderMeetingSearchResults() {
  1962. guard let stack = meetingSearchResultsStack else { return }
  1963. stack.arrangedSubviews.forEach { row in
  1964. stack.removeArrangedSubview(row)
  1965. row.removeFromSuperview()
  1966. }
  1967. meetingSearchEmptyLabel?.isHidden = globalSearchResults.isEmpty == false
  1968. for (index, meeting) in globalSearchResults.enumerated() {
  1969. let row = makeMeetingSearchResultRow(meeting)
  1970. row.tag = index
  1971. stack.addArrangedSubview(row)
  1972. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  1973. }
  1974. }
  1975. private func makeMeetingSearchResultRow(_ meeting: ScheduledMeeting) -> NSButton {
  1976. let row = NSButton(title: "", target: self, action: #selector(meetingSearchResultTapped(_:)))
  1977. row.isBordered = false
  1978. row.bezelStyle = .regularSquare
  1979. row.setButtonType(.momentaryChange)
  1980. row.wantsLayer = true
  1981. row.layer?.backgroundColor = meetingCardBackground.cgColor
  1982. row.layer?.cornerRadius = 10
  1983. row.layer?.borderWidth = 1
  1984. row.layer?.borderColor = palette.inputBorder.cgColor
  1985. row.translatesAutoresizingMaskIntoConstraints = false
  1986. row.heightAnchor.constraint(equalToConstant: 84).isActive = true
  1987. let title = makeLabel(meeting.title, size: 14, color: primaryText, weight: .semibold, centered: false)
  1988. title.lineBreakMode = .byTruncatingTail
  1989. let subtitle = makeLabel(compactMeetingSummary(meeting), size: 12, color: secondaryText, weight: .regular, centered: false)
  1990. subtitle.lineBreakMode = .byTruncatingTail
  1991. let meta = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: mutedText, weight: .regular, centered: false)
  1992. meta.lineBreakMode = .byTruncatingTail
  1993. [title, subtitle, meta].forEach {
  1994. $0.translatesAutoresizingMaskIntoConstraints = false
  1995. row.addSubview($0)
  1996. }
  1997. NSLayoutConstraint.activate([
  1998. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  1999. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12),
  2000. title.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12),
  2001. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  2002. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2003. subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  2004. meta.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
  2005. meta.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  2006. meta.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  2007. ])
  2008. return row
  2009. }
  2010. private func compactMeetingSummary(_ meeting: ScheduledMeeting) -> String {
  2011. let dateFormatter = DateFormatter()
  2012. dateFormatter.dateFormat = "EEE, MMM d"
  2013. let timeFormatter = DateFormatter()
  2014. timeFormatter.dateFormat = "h:mm a"
  2015. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  2016. dateFormatter.timeZone = displayTimeZone
  2017. timeFormatter.timeZone = displayTimeZone
  2018. let start = timeFormatter.string(from: meeting.start)
  2019. let end = meeting.end.map { timeFormatter.string(from: $0) }
  2020. let range = end.map { "\(start) - \($0)" } ?? start
  2021. if let id = meeting.meetingID, id.isEmpty == false {
  2022. return "\(dateFormatter.string(from: meeting.start)) • \(range) • ID \(id)"
  2023. }
  2024. return "\(dateFormatter.string(from: meeting.start)) • \(range)"
  2025. }
  2026. @objc private func topSearchPillTapped() {
  2027. showMeetingSearchPopup(seedQuery: meetingSearchField?.stringValue ?? "")
  2028. }
  2029. @objc private func meetingSearchResultTapped(_ sender: NSButton) {
  2030. guard let meeting = globalSearchResults[safe: sender.tag] else { return }
  2031. if let url = meeting.webURL {
  2032. openWebURLPreferringInApp(url)
  2033. } else {
  2034. let pasteboard = NSPasteboard.general
  2035. pasteboard.clearContents()
  2036. if let id = meeting.meetingID, id.isEmpty == false {
  2037. pasteboard.setString(id, forType: .string)
  2038. } else {
  2039. pasteboard.setString(meeting.title, forType: .string)
  2040. }
  2041. }
  2042. hideMeetingSearchPopup()
  2043. }
  2044. private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
  2045. clearMeetingsScrollObserver()
  2046. meetingsScrollView = scrollView
  2047. scrollView.contentView.postsBoundsChangedNotifications = true
  2048. meetingsScrollObserver = NotificationCenter.default.addObserver(
  2049. forName: NSView.boundsDidChangeNotification,
  2050. object: scrollView.contentView,
  2051. queue: .main
  2052. ) { [weak self, weak scrollView] _ in
  2053. guard let self, let scrollView else { return }
  2054. self.refreshMeetingsIfScrolledToEdge(scrollView)
  2055. }
  2056. }
  2057. private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
  2058. guard let documentView = scrollView.documentView else { return }
  2059. let visibleRect = scrollView.contentView.bounds
  2060. let contentHeight = documentView.bounds.height
  2061. let viewportHeight = visibleRect.height
  2062. if contentHeight <= viewportHeight + 2 {
  2063. return
  2064. }
  2065. let maxOffset = max(contentHeight - viewportHeight, 0)
  2066. let y = visibleRect.origin.y
  2067. let threshold: CGFloat = 12
  2068. let reachedTop = y <= threshold
  2069. let reachedBottom = y >= (maxOffset - threshold)
  2070. guard reachedTop || reachedBottom else { return }
  2071. let now = Date()
  2072. if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
  2073. return
  2074. }
  2075. lastScrollEdgeRefreshAt = now
  2076. triggerMeetingsRefresh(force: true)
  2077. }
  2078. private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
  2079. clearMeetingsPageScrollObserver()
  2080. meetingsPageScrollView = scrollView
  2081. scrollView.contentView.postsBoundsChangedNotifications = true
  2082. meetingsPageScrollObserver = NotificationCenter.default.addObserver(
  2083. forName: NSView.boundsDidChangeNotification,
  2084. object: scrollView.contentView,
  2085. queue: .main
  2086. ) { [weak self, weak scrollView] _ in
  2087. guard let self, let scrollView else { return }
  2088. self.loadMoreMeetingsIfNeededForScroll(scrollView)
  2089. }
  2090. }
  2091. private func loadMoreMeetingsIfNeededForScroll(_ scrollView: NSScrollView) {
  2092. guard meetingsPageVisibleCount < meetingsPageFilteredMeetings.count else { return }
  2093. guard let documentView = scrollView.documentView else { return }
  2094. let visibleRect = scrollView.contentView.bounds
  2095. let contentHeight = documentView.bounds.height
  2096. let viewportHeight = visibleRect.height
  2097. guard contentHeight > 0, viewportHeight > 0 else { return }
  2098. let maxOffset = max(contentHeight - viewportHeight, 0)
  2099. let y = visibleRect.origin.y
  2100. let threshold: CGFloat = 72
  2101. guard y >= (maxOffset - threshold) else { return }
  2102. let now = Date()
  2103. guard now.timeIntervalSince(lastMeetingsPageAutoLoadAt) >= meetingsPageAutoLoadCooldown else { return }
  2104. lastMeetingsPageAutoLoadAt = now
  2105. let nextCount = meetingsPageVisibleCount + meetingsPageSize
  2106. meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
  2107. renderMeetingsPageList()
  2108. }
  2109. @MainActor
  2110. private func resetLoginSigningInState() {
  2111. isSigningIn = false
  2112. googleButton?.title = "G"
  2113. updateTopBarAuthButton()
  2114. googleButton?.isEnabled = true
  2115. }
  2116. @MainActor
  2117. private func completeLoginAndRestart(profile: GoogleUserProfile?) {
  2118. persistLoggedInState(true)
  2119. showHomeView(profile: profile)
  2120. if let window = view.window {
  2121. window.makeKeyAndOrderFront(nil)
  2122. }
  2123. NSApp.activate(ignoringOtherApps: true)
  2124. }
  2125. @MainActor
  2126. private func updateTopBarAuthButton() {
  2127. guard let button = topBarAuthButton else { return }
  2128. let loggedIn = isUserLoggedIn()
  2129. if loggedIn {
  2130. button.title = topBarProfileInitial
  2131. button.toolTip = "Profile"
  2132. button.font = .systemFont(ofSize: 14, weight: .bold)
  2133. button.contentTintColor = primaryText
  2134. button.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
  2135. button.layer?.cornerRadius = 10
  2136. topBarAuthButtonWidthConstraint?.constant = 34
  2137. } else {
  2138. button.title = "Sign in with Zoom"
  2139. button.toolTip = "Sign in with Zoom"
  2140. button.font = .systemFont(ofSize: 12, weight: .semibold)
  2141. button.contentTintColor = .white
  2142. button.layer?.backgroundColor = accentBlue.cgColor
  2143. button.layer?.cornerRadius = 11
  2144. topBarAuthButtonWidthConstraint?.constant = 120
  2145. }
  2146. button.isEnabled = isSigningIn == false
  2147. }
  2148. @MainActor
  2149. private func applyGuestLoginMessages() {
  2150. let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
  2151. let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
  2152. let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
  2153. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  2154. emptyMeetingLabel?.isHidden = isHome == false
  2155. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  2156. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  2157. meetingsPageEmptyLabel?.isHidden = isMeetings == false
  2158. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  2159. meetingsPageCountLabel?.stringValue = "0 scheduled"
  2160. schedulerMeetingsEmptyLabel?.isHidden = isScheduler == false
  2161. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  2162. }
  2163. @MainActor
  2164. private func applyPremiumLockMessages() {
  2165. // Keep underlying section labels hidden while locked to avoid duplicate text beneath overlays.
  2166. meetingsStatusLabel?.stringValue = ""
  2167. emptyMeetingLabel?.isHidden = true
  2168. meetingsPageStatusLabel?.stringValue = ""
  2169. meetingsPageEmptyLabel?.isHidden = true
  2170. meetingsPageCountLabel?.stringValue = ""
  2171. schedulerMeetingsEmptyLabel?.isHidden = true
  2172. }
  2173. private func ensurePremiumAccess() -> Bool {
  2174. guard storeKitCoordinator.hasPremiumAccess else {
  2175. showPaywall()
  2176. return false
  2177. }
  2178. return true
  2179. }
  2180. @MainActor
  2181. private func applyAuthenticationUIState(triggerInitialRefresh: Bool) {
  2182. let loggedIn = isUserLoggedIn()
  2183. updateTopBarAuthButton()
  2184. settingsGoogleActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
  2185. settingsGoogleActionButton?.isEnabled = true
  2186. updatePremiumLockedSections()
  2187. meetingsRefreshTimer?.invalidate()
  2188. meetingsRefreshTimer = nil
  2189. meetingsRateLimitedUntil = nil
  2190. if loggedIn {
  2191. if storeKitCoordinator.hasPremiumAccess {
  2192. startMeetingsAutoRefresh()
  2193. if triggerInitialRefresh {
  2194. triggerMeetingsRefresh(force: true)
  2195. }
  2196. } else {
  2197. applyMeetings([])
  2198. applyPremiumLockMessages()
  2199. }
  2200. } else {
  2201. applyGuestLoginMessages()
  2202. }
  2203. }
  2204. /// Returns false if the user cancelled or left credentials empty.
  2205. @MainActor
  2206. private func ensureZoomOAuthClientConfigured() -> Bool {
  2207. if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
  2208. return true
  2209. }
  2210. return presentZoomOAuthCredentialPrompt()
  2211. }
  2212. private func showSimpleError(_ title: String, error: Error) {
  2213. let alert = NSAlert()
  2214. alert.alertStyle = .warning
  2215. alert.messageText = title
  2216. alert.informativeText = error.localizedDescription
  2217. alert.runModal()
  2218. }
  2219. private func showSimpleAlert(title: String, message: String) {
  2220. let alert = NSAlert()
  2221. alert.alertStyle = .informational
  2222. alert.messageText = title
  2223. alert.informativeText = message
  2224. alert.runModal()
  2225. }
  2226. private struct ScheduledMeeting {
  2227. let title: String
  2228. let start: Date
  2229. let end: Date?
  2230. let meetingTimeZone: TimeZone?
  2231. let host: String
  2232. let source: String
  2233. let meetingID: String?
  2234. let typeLabel: String
  2235. let statusLabel: String
  2236. let webURL: URL?
  2237. }
  2238. @MainActor
  2239. private func applyMeetings(_ meetings: [ScheduledMeeting]) {
  2240. allScheduledMeetings = meetings
  2241. if meetings.isEmpty == false {
  2242. let calendar = Calendar.current
  2243. let selectedStart = calendar.startOfDay(for: selectedMeetingsDayStart)
  2244. let selectedEnd = calendar.date(byAdding: .day, value: 1, to: selectedStart) ?? selectedStart.addingTimeInterval(60 * 60 * 24)
  2245. let selectedDayHasMeetings = meetings.contains { meeting in
  2246. meeting.start >= selectedStart && meeting.start < selectedEnd
  2247. }
  2248. if selectedDayHasMeetings == false,
  2249. let firstUpcoming = meetings
  2250. .filter({ $0.start >= Date() })
  2251. .sorted(by: { $0.start < $1.start })
  2252. .first {
  2253. selectedMeetingsDayStart = calendar.startOfDay(for: firstUpcoming.start)
  2254. } else if selectedDayHasMeetings == false,
  2255. let latest = meetings.max(by: { $0.start < $1.start }) {
  2256. // All instances are in the past: jump to the most recent meeting day so the list is not stuck empty.
  2257. selectedMeetingsDayStart = calendar.startOfDay(for: latest.start)
  2258. }
  2259. }
  2260. applyFilteredMeetings()
  2261. applyMeetingsPageFilters(resetVisible: true)
  2262. updateWidgetMeetingStoreFromCurrentMeetings()
  2263. rescheduleMeetingRemindersIfNeeded()
  2264. }
  2265. @MainActor
  2266. private func applyFilteredMeetings() {
  2267. guard let stack = meetingsListStack else { return }
  2268. let showingHomeSection = selectedHomeSidebarItem == HomeSection.home.rawValue
  2269. stack.arrangedSubviews.forEach { view in
  2270. stack.removeArrangedSubview(view)
  2271. view.removeFromSuperview()
  2272. }
  2273. guard isUserLoggedIn() else {
  2274. meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
  2275. emptyMeetingLabel?.isHidden = showingHomeSection == false
  2276. emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
  2277. applySchedulerMeetingsForSelectedDate()
  2278. renderSchedulerCalendarGrid()
  2279. return
  2280. }
  2281. guard storeKitCoordinator.hasPremiumAccess else {
  2282. meetingsStatusLabel?.stringValue = ""
  2283. emptyMeetingLabel?.isHidden = true
  2284. applySchedulerMeetingsForSelectedDate()
  2285. renderSchedulerCalendarGrid()
  2286. return
  2287. }
  2288. let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
  2289. let calendar = Calendar.current
  2290. let dayStart = selectedMeetingsDayStart
  2291. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
  2292. let source = allScheduledMeetings.filter { meeting in
  2293. meeting.start >= dayStart && meeting.start < dayEnd
  2294. }
  2295. let filtered: [ScheduledMeeting]
  2296. if query.isEmpty {
  2297. filtered = source
  2298. } else {
  2299. filtered = source.filter { meeting in
  2300. meeting.title.lowercased().contains(query)
  2301. || meeting.host.lowercased().contains(query)
  2302. || meeting.source.lowercased().contains(query)
  2303. }
  2304. }
  2305. let ordered = filtered.sorted(by: { $0.start < $1.start })
  2306. if ordered.isEmpty {
  2307. emptyMeetingLabel?.isHidden = showingHomeSection == false
  2308. if source.isEmpty {
  2309. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  2310. emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
  2311. } else {
  2312. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  2313. emptyMeetingLabel?.stringValue = "No meetings match your search."
  2314. }
  2315. applySchedulerMeetingsForSelectedDate()
  2316. renderSchedulerCalendarGrid()
  2317. return
  2318. }
  2319. emptyMeetingLabel?.isHidden = true
  2320. meetingsStatusLabel?.stringValue = "Upcoming meetings"
  2321. for meeting in ordered {
  2322. let card = makeMeetingRowCard(meeting)
  2323. stack.addArrangedSubview(card)
  2324. // Make each meeting card span the full list width (like Zoom).
  2325. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  2326. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2327. }
  2328. }
  2329. applySchedulerMeetingsForSelectedDate()
  2330. renderSchedulerCalendarGrid()
  2331. }
  2332. @MainActor
  2333. private func applyMeetingsPageFilters(resetVisible: Bool) {
  2334. guard isUserLoggedIn() else {
  2335. meetingsPageFilteredMeetings = []
  2336. meetingsPageVisibleCount = 0
  2337. renderMeetingsPageList()
  2338. return
  2339. }
  2340. guard storeKitCoordinator.hasPremiumAccess else {
  2341. meetingsPageFilteredMeetings = []
  2342. meetingsPageVisibleCount = 0
  2343. renderMeetingsPageList()
  2344. return
  2345. }
  2346. let calendar = Calendar.current
  2347. let now = Date()
  2348. let activePreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? meetingsPageSelectedRangePreset
  2349. meetingsPageSelectedRangePreset = activePreset
  2350. let activeFromDate: Date?
  2351. let activeToDate: Date?
  2352. if activePreset == "Custom range",
  2353. let fromPicker = meetingsPageFromDatePicker,
  2354. let toPicker = meetingsPageToDatePicker {
  2355. let fromDay = calendar.startOfDay(for: fromPicker.dateValue)
  2356. let toDay = calendar.startOfDay(for: toPicker.dateValue)
  2357. if fromDay <= toDay {
  2358. activeFromDate = fromDay
  2359. activeToDate = toDay
  2360. } else {
  2361. activeFromDate = toDay
  2362. activeToDate = fromDay
  2363. }
  2364. meetingsPageFromDateFilter = activeFromDate
  2365. meetingsPageToDateFilter = activeToDate
  2366. } else {
  2367. activeFromDate = meetingsPageFromDateFilter
  2368. activeToDate = meetingsPageToDateFilter
  2369. }
  2370. var filtered = allScheduledMeetings.filter { meeting in
  2371. guard meeting.start >= now else { return false }
  2372. switch activePreset {
  2373. case "Today":
  2374. let dayStart = calendar.startOfDay(for: now)
  2375. let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now
  2376. return meeting.start >= now && meeting.start < dayEnd
  2377. case "This week":
  2378. let weekInterval = calendar.dateInterval(of: .weekOfYear, for: now)
  2379. let weekEnd = weekInterval?.end ?? now
  2380. return meeting.start >= now && meeting.start < weekEnd
  2381. case "This month":
  2382. let monthInterval = calendar.dateInterval(of: .month, for: now)
  2383. let monthEnd = monthInterval?.end ?? now
  2384. return meeting.start >= now && meeting.start < monthEnd
  2385. case "Custom range":
  2386. if let fromDate = activeFromDate {
  2387. let startOfFrom = calendar.startOfDay(for: fromDate)
  2388. if meeting.start < startOfFrom { return false }
  2389. }
  2390. if let toDate = activeToDate {
  2391. let startOfTo = calendar.startOfDay(for: toDate)
  2392. let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
  2393. if meeting.start >= endOfTo { return false }
  2394. }
  2395. return true
  2396. default:
  2397. return true
  2398. }
  2399. }
  2400. filtered.sort { $0.start < $1.start }
  2401. meetingsPageFilteredMeetings = filtered
  2402. if resetVisible {
  2403. meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
  2404. } else if filtered.count < meetingsPageVisibleCount {
  2405. meetingsPageVisibleCount = filtered.count
  2406. }
  2407. renderMeetingsPageList()
  2408. }
  2409. @MainActor
  2410. private func renderMeetingsPageList() {
  2411. guard let stack = meetingsPageListStack else { return }
  2412. stack.arrangedSubviews.forEach { view in
  2413. stack.removeArrangedSubview(view)
  2414. view.removeFromSuperview()
  2415. }
  2416. guard isUserLoggedIn() else {
  2417. meetingsPageCountLabel?.stringValue = "0 scheduled"
  2418. meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
  2419. meetingsPageEmptyLabel?.isHidden = false
  2420. meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
  2421. meetingsPageLoadMoreButton?.isHidden = true
  2422. return
  2423. }
  2424. guard storeKitCoordinator.hasPremiumAccess else {
  2425. meetingsPageCountLabel?.stringValue = ""
  2426. meetingsPageStatusLabel?.stringValue = ""
  2427. meetingsPageEmptyLabel?.isHidden = true
  2428. meetingsPageLoadMoreButton?.isHidden = true
  2429. return
  2430. }
  2431. let totalCount = meetingsPageFilteredMeetings.count
  2432. let visibleCount = min(meetingsPageVisibleCount, totalCount)
  2433. meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
  2434. if totalCount == 0 {
  2435. meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
  2436. meetingsPageEmptyLabel?.isHidden = false
  2437. meetingsPageLoadMoreButton?.isHidden = true
  2438. return
  2439. }
  2440. meetingsPageEmptyLabel?.isHidden = true
  2441. meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
  2442. for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
  2443. let card = makeMeetingRowCard(meeting)
  2444. stack.addArrangedSubview(card)
  2445. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  2446. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2447. }
  2448. }
  2449. meetingsPageLoadMoreButton?.isHidden = true
  2450. }
  2451. @objc private func meetingsPageApplyFiltersTapped() {
  2452. let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
  2453. meetingsPageSelectedRangePreset = selectedPreset
  2454. updateMeetingsDatePickersEnabledState()
  2455. if selectedPreset == "Custom range",
  2456. let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
  2457. let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
  2458. let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
  2459. if fromDay <= toDay {
  2460. meetingsPageFromDateFilter = fromDay
  2461. meetingsPageToDateFilter = toDay
  2462. } else {
  2463. meetingsPageFromDateFilter = toDay
  2464. meetingsPageToDateFilter = fromDay
  2465. }
  2466. } else {
  2467. meetingsPageFromDateFilter = nil
  2468. meetingsPageToDateFilter = nil
  2469. }
  2470. Task { @MainActor in
  2471. self.applyMeetingsPageFilters(resetVisible: true)
  2472. }
  2473. }
  2474. @objc private func meetingsPageResetFiltersTapped() {
  2475. meetingsPageSelectedRangePreset = "All"
  2476. meetingsPageFromDateFilter = nil
  2477. meetingsPageToDateFilter = nil
  2478. meetingsPageCategoryFilter?.selectItem(withTitle: "All")
  2479. meetingsPageFromDatePicker?.dateValue = Date()
  2480. meetingsPageToDatePicker?.dateValue = Date()
  2481. updateMeetingsDatePickersEnabledState()
  2482. Task { @MainActor in
  2483. self.applyMeetingsPageFilters(resetVisible: true)
  2484. }
  2485. }
  2486. @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
  2487. meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
  2488. updateMeetingsDatePickersEnabledState()
  2489. Task { @MainActor in
  2490. self.applyMeetingsPageFilters(resetVisible: true)
  2491. }
  2492. }
  2493. private func updateMeetingsDatePickersEnabledState() {
  2494. let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
  2495. meetingsPageFromDatePicker?.isEnabled = isCustomRange
  2496. meetingsPageToDatePicker?.isEnabled = isCustomRange
  2497. meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  2498. meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
  2499. }
  2500. private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
  2501. let calendar = Calendar.current
  2502. let safeDayStart = calendar.startOfDay(for: dayStart)
  2503. let dayEnd = calendar.date(byAdding: .day, value: 1, to: safeDayStart) ?? safeDayStart.addingTimeInterval(60 * 60 * 24)
  2504. return allScheduledMeetings
  2505. .filter { meeting in
  2506. meeting.start >= safeDayStart && meeting.start < dayEnd
  2507. }
  2508. .sorted(by: { $0.start < $1.start })
  2509. }
  2510. @MainActor
  2511. private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
  2512. let calendar = Calendar.current
  2513. selectedMeetingsDayStart = calendar.startOfDay(for: newDayStart)
  2514. let monthComponents = calendar.dateComponents([.year, .month], from: selectedMeetingsDayStart)
  2515. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? selectedMeetingsDayStart
  2516. updateMeetingsDayUI()
  2517. applyFilteredMeetings()
  2518. }
  2519. @MainActor
  2520. private func updateMeetingsDayUI() {
  2521. let dayStart = selectedMeetingsDayStart
  2522. let formatter = DateFormatter()
  2523. formatter.dateFormat = "EEEE, MMM d"
  2524. meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
  2525. let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
  2526. meetingsTodayButton?.isEnabled = isToday == false
  2527. meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  2528. schedulerTodayButton?.isEnabled = isToday == false
  2529. schedulerTodayButton?.alphaValue = isToday ? 0.55 : 1.0
  2530. updateSchedulerDateHeader()
  2531. applySchedulerMeetingsForSelectedDate()
  2532. renderSchedulerCalendarGrid()
  2533. }
  2534. private func meetingsDayDisplayName(for dayStart: Date) -> String {
  2535. let calendar = Calendar.current
  2536. if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
  2537. if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
  2538. calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
  2539. if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
  2540. calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
  2541. let formatter = DateFormatter()
  2542. formatter.dateFormat = "EEEE, MMM d"
  2543. return formatter.string(from: dayStart)
  2544. }
  2545. @objc private func meetingsPrevDayTapped() {
  2546. let calendar = Calendar.current
  2547. let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
  2548. Task { @MainActor in
  2549. self.setSelectedMeetingsDayStart(prev)
  2550. }
  2551. }
  2552. @objc private func meetingsNextDayTapped() {
  2553. let calendar = Calendar.current
  2554. let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
  2555. Task { @MainActor in
  2556. self.setSelectedMeetingsDayStart(next)
  2557. }
  2558. }
  2559. @objc private func meetingsTodayTapped() {
  2560. Task { @MainActor in
  2561. self.setSelectedMeetingsDayStart(Date())
  2562. }
  2563. }
  2564. private func updateSchedulerDateHeader() {
  2565. let formatter = DateFormatter()
  2566. formatter.dateFormat = "MMMM yyyy"
  2567. schedulerMonthLabel?.stringValue = formatter.string(from: schedulerVisibleMonthStart)
  2568. let selectedFormatter = DateFormatter()
  2569. selectedFormatter.dateFormat = "EEEE, MMM d"
  2570. schedulerSelectedDateLabel?.stringValue = selectedFormatter.string(from: selectedMeetingsDayStart)
  2571. }
  2572. @MainActor
  2573. private func applySchedulerMeetingsForSelectedDate() {
  2574. guard let stack = schedulerMeetingsListStack else { return }
  2575. stack.arrangedSubviews.forEach { view in
  2576. stack.removeArrangedSubview(view)
  2577. view.removeFromSuperview()
  2578. }
  2579. guard isUserLoggedIn() else {
  2580. schedulerMeetingsEmptyLabel?.isHidden = false
  2581. schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
  2582. return
  2583. }
  2584. guard storeKitCoordinator.hasPremiumAccess else {
  2585. schedulerMeetingsEmptyLabel?.isHidden = true
  2586. return
  2587. }
  2588. let meetings = meetingsForDay(selectedMeetingsDayStart)
  2589. if meetings.isEmpty {
  2590. schedulerMeetingsEmptyLabel?.isHidden = false
  2591. schedulerMeetingsEmptyLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: selectedMeetingsDayStart))."
  2592. return
  2593. }
  2594. schedulerMeetingsEmptyLabel?.isHidden = true
  2595. for meeting in meetings {
  2596. let card = makeMeetingRowCard(meeting)
  2597. stack.addArrangedSubview(card)
  2598. if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
  2599. card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2600. }
  2601. }
  2602. }
  2603. @MainActor
  2604. private func renderSchedulerCalendarGrid() {
  2605. guard let gridStack = schedulerCalendarGridStack else { return }
  2606. schedulerDayButtons = [:]
  2607. schedulerDateByButtonTag = [:]
  2608. nextSchedulerButtonTag = 1
  2609. gridStack.arrangedSubviews.forEach { row in
  2610. gridStack.removeArrangedSubview(row)
  2611. row.removeFromSuperview()
  2612. }
  2613. let calendar = Calendar.current
  2614. let monthStart = schedulerVisibleMonthStart
  2615. let monthRange = calendar.range(of: .day, in: .month, for: monthStart) ?? 1..<32
  2616. let monthWeekday = calendar.component(.weekday, from: monthStart)
  2617. let leadingSlots = monthWeekday - 1
  2618. let totalSlots = 42
  2619. for week in 0..<6 {
  2620. let row = NSStackView()
  2621. row.orientation = .horizontal
  2622. row.distribution = .fillEqually
  2623. row.spacing = 6
  2624. row.translatesAutoresizingMaskIntoConstraints = false
  2625. gridStack.addArrangedSubview(row)
  2626. row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
  2627. for weekday in 0..<7 {
  2628. let cellIndex = (week * 7) + weekday
  2629. let dayOffset = cellIndex - leadingSlots
  2630. let dayDate = calendar.date(byAdding: .day, value: dayOffset, to: monthStart) ?? monthStart
  2631. let dayNumber = calendar.component(.day, from: dayDate)
  2632. let inCurrentMonth = monthRange.contains(dayNumber) && calendar.isDate(dayDate, equalTo: monthStart, toGranularity: .month)
  2633. let hasMeetings = isUserLoggedIn() && meetingsForDay(dayDate).isEmpty == false
  2634. let button = makeSchedulerDayCell(
  2635. day: dayNumber,
  2636. date: dayDate,
  2637. inCurrentMonth: inCurrentMonth,
  2638. selected: calendar.isDate(dayDate, inSameDayAs: selectedMeetingsDayStart),
  2639. isToday: calendar.isDateInToday(dayDate),
  2640. hasMeetings: hasMeetings
  2641. )
  2642. row.addArrangedSubview(button)
  2643. schedulerDayButtons[calendar.startOfDay(for: dayDate)] = button
  2644. }
  2645. }
  2646. // Keep a consistent 6x7 grid footprint.
  2647. if totalSlots == 42 {
  2648. gridStack.needsLayout = true
  2649. }
  2650. }
  2651. private func makeSchedulerDayCell(
  2652. day: Int,
  2653. date: Date,
  2654. inCurrentMonth: Bool,
  2655. selected: Bool,
  2656. isToday: Bool,
  2657. hasMeetings: Bool
  2658. ) -> NSButton {
  2659. let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
  2660. button.isBordered = false
  2661. button.bezelStyle = .shadowlessSquare
  2662. button.focusRingType = .none
  2663. button.font = .systemFont(ofSize: 16, weight: selected ? .semibold : .regular)
  2664. button.alignment = .center
  2665. button.wantsLayer = true
  2666. button.layer?.cornerRadius = 16
  2667. button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
  2668. let normalBackground = selected
  2669. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
  2670. : NSColor.clear
  2671. let hoverBackground = selected
  2672. ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
  2673. : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
  2674. button.normalColor = normalBackground
  2675. button.hoverColor = hoverBackground
  2676. button.onHoverChanged = { [weak button] hovering in
  2677. guard let button, selected == false else { return }
  2678. if hovering {
  2679. button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
  2680. button.layer?.borderColor = (hasMeetings
  2681. ? self.accentBlue.withAlphaComponent(0.65)
  2682. : self.accentBlue.withAlphaComponent(0.45)
  2683. ).cgColor
  2684. } else if isToday {
  2685. button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
  2686. button.layer?.borderWidth = 1
  2687. } else if hasMeetings {
  2688. button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
  2689. button.layer?.borderWidth = 1
  2690. } else {
  2691. button.layer?.borderColor = NSColor.clear.cgColor
  2692. button.layer?.borderWidth = 0
  2693. }
  2694. }
  2695. if selected {
  2696. button.layer?.borderColor = accentBlue.cgColor
  2697. } else if isToday {
  2698. button.layer?.borderColor = accentOrange.withAlphaComponent(0.8).cgColor
  2699. button.layer?.borderWidth = 1
  2700. } else if hasMeetings {
  2701. button.layer?.borderColor = accentBlue.withAlphaComponent(0.45).cgColor
  2702. } else {
  2703. button.layer?.borderColor = NSColor.clear.cgColor
  2704. }
  2705. button.contentTintColor = selected
  2706. ? primaryText
  2707. : (inCurrentMonth ? primaryText : mutedText.withAlphaComponent(0.75))
  2708. let tag = nextSchedulerButtonTag
  2709. nextSchedulerButtonTag += 1
  2710. button.tag = tag
  2711. schedulerDateByButtonTag[tag] = Calendar.current.startOfDay(for: date)
  2712. button.translatesAutoresizingMaskIntoConstraints = false
  2713. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  2714. return button
  2715. }
  2716. @objc private func schedulerDayTapped(_ sender: NSButton) {
  2717. guard let selectedDate = schedulerDateByButtonTag[sender.tag] else { return }
  2718. Task { @MainActor in
  2719. self.setSelectedMeetingsDayStart(selectedDate)
  2720. }
  2721. }
  2722. @objc private func schedulerPrevMonthTapped() {
  2723. let calendar = Calendar.current
  2724. let prev = calendar.date(byAdding: .month, value: -1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  2725. let monthComponents = calendar.dateComponents([.year, .month], from: prev)
  2726. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? prev
  2727. updateSchedulerDateHeader()
  2728. renderSchedulerCalendarGrid()
  2729. }
  2730. @objc private func schedulerNextMonthTapped() {
  2731. let calendar = Calendar.current
  2732. let next = calendar.date(byAdding: .month, value: 1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
  2733. let monthComponents = calendar.dateComponents([.year, .month], from: next)
  2734. schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? next
  2735. updateSchedulerDateHeader()
  2736. renderSchedulerCalendarGrid()
  2737. }
  2738. @objc private func schedulerTodayTapped() {
  2739. Task { @MainActor in
  2740. self.setSelectedMeetingsDayStart(Date())
  2741. }
  2742. }
  2743. private func loadScheduledMeetings() async {
  2744. guard isUserLoggedIn() else {
  2745. await MainActor.run {
  2746. self.applyMeetings([])
  2747. self.applyGuestLoginMessages()
  2748. }
  2749. return
  2750. }
  2751. guard storeKitCoordinator.hasPremiumAccess else {
  2752. await MainActor.run {
  2753. self.applyMeetings([])
  2754. self.applyPremiumLockMessages()
  2755. }
  2756. return
  2757. }
  2758. if isLoadingMeetings { return }
  2759. isLoadingMeetings = true
  2760. defer {
  2761. isLoadingMeetings = false
  2762. Task { @MainActor in
  2763. self.setMeetingsLoadingUI(false)
  2764. }
  2765. }
  2766. await MainActor.run {
  2767. self.setMeetingsLoadingUI(true)
  2768. }
  2769. do {
  2770. let zoomToken = try await zoomOAuth.validAccessToken(
  2771. presentingWindow: view.window,
  2772. requiringAnyOf: ["meeting:read:list_meetings"]
  2773. )
  2774. if zoomAccountTimeZone == nil {
  2775. if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
  2776. let profileTZ = parseZoomTimeZone(profile.timezone)
  2777. await MainActor.run { self.zoomAccountTimeZone = profileTZ }
  2778. }
  2779. }
  2780. let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: zoomAccountTimeZone)
  2781. await MainActor.run {
  2782. self.meetingsRateLimitedUntil = nil
  2783. self.applyMeetings(zoomMeetings)
  2784. }
  2785. } catch {
  2786. await MainActor.run {
  2787. self.applyMeetings([])
  2788. if case ZoomOAuthError.missingClientId = error {
  2789. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2790. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2791. self.promptForZoomOAuthCredentialsIfNeeded()
  2792. } else if case ZoomOAuthError.missingClientSecret = error {
  2793. self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2794. self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
  2795. self.promptForZoomOAuthCredentialsIfNeeded()
  2796. } else if case ZoomOAuthError.missingRequiredScope = error {
  2797. self.zoomOAuth.clearSavedTokens()
  2798. self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  2799. self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
  2800. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  2801. let seconds = max(retryAfterSeconds ?? 300, 30)
  2802. self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
  2803. let minutes = Int(ceil(Double(seconds) / 60.0))
  2804. self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  2805. self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  2806. } else {
  2807. self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  2808. self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
  2809. }
  2810. }
  2811. }
  2812. }
  2813. @MainActor
  2814. private func presentZoomOAuthCredentialPrompt() -> Bool {
  2815. let alert = NSAlert()
  2816. alert.alertStyle = .informational
  2817. alert.messageText = "Configure Zoom OAuth"
  2818. alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable granular scopes for listing meetings (meeting:read:list_meetings), scheduling (meeting:write:meeting), listing AI summaries (meeting:read:list_summaries), and reading a single summary (meeting:read:summary)."
  2819. let wrapper = NSStackView()
  2820. wrapper.orientation = .vertical
  2821. wrapper.spacing = 8
  2822. wrapper.translatesAutoresizingMaskIntoConstraints = false
  2823. let clientIdField = NSTextField()
  2824. clientIdField.placeholderString = "Zoom Client ID"
  2825. clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
  2826. let clientSecretField = NSSecureTextField()
  2827. clientSecretField.placeholderString = "Zoom Client Secret"
  2828. clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
  2829. [clientIdField, clientSecretField].forEach { field in
  2830. field.translatesAutoresizingMaskIntoConstraints = false
  2831. field.widthAnchor.constraint(equalToConstant: 420).isActive = true
  2832. wrapper.addArrangedSubview(field)
  2833. }
  2834. alert.accessoryView = wrapper
  2835. alert.addButton(withTitle: "Save")
  2836. alert.addButton(withTitle: "Cancel")
  2837. let result = alert.runModal()
  2838. if result == .alertFirstButtonReturn {
  2839. var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  2840. var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  2841. if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
  2842. if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
  2843. if clientId.isEmpty == false, clientSecret.isEmpty == false {
  2844. zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
  2845. return true
  2846. }
  2847. meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
  2848. }
  2849. return false
  2850. }
  2851. @MainActor
  2852. private func promptForZoomOAuthCredentialsIfNeeded() {
  2853. guard isPromptingZoomCredentials == false else { return }
  2854. isPromptingZoomCredentials = true
  2855. defer { isPromptingZoomCredentials = false }
  2856. if presentZoomOAuthCredentialPrompt() {
  2857. meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
  2858. Task { await self.loadScheduledMeetings() }
  2859. }
  2860. }
  2861. private struct ZoomUserMeResponse: Decodable {
  2862. let first_name: String?
  2863. let last_name: String?
  2864. let display_name: String?
  2865. let email: String?
  2866. let pic_url: String?
  2867. let timezone: String?
  2868. var displayName: String? {
  2869. if let display_name, display_name.isEmpty == false { return display_name }
  2870. let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
  2871. return parts.isEmpty ? nil : parts.joined(separator: " ")
  2872. }
  2873. var pictureURL: String? {
  2874. guard let pic_url, pic_url.isEmpty == false else { return nil }
  2875. return pic_url
  2876. }
  2877. }
  2878. private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
  2879. let url = URL(string: "https://api.zoom.us/v2/users/me")!
  2880. var request = URLRequest(url: url)
  2881. request.httpMethod = "GET"
  2882. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  2883. let (data, response) = try await URLSession.shared.data(for: request)
  2884. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  2885. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
  2886. }
  2887. return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
  2888. }
  2889. private struct ZoomCreateMeetingBody: Encodable {
  2890. let topic: String
  2891. let type: Int
  2892. let start_time: String
  2893. let duration: Int
  2894. let timezone: String
  2895. }
  2896. private struct ZoomCreateMeetingAPIResult: Decodable {
  2897. let id: Int?
  2898. let join_url: String?
  2899. }
  2900. /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
  2901. private func createZoomMeeting(accessToken: String, topic: String, startTimeUTC: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
  2902. let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
  2903. var request = URLRequest(url: url)
  2904. request.httpMethod = "POST"
  2905. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  2906. request.setValue("application/json", forHTTPHeaderField: "Content-Type")
  2907. let body = ZoomCreateMeetingBody(
  2908. topic: topic,
  2909. type: 2,
  2910. start_time: startTimeUTC,
  2911. duration: durationMinutes,
  2912. timezone: timeZone.identifier
  2913. )
  2914. request.httpBody = try JSONEncoder().encode(body)
  2915. let (data, response) = try await URLSession.shared.data(for: request)
  2916. guard let http = response as? HTTPURLResponse else {
  2917. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
  2918. }
  2919. if http.statusCode == 429 {
  2920. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  2921. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  2922. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  2923. }
  2924. guard (200..<300).contains(http.statusCode) else {
  2925. let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
  2926. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  2927. throw ZoomOAuthError.missingRequiredScope(raw)
  2928. }
  2929. throw GoogleOAuthError.tokenExchangeFailed(raw)
  2930. }
  2931. return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
  2932. }
  2933. private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
  2934. struct ZoomMeeting: Decodable {
  2935. let id: Int?
  2936. let topic: String?
  2937. let start_time: String?
  2938. let timezone: String?
  2939. let duration: Int?
  2940. let host_id: String?
  2941. let type: Int?
  2942. let status: String?
  2943. let join_url: String?
  2944. }
  2945. struct ZoomMeetingsPage: Decodable {
  2946. let meetings: [ZoomMeeting]
  2947. let next_page_token: String?
  2948. }
  2949. let iso = ISO8601DateFormatter()
  2950. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  2951. let fallbackISO = ISO8601DateFormatter()
  2952. fallbackISO.formatOptions = [.withInternetDateTime]
  2953. func inferredTimeZone(from startRaw: String, explicitZoomTimezone: String?) -> TimeZone? {
  2954. if let parsedExplicit = parseZoomTimeZone(explicitZoomTimezone) {
  2955. return parsedExplicit
  2956. }
  2957. if let fallbackTimeZone { return fallbackTimeZone }
  2958. if startRaw.hasSuffix("Z") || startRaw.hasSuffix("z") {
  2959. return TimeZone(secondsFromGMT: 0)
  2960. }
  2961. let startPattern = #"([+-]\d{2}:?\d{2}|[+-]\d{2})$"#
  2962. guard let regex = try? NSRegularExpression(pattern: startPattern) else { return nil }
  2963. let nsRange = NSRange(startRaw.startIndex..<startRaw.endIndex, in: startRaw)
  2964. guard let match = regex.firstMatch(in: startRaw, options: [], range: nsRange),
  2965. let offsetRange = Range(match.range(at: 1), in: startRaw) else {
  2966. return nil
  2967. }
  2968. return parseZoomTimeZone(String(startRaw[offsetRange]))
  2969. }
  2970. func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
  2971. raw.compactMap { meeting in
  2972. guard let startRaw = meeting.start_time else { return nil }
  2973. let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
  2974. guard let start else { return nil }
  2975. let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
  2976. let meetingIDString = meeting.id.map(String.init)
  2977. let overrideZone = meetingIDString
  2978. .flatMap { self.meetingTimeZoneOverrides[$0] }
  2979. .flatMap { TimeZone(identifier: $0) }
  2980. let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
  2981. let webURL: URL? = {
  2982. func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
  2983. var components = URLComponents()
  2984. components.scheme = "https"
  2985. components.host = "zoom.us"
  2986. components.path = "/wc/join/\(meetingId)"
  2987. if let pwd, pwd.isEmpty == false {
  2988. components.queryItems = [URLQueryItem(name: "pwd", value: pwd)]
  2989. }
  2990. return components.url
  2991. }
  2992. if let join = meeting.join_url, let url = URL(string: join), url.scheme != nil {
  2993. // Prefer the Zoom Web Client join URL so a click joins in the browser.
  2994. // join_url is often `https://zoom.us/j/<id>?pwd=...`
  2995. if url.path.contains("/wc/join/") {
  2996. return url
  2997. }
  2998. if let id = meeting.id {
  2999. let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
  3000. .queryItems?
  3001. .first(where: { $0.name == "pwd" })?
  3002. .value
  3003. return wcJoinURL(meetingId: id, pwd: pwd)
  3004. }
  3005. return url
  3006. }
  3007. if let id = meeting.id {
  3008. return wcJoinURL(meetingId: id, pwd: nil)
  3009. }
  3010. return nil
  3011. }()
  3012. return ScheduledMeeting(
  3013. title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
  3014. start: start,
  3015. end: end,
  3016. meetingTimeZone: displayZone,
  3017. host: meeting.host_id ?? "Zoom Host",
  3018. source: "Zoom",
  3019. meetingID: meetingIDString,
  3020. typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
  3021. statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
  3022. webURL: webURL
  3023. )
  3024. }
  3025. }
  3026. var allMeetings: [ZoomMeeting] = []
  3027. var nextPageToken: String?
  3028. repeat {
  3029. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  3030. var items: [URLQueryItem] = [
  3031. URLQueryItem(name: "type", value: "scheduled"),
  3032. URLQueryItem(name: "page_size", value: "30")
  3033. ]
  3034. if let nextPageToken, nextPageToken.isEmpty == false {
  3035. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  3036. }
  3037. components.queryItems = items
  3038. var request = URLRequest(url: components.url!)
  3039. request.httpMethod = "GET"
  3040. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3041. let (data, response) = try await URLSession.shared.data(for: request)
  3042. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  3043. let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
  3044. if (response as? HTTPURLResponse)?.statusCode == 429 {
  3045. let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
  3046. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3047. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3048. }
  3049. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3050. throw ZoomOAuthError.missingRequiredScope(raw)
  3051. }
  3052. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3053. }
  3054. let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
  3055. allMeetings.append(contentsOf: decoded.meetings)
  3056. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  3057. nextPageToken = (token?.isEmpty == false) ? token : nil
  3058. } while nextPageToken != nil
  3059. return mapMeetings(allMeetings)
  3060. }
  3061. // MARK: - AI Companion Meeting Summaries (Zoom API)
  3062. /// In-memory model for an AI Companion meeting summary. Mirrors the fields returned by
  3063. /// `GET /v2/meetings/meeting_summaries` and `GET /v2/meetings/{meetingId}/meeting_summary`.
  3064. /// Docs: https://developers.zoom.us/changelog/meetings/november-20-2023/
  3065. struct AICompanionSummary: Equatable {
  3066. struct SummarySection: Equatable {
  3067. let label: String
  3068. let summary: String
  3069. }
  3070. let meetingUUID: String?
  3071. let meetingID: String?
  3072. let meetingTopic: String?
  3073. let meetingHostEmail: String?
  3074. let meetingStartTime: Date?
  3075. let meetingEndTime: Date?
  3076. let summaryTitle: String?
  3077. let summaryOverview: String?
  3078. let summaryDetails: [SummarySection]
  3079. let nextSteps: [String]
  3080. let summaryContent: String?
  3081. let summaryDocURL: URL?
  3082. let summaryCreatedTime: Date?
  3083. let summaryLastModifiedTime: Date?
  3084. }
  3085. private struct ZoomMeetingSummaryAPIItem: Decodable {
  3086. struct SummaryDetail: Decodable {
  3087. let label: String?
  3088. let summary: String?
  3089. }
  3090. let meeting_host_id: String?
  3091. let meeting_host_email: String?
  3092. let meeting_uuid: String?
  3093. let meeting_id: Int64?
  3094. let meeting_topic: String?
  3095. let meeting_start_time: String?
  3096. let meeting_end_time: String?
  3097. let summary_start_time: String?
  3098. let summary_end_time: String?
  3099. let summary_created_time: String?
  3100. let summary_last_modified_time: String?
  3101. let summary_title: String?
  3102. let summary_overview: String?
  3103. let summary_details: [SummaryDetail]?
  3104. let next_steps: [String]?
  3105. let summary_content: String?
  3106. let summary_doc_url: String?
  3107. func toModel(using iso: ISO8601DateFormatter, fallback: ISO8601DateFormatter) -> ViewController.AICompanionSummary {
  3108. func parseDate(_ s: String?) -> Date? {
  3109. guard let s, s.isEmpty == false else { return nil }
  3110. return iso.date(from: s) ?? fallback.date(from: s)
  3111. }
  3112. let details: [AICompanionSummary.SummarySection] = (summary_details ?? []).compactMap { detail in
  3113. let label = (detail.label ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  3114. let body = (detail.summary ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
  3115. if label.isEmpty && body.isEmpty { return nil }
  3116. return AICompanionSummary.SummarySection(label: label, summary: body)
  3117. }
  3118. let steps: [String] = (next_steps ?? [])
  3119. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  3120. .filter { $0.isEmpty == false }
  3121. let docURL: URL? = {
  3122. guard let raw = summary_doc_url?.trimmingCharacters(in: .whitespacesAndNewlines),
  3123. raw.isEmpty == false else { return nil }
  3124. return URL(string: raw)
  3125. }()
  3126. return AICompanionSummary(
  3127. meetingUUID: meeting_uuid,
  3128. meetingID: meeting_id.map(String.init),
  3129. meetingTopic: meeting_topic,
  3130. meetingHostEmail: meeting_host_email,
  3131. meetingStartTime: parseDate(meeting_start_time),
  3132. meetingEndTime: parseDate(meeting_end_time),
  3133. summaryTitle: summary_title,
  3134. summaryOverview: summary_overview,
  3135. summaryDetails: details,
  3136. nextSteps: steps,
  3137. summaryContent: summary_content,
  3138. summaryDocURL: docURL,
  3139. summaryCreatedTime: parseDate(summary_created_time),
  3140. summaryLastModifiedTime: parseDate(summary_last_modified_time)
  3141. )
  3142. }
  3143. }
  3144. /// Fetches AI Companion summaries: prefers `GET /v2/meetings/meeting_summaries` when the token has
  3145. /// `meeting:read:list_summaries`. User-managed apps usually lack that scope; we then list **past**
  3146. /// meetings via `meeting:read:list_meetings` and load each `meeting:read:summary`.
  3147. private func fetchZoomMeetingSummaries(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
  3148. if let bulk = try await fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: accessToken, from: from, to: to) {
  3149. return bulk
  3150. }
  3151. return try await fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: accessToken, from: from, to: to)
  3152. }
  3153. private func zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: Data, statusCode: Int) -> Bool {
  3154. guard (200..<300).contains(statusCode) == false else { return false }
  3155. let raw = String(data: data, encoding: .utf8) ?? ""
  3156. if raw.localizedCaseInsensitiveContains("does not contain scopes") { return true }
  3157. if raw.localizedCaseInsensitiveContains("list_summaries") { return true }
  3158. if raw.localizedCaseInsensitiveContains("meeting_summaries") && raw.localizedCaseInsensitiveContains("scope") {
  3159. return true
  3160. }
  3161. return false
  3162. }
  3163. /// Returns `nil` when the token cannot use the bulk list endpoint (typical for user-managed apps).
  3164. private func fetchZoomMeetingSummariesViaListEndpointIfAllowed(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary]? {
  3165. struct Page: Decodable {
  3166. let summaries: [ZoomMeetingSummaryAPIItem]?
  3167. let meeting_summaries: [ZoomMeetingSummaryAPIItem]?
  3168. let next_page_token: String?
  3169. }
  3170. let iso = ISO8601DateFormatter()
  3171. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  3172. let fallbackISO = ISO8601DateFormatter()
  3173. fallbackISO.formatOptions = [.withInternetDateTime]
  3174. let dayFormatter = DateFormatter()
  3175. dayFormatter.dateFormat = "yyyy-MM-dd"
  3176. dayFormatter.timeZone = TimeZone(identifier: "UTC")
  3177. var collected: [AICompanionSummary] = []
  3178. var seenUUIDs = Set<String>()
  3179. var nextPageToken: String?
  3180. var safetyCounter = 0
  3181. repeat {
  3182. var components = URLComponents(string: "https://api.zoom.us/v2/meetings/meeting_summaries")!
  3183. var items: [URLQueryItem] = [
  3184. URLQueryItem(name: "page_size", value: "30")
  3185. ]
  3186. if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
  3187. if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
  3188. if let nextPageToken, nextPageToken.isEmpty == false {
  3189. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  3190. }
  3191. components.queryItems = items
  3192. var request = URLRequest(url: components.url!)
  3193. request.httpMethod = "GET"
  3194. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3195. let (data, response) = try await URLSession.shared.data(for: request)
  3196. guard let http = response as? HTTPURLResponse else {
  3197. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summaries)")
  3198. }
  3199. if http.statusCode == 429 {
  3200. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  3201. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3202. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3203. }
  3204. guard (200..<300).contains(http.statusCode) else {
  3205. if safetyCounter == 0, zoomMeetingSummariesListErrorShouldFallbackToPerMeetingFetch(data: data, statusCode: http.statusCode) {
  3206. return nil
  3207. }
  3208. let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summaries"
  3209. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3210. throw ZoomOAuthError.missingRequiredScope(raw)
  3211. }
  3212. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3213. }
  3214. let decoded = try JSONDecoder().decode(Page.self, from: data)
  3215. let raw = decoded.summaries ?? decoded.meeting_summaries ?? []
  3216. for item in raw {
  3217. let model = item.toModel(using: iso, fallback: fallbackISO)
  3218. let dedupKey = model.meetingUUID ?? (model.meetingID ?? UUID().uuidString)
  3219. if seenUUIDs.insert(dedupKey).inserted {
  3220. collected.append(model)
  3221. }
  3222. }
  3223. let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  3224. nextPageToken = (token?.isEmpty == false) ? token : nil
  3225. safetyCounter += 1
  3226. if safetyCounter >= 10 { break }
  3227. } while nextPageToken != nil
  3228. return collected
  3229. }
  3230. private func fetchZoomMeetingSummariesViaPastMeetingsAndDetail(accessToken: String, from: Date?, to: Date?) async throws -> [AICompanionSummary] {
  3231. struct PastMeeting: Decodable {
  3232. let id: Int?
  3233. let uuid: String?
  3234. let topic: String?
  3235. let start_time: String?
  3236. }
  3237. struct PastMeetingsPage: Decodable {
  3238. let meetings: [PastMeeting]
  3239. let next_page_token: String?
  3240. }
  3241. let iso = ISO8601DateFormatter()
  3242. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  3243. let fallbackISO = ISO8601DateFormatter()
  3244. fallbackISO.formatOptions = [.withInternetDateTime]
  3245. let dayFormatter = DateFormatter()
  3246. dayFormatter.dateFormat = "yyyy-MM-dd"
  3247. dayFormatter.timeZone = TimeZone(identifier: "UTC")
  3248. func parseStart(_ raw: String?) -> Date? {
  3249. guard let raw, raw.isEmpty == false else { return nil }
  3250. return iso.date(from: raw) ?? fallbackISO.date(from: raw)
  3251. }
  3252. func isInSelectedRange(start: Date?) -> Bool {
  3253. guard let start else { return true }
  3254. if let from, start < from { return false }
  3255. if let to, start > to { return false }
  3256. return true
  3257. }
  3258. var candidates: [(meetingId: String, start: Date?)] = []
  3259. var nextPageToken: String?
  3260. var pageIdx = 0
  3261. repeat {
  3262. var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
  3263. var items: [URLQueryItem] = [
  3264. URLQueryItem(name: "type", value: "past"),
  3265. URLQueryItem(name: "page_size", value: "30")
  3266. ]
  3267. if let from { items.append(URLQueryItem(name: "from", value: dayFormatter.string(from: from))) }
  3268. if let to { items.append(URLQueryItem(name: "to", value: dayFormatter.string(from: to))) }
  3269. if let nextPageToken, nextPageToken.isEmpty == false {
  3270. items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
  3271. }
  3272. components.queryItems = items
  3273. var request = URLRequest(url: components.url!)
  3274. request.httpMethod = "GET"
  3275. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3276. let (data, response) = try await URLSession.shared.data(for: request)
  3277. guard let http = response as? HTTPURLResponse else {
  3278. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (past meetings)")
  3279. }
  3280. if http.statusCode == 429 {
  3281. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  3282. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3283. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3284. }
  3285. guard (200..<300).contains(http.statusCode) else {
  3286. let raw = String(data: data, encoding: .utf8) ?? "Failed to load past meetings"
  3287. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3288. throw ZoomOAuthError.missingRequiredScope(raw)
  3289. }
  3290. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3291. }
  3292. let decoded = try JSONDecoder().decode(PastMeetingsPage.self, from: data)
  3293. for m in decoded.meetings {
  3294. guard let numericId = m.id else { continue }
  3295. let start = parseStart(m.start_time)
  3296. guard isInSelectedRange(start: start) else { continue }
  3297. candidates.append((String(numericId), start))
  3298. }
  3299. let t = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
  3300. nextPageToken = (t?.isEmpty == false) ? t : nil
  3301. pageIdx += 1
  3302. if pageIdx >= 15 { break }
  3303. if candidates.count >= 80 { break }
  3304. } while nextPageToken != nil
  3305. candidates.sort { ($0.start ?? .distantPast) > ($1.start ?? .distantPast) }
  3306. var summaries: [AICompanionSummary] = []
  3307. let maxDetailFetches = 40
  3308. for c in candidates.prefix(maxDetailFetches) {
  3309. if let detail = try await fetchZoomMeetingSummaryDetail(accessToken: accessToken, meetingIdOrUUID: c.meetingId) {
  3310. summaries.append(detail)
  3311. }
  3312. }
  3313. return summaries
  3314. }
  3315. /// Fetches a single AI Companion summary by meeting ID or UUID via
  3316. /// `GET /v2/meetings/{meetingId}/meeting_summary`. UUIDs containing `/` or `//` must be
  3317. /// double-URL-encoded per Zoom's API conventions.
  3318. private func fetchZoomMeetingSummaryDetail(accessToken: String, meetingIdOrUUID: String) async throws -> AICompanionSummary? {
  3319. let trimmed = meetingIdOrUUID.trimmingCharacters(in: .whitespacesAndNewlines)
  3320. guard trimmed.isEmpty == false else { return nil }
  3321. let isLikelyUUID = trimmed.contains("/") || trimmed.contains("=")
  3322. let pathSegment: String
  3323. if isLikelyUUID {
  3324. // Zoom requires double encoding for UUIDs that begin with `/` or contain `//`.
  3325. let allowed = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
  3326. let once = trimmed.addingPercentEncoding(withAllowedCharacters: allowed) ?? trimmed
  3327. pathSegment = once.addingPercentEncoding(withAllowedCharacters: allowed) ?? once
  3328. } else {
  3329. pathSegment = trimmed
  3330. }
  3331. let urlString = "https://api.zoom.us/v2/meetings/\(pathSegment)/meeting_summary"
  3332. guard let url = URL(string: urlString) else {
  3333. throw GoogleOAuthError.tokenExchangeFailed("Invalid Zoom meeting summary URL")
  3334. }
  3335. var request = URLRequest(url: url)
  3336. request.httpMethod = "GET"
  3337. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  3338. let (data, response) = try await URLSession.shared.data(for: request)
  3339. guard let http = response as? HTTPURLResponse else {
  3340. throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom (meeting_summary)")
  3341. }
  3342. if http.statusCode == 404 || http.statusCode == 204 { return nil }
  3343. if http.statusCode == 429 {
  3344. let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
  3345. let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
  3346. throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
  3347. }
  3348. guard (200..<300).contains(http.statusCode) else {
  3349. let raw = String(data: data, encoding: .utf8) ?? "Failed to load AI summary"
  3350. if raw.localizedCaseInsensitiveContains("does not contain scopes") {
  3351. throw ZoomOAuthError.missingRequiredScope(raw)
  3352. }
  3353. throw GoogleOAuthError.tokenExchangeFailed(raw)
  3354. }
  3355. let iso = ISO8601DateFormatter()
  3356. iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
  3357. let fallbackISO = ISO8601DateFormatter()
  3358. fallbackISO.formatOptions = [.withInternetDateTime]
  3359. let item = try JSONDecoder().decode(ZoomMeetingSummaryAPIItem.self, from: data)
  3360. return item.toModel(using: iso, fallback: fallbackISO)
  3361. }
  3362. private func makeSettingsView() -> NSView {
  3363. let panel = NSView()
  3364. panel.translatesAutoresizingMaskIntoConstraints = false
  3365. let scroll = NSScrollView()
  3366. scroll.translatesAutoresizingMaskIntoConstraints = false
  3367. scroll.drawsBackground = false
  3368. scroll.hasHorizontalScroller = false
  3369. scroll.hasVerticalScroller = true
  3370. scroll.autohidesScrollers = true
  3371. scroll.borderType = .noBorder
  3372. scroll.scrollerStyle = .overlay
  3373. scroll.automaticallyAdjustsContentInsets = false
  3374. let clip = TopAlignedClipView()
  3375. clip.drawsBackground = false
  3376. scroll.contentView = clip
  3377. panel.addSubview(scroll)
  3378. let content = NSView()
  3379. content.translatesAutoresizingMaskIntoConstraints = false
  3380. scroll.documentView = content
  3381. let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
  3382. card.translatesAutoresizingMaskIntoConstraints = false
  3383. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  3384. content.addSubview(card)
  3385. let stack = NSStackView()
  3386. stack.translatesAutoresizingMaskIntoConstraints = false
  3387. stack.orientation = .vertical
  3388. stack.spacing = 18
  3389. stack.alignment = .leading
  3390. card.addSubview(stack)
  3391. let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
  3392. let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  3393. stack.addArrangedSubview(pageTitle)
  3394. stack.addArrangedSubview(pageSubtitle)
  3395. stack.setCustomSpacing(24, after: pageSubtitle)
  3396. let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  3397. stack.addArrangedSubview(appearanceTitle)
  3398. let darkModeRow = makeSettingsDarkModeRow()
  3399. stack.addArrangedSubview(darkModeRow)
  3400. darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3401. stack.setCustomSpacing(24, after: darkModeRow)
  3402. let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  3403. stack.addArrangedSubview(accountTitle)
  3404. let googleAccountRow = makeSettingsGoogleAccountRow()
  3405. stack.addArrangedSubview(googleAccountRow)
  3406. googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3407. stack.setCustomSpacing(24, after: googleAccountRow)
  3408. let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  3409. stack.addArrangedSubview(appTitle)
  3410. let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
  3411. stack.addArrangedSubview(shareButton)
  3412. shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3413. let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
  3414. stack.addArrangedSubview(upgradeButton)
  3415. upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3416. settingsUpgradeButton = upgradeButton
  3417. stack.setCustomSpacing(24, after: upgradeButton)
  3418. let notificationsTitle = textLabel("Notifications", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  3419. let remindersSection = makeSettingsRemindersSection()
  3420. let notificationsBlock = NSStackView()
  3421. notificationsBlock.translatesAutoresizingMaskIntoConstraints = false
  3422. notificationsBlock.orientation = .vertical
  3423. notificationsBlock.spacing = 18
  3424. notificationsBlock.alignment = .leading
  3425. notificationsBlock.addArrangedSubview(notificationsTitle)
  3426. notificationsBlock.addArrangedSubview(remindersSection)
  3427. remindersSection.widthAnchor.constraint(equalTo: notificationsBlock.widthAnchor).isActive = true
  3428. stack.addArrangedSubview(notificationsBlock)
  3429. notificationsBlock.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3430. settingsNotificationsBlock = notificationsBlock
  3431. stack.setCustomSpacing(24, after: notificationsBlock)
  3432. let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
  3433. stack.addArrangedSubview(legalTitle)
  3434. let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
  3435. stack.addArrangedSubview(privacyButton)
  3436. privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3437. let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
  3438. stack.addArrangedSubview(supportButton)
  3439. supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3440. let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
  3441. stack.addArrangedSubview(termsButton)
  3442. termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  3443. NSLayoutConstraint.activate([
  3444. scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  3445. scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  3446. scroll.topAnchor.constraint(equalTo: panel.topAnchor),
  3447. scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
  3448. content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  3449. content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
  3450. content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  3451. content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
  3452. content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
  3453. card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
  3454. card.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
  3455. content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
  3456. card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
  3457. card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
  3458. card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
  3459. card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
  3460. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
  3461. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
  3462. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
  3463. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
  3464. ])
  3465. updatePremiumButtons()
  3466. return panel
  3467. }
  3468. @MainActor
  3469. private func rebuildHomeSettingsViewPreservingSelection() {
  3470. guard let column = homeSettingsHostColumn else { return }
  3471. homeSettingsView?.removeFromSuperview()
  3472. let newSettings = makeSettingsView()
  3473. newSettings.translatesAutoresizingMaskIntoConstraints = false
  3474. column.addSubview(newSettings)
  3475. NSLayoutConstraint.activate([
  3476. newSettings.topAnchor.constraint(equalTo: column.topAnchor),
  3477. newSettings.leadingAnchor.constraint(equalTo: column.leadingAnchor),
  3478. newSettings.trailingAnchor.constraint(equalTo: column.trailingAnchor),
  3479. newSettings.bottomAnchor.constraint(equalTo: column.bottomAnchor)
  3480. ])
  3481. homeSettingsView = newSettings
  3482. updateSelectedHomeSectionUI()
  3483. }
  3484. private enum SettingsAction: Int {
  3485. case shareApp = 1
  3486. case upgrade = 2
  3487. case privacyPolicy = 4
  3488. case support = 5
  3489. case termsOfServices = 6
  3490. }
  3491. private func makeSettingsDarkModeRow() -> NSView {
  3492. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  3493. row.translatesAutoresizingMaskIntoConstraints = false
  3494. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  3495. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3496. let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  3497. let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  3498. let toggle = NSSwitch()
  3499. toggle.translatesAutoresizingMaskIntoConstraints = false
  3500. toggle.state = darkModeEnabled ? .on : .off
  3501. toggle.target = self
  3502. toggle.action = #selector(settingsDarkModeToggled(_:))
  3503. settingsDarkModeSwitch = toggle
  3504. row.addSubview(icon)
  3505. row.addSubview(title)
  3506. row.addSubview(toggle)
  3507. NSLayoutConstraint.activate([
  3508. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  3509. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  3510. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  3511. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  3512. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  3513. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  3514. ])
  3515. return row
  3516. }
  3517. private func makeSettingsRemindersSection() -> NSView {
  3518. let container = NSStackView()
  3519. container.translatesAutoresizingMaskIntoConstraints = false
  3520. container.orientation = .vertical
  3521. container.spacing = 1
  3522. container.alignment = .leading
  3523. let masterRow = makeReminderToggleRow(
  3524. icon: "🔔",
  3525. title: "Reminders",
  3526. state: remindersEnabled,
  3527. action: #selector(settingsReminderMasterToggled(_:)),
  3528. isSubRow: false
  3529. )
  3530. container.addArrangedSubview(masterRow)
  3531. masterRow.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  3532. let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
  3533. let day1Row = makeReminderToggleRow(
  3534. icon: "📅",
  3535. title: "1 Day Before",
  3536. state: remind1Day,
  3537. action: #selector(settingsReminder1DayToggled(_:)),
  3538. isSubRow: true
  3539. )
  3540. day1Row.alphaValue = subAlpha
  3541. container.addArrangedSubview(day1Row)
  3542. day1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  3543. let hours12Row = makeReminderToggleRow(
  3544. icon: "🕛",
  3545. title: "12 Hours Before",
  3546. state: remind12Hours,
  3547. action: #selector(settingsReminder12HoursToggled(_:)),
  3548. isSubRow: true
  3549. )
  3550. hours12Row.alphaValue = subAlpha
  3551. container.addArrangedSubview(hours12Row)
  3552. hours12Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  3553. let hour1Row = makeReminderToggleRow(
  3554. icon: "⏰",
  3555. title: "1 Hour Before",
  3556. state: remind1Hour,
  3557. action: #selector(settingsReminder1HourToggled(_:)),
  3558. isSubRow: true
  3559. )
  3560. hour1Row.alphaValue = subAlpha
  3561. container.addArrangedSubview(hour1Row)
  3562. hour1Row.widthAnchor.constraint(equalTo: container.widthAnchor).isActive = true
  3563. // Keep references so toggles can update UI without rebuilding the whole settings view.
  3564. if let masterSwitch = (masterRow.subviews.compactMap { $0 as? NSSwitch }.first) {
  3565. settingsRemindersMasterSwitch = masterSwitch
  3566. }
  3567. if let day1Switch = (day1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  3568. settingsReminders1DaySwitch = day1Switch
  3569. }
  3570. if let hours12Switch = (hours12Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  3571. settingsReminders12HoursSwitch = hours12Switch
  3572. }
  3573. if let hour1Switch = (hour1Row.subviews.compactMap { $0 as? NSSwitch }.first) {
  3574. settingsReminders1HourSwitch = hour1Switch
  3575. }
  3576. settingsReminders1DayRow = day1Row
  3577. settingsReminders12HoursRow = hours12Row
  3578. settingsReminders1HourRow = hour1Row
  3579. return container
  3580. }
  3581. private func makeReminderToggleRow(
  3582. icon: String,
  3583. title: String,
  3584. state: Bool,
  3585. action: Selector,
  3586. isSubRow: Bool
  3587. ) -> NSView {
  3588. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  3589. row.translatesAutoresizingMaskIntoConstraints = false
  3590. row.heightAnchor.constraint(equalToConstant: 52).isActive = true
  3591. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3592. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
  3593. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: isSubRow ? .medium : .semibold), color: primaryText)
  3594. let toggle = NSSwitch()
  3595. toggle.translatesAutoresizingMaskIntoConstraints = false
  3596. toggle.state = state ? .on : .off
  3597. toggle.target = self
  3598. toggle.action = action
  3599. row.addSubview(iconLabel)
  3600. row.addSubview(titleLabel)
  3601. row.addSubview(toggle)
  3602. let leading: CGFloat = isSubRow ? 26 : 14
  3603. NSLayoutConstraint.activate([
  3604. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: leading),
  3605. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  3606. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  3607. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  3608. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  3609. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  3610. ])
  3611. return row
  3612. }
  3613. private func makeSettingsGoogleAccountRow() -> NSView {
  3614. let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  3615. row.translatesAutoresizingMaskIntoConstraints = false
  3616. styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3617. let signedIn = isUserLoggedIn()
  3618. let titleText = signedIn ? "Zoom account connected" : "Zoom account not connected"
  3619. let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
  3620. let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  3621. let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
  3622. subtitle.maximumNumberOfLines = 2
  3623. subtitle.lineBreakMode = .byTruncatingTail
  3624. let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
  3625. actionButton.translatesAutoresizingMaskIntoConstraints = false
  3626. actionButton.bezelStyle = .rounded
  3627. actionButton.controlSize = .regular
  3628. settingsGoogleActionButton = actionButton
  3629. row.addSubview(title)
  3630. row.addSubview(subtitle)
  3631. row.addSubview(actionButton)
  3632. NSLayoutConstraint.activate([
  3633. row.heightAnchor.constraint(equalToConstant: 78),
  3634. title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
  3635. title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
  3636. subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  3637. subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
  3638. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
  3639. actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
  3640. actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  3641. ])
  3642. return row
  3643. }
  3644. private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
  3645. let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
  3646. button.translatesAutoresizingMaskIntoConstraints = false
  3647. button.isBordered = false
  3648. button.wantsLayer = true
  3649. button.layer?.cornerRadius = 10
  3650. button.normalColor = palette.inputBackground
  3651. button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
  3652. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  3653. button.heightAnchor.constraint(equalToConstant: 46).isActive = true
  3654. button.tag = action.rawValue
  3655. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
  3656. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
  3657. if action == .upgrade {
  3658. settingsUpgradeTitleLabel = titleLabel
  3659. }
  3660. button.addSubview(iconLabel)
  3661. button.addSubview(titleLabel)
  3662. NSLayoutConstraint.activate([
  3663. iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
  3664. iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  3665. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  3666. titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  3667. ])
  3668. return button
  3669. }
  3670. @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
  3671. guard let action = SettingsAction(rawValue: sender.tag) else { return }
  3672. switch action {
  3673. case .shareApp:
  3674. showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
  3675. case .upgrade:
  3676. settingsUpgradePremiumTapped()
  3677. case .privacyPolicy:
  3678. showSimpleAlert(title: "Privacy Policy", message: "Add your Privacy Policy URL in the app and open it here.")
  3679. case .support:
  3680. showSimpleAlert(title: "Support", message: "Add your Support URL/email in the app and open it here.")
  3681. case .termsOfServices:
  3682. showSimpleAlert(title: "Terms", message: "Add your Terms URL in the app and open it here.")
  3683. }
  3684. }
  3685. @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
  3686. if isUserLoggedIn() {
  3687. logoutTapped()
  3688. return
  3689. }
  3690. zoomPrimarySignInTapped()
  3691. }
  3692. private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  3693. let v = NSView()
  3694. v.wantsLayer = true
  3695. v.layer?.backgroundColor = color.cgColor
  3696. v.layer?.cornerRadius = cornerRadius
  3697. return v
  3698. }
  3699. private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  3700. view.wantsLayer = true
  3701. view.layer?.borderColor = borderColor.cgColor
  3702. view.layer?.borderWidth = borderWidth
  3703. if shadow {
  3704. view.layer?.shadowColor = NSColor.black.cgColor
  3705. view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
  3706. view.layer?.shadowRadius = 18
  3707. view.layer?.shadowOffset = NSSize(width: 0, height: -2)
  3708. } else {
  3709. view.layer?.shadowOpacity = 0
  3710. }
  3711. }
  3712. private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  3713. let label = NSTextField(labelWithString: text)
  3714. label.translatesAutoresizingMaskIntoConstraints = false
  3715. label.font = font
  3716. label.textColor = color
  3717. label.backgroundColor = .clear
  3718. label.isBezeled = false
  3719. label.isEditable = false
  3720. label.isSelectable = false
  3721. return label
  3722. }
  3723. private func styledLabel(
  3724. _ text: String,
  3725. font: NSFont,
  3726. color: NSColor,
  3727. kern: CGFloat? = nil,
  3728. lineSpacing: CGFloat? = nil
  3729. ) -> NSTextField {
  3730. let label = textLabel(text, font: font, color: color)
  3731. if kern == nil, lineSpacing == nil {
  3732. return label
  3733. }
  3734. let paragraph = NSMutableParagraphStyle()
  3735. paragraph.alignment = label.alignment
  3736. paragraph.lineBreakMode = .byWordWrapping
  3737. if let lineSpacing { paragraph.lineSpacing = lineSpacing }
  3738. var attrs: [NSAttributedString.Key: Any] = [
  3739. .font: font,
  3740. .foregroundColor: color,
  3741. .paragraphStyle: paragraph
  3742. ]
  3743. if let kern { attrs[.kern] = kern }
  3744. label.attributedStringValue = NSAttributedString(string: text, attributes: attrs)
  3745. return label
  3746. }
  3747. private func startStoreKit() {
  3748. storeKitStartupTask?.cancel()
  3749. storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
  3750. DispatchQueue.main.async {
  3751. guard let self else { return }
  3752. let newIsPremium = self.storeKitCoordinator.hasPremiumAccess
  3753. let wasPremium = self.lastKnownPremiumAccess
  3754. self.lastKnownPremiumAccess = newIsPremium
  3755. self.updatePremiumButtons()
  3756. if wasPremium != newIsPremium {
  3757. self.rebuildHomeSettingsViewPreservingSelection()
  3758. }
  3759. self.reconcileDesktopWidgetsForPremiumAccess()
  3760. if (wasPremium == false) && newIsPremium {
  3761. self.startMeetingsAutoRefresh()
  3762. self.triggerMeetingsRefresh(force: true)
  3763. self.schedulePremiumRatingPromptIfNeeded()
  3764. }
  3765. if wasPremium && !newIsPremium {
  3766. self.cancelAllMeetingReminders()
  3767. self.premiumRatingPromptWorkItem?.cancel()
  3768. self.premiumRatingPromptWorkItem = nil
  3769. self.showPaywall()
  3770. }
  3771. if newIsPremium {
  3772. self.launchPaywallWorkItem?.cancel()
  3773. self.launchPaywallWorkItem = nil
  3774. self.hasPresentedLaunchPaywall = true
  3775. } else {
  3776. self.scheduleLaunchPaywallIfNeeded()
  3777. }
  3778. }
  3779. }
  3780. storeKitStartupTask = Task { [weak self] in
  3781. await self?.storeKitCoordinator.start()
  3782. await MainActor.run {
  3783. self?.updatePremiumButtons()
  3784. self?.reconcileDesktopWidgetsForPremiumAccess()
  3785. self?.scheduleLaunchPaywallIfNeeded()
  3786. }
  3787. }
  3788. }
  3789. private func scheduleLaunchPaywallIfNeeded() {
  3790. guard hasPresentedLaunchPaywall == false else { return }
  3791. guard storeKitCoordinator.hasPremiumAccess == false else {
  3792. hasPresentedLaunchPaywall = true
  3793. return
  3794. }
  3795. guard launchPaywallWorkItem == nil else { return }
  3796. let workItem = DispatchWorkItem { [weak self] in
  3797. guard let self else { return }
  3798. self.launchPaywallWorkItem = nil
  3799. guard self.hasPresentedLaunchPaywall == false else { return }
  3800. guard self.storeKitCoordinator.hasPremiumAccess == false else {
  3801. self.hasPresentedLaunchPaywall = true
  3802. return
  3803. }
  3804. self.hasPresentedLaunchPaywall = true
  3805. self.showPaywall()
  3806. }
  3807. launchPaywallWorkItem = workItem
  3808. DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
  3809. }
  3810. @MainActor
  3811. private func updatePremiumButtons() {
  3812. let isPremium = storeKitCoordinator.hasPremiumAccess
  3813. let hasLifetimePremium = storeKitCoordinator.hasLifetimePremiumAccess
  3814. settingsUpgradeButton?.title = ""
  3815. settingsUpgradeTitleLabel?.stringValue = hasLifetimePremium ? "Premium Lifetime" : "Upgrade"
  3816. settingsUpgradeButton?.isHidden = hasLifetimePremium
  3817. settingsUpgradeButton?.isEnabled = hasLifetimePremium == false
  3818. settingsUpgradeButton?.alphaValue = hasLifetimePremium ? 0.6 : 1.0
  3819. settingsNotificationsBlock?.isHidden = isPremium == false
  3820. updatePremiumLockedSections()
  3821. if isPremium == false, isUserLoggedIn() {
  3822. applyMeetings([])
  3823. applyPremiumLockMessages()
  3824. }
  3825. if isPremium {
  3826. schedulePremiumRatingPromptIfNeeded()
  3827. }
  3828. updateTopBarPremiumButton()
  3829. }
  3830. @MainActor
  3831. private func updateTopBarPremiumButton() {
  3832. guard let button = topBarPremiumButton else { return }
  3833. let isPremium = storeKitCoordinator.hasPremiumAccess
  3834. if isPremium {
  3835. let title = "Manage Subscription"
  3836. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  3837. button.attributedTitle = NSAttributedString(string: title, attributes: [
  3838. .foregroundColor: NSColor.white,
  3839. .font: font
  3840. ])
  3841. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  3842. if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
  3843. let img = base.withSymbolConfiguration(symbolConfig) {
  3844. img.isTemplate = true
  3845. button.image = img
  3846. button.imagePosition = .imageLeading
  3847. button.imageHugsTitle = true
  3848. }
  3849. button.contentTintColor = NSColor.white
  3850. button.toolTip = title
  3851. button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
  3852. } else {
  3853. let title = "Upgrade to Pro"
  3854. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  3855. button.attributedTitle = NSAttributedString(string: title, attributes: [
  3856. .foregroundColor: NSColor.white,
  3857. .font: font
  3858. ])
  3859. button.image = nil
  3860. button.toolTip = title
  3861. button.layer?.backgroundColor = accentBlue.cgColor
  3862. button.contentTintColor = .white
  3863. }
  3864. }
  3865. @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
  3866. setDarkMode(sender.state == .on)
  3867. }
  3868. @objc private func settingsReminderMasterToggled(_ sender: NSSwitch) {
  3869. let enabled = sender.state == .on
  3870. remindersEnabled = enabled
  3871. updateRemindersSubRowState()
  3872. if enabled {
  3873. requestNotificationPermissionIfNeeded { [weak self] granted in
  3874. guard let self else { return }
  3875. if granted {
  3876. self.rescheduleMeetingRemindersIfNeeded()
  3877. } else {
  3878. self.remindersEnabled = false
  3879. self.settingsRemindersMasterSwitch?.state = .off
  3880. self.updateRemindersSubRowState()
  3881. self.cancelAllMeetingReminders()
  3882. self.showSimpleAlert(
  3883. title: "Notifications disabled",
  3884. message: "To enable reminders, allow notifications for this app in System Settings → Notifications."
  3885. )
  3886. }
  3887. }
  3888. } else {
  3889. cancelAllMeetingReminders()
  3890. }
  3891. }
  3892. @objc private func settingsReminder1DayToggled(_ sender: NSSwitch) {
  3893. remind1Day = sender.state == .on
  3894. rescheduleMeetingRemindersIfNeeded()
  3895. }
  3896. @objc private func settingsReminder12HoursToggled(_ sender: NSSwitch) {
  3897. remind12Hours = sender.state == .on
  3898. rescheduleMeetingRemindersIfNeeded()
  3899. }
  3900. @objc private func settingsReminder1HourToggled(_ sender: NSSwitch) {
  3901. remind1Hour = sender.state == .on
  3902. rescheduleMeetingRemindersIfNeeded()
  3903. }
  3904. private func updateRemindersSubRowState() {
  3905. let subAlpha: CGFloat = remindersEnabled ? 1.0 : 0.4
  3906. settingsReminders1DayRow?.alphaValue = subAlpha
  3907. settingsReminders12HoursRow?.alphaValue = subAlpha
  3908. settingsReminders1HourRow?.alphaValue = subAlpha
  3909. let enabled = remindersEnabled
  3910. settingsReminders1DaySwitch?.isEnabled = enabled
  3911. settingsReminders12HoursSwitch?.isEnabled = enabled
  3912. settingsReminders1HourSwitch?.isEnabled = enabled
  3913. }
  3914. private func requestNotificationPermissionIfNeeded(completion: @escaping (Bool) -> Void) {
  3915. let center = UNUserNotificationCenter.current()
  3916. center.getNotificationSettings { settings in
  3917. switch settings.authorizationStatus {
  3918. case .notDetermined:
  3919. center.requestAuthorization(options: [.alert, .sound]) { granted, _ in
  3920. DispatchQueue.main.async { completion(granted) }
  3921. }
  3922. case .authorized, .provisional, .ephemeral:
  3923. DispatchQueue.main.async { completion(true) }
  3924. default:
  3925. DispatchQueue.main.async { completion(false) }
  3926. }
  3927. }
  3928. }
  3929. private func rescheduleMeetingRemindersIfNeeded() {
  3930. guard storeKitCoordinator.hasPremiumAccess else {
  3931. cancelAllMeetingReminders()
  3932. return
  3933. }
  3934. guard remindersEnabled else {
  3935. cancelAllMeetingReminders()
  3936. return
  3937. }
  3938. // If nothing is selected, don't schedule anything (but keep master toggle on).
  3939. if remind1Day == false, remind12Hours == false, remind1Hour == false {
  3940. cancelAllMeetingReminders()
  3941. return
  3942. }
  3943. scheduleMeetingReminders(for: allScheduledMeetings)
  3944. }
  3945. private func cancelAllMeetingReminders() {
  3946. UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
  3947. }
  3948. private func scheduleMeetingReminders(for meetings: [ScheduledMeeting]) {
  3949. let center = UNUserNotificationCenter.current()
  3950. let now = Date()
  3951. struct Interval {
  3952. let offset: TimeInterval
  3953. let label: String
  3954. let suffix: String
  3955. let isEnabled: () -> Bool
  3956. }
  3957. let intervals: [Interval] = [
  3958. Interval(offset: 60 * 60 * 24, label: "Starts in 1 day", suffix: "1d", isEnabled: { self.remind1Day }),
  3959. Interval(offset: 60 * 60 * 12, label: "Starts in 12 hours", suffix: "12h", isEnabled: { self.remind12Hours }),
  3960. Interval(offset: 60 * 60, label: "Starts in 1 hour", suffix: "1h", isEnabled: { self.remind1Hour })
  3961. ]
  3962. var requests: [UNNotificationRequest] = []
  3963. for meeting in meetings {
  3964. let title = meeting.title.trimmingCharacters(in: .whitespacesAndNewlines)
  3965. guard title.isEmpty == false else { continue }
  3966. for interval in intervals where interval.isEnabled() {
  3967. let triggerDate = meeting.start.addingTimeInterval(-interval.offset)
  3968. guard triggerDate > now else { continue }
  3969. let content = UNMutableNotificationContent()
  3970. content.title = "📅 \(title)"
  3971. let subtitle = meeting.host.trimmingCharacters(in: .whitespacesAndNewlines)
  3972. content.subtitle = subtitle.isEmpty ? meeting.source : subtitle
  3973. content.body = interval.label
  3974. content.sound = .default
  3975. let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: triggerDate)
  3976. let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false)
  3977. let id = "zoom.meeting.reminder.\(stableReminderKey(for: meeting)).\(interval.suffix)"
  3978. requests.append(UNNotificationRequest(identifier: id, content: content, trigger: trigger))
  3979. }
  3980. }
  3981. center.removeAllPendingNotificationRequests()
  3982. for request in requests {
  3983. center.add(request, withCompletionHandler: nil)
  3984. }
  3985. }
  3986. private func stableReminderKey(for meeting: ScheduledMeeting) -> String {
  3987. if let id = meeting.meetingID?.trimmingCharacters(in: .whitespacesAndNewlines), id.isEmpty == false {
  3988. return id
  3989. }
  3990. if let url = meeting.webURL?.absoluteString, url.isEmpty == false {
  3991. return sha256Prefix(url)
  3992. }
  3993. return sha256Prefix("\(meeting.title)|\(meeting.start.timeIntervalSince1970)")
  3994. }
  3995. private func sha256Prefix(_ input: String) -> String {
  3996. let digest = SHA256.hash(data: Data(input.utf8))
  3997. let hex = digest.compactMap { String(format: "%02x", $0) }.joined()
  3998. return String(hex.prefix(24))
  3999. }
  4000. private func setDarkMode(_ enabled: Bool) {
  4001. darkModeEnabled = enabled
  4002. palette = Palette(isDarkMode: enabled)
  4003. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  4004. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  4005. if let window = view.window {
  4006. applyWindowBackgroundForCurrentTheme(window)
  4007. }
  4008. let keepSelected = selectedHomeSidebarItem
  4009. selectedHomeSidebarItem = keepSelected
  4010. showHomeView(profile: nil)
  4011. updatePremiumLockedSections()
  4012. }
  4013. private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
  4014. // Keep the window's backing in sync with the app's current theme, otherwise rounded
  4015. // shell corners can briefly show the previous mode's lighter/darker color.
  4016. window.backgroundColor = appBackground
  4017. window.isOpaque = true
  4018. if let contentView = window.contentView {
  4019. contentView.wantsLayer = true
  4020. contentView.layer?.backgroundColor = appBackground.cgColor
  4021. }
  4022. }
  4023. private func systemPrefersDarkMode() -> Bool {
  4024. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  4025. let style = global?["AppleInterfaceStyle"] as? String
  4026. return style?.lowercased() == "dark"
  4027. }
  4028. @objc private func settingsUpgradePremiumTapped() {
  4029. guard storeKitCoordinator.hasLifetimePremiumAccess == false else { return }
  4030. showPaywall()
  4031. }
  4032. // MARK: - Paywall (ported from meetings_app)
  4033. private func showPaywall() {
  4034. if storeKitCoordinator.hasPremiumAccess && storeKitCoordinator.hasLifetimePremiumAccess == false {
  4035. selectedPremiumPlan = .lifetime
  4036. }
  4037. if let existing = paywallOverlayView {
  4038. refreshPaywallStoreUI()
  4039. if let superview = existing.superview {
  4040. superview.addSubview(existing)
  4041. }
  4042. updatePaywallResponsiveLayout()
  4043. NSApp.activate(ignoringOtherApps: true)
  4044. return
  4045. }
  4046. let overlay = NSView()
  4047. overlay.translatesAutoresizingMaskIntoConstraints = false
  4048. overlay.wantsLayer = true
  4049. overlay.alphaValue = 0
  4050. overlay.layer?.backgroundColor = NSColor.clear.cgColor
  4051. let dim = NSView()
  4052. dim.translatesAutoresizingMaskIntoConstraints = false
  4053. dim.wantsLayer = true
  4054. dim.layer?.backgroundColor = palette.isDarkMode
  4055. ? NSColor.black.withAlphaComponent(0.62).cgColor
  4056. : NSColor.black.withAlphaComponent(0.28).cgColor
  4057. overlay.addSubview(dim)
  4058. // Clicking outside the paywall dismisses it.
  4059. let outsideButton = NSButton(title: "", target: self, action: #selector(closePaywallClicked(_:)))
  4060. outsideButton.translatesAutoresizingMaskIntoConstraints = false
  4061. outsideButton.isBordered = false
  4062. outsideButton.bezelStyle = .regularSquare
  4063. outsideButton.contentTintColor = .clear
  4064. outsideButton.wantsLayer = true
  4065. outsideButton.layer?.backgroundColor = NSColor.clear.cgColor
  4066. overlay.addSubview(outsideButton)
  4067. let content = makePaywallContent()
  4068. overlay.addSubview(content)
  4069. view.addSubview(overlay)
  4070. NSLayoutConstraint.activate([
  4071. overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  4072. overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  4073. overlay.topAnchor.constraint(equalTo: view.topAnchor),
  4074. overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  4075. dim.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  4076. dim.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  4077. dim.topAnchor.constraint(equalTo: overlay.topAnchor),
  4078. dim.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  4079. outsideButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  4080. outsideButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  4081. outsideButton.topAnchor.constraint(equalTo: overlay.topAnchor),
  4082. outsideButton.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
  4083. content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
  4084. content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
  4085. content.topAnchor.constraint(equalTo: overlay.topAnchor),
  4086. content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
  4087. ])
  4088. paywallPanelWidthConstraint = nil
  4089. paywallPanelHeightConstraint = nil
  4090. paywallOverlayView = overlay
  4091. NSAnimationContext.runAnimationGroup { context in
  4092. context.duration = 0.18
  4093. overlay.animator().alphaValue = 1
  4094. }
  4095. Task { [weak self] in
  4096. guard let self else { return }
  4097. await self.storeKitCoordinator.refreshProducts()
  4098. await MainActor.run {
  4099. self.refreshPaywallStoreUI()
  4100. self.updatePaywallResponsiveLayout()
  4101. }
  4102. }
  4103. }
  4104. @objc private func closePaywallClicked(_ sender: Any?) {
  4105. hidePaywall()
  4106. }
  4107. private func hidePaywall() {
  4108. guard let overlay = paywallOverlayView else { return }
  4109. NSAnimationContext.runAnimationGroup { context in
  4110. context.duration = 0.16
  4111. overlay.animator().alphaValue = 0
  4112. } completionHandler: { [weak self] in
  4113. overlay.removeFromSuperview()
  4114. self?.paywallPanelWidthConstraint = nil
  4115. self?.paywallPanelHeightConstraint = nil
  4116. self?.paywallPanelView = nil
  4117. self?.paywallBodyStack = nil
  4118. self?.paywallIsDesktopLayout = false
  4119. }
  4120. }
  4121. private func updatePaywallPanelSize() {
  4122. guard paywallPanelWidthConstraint != nil, paywallPanelHeightConstraint != nil else { return }
  4123. let safeInsetX: CGFloat = 80
  4124. let safeInsetY: CGFloat = 110
  4125. let maxWidth = max(640, view.bounds.width - safeInsetX)
  4126. let maxHeight = max(560, view.bounds.height - safeInsetY)
  4127. let panelWidth = min(1040, maxWidth)
  4128. let panelHeight = min(860, maxHeight)
  4129. if abs(paywallPanelWidthConstraint?.constant ?? 0 - panelWidth) > 1 {
  4130. paywallPanelWidthConstraint?.constant = panelWidth
  4131. }
  4132. if abs(paywallPanelHeightConstraint?.constant ?? 0 - panelHeight) > 1 {
  4133. paywallPanelHeightConstraint?.constant = panelHeight
  4134. }
  4135. }
  4136. private func updatePaywallResponsiveLayout() {
  4137. guard let bodyStack = paywallBodyStack, let panel = paywallPanelView else { return }
  4138. let width = panel.bounds.width
  4139. // Two-column desktop layout needs room for the plan column (520+) + a slimmer benefits column.
  4140. let shouldUseDesktop = width >= 900
  4141. guard shouldUseDesktop != paywallIsDesktopLayout else { return }
  4142. paywallIsDesktopLayout = shouldUseDesktop
  4143. if shouldUseDesktop {
  4144. bodyStack.orientation = .horizontal
  4145. bodyStack.alignment = .top
  4146. bodyStack.spacing = 26
  4147. } else {
  4148. bodyStack.orientation = .vertical
  4149. bodyStack.alignment = .leading
  4150. bodyStack.spacing = 18
  4151. }
  4152. let useWidePlans = width >= 880
  4153. paywallPlansRowWide?.isHidden = useWidePlans == false
  4154. paywallPlansGridNarrow?.isHidden = useWidePlans
  4155. bodyStack.layoutSubtreeIfNeeded()
  4156. }
  4157. private func makePaywallContent() -> NSView {
  4158. paywallPlanViews.removeAll()
  4159. paywallPlanPriceLabels.removeAll()
  4160. paywallPlanSubtitleLabels.removeAll()
  4161. paywallPlanStrikeLabels.removeAll()
  4162. premiumPlanByView.removeAll()
  4163. paywallOfferLabel = nil
  4164. paywallContinueLabel = nil
  4165. paywallContinueButton = nil
  4166. paywallContinueEnabled = true
  4167. paywallPlansRowWide = nil
  4168. paywallPlansGridNarrow = nil
  4169. let root = NSView()
  4170. root.translatesAutoresizingMaskIntoConstraints = false
  4171. root.wantsLayer = true
  4172. root.layer?.backgroundColor = NSColor.clear.cgColor
  4173. let panel = NSView()
  4174. panel.translatesAutoresizingMaskIntoConstraints = false
  4175. panel.wantsLayer = true
  4176. // Theme-aware surface so Light Mode doesn't render on a black backdrop.
  4177. panel.layer?.backgroundColor = palette.appBackground.cgColor
  4178. panel.layer?.cornerRadius = 0
  4179. styleSurface(panel, borderColor: NSColor.clear, borderWidth: 0, shadow: false)
  4180. root.addSubview(panel)
  4181. // Full-screen paywall: fill the entire app content area.
  4182. NSLayoutConstraint.activate([
  4183. panel.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  4184. panel.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  4185. panel.topAnchor.constraint(equalTo: root.topAnchor),
  4186. panel.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  4187. ])
  4188. paywallPanelWidthConstraint = nil
  4189. paywallPanelHeightConstraint = nil
  4190. // Responsive logic should measure the panel size (not the full-screen root).
  4191. self.paywallPanelView = panel
  4192. let contentStack = NSStackView()
  4193. contentStack.translatesAutoresizingMaskIntoConstraints = false
  4194. contentStack.orientation = .vertical
  4195. contentStack.spacing = 14
  4196. contentStack.alignment = .leading
  4197. panel.addSubview(contentStack)
  4198. let topRow = NSStackView()
  4199. topRow.translatesAutoresizingMaskIntoConstraints = false
  4200. topRow.orientation = .horizontal
  4201. topRow.alignment = .centerY
  4202. topRow.distribution = .fill
  4203. topRow.spacing = 10
  4204. topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 26, weight: .semibold), color: primaryText))
  4205. let topSpacer = NSView()
  4206. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  4207. topRow.addArrangedSubview(topSpacer)
  4208. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  4209. closeButton.translatesAutoresizingMaskIntoConstraints = false
  4210. closeButton.isBordered = false
  4211. closeButton.bezelStyle = .regularSquare
  4212. closeButton.wantsLayer = true
  4213. closeButton.layer?.cornerRadius = 14
  4214. closeButton.normalColor = palette.inputBackground
  4215. closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
  4216. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  4217. closeButton.layer?.borderWidth = 1
  4218. closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
  4219. closeButton.contentTintColor = secondaryText
  4220. closeButton.toolTip = "Continue with free plan"
  4221. panel.addSubview(closeButton)
  4222. NSLayoutConstraint.activate([
  4223. closeButton.widthAnchor.constraint(equalToConstant: 28),
  4224. closeButton.heightAnchor.constraint(equalToConstant: 28),
  4225. closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  4226. closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48)
  4227. ])
  4228. contentStack.addArrangedSubview(topRow)
  4229. topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  4230. let hero = paywallHeroCard(
  4231. eyebrow: "PREMIUM EXPERIENCE",
  4232. title: "Elevate your meetings workflow",
  4233. subtitle: "Unlock automation, premium meeting tools, and priority support for every session."
  4234. )
  4235. contentStack.addArrangedSubview(hero)
  4236. hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  4237. let features = paywallFeaturesRow()
  4238. contentStack.addArrangedSubview(features)
  4239. features.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  4240. let bodyStack = NSStackView()
  4241. bodyStack.translatesAutoresizingMaskIntoConstraints = false
  4242. bodyStack.orientation = .vertical
  4243. bodyStack.spacing = 16
  4244. bodyStack.alignment = .leading
  4245. bodyStack.distribution = .fill
  4246. contentStack.addArrangedSubview(bodyStack)
  4247. paywallBodyStack = bodyStack
  4248. bodyStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
  4249. let planColumn = NSStackView()
  4250. planColumn.translatesAutoresizingMaskIntoConstraints = false
  4251. planColumn.orientation = .vertical
  4252. planColumn.spacing = 12
  4253. planColumn.alignment = .leading
  4254. planColumn.distribution = .fill
  4255. bodyStack.addArrangedSubview(planColumn)
  4256. planColumn.widthAnchor.constraint(equalTo: bodyStack.widthAnchor).isActive = true
  4257. let weeklyCard = paywallPlanCard(
  4258. title: "Weekly",
  4259. price: paywallDisplayPrice(for: .weekly),
  4260. badge: "Basic",
  4261. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  4262. subtitle: paywallPlanSubtitle(for: .weekly),
  4263. plan: .weekly,
  4264. strikePrice: nil
  4265. )
  4266. let monthlyCard = paywallPlanCard(
  4267. title: "Monthly",
  4268. price: paywallDisplayPrice(for: .monthly),
  4269. badge: "Popular",
  4270. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  4271. subtitle: paywallPlanSubtitle(for: .monthly),
  4272. plan: .monthly,
  4273. strikePrice: nil
  4274. )
  4275. let yearlyCard = paywallPlanCard(
  4276. title: "Yearly",
  4277. price: paywallDisplayPrice(for: .yearly),
  4278. badge: "Save 67%",
  4279. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  4280. subtitle: paywallPlanSubtitle(for: .yearly),
  4281. plan: .yearly,
  4282. strikePrice: nil
  4283. )
  4284. let lifetimeCard = paywallPlanCard(
  4285. title: "Lifetime",
  4286. price: paywallDisplayPrice(for: .lifetime),
  4287. badge: "Best Value",
  4288. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  4289. subtitle: paywallPlanSubtitle(for: .lifetime),
  4290. plan: .lifetime,
  4291. strikePrice: nil
  4292. )
  4293. let plansRowWide = NSStackView()
  4294. plansRowWide.translatesAutoresizingMaskIntoConstraints = false
  4295. plansRowWide.orientation = .horizontal
  4296. plansRowWide.spacing = 12
  4297. plansRowWide.alignment = .centerY
  4298. plansRowWide.distribution = .fillEqually
  4299. plansRowWide.addArrangedSubview(weeklyCard)
  4300. plansRowWide.addArrangedSubview(monthlyCard)
  4301. plansRowWide.addArrangedSubview(yearlyCard)
  4302. plansRowWide.addArrangedSubview(lifetimeCard)
  4303. paywallPlansRowWide = plansRowWide
  4304. planColumn.addArrangedSubview(plansRowWide)
  4305. plansRowWide.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  4306. let plansGridNarrow = NSStackView()
  4307. plansGridNarrow.translatesAutoresizingMaskIntoConstraints = false
  4308. plansGridNarrow.orientation = .vertical
  4309. plansGridNarrow.spacing = 12
  4310. plansGridNarrow.alignment = .leading
  4311. plansGridNarrow.distribution = .fillEqually
  4312. let plansRow1 = NSStackView()
  4313. plansRow1.translatesAutoresizingMaskIntoConstraints = false
  4314. plansRow1.orientation = .horizontal
  4315. plansRow1.spacing = 12
  4316. plansRow1.alignment = .centerY
  4317. plansRow1.distribution = .fillEqually
  4318. plansRow1.addArrangedSubview(paywallPlanCard(
  4319. title: "Weekly",
  4320. price: paywallDisplayPrice(for: .weekly),
  4321. badge: "Basic Deal",
  4322. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  4323. subtitle: paywallPlanSubtitle(for: .weekly),
  4324. plan: .weekly,
  4325. strikePrice: nil
  4326. ))
  4327. plansRow1.addArrangedSubview(paywallPlanCard(
  4328. title: "Monthly",
  4329. price: paywallDisplayPrice(for: .monthly),
  4330. badge: "Popular",
  4331. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  4332. subtitle: paywallPlanSubtitle(for: .monthly),
  4333. plan: .monthly,
  4334. strikePrice: nil
  4335. ))
  4336. let plansRow2 = NSStackView()
  4337. plansRow2.translatesAutoresizingMaskIntoConstraints = false
  4338. plansRow2.orientation = .horizontal
  4339. plansRow2.spacing = 12
  4340. plansRow2.alignment = .centerY
  4341. plansRow2.distribution = .fillEqually
  4342. plansRow2.addArrangedSubview(paywallPlanCard(
  4343. title: "Yearly",
  4344. price: paywallDisplayPrice(for: .yearly),
  4345. badge: "Best Deal",
  4346. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  4347. subtitle: paywallPlanSubtitle(for: .yearly),
  4348. plan: .yearly,
  4349. strikePrice: nil
  4350. ))
  4351. plansRow2.addArrangedSubview(paywallPlanCard(
  4352. title: "Lifetime",
  4353. price: paywallDisplayPrice(for: .lifetime),
  4354. badge: "Save 50%",
  4355. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  4356. subtitle: paywallPlanSubtitle(for: .lifetime),
  4357. plan: .lifetime,
  4358. strikePrice: nil
  4359. ))
  4360. plansGridNarrow.addArrangedSubview(plansRow1)
  4361. plansGridNarrow.addArrangedSubview(plansRow2)
  4362. plansGridNarrow.isHidden = true
  4363. paywallPlansGridNarrow = plansGridNarrow
  4364. planColumn.addArrangedSubview(plansGridNarrow)
  4365. plansGridNarrow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  4366. plansRow1.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  4367. plansRow2.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
  4368. updatePaywallPlanSelection()
  4369. planColumn.setCustomSpacing(16, after: plansGridNarrow)
  4370. let trustRow = paywallTrustRow()
  4371. planColumn.addArrangedSubview(trustRow)
  4372. trustRow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  4373. planColumn.setCustomSpacing(14, after: trustRow)
  4374. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
  4375. offer.alignment = .center
  4376. paywallOfferLabel = offer
  4377. let offerWrap = NSView()
  4378. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  4379. offerWrap.addSubview(offer)
  4380. NSLayoutConstraint.activate([
  4381. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  4382. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  4383. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  4384. ])
  4385. planColumn.addArrangedSubview(offerWrap)
  4386. offerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  4387. planColumn.setCustomSpacing(14, after: offerWrap)
  4388. let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
  4389. continueButton.translatesAutoresizingMaskIntoConstraints = false
  4390. continueButton.isBordered = false
  4391. continueButton.bezelStyle = .regularSquare
  4392. continueButton.wantsLayer = true
  4393. continueButton.layer?.cornerRadius = 14
  4394. continueButton.normalColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 1)
  4395. continueButton.hoverColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 0.92)
  4396. continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
  4397. styleSurface(continueButton, borderColor: NSColor.black.withAlphaComponent(0.12), borderWidth: 1, shadow: true)
  4398. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  4399. continueButton.addSubview(continueLabel)
  4400. NSLayoutConstraint.activate([
  4401. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  4402. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  4403. ])
  4404. paywallContinueButton = continueButton
  4405. paywallContinueLabel = continueLabel
  4406. let continueButtonWrap = NSView()
  4407. continueButtonWrap.translatesAutoresizingMaskIntoConstraints = false
  4408. continueButtonWrap.addSubview(continueButton)
  4409. planColumn.addArrangedSubview(continueButtonWrap)
  4410. NSLayoutConstraint.activate([
  4411. continueButtonWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor),
  4412. continueButton.topAnchor.constraint(equalTo: continueButtonWrap.topAnchor),
  4413. continueButton.bottomAnchor.constraint(equalTo: continueButtonWrap.bottomAnchor),
  4414. continueButton.centerXAnchor.constraint(equalTo: continueButtonWrap.centerXAnchor),
  4415. continueButton.widthAnchor.constraint(equalToConstant: 400),
  4416. continueButton.widthAnchor.constraint(lessThanOrEqualTo: continueButtonWrap.widthAnchor, constant: -40)
  4417. ])
  4418. planColumn.setCustomSpacing(16, after: continueButtonWrap)
  4419. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
  4420. secure.alignment = .center
  4421. let secureWrap = NSView()
  4422. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  4423. secureWrap.addSubview(secure)
  4424. NSLayoutConstraint.activate([
  4425. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  4426. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  4427. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  4428. ])
  4429. planColumn.addArrangedSubview(secureWrap)
  4430. secureWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  4431. planColumn.setCustomSpacing(8, after: secureWrap)
  4432. let primaryFooterButton = paywallFooterButton(
  4433. title: storeKitCoordinator.hasPremiumAccess ? "Manage Subscription" : "Continue with free plan",
  4434. action: storeKitCoordinator.hasPremiumAccess
  4435. ? #selector(paywallManageSubscriptionClicked(_:))
  4436. : #selector(closePaywallClicked(_:))
  4437. )
  4438. let restoreButton = paywallFooterButton(
  4439. title: "Restore Purchase",
  4440. action: #selector(paywallRestorePurchaseClicked(_:))
  4441. )
  4442. let privacyButton = paywallFooterButton(
  4443. title: "Privacy Policy",
  4444. action: #selector(paywallPrivacyPolicyClicked(_:))
  4445. )
  4446. let supportButton = paywallFooterButton(
  4447. title: "Support",
  4448. action: #selector(paywallSupportClicked(_:))
  4449. )
  4450. let termsButton = paywallFooterButton(
  4451. title: "Terms of Services",
  4452. action: #selector(paywallTermsOfServicesClicked(_:))
  4453. )
  4454. // Footer: evenly spaced links across the paywall width.
  4455. let footerContent = NSStackView()
  4456. footerContent.translatesAutoresizingMaskIntoConstraints = false
  4457. footerContent.orientation = .horizontal
  4458. footerContent.alignment = .centerY
  4459. footerContent.distribution = .equalSpacing
  4460. footerContent.spacing = 36
  4461. footerContent.addArrangedSubview(primaryFooterButton)
  4462. footerContent.addArrangedSubview(restoreButton)
  4463. footerContent.addArrangedSubview(privacyButton)
  4464. footerContent.addArrangedSubview(supportButton)
  4465. footerContent.addArrangedSubview(termsButton)
  4466. let footerWrap = NSView()
  4467. footerWrap.translatesAutoresizingMaskIntoConstraints = false
  4468. footerWrap.addSubview(footerContent)
  4469. NSLayoutConstraint.activate([
  4470. footerContent.leadingAnchor.constraint(equalTo: footerWrap.leadingAnchor, constant: 44),
  4471. footerContent.trailingAnchor.constraint(equalTo: footerWrap.trailingAnchor, constant: -44),
  4472. footerContent.topAnchor.constraint(equalTo: footerWrap.topAnchor),
  4473. footerContent.bottomAnchor.constraint(equalTo: footerWrap.bottomAnchor)
  4474. ])
  4475. // Push footer to the bottom of the paywall.
  4476. let footerPushSpacer = NSView()
  4477. footerPushSpacer.translatesAutoresizingMaskIntoConstraints = false
  4478. footerPushSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
  4479. footerPushSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
  4480. planColumn.addArrangedSubview(footerPushSpacer)
  4481. planColumn.addArrangedSubview(footerWrap)
  4482. footerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
  4483. // Keep a comfortable top margin, but center the paywall content vertically when there's room.
  4484. let contentTopMin = contentStack.topAnchor.constraint(greaterThanOrEqualTo: panel.topAnchor, constant: 84)
  4485. let contentBottomMax = contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -18)
  4486. let contentCenterY = contentStack.centerYAnchor.constraint(equalTo: panel.centerYAnchor)
  4487. contentCenterY.priority = .defaultLow
  4488. let contentBottomEq = contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -18)
  4489. contentBottomEq.priority = .defaultLow
  4490. NSLayoutConstraint.activate([
  4491. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 70),
  4492. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
  4493. contentTopMin,
  4494. contentBottomMax,
  4495. contentCenterY,
  4496. contentBottomEq
  4497. ])
  4498. refreshPaywallStoreUI()
  4499. updatePaywallResponsiveLayout()
  4500. return root
  4501. }
  4502. private func paywallBenefitsSection() -> NSView {
  4503. let stack = NSStackView()
  4504. stack.translatesAutoresizingMaskIntoConstraints = false
  4505. stack.orientation = .vertical
  4506. stack.spacing = 8
  4507. stack.alignment = .leading
  4508. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
  4509. let rowOne = NSStackView()
  4510. rowOne.translatesAutoresizingMaskIntoConstraints = false
  4511. rowOne.orientation = .horizontal
  4512. rowOne.spacing = 10
  4513. rowOne.distribution = .fillEqually
  4514. rowOne.alignment = .centerY
  4515. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  4516. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  4517. let rowTwo = NSStackView()
  4518. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  4519. rowTwo.orientation = .horizontal
  4520. rowTwo.spacing = 10
  4521. rowTwo.distribution = .fillEqually
  4522. rowTwo.alignment = .centerY
  4523. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  4524. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  4525. stack.addArrangedSubview(rowOne)
  4526. stack.addArrangedSubview(rowTwo)
  4527. return stack
  4528. }
  4529. private func paywallHeroCard(eyebrow: String, title: String, subtitle: String) -> NSView {
  4530. let card = NSView()
  4531. card.translatesAutoresizingMaskIntoConstraints = false
  4532. card.wantsLayer = true
  4533. card.layer?.cornerRadius = 14
  4534. card.layer?.backgroundColor = palette.secondaryCardBackground.cgColor
  4535. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4536. let eyebrowLabel = styledLabel(
  4537. eyebrow,
  4538. font: NSFont.systemFont(ofSize: 11, weight: .semibold),
  4539. color: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 0.92),
  4540. kern: 0.8
  4541. )
  4542. eyebrowLabel.alignment = .left
  4543. let titleLabel = styledLabel(
  4544. title,
  4545. font: NSFont.systemFont(ofSize: 18, weight: .semibold),
  4546. color: primaryText,
  4547. lineSpacing: 1
  4548. )
  4549. titleLabel.alignment = .left
  4550. let subtitleLabel = styledLabel(
  4551. subtitle,
  4552. font: NSFont.systemFont(ofSize: 13, weight: .regular),
  4553. color: secondaryText,
  4554. lineSpacing: 2
  4555. )
  4556. subtitleLabel.maximumNumberOfLines = 2
  4557. subtitleLabel.lineBreakMode = .byTruncatingTail
  4558. subtitleLabel.alignment = .left
  4559. let stack = NSStackView()
  4560. stack.translatesAutoresizingMaskIntoConstraints = false
  4561. stack.orientation = .vertical
  4562. stack.alignment = .leading
  4563. stack.spacing = 6
  4564. stack.addArrangedSubview(eyebrowLabel)
  4565. stack.addArrangedSubview(titleLabel)
  4566. stack.addArrangedSubview(subtitleLabel)
  4567. card.addSubview(stack)
  4568. NSLayoutConstraint.activate([
  4569. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  4570. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  4571. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
  4572. stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
  4573. ])
  4574. return card
  4575. }
  4576. private func paywallFeaturesRow() -> NSView {
  4577. let row = NSStackView()
  4578. row.translatesAutoresizingMaskIntoConstraints = false
  4579. row.orientation = .horizontal
  4580. row.spacing = 10
  4581. row.alignment = .centerY
  4582. row.distribution = .fillEqually
  4583. row.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  4584. row.addArrangedSubview(paywallBenefitItem(icon: "💬", text: "Smart scheduling"))
  4585. row.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
  4586. row.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
  4587. return row
  4588. }
  4589. private func paywallTrustRow() -> NSView {
  4590. let row = NSStackView()
  4591. row.translatesAutoresizingMaskIntoConstraints = false
  4592. row.orientation = .horizontal
  4593. row.spacing = 10
  4594. row.alignment = .centerY
  4595. row.distribution = .fillEqually
  4596. row.addArrangedSubview(paywallTrustItem(title: "Cancel anytime", subtitle: "No lock-in"))
  4597. row.addArrangedSubview(paywallTrustItem(title: "Instant access", subtitle: "Unlock all tools"))
  4598. row.addArrangedSubview(paywallTrustItem(title: "Secure billing", subtitle: "Handled by Apple"))
  4599. return row
  4600. }
  4601. private func paywallTrustItem(title: String, subtitle: String) -> NSView {
  4602. let card = NSView()
  4603. card.translatesAutoresizingMaskIntoConstraints = false
  4604. card.wantsLayer = true
  4605. card.layer?.cornerRadius = 12
  4606. card.layer?.backgroundColor = palette.inputBackground.cgColor
  4607. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4608. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: primaryText)
  4609. let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  4610. let stack = NSStackView()
  4611. stack.translatesAutoresizingMaskIntoConstraints = false
  4612. stack.orientation = .vertical
  4613. stack.alignment = .centerX
  4614. stack.spacing = 2
  4615. stack.addArrangedSubview(titleLabel)
  4616. stack.addArrangedSubview(subtitleLabel)
  4617. card.addSubview(stack)
  4618. NSLayoutConstraint.activate([
  4619. card.heightAnchor.constraint(equalToConstant: 44),
  4620. stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  4621. stack.centerYAnchor.constraint(equalTo: card.centerYAnchor)
  4622. ])
  4623. return card
  4624. }
  4625. private func paywallBenefitItem(icon: String, text: String) -> NSView {
  4626. let card = NSView()
  4627. card.translatesAutoresizingMaskIntoConstraints = false
  4628. card.wantsLayer = true
  4629. card.layer?.cornerRadius = 10
  4630. card.layer?.backgroundColor = palette.inputBackground.cgColor
  4631. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  4632. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4633. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  4634. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  4635. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  4636. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  4637. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4638. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 13, weight: .medium), color: accentBlue)
  4639. iconWrap.addSubview(iconLabel)
  4640. NSLayoutConstraint.activate([
  4641. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  4642. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  4643. ])
  4644. let title = textLabel(text, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: primaryText)
  4645. card.addSubview(iconWrap)
  4646. card.addSubview(title)
  4647. NSLayoutConstraint.activate([
  4648. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  4649. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  4650. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  4651. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  4652. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  4653. ])
  4654. return card
  4655. }
  4656. private func paywallPlanCard(
  4657. title: String,
  4658. price: String,
  4659. badge: String,
  4660. badgeColor: NSColor,
  4661. subtitle: String?,
  4662. plan: PremiumPlan,
  4663. strikePrice: String?
  4664. ) -> NSView {
  4665. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  4666. wrapper.translatesAutoresizingMaskIntoConstraints = false
  4667. wrapper.isBordered = false
  4668. wrapper.bezelStyle = .regularSquare
  4669. wrapper.wantsLayer = true
  4670. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  4671. wrapper.heightAnchor.constraint(equalToConstant: 138).isActive = true
  4672. wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
  4673. let card = NSView()
  4674. card.translatesAutoresizingMaskIntoConstraints = false
  4675. card.wantsLayer = true
  4676. card.layer?.cornerRadius = 16
  4677. card.layer?.backgroundColor = palette.sectionCard.cgColor
  4678. wrapper.addSubview(card)
  4679. NSLayoutConstraint.activate([
  4680. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  4681. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  4682. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 14),
  4683. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  4684. ])
  4685. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  4686. let badgeLabel = styledLabel(
  4687. badge.uppercased(),
  4688. font: NSFont.systemFont(ofSize: 10.5, weight: .semibold),
  4689. color: .white,
  4690. kern: 0.6
  4691. )
  4692. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  4693. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  4694. badgeWrap.wantsLayer = true
  4695. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  4696. badgeWrap.layer?.borderWidth = 1
  4697. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  4698. badgeWrap.layer?.shadowOpacity = 0.20
  4699. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  4700. badgeWrap.layer?.shadowRadius = 3
  4701. badgeWrap.addSubview(badgeLabel)
  4702. NSLayoutConstraint.activate([
  4703. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  4704. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  4705. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  4706. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  4707. ])
  4708. wrapper.addSubview(badgeWrap)
  4709. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: accentBlue)
  4710. card.addSubview(titleLabel)
  4711. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: primaryText)
  4712. card.addSubview(priceLabel)
  4713. paywallPlanPriceLabels[plan, default: []].append(priceLabel)
  4714. NSLayoutConstraint.activate([
  4715. badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  4716. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  4717. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  4718. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
  4719. priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  4720. priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  4721. ])
  4722. if let subtitle {
  4723. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
  4724. card.addSubview(sub)
  4725. paywallPlanSubtitleLabels[plan, default: []].append(sub)
  4726. NSLayoutConstraint.activate([
  4727. sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
  4728. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
  4729. sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
  4730. ])
  4731. }
  4732. if let strikePrice {
  4733. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  4734. card.addSubview(strike)
  4735. paywallPlanStrikeLabels[plan, default: []].append(strike)
  4736. NSLayoutConstraint.activate([
  4737. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  4738. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  4739. ])
  4740. }
  4741. paywallPlanViews[plan, default: []].append(card)
  4742. premiumPlanByView[ObjectIdentifier(card)] = plan
  4743. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  4744. guard let self, let card else { return }
  4745. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  4746. }
  4747. wrapper.onHoverChanged?(false)
  4748. return wrapper
  4749. }
  4750. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  4751. guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
  4752. selectedPremiumPlan = plan
  4753. updatePaywallPlanSelection()
  4754. }
  4755. private func updatePaywallPlanSelection() {
  4756. for (plan, views) in paywallPlanViews {
  4757. for view in views {
  4758. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  4759. }
  4760. }
  4761. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  4762. updatePaywallContinueState(isLoading: false)
  4763. }
  4764. private func paywallOfferText(for plan: PremiumPlan) -> String {
  4765. if storeKitCoordinator.hasLifetimePremiumAccess {
  4766. return "Lifetime premium is active on this Apple ID."
  4767. }
  4768. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  4769. let price = paywallDisplayPrice(for: plan)
  4770. switch plan {
  4771. case .weekly: return "\(price)/week"
  4772. case .monthly: return "\(price)/month"
  4773. case .yearly: return "\(price)/year"
  4774. case .lifetime: return "\(price) one-time purchase"
  4775. }
  4776. }
  4777. if let offer = product.subscription?.introductoryOffer,
  4778. offer.paymentMode == .freeTrial,
  4779. let trial = paywallSubscriptionPeriodDescription(offer.period) {
  4780. let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
  4781. if let renewal {
  4782. return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
  4783. }
  4784. return "Free for \(trial), then \(product.displayPrice)"
  4785. }
  4786. if plan == .lifetime {
  4787. return "\(product.displayPrice) one-time purchase"
  4788. }
  4789. if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
  4790. return "\(product.displayPrice)/\(renewal)"
  4791. }
  4792. let fallbackPrice = paywallDisplayPrice(for: plan)
  4793. switch plan {
  4794. case .weekly: return "\(fallbackPrice)/week"
  4795. case .monthly: return "\(fallbackPrice)/month"
  4796. case .yearly: return "\(fallbackPrice)/year"
  4797. case .lifetime: return "\(fallbackPrice) one-time purchase"
  4798. }
  4799. }
  4800. private func paywallFreeTrialText(for plan: PremiumPlan) -> String? {
  4801. guard let product = storeKitCoordinator.productsByID[plan.rawValue],
  4802. let offer = product.subscription?.introductoryOffer,
  4803. offer.paymentMode == .freeTrial,
  4804. let trial = paywallSubscriptionPeriodDescription(offer.period) else {
  4805. return nil
  4806. }
  4807. return trial
  4808. }
  4809. private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
  4810. if plan == .lifetime { return "One-time purchase" }
  4811. guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
  4812. switch plan {
  4813. case .weekly: return "Billed weekly"
  4814. case .monthly: return "Billed monthly"
  4815. case .yearly: return "Billed yearly"
  4816. case .lifetime: return "One-time purchase"
  4817. }
  4818. }
  4819. if let period = product.subscription?.subscriptionPeriod,
  4820. let unit = paywallSubscriptionPeriodDescription(period),
  4821. let trial = paywallFreeTrialText(for: plan) {
  4822. return "\(product.displayPrice)/\(unit) • \(trial) free trial"
  4823. }
  4824. if let period = product.subscription?.subscriptionPeriod,
  4825. let unit = paywallSubscriptionPeriodDescription(period) {
  4826. return "Billed \(unit)"
  4827. }
  4828. switch plan {
  4829. case .weekly: return "Billed weekly"
  4830. case .monthly: return "Billed monthly"
  4831. case .yearly: return "Billed yearly"
  4832. case .lifetime: return "One-time purchase"
  4833. }
  4834. }
  4835. private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
  4836. guard let period else { return nil }
  4837. let value = period.value
  4838. switch period.unit {
  4839. case .day:
  4840. return value == 1 ? "1 day" : "\(value) days"
  4841. case .week:
  4842. return value == 1 ? "1 week" : "\(value) weeks"
  4843. case .month:
  4844. return value == 1 ? "1 month" : "\(value) months"
  4845. case .year:
  4846. return value == 1 ? "1 year" : "\(value) years"
  4847. @unknown default:
  4848. return nil
  4849. }
  4850. }
  4851. private func refreshPaywallStoreUI() {
  4852. refreshPaywallPlanPrices()
  4853. updatePaywallPlanSelection()
  4854. updatePaywallContinueState(isLoading: false)
  4855. }
  4856. private func refreshPaywallPlanPrices() {
  4857. for plan in PremiumPlan.allCases {
  4858. let price = paywallDisplayPrice(for: plan)
  4859. paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
  4860. let subtitle = paywallPlanSubtitle(for: plan)
  4861. paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
  4862. }
  4863. }
  4864. private func paywallDisplayPrice(for plan: PremiumPlan) -> String {
  4865. if let product = storeKitCoordinator.productsByID[plan.rawValue] {
  4866. return product.displayPrice
  4867. }
  4868. switch plan {
  4869. case .weekly: return "3.99"
  4870. case .monthly: return "7.99"
  4871. case .yearly: return "24.99"
  4872. case .lifetime: return "44.99"
  4873. }
  4874. }
  4875. @objc private func paywallContinueClicked(_ sender: Any?) {
  4876. startSelectedPlanPurchase()
  4877. }
  4878. private func paywallFooterButton(title: String, action: Selector) -> NSButton {
  4879. let button = NSButton(title: title, target: self, action: action)
  4880. button.translatesAutoresizingMaskIntoConstraints = false
  4881. button.isBordered = false
  4882. button.bezelStyle = .shadowlessSquare
  4883. button.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  4884. button.contentTintColor = secondaryText
  4885. button.alignment = .center
  4886. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  4887. return button
  4888. }
  4889. @objc private func paywallPrivacyPolicyClicked(_ sender: Any?) {
  4890. showSimpleAlert(
  4891. title: "Privacy Policy",
  4892. message: "Placeholder: Privacy Policy content/link will be added before publishing."
  4893. )
  4894. }
  4895. @objc private func paywallSupportClicked(_ sender: Any?) {
  4896. showSimpleAlert(
  4897. title: "Support",
  4898. message: "Placeholder: Support contact details/link will be added before publishing."
  4899. )
  4900. }
  4901. @objc private func paywallTermsOfServicesClicked(_ sender: Any?) {
  4902. showSimpleAlert(
  4903. title: "Terms of Services",
  4904. message: "Placeholder: Terms of Services content/link will be added before publishing."
  4905. )
  4906. }
  4907. @objc private func paywallManageSubscriptionClicked(_ sender: Any?) {
  4908. openManageSubscriptions()
  4909. }
  4910. @objc private func paywallRestorePurchaseClicked(_ sender: Any?) {
  4911. Task { [weak self] in
  4912. guard let self else { return }
  4913. let message = await self.storeKitCoordinator.restorePurchases()
  4914. await MainActor.run {
  4915. self.refreshPaywallStoreUI()
  4916. self.updatePremiumButtons()
  4917. self.showSimpleAlert(title: "Restore Purchase", message: message)
  4918. }
  4919. }
  4920. }
  4921. private func startSelectedPlanPurchase() {
  4922. guard paywallContinueEnabled else { return }
  4923. paywallPurchaseTask?.cancel()
  4924. updatePaywallContinueState(isLoading: true)
  4925. let selectedPlan = selectedPremiumPlan
  4926. let hadPremiumBeforePurchase = storeKitCoordinator.hasPremiumAccess
  4927. let hadLifetimeBeforePurchase = storeKitCoordinator.hasLifetimePremiumAccess
  4928. paywallPurchaseTask = Task { [weak self] in
  4929. guard let self else { return }
  4930. if self.storeKitCoordinator.productsByID[selectedPlan.rawValue] == nil {
  4931. await self.storeKitCoordinator.refreshProducts()
  4932. }
  4933. let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
  4934. await MainActor.run {
  4935. self.updatePaywallContinueState(isLoading: false)
  4936. self.refreshPaywallStoreUI()
  4937. self.updatePremiumButtons()
  4938. switch result {
  4939. case .success:
  4940. if self.storeKitCoordinator.hasPremiumAccess {
  4941. // Ensure QA can repeatedly verify rating flow right after upgrading.
  4942. UserDefaults.standard.set(false, forKey: self.ratingCompletedKey)
  4943. UserDefaults.standard.set(false, forKey: self.ratingPromptShownKey)
  4944. self.schedulePremiumRatingPromptIfNeeded()
  4945. }
  4946. if selectedPlan == .lifetime, hadLifetimeBeforePurchase == false, hadPremiumBeforePurchase {
  4947. self.showSimpleAlert(
  4948. title: "Lifetime Activated",
  4949. 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."
  4950. )
  4951. } else {
  4952. self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
  4953. }
  4954. self.hidePaywall()
  4955. if self.storeKitCoordinator.hasPremiumAccess {
  4956. DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
  4957. self?.presentRatingPrompt(source: "premium")
  4958. }
  4959. }
  4960. case .pending:
  4961. self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
  4962. case .cancelled:
  4963. break
  4964. case .failed(let message):
  4965. let fallbackMessage = "StoreKit did not return products for this plan yet. Wait a few seconds and try again, or reopen the app."
  4966. if message.contains("Product not available") {
  4967. self.showSimpleAlert(title: "Purchase Failed", message: fallbackMessage)
  4968. } else {
  4969. self.showSimpleAlert(title: "Purchase Failed", message: message)
  4970. }
  4971. }
  4972. }
  4973. }
  4974. }
  4975. private func updatePaywallContinueState(isLoading: Bool) {
  4976. if isLoading {
  4977. paywallContinueEnabled = false
  4978. paywallContinueLabel?.stringValue = "Processing..."
  4979. paywallContinueButton?.alphaValue = 0.75
  4980. return
  4981. }
  4982. if storeKitCoordinator.hasLifetimePremiumAccess {
  4983. paywallContinueEnabled = false
  4984. paywallContinueLabel?.stringValue = "Lifetime Premium Active"
  4985. paywallContinueButton?.alphaValue = 0.75
  4986. } else if storeKitCoordinator.productsByID[selectedPremiumPlan.rawValue] == nil {
  4987. paywallContinueEnabled = false
  4988. paywallContinueLabel?.stringValue = "Store Unavailable"
  4989. paywallContinueButton?.alphaValue = 0.75
  4990. } else {
  4991. paywallContinueEnabled = true
  4992. if let trial = paywallFreeTrialText(for: selectedPremiumPlan) {
  4993. paywallContinueLabel?.stringValue = "Start with \(trial) trial"
  4994. } else {
  4995. paywallContinueLabel?.stringValue = "Continue"
  4996. }
  4997. paywallContinueButton?.alphaValue = 1.0
  4998. }
  4999. }
  5000. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  5001. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  5002. let idleBorder = palette.inputBorder
  5003. let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
  5004. let hoverIdleBackground =
  5005. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  5006. let selectedBackground = palette.isDarkMode
  5007. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  5008. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  5009. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  5010. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  5011. card.layer?.borderWidth = isSelected ? 2 : 1
  5012. card.layer?.shadowColor = NSColor.black.cgColor
  5013. card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  5014. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  5015. card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
  5016. }
  5017. // MARK: - Home UI
  5018. private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
  5019. let root = NSView()
  5020. let shell = NSView()
  5021. shell.wantsLayer = true
  5022. shell.layer?.backgroundColor = appShellBackground.cgColor
  5023. shell.layer?.cornerRadius = appShellCornerRadius
  5024. shell.layer?.borderWidth = 1
  5025. shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
  5026. let chromeColumn = NSView()
  5027. chromeColumn.wantsLayer = true
  5028. chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  5029. chromeColumn.layer?.cornerRadius = appShellCornerRadius
  5030. chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
  5031. chromeColumn.layer?.masksToBounds = true
  5032. let chromeDivider = NSView()
  5033. chromeDivider.wantsLayer = true
  5034. chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
  5035. let chromeHeader = NSView()
  5036. chromeHeader.wantsLayer = true
  5037. chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  5038. homeSidebarRowViews = [:]
  5039. homeSidebarIconViews = [:]
  5040. homeSidebarLabels = [:]
  5041. let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Widgets", "Ai Companion", "Settings"], selected: selectedHomeSidebarItem, style: .home)
  5042. let content = NSView()
  5043. content.wantsLayer = true
  5044. content.layer?.backgroundColor = NSColor.clear.cgColor
  5045. root.addSubview(shell)
  5046. shell.addSubview(chromeColumn)
  5047. shell.addSubview(content)
  5048. chromeColumn.addSubview(chromeDivider)
  5049. chromeColumn.addSubview(chromeHeader)
  5050. chromeColumn.addSubview(sidebar)
  5051. shell.translatesAutoresizingMaskIntoConstraints = false
  5052. chromeColumn.translatesAutoresizingMaskIntoConstraints = false
  5053. chromeDivider.translatesAutoresizingMaskIntoConstraints = false
  5054. chromeHeader.translatesAutoresizingMaskIntoConstraints = false
  5055. sidebar.translatesAutoresizingMaskIntoConstraints = false
  5056. content.translatesAutoresizingMaskIntoConstraints = false
  5057. NSLayoutConstraint.activate([
  5058. shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  5059. shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  5060. shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
  5061. shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  5062. chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
  5063. chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
  5064. chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
  5065. chromeColumn.widthAnchor.constraint(equalToConstant: 82),
  5066. chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  5067. chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  5068. chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  5069. chromeDivider.widthAnchor.constraint(equalToConstant: 1),
  5070. chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
  5071. chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  5072. chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  5073. chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
  5074. sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
  5075. sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  5076. sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
  5077. sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
  5078. content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
  5079. content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
  5080. content.topAnchor.constraint(equalTo: shell.topAnchor),
  5081. content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
  5082. ])
  5083. let brandStack = NSStackView()
  5084. brandStack.orientation = .vertical
  5085. brandStack.spacing = 0
  5086. brandStack.alignment = .leading
  5087. let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
  5088. let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
  5089. brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
  5090. brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
  5091. [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
  5092. let topBar = NSView()
  5093. topBar.wantsLayer = true
  5094. topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
  5095. let topBarDivider = NSView()
  5096. topBarDivider.wantsLayer = true
  5097. topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
  5098. let searchPill = NSView()
  5099. searchPill.wantsLayer = true
  5100. searchPill.layer?.backgroundColor = searchPillBackground.cgColor
  5101. searchPill.layer?.cornerRadius = 10
  5102. searchPill.layer?.borderWidth = 0
  5103. let searchIcon = NSImageView()
  5104. searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
  5105. searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
  5106. searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
  5107. searchIcon.imageScaling = .scaleProportionallyUpOrDown
  5108. let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
  5109. searchHintLabel.isHidden = false
  5110. let searchField = SearchPillTextField()
  5111. searchField.isBordered = false
  5112. searchField.drawsBackground = false
  5113. searchField.backgroundColor = .clear
  5114. searchField.focusRingType = .none
  5115. searchField.isEditable = false
  5116. searchField.isSelectable = false
  5117. searchField.font = .systemFont(ofSize: 13, weight: .regular)
  5118. searchField.textColor = primaryText
  5119. searchField.alignment = .left
  5120. searchField.placeholderString = nil
  5121. if let cell = searchField.cell as? NSTextFieldCell {
  5122. cell.isBezeled = false
  5123. cell.isBordered = false
  5124. cell.backgroundColor = .clear
  5125. }
  5126. let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
  5127. guard let searchField, let searchHintLabel else { return }
  5128. let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
  5129. searchHintLabel.isHidden = shouldShow == false
  5130. }
  5131. searchField.onFocusChange = { [weak self] focused in
  5132. self?.applySearchPillFocusBorder(focused: focused)
  5133. updateSearchHintVisibility()
  5134. }
  5135. updateSearchHintVisibility()
  5136. let searchTap = NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped))
  5137. searchPill.addGestureRecognizer(searchTap)
  5138. searchField.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  5139. searchIcon.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  5140. searchHintLabel.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
  5141. let searchRow = NSStackView()
  5142. searchRow.orientation = .horizontal
  5143. searchRow.spacing = 14
  5144. searchRow.alignment = .centerY
  5145. searchRow.addArrangedSubview(searchPill)
  5146. let rightTopBarCluster = NSStackView()
  5147. rightTopBarCluster.orientation = .horizontal
  5148. rightTopBarCluster.spacing = 10
  5149. rightTopBarCluster.alignment = .centerY
  5150. let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
  5151. topBarPremiumButton = upgradeToProButton
  5152. updateTopBarPremiumButton()
  5153. let authButton = NSButton(title: "", target: self, action: #selector(topBarAuthButtonTapped))
  5154. authButton.isBordered = false
  5155. authButton.wantsLayer = true
  5156. authButton.layer?.cornerRadius = 11
  5157. authButton.contentTintColor = .white
  5158. authButton.font = .systemFont(ofSize: 12, weight: .semibold)
  5159. authButton.bezelStyle = .shadowlessSquare
  5160. self.topBarAuthButton = authButton
  5161. [upgradeToProButton, authButton].forEach { rightTopBarCluster.addArrangedSubview($0) }
  5162. let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
  5163. let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
  5164. let actions = NSStackView(views: [
  5165. makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange, action: #selector(newMeetingTapped)),
  5166. makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
  5167. makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
  5168. ])
  5169. actions.orientation = .horizontal
  5170. actions.spacing = 12
  5171. actions.alignment = .centerY
  5172. actions.distribution = .fillEqually
  5173. let panel = NSView()
  5174. panel.wantsLayer = true
  5175. panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  5176. panel.layer?.cornerRadius = 16
  5177. panel.layer?.borderWidth = 1
  5178. panel.layer?.borderColor = palette.inputBorder.cgColor
  5179. let todaysDateFormatter = DateFormatter()
  5180. todaysDateFormatter.dateFormat = "EEEE, MMM d"
  5181. let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
  5182. let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
  5183. let meetingsDayNav = NSStackView()
  5184. meetingsDayNav.orientation = .horizontal
  5185. meetingsDayNav.spacing = 4
  5186. meetingsDayNav.alignment = .centerY
  5187. let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
  5188. let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
  5189. let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
  5190. [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
  5191. let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
  5192. let meetingsScrollView = NSScrollView()
  5193. meetingsScrollView.drawsBackground = false
  5194. meetingsScrollView.hasVerticalScroller = true
  5195. meetingsScrollView.hasHorizontalScroller = false
  5196. meetingsScrollView.autohidesScrollers = true
  5197. let meetingsDocument = FlippedView()
  5198. let meetingsStack = NSStackView()
  5199. meetingsStack.orientation = .vertical
  5200. meetingsStack.spacing = 14
  5201. meetingsStack.alignment = .leading
  5202. // Keep meeting cards pinned to the top of the scroll content.
  5203. meetingsStack.setContentHuggingPriority(.required, for: .vertical)
  5204. meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  5205. let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
  5206. refreshMeetingsButton.isBordered = false
  5207. refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
  5208. refreshMeetingsButton.contentTintColor = primaryText
  5209. let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  5210. if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
  5211. let image = base.withSymbolConfiguration(refreshSymbolConfig) {
  5212. image.isTemplate = true
  5213. refreshMeetingsButton.image = image
  5214. refreshMeetingsButton.imagePosition = .imageLeading
  5215. refreshMeetingsButton.imageHugsTitle = true
  5216. refreshMeetingsButton.imageScaling = .scaleNone
  5217. }
  5218. refreshMeetingsButton.wantsLayer = true
  5219. refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
  5220. ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
  5221. : NSColor.black.withAlphaComponent(0.06)
  5222. ).cgColor
  5223. refreshMeetingsButton.layer?.cornerRadius = 11
  5224. refreshMeetingsButton.layer?.borderWidth = 1
  5225. refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
  5226. self.refreshMeetingsButton = refreshMeetingsButton
  5227. let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
  5228. placeholder.isHidden = true
  5229. let settingsView = makeSettingsView()
  5230. settingsView.isHidden = selectedHomeSidebarItem != "Settings"
  5231. let meetingsPageView = makeMeetingsPageView()
  5232. meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
  5233. let widgetsPageView = makeWidgetsPageView()
  5234. widgetsPageView.isHidden = selectedHomeSidebarItem != "Widgets"
  5235. let aiCompanionPageView = makeAiCompanionPageView()
  5236. aiCompanionPageView.isHidden = selectedHomeSidebarItem != HomeSection.aiCompanion.rawValue
  5237. let searchOverlay = NSView()
  5238. searchOverlay.wantsLayer = true
  5239. searchOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.45).cgColor
  5240. searchOverlay.isHidden = true
  5241. searchOverlay.translatesAutoresizingMaskIntoConstraints = false
  5242. let searchPanel = NSView()
  5243. searchPanel.wantsLayer = true
  5244. searchPanel.layer?.backgroundColor = secondaryCardBackground.cgColor
  5245. searchPanel.layer?.cornerRadius = 14
  5246. searchPanel.layer?.borderWidth = 1
  5247. searchPanel.layer?.borderColor = palette.inputBorder.cgColor
  5248. searchPanel.translatesAutoresizingMaskIntoConstraints = false
  5249. let popupTitle = makeLabel("Search Meetings", size: 18, color: primaryText, weight: .semibold, centered: false)
  5250. popupTitle.translatesAutoresizingMaskIntoConstraints = false
  5251. let popupSubtitle = makeLabel("Find any scheduled meeting instantly", size: 12, color: secondaryText, weight: .regular, centered: false)
  5252. popupSubtitle.translatesAutoresizingMaskIntoConstraints = false
  5253. let popupSearchChrome = NSView()
  5254. popupSearchChrome.translatesAutoresizingMaskIntoConstraints = false
  5255. popupSearchChrome.wantsLayer = true
  5256. popupSearchChrome.layer?.backgroundColor = searchPillBackground.cgColor
  5257. popupSearchChrome.layer?.cornerRadius = 10
  5258. popupSearchChrome.layer?.borderWidth = 1
  5259. popupSearchChrome.layer?.borderColor = palette.inputBorder.cgColor
  5260. let popupSearchIcon = NSImageView()
  5261. popupSearchIcon.translatesAutoresizingMaskIntoConstraints = false
  5262. popupSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search meetings")
  5263. popupSearchIcon.contentTintColor = secondaryText
  5264. popupSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
  5265. let popupSearchField = NSTextField()
  5266. popupSearchField.translatesAutoresizingMaskIntoConstraints = false
  5267. popupSearchField.isBordered = false
  5268. popupSearchField.drawsBackground = false
  5269. popupSearchField.backgroundColor = .clear
  5270. popupSearchField.focusRingType = .none
  5271. popupSearchField.font = .systemFont(ofSize: 15, weight: .regular)
  5272. popupSearchField.textColor = primaryText
  5273. popupSearchField.placeholderString = "Search all meetings by title, host, source, ID..."
  5274. let popupResultsScroll = NSScrollView()
  5275. popupResultsScroll.translatesAutoresizingMaskIntoConstraints = false
  5276. popupResultsScroll.drawsBackground = false
  5277. popupResultsScroll.hasVerticalScroller = true
  5278. popupResultsScroll.hasHorizontalScroller = false
  5279. popupResultsScroll.autohidesScrollers = true
  5280. let popupResultsDocument = FlippedView()
  5281. popupResultsDocument.translatesAutoresizingMaskIntoConstraints = false
  5282. let popupResultsStack = NSStackView()
  5283. popupResultsStack.translatesAutoresizingMaskIntoConstraints = false
  5284. popupResultsStack.orientation = .vertical
  5285. popupResultsStack.spacing = 10
  5286. popupResultsStack.alignment = .leading
  5287. let popupEmpty = makeLabel("No meetings match your search.", size: 13, color: secondaryText, weight: .regular, centered: true)
  5288. popupEmpty.translatesAutoresizingMaskIntoConstraints = false
  5289. popupEmpty.isHidden = true
  5290. root.addSubview(searchOverlay)
  5291. searchOverlay.addSubview(searchPanel)
  5292. searchPanel.addSubview(popupTitle)
  5293. searchPanel.addSubview(popupSubtitle)
  5294. searchPanel.addSubview(popupSearchChrome)
  5295. searchPanel.addSubview(popupResultsScroll)
  5296. searchPanel.addSubview(popupEmpty)
  5297. popupSearchChrome.addSubview(popupSearchIcon)
  5298. popupSearchChrome.addSubview(popupSearchField)
  5299. popupResultsScroll.documentView = popupResultsDocument
  5300. popupResultsDocument.addSubview(popupResultsStack)
  5301. let schedulerRoot = NSView()
  5302. schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
  5303. let schedulerSplit = NSStackView()
  5304. schedulerSplit.orientation = .horizontal
  5305. schedulerSplit.spacing = 14
  5306. schedulerSplit.distribution = .fillEqually
  5307. schedulerSplit.alignment = .top
  5308. let schedulerCalendarPanel = NSView()
  5309. schedulerCalendarPanel.wantsLayer = true
  5310. schedulerCalendarPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  5311. schedulerCalendarPanel.layer?.cornerRadius = 16
  5312. schedulerCalendarPanel.layer?.borderWidth = 1
  5313. schedulerCalendarPanel.layer?.borderColor = palette.inputBorder.cgColor
  5314. let schedulerMeetingsPanel = NSView()
  5315. schedulerMeetingsPanel.wantsLayer = true
  5316. schedulerMeetingsPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  5317. schedulerMeetingsPanel.layer?.cornerRadius = 16
  5318. schedulerMeetingsPanel.layer?.borderWidth = 1
  5319. schedulerMeetingsPanel.layer?.borderColor = palette.inputBorder.cgColor
  5320. let schedulerCalendarTopRow = NSStackView()
  5321. schedulerCalendarTopRow.orientation = .horizontal
  5322. schedulerCalendarTopRow.alignment = .centerY
  5323. schedulerCalendarTopRow.distribution = .fill
  5324. schedulerCalendarTopRow.spacing = 10
  5325. let schedulerConnectLabel = makeLabel("Zoom calendar", size: 18, color: accentBlue, weight: .semibold, centered: false)
  5326. let schedulerPlusButton = NSButton(title: "", target: self, action: #selector(scheduleMeetingTapped))
  5327. schedulerPlusButton.isBordered = false
  5328. schedulerPlusButton.bezelStyle = .shadowlessSquare
  5329. schedulerPlusButton.focusRingType = .none
  5330. schedulerPlusButton.wantsLayer = true
  5331. schedulerPlusButton.layer?.backgroundColor = accentBlue.cgColor
  5332. schedulerPlusButton.layer?.cornerRadius = 14
  5333. schedulerPlusButton.contentTintColor = .white
  5334. schedulerPlusButton.toolTip = "Connect calendar"
  5335. if let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Connect calendar") {
  5336. schedulerPlusButton.image = image
  5337. schedulerPlusButton.imagePosition = .imageOnly
  5338. }
  5339. schedulerPlusButton.translatesAutoresizingMaskIntoConstraints = false
  5340. schedulerPlusButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  5341. schedulerPlusButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  5342. let schedulerMonthRow = NSStackView()
  5343. schedulerMonthRow.orientation = .horizontal
  5344. schedulerMonthRow.alignment = .centerY
  5345. schedulerMonthRow.spacing = 8
  5346. let schedulerPrevMonthButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(schedulerPrevMonthTapped), dimension: 18, pointSize: 10, toolTip: "Previous month")
  5347. let schedulerMonthLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  5348. let schedulerNextMonthButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(schedulerNextMonthTapped), dimension: 18, pointSize: 10, toolTip: "Next month")
  5349. [schedulerPrevMonthButton, schedulerMonthLabel, schedulerNextMonthButton].forEach { schedulerMonthRow.addArrangedSubview($0) }
  5350. let schedulerWeekdaysRow = NSStackView()
  5351. schedulerWeekdaysRow.orientation = .horizontal
  5352. schedulerWeekdaysRow.distribution = .fillEqually
  5353. // Match date-row spacing so each date column aligns under weekday labels.
  5354. schedulerWeekdaysRow.spacing = 6
  5355. let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
  5356. for symbol in weekdaySymbols {
  5357. let weekdayLabel = makeLabel(symbol, size: 12, color: mutedText, weight: .medium, centered: true)
  5358. schedulerWeekdaysRow.addArrangedSubview(weekdayLabel)
  5359. }
  5360. let schedulerCalendarGrid = NSStackView()
  5361. schedulerCalendarGrid.orientation = .vertical
  5362. schedulerCalendarGrid.distribution = .fillEqually
  5363. schedulerCalendarGrid.alignment = .leading
  5364. schedulerCalendarGrid.spacing = 6
  5365. let schedulerRightTopRow = NSStackView()
  5366. schedulerRightTopRow.orientation = .horizontal
  5367. schedulerRightTopRow.alignment = .centerY
  5368. schedulerRightTopRow.spacing = 10
  5369. let schedulerSelectedDateLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
  5370. let schedulerTodayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(schedulerTodayTapped))
  5371. schedulerTodayButton.toolTip = "Jump to today"
  5372. [schedulerTodayButton].forEach { schedulerRightTopRow.addArrangedSubview($0) }
  5373. let schedulerMeetingsHeader = makeLabel("Scheduled meetings", size: 13, color: secondaryText, weight: .medium, centered: false)
  5374. let schedulerMeetingsScroll = NSScrollView()
  5375. schedulerMeetingsScroll.drawsBackground = false
  5376. schedulerMeetingsScroll.hasVerticalScroller = true
  5377. schedulerMeetingsScroll.hasHorizontalScroller = false
  5378. schedulerMeetingsScroll.autohidesScrollers = true
  5379. let schedulerMeetingsDocument = FlippedView()
  5380. let schedulerMeetingsStack = NSStackView()
  5381. schedulerMeetingsStack.orientation = .vertical
  5382. schedulerMeetingsStack.spacing = 12
  5383. schedulerMeetingsStack.alignment = .leading
  5384. schedulerMeetingsStack.setContentHuggingPriority(.required, for: .vertical)
  5385. schedulerMeetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
  5386. schedulerMeetingsScroll.documentView = schedulerMeetingsDocument
  5387. schedulerMeetingsDocument.addSubview(schedulerMeetingsStack)
  5388. let schedulerEmptyLabel = makeLabel("No meetings scheduled for this date.", size: 15, color: secondaryText, weight: .regular, centered: true)
  5389. schedulerEmptyLabel.isHidden = true
  5390. let bodyRow = NSView()
  5391. bodyRow.translatesAutoresizingMaskIntoConstraints = false
  5392. let mainContentColumn = NSView()
  5393. mainContentColumn.translatesAutoresizingMaskIntoConstraints = false
  5394. mainContentColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  5395. let rightWebPanel = NSView()
  5396. rightWebPanel.translatesAutoresizingMaskIntoConstraints = false
  5397. rightWebPanel.wantsLayer = true
  5398. rightWebPanel.layer?.cornerRadius = 16
  5399. rightWebPanel.layer?.masksToBounds = true
  5400. rightWebPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  5401. rightWebPanel.layer?.borderWidth = 1
  5402. rightWebPanel.layer?.borderColor = palette.inputBorder.cgColor
  5403. let webToolbar = NSView()
  5404. webToolbar.translatesAutoresizingMaskIntoConstraints = false
  5405. webToolbar.wantsLayer = true
  5406. webToolbar.layer?.backgroundColor = chromeUnifiedBackground.withAlphaComponent(0.55).cgColor
  5407. func prepareEmbeddedToolButton(_ button: NSButton, symbol: String) {
  5408. button.translatesAutoresizingMaskIntoConstraints = false
  5409. button.bezelStyle = .toolbar
  5410. if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) {
  5411. button.image = image
  5412. }
  5413. }
  5414. let browserBack = NSButton()
  5415. let browserForward = NSButton()
  5416. let browserReload = NSButton()
  5417. let browserClose = NSButton()
  5418. prepareEmbeddedToolButton(browserBack, symbol: "chevron.left")
  5419. prepareEmbeddedToolButton(browserForward, symbol: "chevron.right")
  5420. prepareEmbeddedToolButton(browserReload, symbol: "arrow.clockwise")
  5421. prepareEmbeddedToolButton(browserClose, symbol: "xmark.circle.fill")
  5422. browserClose.toolTip = "Close web panel"
  5423. browserBack.target = self
  5424. browserBack.action = #selector(embeddedBrowserBackTapped)
  5425. browserForward.target = self
  5426. browserForward.action = #selector(embeddedBrowserForwardTapped)
  5427. browserReload.target = self
  5428. browserReload.action = #selector(embeddedBrowserReloadTapped)
  5429. browserClose.target = self
  5430. browserClose.action = #selector(embeddedBrowserClosePanelTapped)
  5431. let toolbarSpring = NSView()
  5432. toolbarSpring.translatesAutoresizingMaskIntoConstraints = false
  5433. toolbarSpring.setContentHuggingPriority(.defaultLow, for: .horizontal)
  5434. let webToolBarStack = NSStackView(views: [browserBack, browserForward, browserReload, toolbarSpring, browserClose])
  5435. webToolBarStack.orientation = .horizontal
  5436. webToolBarStack.spacing = 6
  5437. webToolBarStack.alignment = .centerY
  5438. webToolBarStack.translatesAutoresizingMaskIntoConstraints = false
  5439. let embeddedBrowserConfiguration = WKWebViewConfiguration()
  5440. embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
  5441. let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
  5442. embeddedWK.translatesAutoresizingMaskIntoConstraints = false
  5443. embeddedWK.setContentCompressionResistancePriority(.init(1), for: .horizontal)
  5444. embeddedWK.setContentHuggingPriority(.init(1), for: .horizontal)
  5445. embeddedWK.navigationDelegate = self
  5446. embeddedWK.uiDelegate = self
  5447. content.addSubview(topBar)
  5448. content.addSubview(topBarDivider)
  5449. content.addSubview(bodyRow)
  5450. bodyRow.addSubview(mainContentColumn)
  5451. bodyRow.addSubview(rightWebPanel)
  5452. rightWebPanel.addSubview(webToolbar)
  5453. webToolbar.addSubview(webToolBarStack)
  5454. rightWebPanel.addSubview(embeddedWK)
  5455. let rightPanelWidthConstraint = rightWebPanel.widthAnchor.constraint(equalToConstant: 0)
  5456. let mainColumnCollapsedWidthConstraint = mainContentColumn.widthAnchor.constraint(equalToConstant: 0)
  5457. mainColumnCollapsedWidthConstraint.isActive = false
  5458. [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
  5459. $0.translatesAutoresizingMaskIntoConstraints = false
  5460. }
  5461. [brandStack].forEach {
  5462. shell.addSubview($0)
  5463. }
  5464. [searchRow, rightTopBarCluster].forEach {
  5465. topBar.addSubview($0)
  5466. }
  5467. [searchIcon, searchField, searchHintLabel].forEach {
  5468. searchPill.addSubview($0)
  5469. }
  5470. [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, widgetsPageView, aiCompanionPageView, schedulerRoot].forEach {
  5471. $0.translatesAutoresizingMaskIntoConstraints = false
  5472. mainContentColumn.addSubview($0)
  5473. }
  5474. [schedulerSplit, schedulerCalendarPanel, schedulerMeetingsPanel, schedulerCalendarTopRow, schedulerConnectLabel, schedulerPlusButton, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid, schedulerRightTopRow, schedulerSelectedDateLabel, schedulerTodayButton, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel, schedulerMeetingsDocument, schedulerMeetingsStack].forEach {
  5475. $0.translatesAutoresizingMaskIntoConstraints = false
  5476. }
  5477. schedulerRoot.addSubview(schedulerSplit)
  5478. [schedulerCalendarPanel, schedulerMeetingsPanel].forEach { schedulerSplit.addArrangedSubview($0) }
  5479. [schedulerCalendarTopRow, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid].forEach { schedulerCalendarPanel.addSubview($0) }
  5480. schedulerCalendarTopRow.addArrangedSubview(schedulerConnectLabel)
  5481. schedulerCalendarTopRow.addArrangedSubview(NSView())
  5482. schedulerCalendarTopRow.addArrangedSubview(schedulerPlusButton)
  5483. [schedulerSelectedDateLabel, schedulerRightTopRow, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel].forEach { schedulerMeetingsPanel.addSubview($0) }
  5484. topBar.translatesAutoresizingMaskIntoConstraints = false
  5485. topBarDivider.translatesAutoresizingMaskIntoConstraints = false
  5486. meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
  5487. meetingsStack.translatesAutoresizingMaskIntoConstraints = false
  5488. meetingsScrollView.documentView = meetingsDocument
  5489. meetingsDocument.addSubview(meetingsStack)
  5490. let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
  5491. searchRowCenterX.priority = .defaultHigh
  5492. let brandLeading = brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset)
  5493. brandLeadingConstraint = brandLeading
  5494. NSLayoutConstraint.activate([
  5495. brandLeading,
  5496. brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
  5497. brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
  5498. topBar.topAnchor.constraint(equalTo: content.topAnchor),
  5499. topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  5500. topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  5501. topBar.heightAnchor.constraint(equalToConstant: 56),
  5502. topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
  5503. topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
  5504. topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
  5505. topBarDivider.heightAnchor.constraint(equalToConstant: 1),
  5506. bodyRow.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
  5507. bodyRow.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
  5508. bodyRow.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
  5509. bodyRow.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
  5510. mainContentColumn.leadingAnchor.constraint(equalTo: bodyRow.leadingAnchor),
  5511. mainContentColumn.topAnchor.constraint(equalTo: bodyRow.topAnchor),
  5512. mainContentColumn.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
  5513. mainContentColumn.trailingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor, constant: -homeEmbeddedBrowserColumnGap),
  5514. rightWebPanel.topAnchor.constraint(equalTo: bodyRow.topAnchor),
  5515. rightWebPanel.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
  5516. rightWebPanel.trailingAnchor.constraint(equalTo: bodyRow.trailingAnchor),
  5517. rightPanelWidthConstraint,
  5518. webToolbar.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
  5519. webToolbar.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
  5520. webToolbar.topAnchor.constraint(equalTo: rightWebPanel.topAnchor),
  5521. webToolbar.heightAnchor.constraint(equalToConstant: 40),
  5522. webToolBarStack.leadingAnchor.constraint(equalTo: webToolbar.leadingAnchor, constant: 8),
  5523. webToolBarStack.trailingAnchor.constraint(equalTo: webToolbar.trailingAnchor, constant: -8),
  5524. webToolBarStack.centerYAnchor.constraint(equalTo: webToolbar.centerYAnchor),
  5525. embeddedWK.topAnchor.constraint(equalTo: webToolbar.bottomAnchor),
  5526. embeddedWK.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
  5527. embeddedWK.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
  5528. embeddedWK.bottomAnchor.constraint(equalTo: rightWebPanel.bottomAnchor),
  5529. searchRowCenterX,
  5530. searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  5531. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
  5532. searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
  5533. searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
  5534. rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  5535. rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
  5536. searchPill.heightAnchor.constraint(equalToConstant: 32),
  5537. searchPill.widthAnchor.constraint(equalToConstant: 320),
  5538. searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
  5539. searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  5540. searchIcon.widthAnchor.constraint(equalToConstant: 16),
  5541. searchIcon.heightAnchor.constraint(equalToConstant: 16),
  5542. searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
  5543. searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  5544. searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
  5545. searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
  5546. searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
  5547. searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
  5548. authButton.heightAnchor.constraint(equalToConstant: 30),
  5549. timeTitle.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 20),
  5550. timeTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  5551. dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
  5552. dateTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  5553. actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
  5554. actions.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  5555. actions.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 12),
  5556. actions.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -12),
  5557. actions.heightAnchor.constraint(equalToConstant: 100),
  5558. panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
  5559. panel.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  5560. panel.widthAnchor.constraint(equalToConstant: 610),
  5561. panel.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 6),
  5562. panel.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -6),
  5563. panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  5564. panel.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -14),
  5565. panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
  5566. panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
  5567. meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  5568. meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
  5569. meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
  5570. meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
  5571. noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  5572. noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  5573. noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
  5574. meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
  5575. meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  5576. meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  5577. meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
  5578. meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
  5579. meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
  5580. meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
  5581. meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
  5582. meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
  5583. refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
  5584. refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
  5585. refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
  5586. refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
  5587. placeholder.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
  5588. placeholder.centerYAnchor.constraint(equalTo: mainContentColumn.centerYAnchor),
  5589. settingsView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  5590. settingsView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  5591. settingsView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  5592. settingsView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  5593. meetingsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  5594. meetingsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  5595. meetingsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  5596. meetingsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  5597. widgetsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  5598. widgetsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  5599. widgetsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  5600. widgetsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  5601. aiCompanionPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
  5602. aiCompanionPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
  5603. aiCompanionPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
  5604. aiCompanionPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
  5605. schedulerRoot.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 8),
  5606. schedulerRoot.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor, constant: 8),
  5607. schedulerRoot.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor, constant: -8),
  5608. schedulerRoot.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -8),
  5609. schedulerSplit.topAnchor.constraint(equalTo: schedulerRoot.topAnchor),
  5610. schedulerSplit.leadingAnchor.constraint(equalTo: schedulerRoot.leadingAnchor),
  5611. schedulerSplit.trailingAnchor.constraint(equalTo: schedulerRoot.trailingAnchor),
  5612. schedulerSplit.bottomAnchor.constraint(equalTo: schedulerRoot.bottomAnchor),
  5613. schedulerCalendarTopRow.topAnchor.constraint(equalTo: schedulerCalendarPanel.topAnchor, constant: 18),
  5614. schedulerCalendarTopRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  5615. schedulerCalendarTopRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  5616. schedulerMonthRow.topAnchor.constraint(equalTo: schedulerCalendarTopRow.bottomAnchor, constant: 18),
  5617. schedulerMonthRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  5618. schedulerMonthRow.trailingAnchor.constraint(lessThanOrEqualTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  5619. schedulerWeekdaysRow.topAnchor.constraint(equalTo: schedulerMonthRow.bottomAnchor, constant: 14),
  5620. schedulerWeekdaysRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  5621. schedulerWeekdaysRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  5622. schedulerCalendarGrid.topAnchor.constraint(equalTo: schedulerWeekdaysRow.bottomAnchor, constant: 10),
  5623. schedulerCalendarGrid.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
  5624. schedulerCalendarGrid.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
  5625. schedulerCalendarGrid.bottomAnchor.constraint(equalTo: schedulerCalendarPanel.bottomAnchor, constant: -16),
  5626. schedulerCalendarGrid.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
  5627. schedulerSelectedDateLabel.topAnchor.constraint(equalTo: schedulerMeetingsPanel.topAnchor, constant: 18),
  5628. schedulerSelectedDateLabel.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  5629. schedulerSelectedDateLabel.trailingAnchor.constraint(lessThanOrEqualTo: schedulerRightTopRow.leadingAnchor, constant: -10),
  5630. schedulerRightTopRow.centerYAnchor.constraint(equalTo: schedulerSelectedDateLabel.centerYAnchor),
  5631. schedulerRightTopRow.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  5632. schedulerMeetingsHeader.topAnchor.constraint(equalTo: schedulerSelectedDateLabel.bottomAnchor, constant: 12),
  5633. schedulerMeetingsHeader.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
  5634. schedulerMeetingsHeader.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
  5635. schedulerMeetingsScroll.topAnchor.constraint(equalTo: schedulerMeetingsHeader.bottomAnchor, constant: 10),
  5636. schedulerMeetingsScroll.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 14),
  5637. schedulerMeetingsScroll.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -14),
  5638. schedulerMeetingsScroll.bottomAnchor.constraint(equalTo: schedulerMeetingsPanel.bottomAnchor, constant: -14),
  5639. schedulerMeetingsDocument.widthAnchor.constraint(equalTo: schedulerMeetingsScroll.contentView.widthAnchor),
  5640. schedulerMeetingsStack.topAnchor.constraint(equalTo: schedulerMeetingsDocument.topAnchor),
  5641. schedulerMeetingsStack.leadingAnchor.constraint(equalTo: schedulerMeetingsDocument.leadingAnchor),
  5642. schedulerMeetingsStack.trailingAnchor.constraint(equalTo: schedulerMeetingsDocument.trailingAnchor),
  5643. schedulerMeetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: schedulerMeetingsDocument.bottomAnchor),
  5644. schedulerEmptyLabel.centerXAnchor.constraint(equalTo: schedulerMeetingsPanel.centerXAnchor),
  5645. schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor),
  5646. searchOverlay.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  5647. searchOverlay.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  5648. searchOverlay.topAnchor.constraint(equalTo: root.topAnchor),
  5649. searchOverlay.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  5650. searchPanel.centerXAnchor.constraint(equalTo: searchOverlay.centerXAnchor),
  5651. searchPanel.centerYAnchor.constraint(equalTo: searchOverlay.centerYAnchor),
  5652. searchPanel.widthAnchor.constraint(equalToConstant: 640),
  5653. searchPanel.heightAnchor.constraint(equalToConstant: 460),
  5654. popupTitle.topAnchor.constraint(equalTo: searchPanel.topAnchor, constant: 18),
  5655. popupTitle.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  5656. popupTitle.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  5657. popupSubtitle.topAnchor.constraint(equalTo: popupTitle.bottomAnchor, constant: 4),
  5658. popupSubtitle.leadingAnchor.constraint(equalTo: popupTitle.leadingAnchor),
  5659. popupSubtitle.trailingAnchor.constraint(equalTo: popupTitle.trailingAnchor),
  5660. popupSearchChrome.topAnchor.constraint(equalTo: popupSubtitle.bottomAnchor, constant: 12),
  5661. popupSearchChrome.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  5662. popupSearchChrome.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  5663. popupSearchChrome.heightAnchor.constraint(equalToConstant: 40),
  5664. popupSearchIcon.leadingAnchor.constraint(equalTo: popupSearchChrome.leadingAnchor, constant: 12),
  5665. popupSearchIcon.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  5666. popupSearchIcon.widthAnchor.constraint(equalToConstant: 16),
  5667. popupSearchIcon.heightAnchor.constraint(equalToConstant: 16),
  5668. popupSearchField.leadingAnchor.constraint(equalTo: popupSearchIcon.trailingAnchor, constant: 8),
  5669. popupSearchField.trailingAnchor.constraint(equalTo: popupSearchChrome.trailingAnchor, constant: -12),
  5670. popupSearchField.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
  5671. popupResultsScroll.topAnchor.constraint(equalTo: popupSearchChrome.bottomAnchor, constant: 12),
  5672. popupResultsScroll.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
  5673. popupResultsScroll.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
  5674. popupResultsScroll.bottomAnchor.constraint(equalTo: searchPanel.bottomAnchor, constant: -18),
  5675. popupResultsStack.topAnchor.constraint(equalTo: popupResultsDocument.topAnchor),
  5676. popupResultsStack.leadingAnchor.constraint(equalTo: popupResultsDocument.leadingAnchor),
  5677. popupResultsStack.trailingAnchor.constraint(equalTo: popupResultsDocument.trailingAnchor),
  5678. popupResultsStack.bottomAnchor.constraint(lessThanOrEqualTo: popupResultsDocument.bottomAnchor),
  5679. popupResultsStack.widthAnchor.constraint(equalTo: popupResultsScroll.contentView.widthAnchor),
  5680. popupEmpty.centerXAnchor.constraint(equalTo: popupResultsScroll.centerXAnchor),
  5681. popupEmpty.centerYAnchor.constraint(equalTo: popupResultsScroll.centerYAnchor)
  5682. ])
  5683. timeLabel = timeTitle
  5684. dateLabel = dateTitle
  5685. homeWelcomeLabel = nil
  5686. homeTimeLabelView = timeTitle
  5687. homeDateLabelView = dateTitle
  5688. homeActionsRow = actions
  5689. homeMeetingsPanel = panel
  5690. homePlaceholderLabel = placeholder
  5691. homeSettingsView = settingsView
  5692. homeSettingsHostColumn = mainContentColumn
  5693. meetingsPageRootView = meetingsPageView
  5694. widgetsPageRootView = widgetsPageView
  5695. aiCompanionPageRootView = aiCompanionPageView
  5696. schedulerRootView = schedulerRoot
  5697. self.schedulerMonthLabel = schedulerMonthLabel
  5698. schedulerCalendarGridStack = schedulerCalendarGrid
  5699. self.schedulerSelectedDateLabel = schedulerSelectedDateLabel
  5700. schedulerMeetingsListStack = schedulerMeetingsStack
  5701. schedulerMeetingsEmptyLabel = schedulerEmptyLabel
  5702. self.schedulerTodayButton = schedulerTodayButton
  5703. meetingsDayHeaderLabel = panelHeader
  5704. meetingsListStack = meetingsStack
  5705. meetingsStatusLabel = meetingsStatus
  5706. emptyMeetingLabel = noMeeting
  5707. meetingsPrevDayButton = prevDayButton
  5708. meetingsTodayButton = todayButton
  5709. meetingsNextDayButton = nextDayButton
  5710. observeMeetingsScrollEdges(in: meetingsScrollView)
  5711. updateClock()
  5712. updateMeetingsDayUI()
  5713. applyFilteredMeetings()
  5714. applyMeetingsPageFilters(resetVisible: true)
  5715. renderSchedulerCalendarGrid()
  5716. updateSchedulerDateHeader()
  5717. applySchedulerMeetingsForSelectedDate()
  5718. homeSearchField = searchField
  5719. homeSearchPill = searchPill
  5720. meetingSearchOverlayView = searchOverlay
  5721. meetingSearchPanelView = searchPanel
  5722. meetingSearchField = popupSearchField
  5723. meetingSearchResultsStack = popupResultsStack
  5724. meetingSearchEmptyLabel = popupEmpty
  5725. removeMeetingSearchObserver()
  5726. meetingSearchTextObserver = NotificationCenter.default.addObserver(
  5727. forName: NSControl.textDidChangeNotification,
  5728. object: popupSearchField,
  5729. queue: .main
  5730. ) { [weak self] _ in
  5731. guard let self else { return }
  5732. self.applyGlobalMeetingSearch(query: popupSearchField.stringValue)
  5733. }
  5734. applyGlobalMeetingSearch(query: "")
  5735. topBarAuthButtonWidthConstraint = authButton.widthAnchor.constraint(equalToConstant: 120)
  5736. topBarAuthButtonWidthConstraint?.isActive = true
  5737. updateTopBarAuthButton()
  5738. applyAuthenticationUIState(triggerInitialRefresh: false)
  5739. updatePremiumLockedSections()
  5740. embeddedBrowserWebView = embeddedWK
  5741. embeddedBrowserPanel = rightWebPanel
  5742. homeBodyRowView = bodyRow
  5743. homeMainContentColumnView = mainContentColumn
  5744. embeddedBrowserPanelWidthConstraint = rightPanelWidthConstraint
  5745. embeddedBrowserMainColumnCollapsedWidthConstraint = mainColumnCollapsedWidthConstraint
  5746. embeddedBrowserBackButton = browserBack
  5747. embeddedBrowserForwardButton = browserForward
  5748. refreshEmbeddedBrowserNavButtons()
  5749. return root
  5750. }
  5751. @objc private func premiumLockedAreaTapped(_ sender: NSButton) {
  5752. showPaywall()
  5753. }
  5754. @MainActor
  5755. private func updatePremiumLockedSections() {
  5756. let shouldLock = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  5757. refreshWidgetsPagePremiumState()
  5758. homePremiumLockOverlay = updatePremiumLockOverlay(
  5759. existing: homePremiumLockOverlay,
  5760. in: homeMeetingsPanel,
  5761. locked: shouldLock,
  5762. message: "Upgrade to Premium to access meetings"
  5763. )
  5764. meetingsPremiumLockOverlay = updatePremiumLockOverlay(
  5765. existing: meetingsPremiumLockOverlay,
  5766. in: meetingsPageRootView,
  5767. locked: shouldLock,
  5768. message: "Upgrade to Premium to unlock Meetings"
  5769. )
  5770. schedulerPremiumLockOverlay = updatePremiumLockOverlay(
  5771. existing: schedulerPremiumLockOverlay,
  5772. in: schedulerRootView,
  5773. locked: shouldLock,
  5774. message: "Upgrade to Premium to unlock Scheduler"
  5775. )
  5776. }
  5777. @MainActor
  5778. private func updatePremiumLockOverlay(
  5779. existing: NSButton?,
  5780. in target: NSView?,
  5781. locked: Bool,
  5782. message: String
  5783. ) -> NSButton? {
  5784. guard let target else { return existing }
  5785. let button: NSButton
  5786. if let existing, existing.superview === target {
  5787. button = existing
  5788. } else {
  5789. let created = NSButton(title: message, target: self, action: #selector(premiumLockedAreaTapped(_:)))
  5790. created.translatesAutoresizingMaskIntoConstraints = false
  5791. created.isBordered = false
  5792. created.bezelStyle = .shadowlessSquare
  5793. created.alignment = .center
  5794. created.wantsLayer = true
  5795. created.layer?.cornerRadius = 12
  5796. created.layer?.masksToBounds = true
  5797. created.layer?.backgroundColor = NSColor.clear.cgColor
  5798. created.font = .systemFont(ofSize: 15, weight: .semibold)
  5799. created.contentTintColor = .white
  5800. let blur = NSVisualEffectView()
  5801. blur.translatesAutoresizingMaskIntoConstraints = false
  5802. blur.material = .hudWindow
  5803. blur.blendingMode = .withinWindow
  5804. blur.state = .active
  5805. created.addSubview(blur, positioned: .below, relativeTo: nil)
  5806. let dim = NSView()
  5807. dim.translatesAutoresizingMaskIntoConstraints = false
  5808. dim.wantsLayer = true
  5809. dim.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  5810. created.addSubview(dim, positioned: .above, relativeTo: blur)
  5811. target.addSubview(created)
  5812. NSLayoutConstraint.activate([
  5813. created.leadingAnchor.constraint(equalTo: target.leadingAnchor),
  5814. created.trailingAnchor.constraint(equalTo: target.trailingAnchor),
  5815. created.topAnchor.constraint(equalTo: target.topAnchor),
  5816. created.bottomAnchor.constraint(equalTo: target.bottomAnchor),
  5817. blur.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  5818. blur.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  5819. blur.topAnchor.constraint(equalTo: created.topAnchor),
  5820. blur.bottomAnchor.constraint(equalTo: created.bottomAnchor),
  5821. dim.leadingAnchor.constraint(equalTo: created.leadingAnchor),
  5822. dim.trailingAnchor.constraint(equalTo: created.trailingAnchor),
  5823. dim.topAnchor.constraint(equalTo: created.topAnchor),
  5824. dim.bottomAnchor.constraint(equalTo: created.bottomAnchor)
  5825. ])
  5826. button = created
  5827. }
  5828. let blurView = button.subviews.compactMap { $0 as? NSVisualEffectView }.first
  5829. let dimView = button.subviews.first { $0 is NSVisualEffectView == false }
  5830. if palette.isDarkMode {
  5831. blurView?.material = .hudWindow
  5832. button.contentTintColor = .white
  5833. dimView?.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
  5834. } else {
  5835. // Lighter, cleaner lock treatment in light mode.
  5836. blurView?.material = .popover
  5837. button.contentTintColor = NSColor(calibratedWhite: 0.09, alpha: 1.0)
  5838. dimView?.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.38).cgColor
  5839. }
  5840. button.title = message
  5841. button.isHidden = locked == false
  5842. return button
  5843. }
  5844. private func makeWidgetsPageView() -> NSView {
  5845. let canAdd = storeKitCoordinator.hasPremiumAccess
  5846. let host = NSHostingView(
  5847. rootView: WidgetsRootView(
  5848. canAddWidgets: canAdd,
  5849. onAddBlocked: { [weak self] in
  5850. self?.showPaywall()
  5851. }
  5852. )
  5853. )
  5854. host.wantsLayer = true
  5855. host.layer?.backgroundColor = NSColor.clear.cgColor
  5856. return host
  5857. }
  5858. private func makeAiCompanionPageView() -> NSView {
  5859. let root = NSView()
  5860. root.wantsLayer = true
  5861. root.layer?.backgroundColor = NSColor.clear.cgColor
  5862. let container = NSView()
  5863. container.translatesAutoresizingMaskIntoConstraints = false
  5864. container.wantsLayer = true
  5865. container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  5866. container.layer?.cornerRadius = 16
  5867. container.layer?.borderWidth = 1
  5868. container.layer?.borderColor = palette.inputBorder.cgColor
  5869. root.addSubview(container)
  5870. // Header row: title / subtitle / count + range selector + refresh.
  5871. let title = makeLabel("Ai Companion", size: 28, color: primaryText, weight: .bold, centered: false)
  5872. let subtitle = makeLabel("AI-generated notes from your past Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
  5873. let count = makeLabel("0 summaries", size: 12, color: secondaryText, weight: .medium, centered: false)
  5874. let titleStack = NSStackView(views: [title, subtitle, count])
  5875. titleStack.orientation = .vertical
  5876. titleStack.spacing = 4
  5877. titleStack.alignment = .leading
  5878. titleStack.translatesAutoresizingMaskIntoConstraints = false
  5879. let rangePopup = NSPopUpButton()
  5880. rangePopup.translatesAutoresizingMaskIntoConstraints = false
  5881. rangePopup.addItems(withTitles: ["Last 7 days", "Last 30 days", "Last 90 days", "All available"])
  5882. rangePopup.selectItem(withTitle: aiCompanionSelectedRange)
  5883. rangePopup.target = self
  5884. rangePopup.action = #selector(aiCompanionRangeChanged(_:))
  5885. let refresh = NSButton(title: "Refresh", target: self, action: #selector(aiCompanionRefreshTapped))
  5886. refresh.translatesAutoresizingMaskIntoConstraints = false
  5887. refresh.isBordered = false
  5888. refresh.wantsLayer = true
  5889. refresh.layer?.backgroundColor = accentBlue.cgColor
  5890. refresh.layer?.cornerRadius = 10
  5891. refresh.contentTintColor = .white
  5892. refresh.font = .systemFont(ofSize: 13, weight: .semibold)
  5893. let loadingIndicator = NSProgressIndicator()
  5894. loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
  5895. loadingIndicator.style = .spinning
  5896. loadingIndicator.controlSize = .small
  5897. loadingIndicator.isDisplayedWhenStopped = false
  5898. loadingIndicator.isIndeterminate = true
  5899. let toolbarRight = NSStackView(views: [loadingIndicator, rangePopup, refresh])
  5900. toolbarRight.orientation = .horizontal
  5901. toolbarRight.alignment = .centerY
  5902. toolbarRight.spacing = 10
  5903. toolbarRight.translatesAutoresizingMaskIntoConstraints = false
  5904. container.addSubview(titleStack)
  5905. container.addSubview(toolbarRight)
  5906. // Status label below header.
  5907. let status = makeLabel("Sign in with Zoom to view AI notes.", size: 12, color: secondaryText, weight: .regular, centered: false)
  5908. status.translatesAutoresizingMaskIntoConstraints = false
  5909. container.addSubview(status)
  5910. // List scroll view (left column).
  5911. let listScroll = NSScrollView()
  5912. listScroll.translatesAutoresizingMaskIntoConstraints = false
  5913. listScroll.drawsBackground = false
  5914. listScroll.hasHorizontalScroller = false
  5915. listScroll.hasVerticalScroller = true
  5916. listScroll.autohidesScrollers = true
  5917. listScroll.borderType = .noBorder
  5918. listScroll.scrollerStyle = .overlay
  5919. listScroll.automaticallyAdjustsContentInsets = false
  5920. let listClip = TopAlignedClipView()
  5921. listClip.drawsBackground = false
  5922. listScroll.contentView = listClip
  5923. let listStack = NSStackView()
  5924. listStack.translatesAutoresizingMaskIntoConstraints = false
  5925. listStack.orientation = .vertical
  5926. listStack.alignment = .leading
  5927. listStack.spacing = 10
  5928. listScroll.documentView = listStack
  5929. container.addSubview(listScroll)
  5930. let listEmpty = makeLabel("No AI notes yet for the selected range.", size: 12, color: secondaryText, weight: .regular, centered: true)
  5931. listEmpty.translatesAutoresizingMaskIntoConstraints = false
  5932. listEmpty.maximumNumberOfLines = 3
  5933. listEmpty.lineBreakMode = .byWordWrapping
  5934. listEmpty.isHidden = true
  5935. container.addSubview(listEmpty)
  5936. // Detail scroll view (right column).
  5937. let detailScroll = NSScrollView()
  5938. detailScroll.translatesAutoresizingMaskIntoConstraints = false
  5939. detailScroll.drawsBackground = false
  5940. detailScroll.hasHorizontalScroller = false
  5941. detailScroll.hasVerticalScroller = true
  5942. detailScroll.autohidesScrollers = true
  5943. detailScroll.borderType = .noBorder
  5944. detailScroll.scrollerStyle = .overlay
  5945. detailScroll.automaticallyAdjustsContentInsets = false
  5946. let detailClip = TopAlignedClipView()
  5947. detailClip.drawsBackground = false
  5948. detailScroll.contentView = detailClip
  5949. let detailStack = NSStackView()
  5950. detailStack.translatesAutoresizingMaskIntoConstraints = false
  5951. detailStack.orientation = .vertical
  5952. detailStack.alignment = .leading
  5953. detailStack.spacing = 14
  5954. detailStack.edgeInsets = NSEdgeInsets(top: 18, left: 20, bottom: 20, right: 20)
  5955. detailScroll.documentView = detailStack
  5956. container.addSubview(detailScroll)
  5957. let detailEmpty = makeLabel("Select a meeting on the left to view its AI notes.", size: 13, color: secondaryText, weight: .regular, centered: true)
  5958. detailEmpty.translatesAutoresizingMaskIntoConstraints = false
  5959. detailEmpty.maximumNumberOfLines = 3
  5960. detailEmpty.lineBreakMode = .byWordWrapping
  5961. container.addSubview(detailEmpty)
  5962. let divider = NSView()
  5963. divider.translatesAutoresizingMaskIntoConstraints = false
  5964. divider.wantsLayer = true
  5965. divider.layer?.backgroundColor = palette.inputBorder.cgColor
  5966. container.addSubview(divider)
  5967. NSLayoutConstraint.activate([
  5968. container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  5969. container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  5970. container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  5971. container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  5972. titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
  5973. titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
  5974. titleStack.trailingAnchor.constraint(lessThanOrEqualTo: toolbarRight.leadingAnchor, constant: -16),
  5975. toolbarRight.centerYAnchor.constraint(equalTo: titleStack.centerYAnchor),
  5976. toolbarRight.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
  5977. refresh.heightAnchor.constraint(equalToConstant: 30),
  5978. refresh.widthAnchor.constraint(greaterThanOrEqualToConstant: 84),
  5979. rangePopup.heightAnchor.constraint(equalToConstant: 28),
  5980. rangePopup.widthAnchor.constraint(greaterThanOrEqualToConstant: 140),
  5981. status.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
  5982. status.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
  5983. status.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
  5984. listScroll.topAnchor.constraint(equalTo: status.bottomAnchor, constant: 10),
  5985. listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  5986. listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
  5987. listScroll.widthAnchor.constraint(equalTo: container.widthAnchor, multiplier: 0.34),
  5988. listStack.topAnchor.constraint(equalTo: listClip.topAnchor, constant: 8),
  5989. listStack.leadingAnchor.constraint(equalTo: listClip.leadingAnchor, constant: 8),
  5990. listStack.trailingAnchor.constraint(equalTo: listClip.trailingAnchor, constant: -8),
  5991. listStack.widthAnchor.constraint(equalTo: listClip.widthAnchor, constant: -16),
  5992. listEmpty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
  5993. listEmpty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
  5994. listEmpty.widthAnchor.constraint(lessThanOrEqualTo: listScroll.widthAnchor, constant: -24),
  5995. divider.topAnchor.constraint(equalTo: listScroll.topAnchor),
  5996. divider.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
  5997. divider.leadingAnchor.constraint(equalTo: listScroll.trailingAnchor, constant: 10),
  5998. divider.widthAnchor.constraint(equalToConstant: 1),
  5999. detailScroll.topAnchor.constraint(equalTo: listScroll.topAnchor),
  6000. detailScroll.bottomAnchor.constraint(equalTo: listScroll.bottomAnchor),
  6001. detailScroll.leadingAnchor.constraint(equalTo: divider.trailingAnchor, constant: 10),
  6002. detailScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  6003. detailStack.topAnchor.constraint(equalTo: detailClip.topAnchor),
  6004. detailStack.leadingAnchor.constraint(equalTo: detailClip.leadingAnchor),
  6005. detailStack.trailingAnchor.constraint(equalTo: detailClip.trailingAnchor),
  6006. detailStack.widthAnchor.constraint(equalTo: detailClip.widthAnchor),
  6007. detailEmpty.centerXAnchor.constraint(equalTo: detailScroll.centerXAnchor),
  6008. detailEmpty.centerYAnchor.constraint(equalTo: detailScroll.centerYAnchor),
  6009. detailEmpty.widthAnchor.constraint(lessThanOrEqualTo: detailScroll.widthAnchor, constant: -32)
  6010. ])
  6011. aiCompanionListScroll = listScroll
  6012. aiCompanionListStack = listStack
  6013. aiCompanionDetailScroll = detailScroll
  6014. aiCompanionDetailStack = detailStack
  6015. aiCompanionStatusLabel = status
  6016. aiCompanionCountLabel = count
  6017. aiCompanionRefreshButton = refresh
  6018. aiCompanionRangePopup = rangePopup
  6019. aiCompanionEmptyLabel = listEmpty
  6020. aiCompanionLoadingIndicator = loadingIndicator
  6021. aiCompanionDetailEmptyLabel = detailEmpty
  6022. renderAiCompanionList()
  6023. renderAiCompanionDetail()
  6024. return root
  6025. }
  6026. // MARK: - AI Companion list / detail rendering
  6027. @MainActor
  6028. private func renderAiCompanionList() {
  6029. guard let stack = aiCompanionListStack else { return }
  6030. aiCompanionRowButtons.removeAll()
  6031. stack.arrangedSubviews.forEach { view in
  6032. stack.removeArrangedSubview(view)
  6033. view.removeFromSuperview()
  6034. }
  6035. let count = aiCompanionSummaries.count
  6036. aiCompanionCountLabel?.stringValue = count == 1 ? "1 summary" : "\(count) summaries"
  6037. let showEmpty: Bool
  6038. if isUserLoggedIn() == false {
  6039. showEmpty = true
  6040. aiCompanionEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
  6041. } else if count == 0 {
  6042. showEmpty = true
  6043. aiCompanionEmptyLabel?.stringValue = "No AI notes yet for the selected range."
  6044. } else {
  6045. showEmpty = false
  6046. }
  6047. aiCompanionEmptyLabel?.isHidden = !showEmpty
  6048. for (idx, summary) in aiCompanionSummaries.enumerated() {
  6049. let row = makeAiCompanionListRow(summary: summary, index: idx, isSelected: idx == aiCompanionSelectedIndex)
  6050. stack.addArrangedSubview(row)
  6051. row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  6052. }
  6053. }
  6054. @MainActor
  6055. private func makeAiCompanionListRow(summary: AICompanionSummary, index: Int, isSelected: Bool) -> NSButton {
  6056. let button = NSButton(title: "", target: self, action: #selector(aiCompanionRowTapped(_:)))
  6057. button.translatesAutoresizingMaskIntoConstraints = false
  6058. button.bezelStyle = .regularSquare
  6059. button.isBordered = false
  6060. button.setButtonType(.momentaryChange)
  6061. button.tag = index
  6062. button.wantsLayer = true
  6063. button.layer?.cornerRadius = 12
  6064. button.layer?.borderWidth = 1
  6065. let baseBg: NSColor = palette.isDarkMode
  6066. ? NSColor.white.withAlphaComponent(0.04)
  6067. : NSColor.black.withAlphaComponent(0.03)
  6068. let selectedBg: NSColor = accentBlue.withAlphaComponent(palette.isDarkMode ? 0.22 : 0.14)
  6069. let baseBorder: NSColor = palette.inputBorder
  6070. let selectedBorder: NSColor = accentBlue
  6071. button.layer?.backgroundColor = (isSelected ? selectedBg : baseBg).cgColor
  6072. button.layer?.borderColor = (isSelected ? selectedBorder : baseBorder).cgColor
  6073. let topic = summary.summaryTitle?.isEmpty == false
  6074. ? summary.summaryTitle!
  6075. : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
  6076. let titleLabel = makeLabel(topic, size: 14, color: primaryText, weight: .semibold, centered: false)
  6077. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  6078. titleLabel.maximumNumberOfLines = 2
  6079. titleLabel.lineBreakMode = .byTruncatingTail
  6080. let dateText = aiCompanionDateText(for: summary)
  6081. let dateLabel = makeLabel(dateText, size: 11, color: secondaryText, weight: .regular, centered: false)
  6082. dateLabel.translatesAutoresizingMaskIntoConstraints = false
  6083. let badge = makeLabel("AI notes", size: 10, color: accentBlue, weight: .semibold, centered: false)
  6084. badge.translatesAutoresizingMaskIntoConstraints = false
  6085. button.addSubview(titleLabel)
  6086. button.addSubview(dateLabel)
  6087. button.addSubview(badge)
  6088. NSLayoutConstraint.activate([
  6089. titleLabel.topAnchor.constraint(equalTo: button.topAnchor, constant: 10),
  6090. titleLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
  6091. titleLabel.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -12),
  6092. dateLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
  6093. dateLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  6094. dateLabel.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor),
  6095. badge.topAnchor.constraint(equalTo: dateLabel.bottomAnchor, constant: 6),
  6096. badge.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  6097. badge.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -10)
  6098. ])
  6099. aiCompanionRowButtons.append(button)
  6100. return button
  6101. }
  6102. @MainActor
  6103. private func renderAiCompanionDetail() {
  6104. guard let stack = aiCompanionDetailStack else { return }
  6105. stack.arrangedSubviews.forEach { view in
  6106. stack.removeArrangedSubview(view)
  6107. view.removeFromSuperview()
  6108. }
  6109. guard isUserLoggedIn() else {
  6110. aiCompanionDetailEmptyLabel?.stringValue = "Please sign in with Zoom to view your AI notes."
  6111. aiCompanionDetailEmptyLabel?.isHidden = false
  6112. return
  6113. }
  6114. guard let index = aiCompanionSelectedIndex,
  6115. aiCompanionSummaries.indices.contains(index) else {
  6116. aiCompanionDetailEmptyLabel?.stringValue = aiCompanionSummaries.isEmpty
  6117. ? "Run an AI Companion-enabled meeting to see notes here."
  6118. : "Select a meeting on the left to view its AI notes."
  6119. aiCompanionDetailEmptyLabel?.isHidden = false
  6120. return
  6121. }
  6122. aiCompanionDetailEmptyLabel?.isHidden = true
  6123. let summary = aiCompanionSummaries[index]
  6124. let topic = summary.summaryTitle?.isEmpty == false
  6125. ? summary.summaryTitle!
  6126. : (summary.meetingTopic?.isEmpty == false ? summary.meetingTopic! : "Untitled meeting")
  6127. let titleLabel = makeLabel(topic, size: 22, color: primaryText, weight: .bold, centered: false)
  6128. titleLabel.maximumNumberOfLines = 0
  6129. titleLabel.lineBreakMode = .byWordWrapping
  6130. stack.addArrangedSubview(titleLabel)
  6131. let metaText = aiCompanionDateText(for: summary)
  6132. let metaLabel = makeLabel(metaText, size: 12, color: secondaryText, weight: .regular, centered: false)
  6133. metaLabel.maximumNumberOfLines = 0
  6134. stack.addArrangedSubview(metaLabel)
  6135. let actionsRow = NSStackView()
  6136. actionsRow.orientation = .horizontal
  6137. actionsRow.spacing = 10
  6138. actionsRow.alignment = .centerY
  6139. actionsRow.translatesAutoresizingMaskIntoConstraints = false
  6140. let openDocButton = NSButton(title: "Open in Zoom Docs", target: self, action: #selector(aiCompanionOpenDocTapped))
  6141. openDocButton.translatesAutoresizingMaskIntoConstraints = false
  6142. openDocButton.isBordered = false
  6143. openDocButton.wantsLayer = true
  6144. openDocButton.layer?.backgroundColor = accentBlue.cgColor
  6145. openDocButton.layer?.cornerRadius = 10
  6146. openDocButton.contentTintColor = .white
  6147. openDocButton.font = .systemFont(ofSize: 12, weight: .semibold)
  6148. openDocButton.isEnabled = summary.summaryDocURL != nil
  6149. openDocButton.alphaValue = summary.summaryDocURL != nil ? 1.0 : 0.45
  6150. let copyButton = NSButton(title: "Copy as Markdown", target: self, action: #selector(aiCompanionCopyMarkdownTapped))
  6151. copyButton.translatesAutoresizingMaskIntoConstraints = false
  6152. copyButton.isBordered = false
  6153. copyButton.wantsLayer = true
  6154. copyButton.layer?.backgroundColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)).cgColor
  6155. copyButton.layer?.cornerRadius = 10
  6156. copyButton.layer?.borderWidth = 1
  6157. copyButton.layer?.borderColor = palette.inputBorder.cgColor
  6158. copyButton.contentTintColor = primaryText
  6159. copyButton.font = .systemFont(ofSize: 12, weight: .semibold)
  6160. [openDocButton, copyButton].forEach { btn in
  6161. btn.heightAnchor.constraint(equalToConstant: 30).isActive = true
  6162. btn.widthAnchor.constraint(greaterThanOrEqualToConstant: 130).isActive = true
  6163. }
  6164. actionsRow.addArrangedSubview(openDocButton)
  6165. actionsRow.addArrangedSubview(copyButton)
  6166. stack.addArrangedSubview(actionsRow)
  6167. aiCompanionOpenDocButton = openDocButton
  6168. aiCompanionCopyMarkdownButton = copyButton
  6169. if let overview = summary.summaryOverview?.trimmingCharacters(in: .whitespacesAndNewlines), overview.isEmpty == false {
  6170. stack.addArrangedSubview(makeAiCompanionSectionHeader("Overview"))
  6171. stack.addArrangedSubview(makeAiCompanionBodyLabel(overview))
  6172. }
  6173. for section in summary.summaryDetails {
  6174. let labelText = section.label.isEmpty ? "Details" : section.label
  6175. stack.addArrangedSubview(makeAiCompanionSectionHeader(labelText))
  6176. if section.summary.isEmpty == false {
  6177. stack.addArrangedSubview(makeAiCompanionBodyLabel(section.summary))
  6178. }
  6179. }
  6180. if summary.nextSteps.isEmpty == false {
  6181. stack.addArrangedSubview(makeAiCompanionSectionHeader("Next steps"))
  6182. let bullets = summary.nextSteps.map { "• \($0)" }.joined(separator: "\n")
  6183. stack.addArrangedSubview(makeAiCompanionBodyLabel(bullets))
  6184. }
  6185. if summary.summaryOverview == nil && summary.summaryDetails.isEmpty && summary.nextSteps.isEmpty,
  6186. let fullContent = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
  6187. fullContent.isEmpty == false {
  6188. stack.addArrangedSubview(makeAiCompanionSectionHeader("Summary"))
  6189. stack.addArrangedSubview(makeAiCompanionBodyLabel(fullContent))
  6190. }
  6191. }
  6192. @MainActor
  6193. private func makeAiCompanionSectionHeader(_ text: String) -> NSTextField {
  6194. let label = makeLabel(text.uppercased(), size: 11, color: accentBlue, weight: .bold, centered: false)
  6195. label.maximumNumberOfLines = 1
  6196. return label
  6197. }
  6198. @MainActor
  6199. private func makeAiCompanionBodyLabel(_ text: String) -> NSTextField {
  6200. let label = makeLabel(text, size: 13, color: primaryText, weight: .regular, centered: false)
  6201. label.maximumNumberOfLines = 0
  6202. label.lineBreakMode = .byWordWrapping
  6203. label.isSelectable = true
  6204. return label
  6205. }
  6206. private func aiCompanionDateText(for summary: AICompanionSummary) -> String {
  6207. let formatter = DateFormatter()
  6208. formatter.dateStyle = .medium
  6209. formatter.timeStyle = .short
  6210. formatter.timeZone = TimeZone.current
  6211. let startText = summary.meetingStartTime.map { formatter.string(from: $0) }
  6212. let endText = summary.meetingEndTime.map { formatter.string(from: $0) }
  6213. switch (startText, endText) {
  6214. case let (start?, end?): return "\(start) → \(end)"
  6215. case let (start?, nil): return start
  6216. case let (nil, end?): return "Ended \(end)"
  6217. case (nil, nil): return "Date unavailable"
  6218. }
  6219. }
  6220. // MARK: - AI Companion data loading & actions
  6221. private func aiCompanionDateRange() -> (from: Date?, to: Date?) {
  6222. let now = Date()
  6223. let calendar = Calendar.current
  6224. let days: Int
  6225. switch aiCompanionSelectedRange {
  6226. case "Last 7 days": days = 7
  6227. case "Last 30 days": days = 30
  6228. case "Last 90 days": days = 90
  6229. case "All available": return (nil, nil)
  6230. default: days = 30
  6231. }
  6232. let from = calendar.date(byAdding: .day, value: -days, to: now) ?? now
  6233. return (from, now)
  6234. }
  6235. @MainActor
  6236. private func setAiCompanionLoadingUI(_ loading: Bool) {
  6237. if loading {
  6238. aiCompanionLoadingIndicator?.startAnimation(nil)
  6239. } else {
  6240. aiCompanionLoadingIndicator?.stopAnimation(nil)
  6241. }
  6242. aiCompanionRefreshButton?.isEnabled = !loading
  6243. aiCompanionRefreshButton?.alphaValue = loading ? 0.55 : 1.0
  6244. aiCompanionRangePopup?.isEnabled = !loading
  6245. }
  6246. private func loadAICompanionSummaries(force: Bool) async {
  6247. guard isUserLoggedIn() else {
  6248. await MainActor.run {
  6249. self.aiCompanionSummaries = []
  6250. self.aiCompanionSelectedIndex = nil
  6251. self.renderAiCompanionList()
  6252. self.renderAiCompanionDetail()
  6253. self.aiCompanionStatusLabel?.stringValue = "Sign in with Zoom to view AI notes."
  6254. }
  6255. return
  6256. }
  6257. if aiCompanionIsLoading && force == false { return }
  6258. aiCompanionIsLoading = true
  6259. await MainActor.run {
  6260. self.setAiCompanionLoadingUI(true)
  6261. self.aiCompanionStatusLabel?.stringValue = "Loading AI notes…"
  6262. }
  6263. defer {
  6264. aiCompanionIsLoading = false
  6265. Task { @MainActor in self.setAiCompanionLoadingUI(false) }
  6266. }
  6267. do {
  6268. let token = try await zoomOAuth.validAccessTokenForMeetingSummaries(presentingWindow: view.window)
  6269. let (from, to) = aiCompanionDateRange()
  6270. let summaries = try await fetchZoomMeetingSummaries(accessToken: token, from: from, to: to)
  6271. await MainActor.run {
  6272. self.aiCompanionSummaries = summaries.sorted(by: { lhs, rhs in
  6273. (lhs.meetingStartTime ?? .distantPast) > (rhs.meetingStartTime ?? .distantPast)
  6274. })
  6275. self.aiCompanionLastLoadedAt = Date()
  6276. if self.aiCompanionSummaries.isEmpty {
  6277. self.aiCompanionSelectedIndex = nil
  6278. } else {
  6279. if let idx = self.aiCompanionSelectedIndex, idx >= self.aiCompanionSummaries.count {
  6280. self.aiCompanionSelectedIndex = 0
  6281. } else if self.aiCompanionSelectedIndex == nil {
  6282. self.aiCompanionSelectedIndex = 0
  6283. }
  6284. }
  6285. self.renderAiCompanionList()
  6286. self.renderAiCompanionDetail()
  6287. if self.aiCompanionSummaries.isEmpty {
  6288. self.aiCompanionStatusLabel?.stringValue = "No AI notes found for this range yet."
  6289. } else {
  6290. let formatter = DateFormatter()
  6291. formatter.dateStyle = .none
  6292. formatter.timeStyle = .short
  6293. self.aiCompanionStatusLabel?.stringValue = "Updated \(formatter.string(from: Date()))"
  6294. }
  6295. }
  6296. } catch {
  6297. await MainActor.run {
  6298. self.aiCompanionSummaries = []
  6299. self.aiCompanionSelectedIndex = nil
  6300. self.renderAiCompanionList()
  6301. self.renderAiCompanionDetail()
  6302. if case ZoomOAuthError.missingRequiredScope = error {
  6303. self.zoomOAuth.clearSavedTokens()
  6304. 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."
  6305. } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
  6306. let minutes = max(1, Int(ceil(Double(retryAfterSeconds ?? 300) / 60.0)))
  6307. self.aiCompanionStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
  6308. } else if case ZoomOAuthError.missingClientId = error {
  6309. self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
  6310. } else if case ZoomOAuthError.missingClientSecret = error {
  6311. self.aiCompanionStatusLabel?.stringValue = "Zoom OAuth app not configured."
  6312. } else if case ZoomOAuthError.authenticationCancelled = error {
  6313. self.aiCompanionStatusLabel?.stringValue = "Sign-in cancelled. Please sign in with Zoom to load AI notes."
  6314. } else {
  6315. self.aiCompanionStatusLabel?.stringValue = "Unable to load AI notes right now. Please try again shortly."
  6316. }
  6317. }
  6318. }
  6319. }
  6320. private func aiCompanionRefreshIfStale() {
  6321. let stale: Bool
  6322. if let lastLoaded = aiCompanionLastLoadedAt {
  6323. stale = Date().timeIntervalSince(lastLoaded) > 60
  6324. } else {
  6325. stale = true
  6326. }
  6327. if stale {
  6328. Task { await self.loadAICompanionSummaries(force: false) }
  6329. }
  6330. }
  6331. @objc private func aiCompanionRefreshTapped() {
  6332. Task { await self.loadAICompanionSummaries(force: true) }
  6333. }
  6334. @objc private func aiCompanionRangeChanged(_ sender: NSPopUpButton) {
  6335. aiCompanionSelectedRange = sender.titleOfSelectedItem ?? "Last 30 days"
  6336. Task { await self.loadAICompanionSummaries(force: true) }
  6337. }
  6338. @objc private func aiCompanionRowTapped(_ sender: NSButton) {
  6339. let index = sender.tag
  6340. guard aiCompanionSummaries.indices.contains(index) else { return }
  6341. aiCompanionSelectedIndex = index
  6342. renderAiCompanionList()
  6343. renderAiCompanionDetail()
  6344. }
  6345. @objc private func aiCompanionOpenDocTapped() {
  6346. guard let index = aiCompanionSelectedIndex,
  6347. aiCompanionSummaries.indices.contains(index),
  6348. let url = aiCompanionSummaries[index].summaryDocURL else { return }
  6349. openWebURLPreferringInApp(url)
  6350. }
  6351. @objc private func aiCompanionCopyMarkdownTapped() {
  6352. guard let index = aiCompanionSelectedIndex,
  6353. aiCompanionSummaries.indices.contains(index) else { return }
  6354. let summary = aiCompanionSummaries[index]
  6355. let markdown = aiCompanionMarkdown(for: summary)
  6356. let pasteboard = NSPasteboard.general
  6357. pasteboard.clearContents()
  6358. pasteboard.setString(markdown, forType: .string)
  6359. aiCompanionStatusLabel?.stringValue = "Copied AI notes as Markdown."
  6360. }
  6361. private func aiCompanionMarkdown(for summary: AICompanionSummary) -> String {
  6362. if let content = summary.summaryContent?.trimmingCharacters(in: .whitespacesAndNewlines),
  6363. content.isEmpty == false {
  6364. return content
  6365. }
  6366. var lines: [String] = []
  6367. let title = summary.summaryTitle?.isEmpty == false ? summary.summaryTitle! : (summary.meetingTopic ?? "Meeting summary")
  6368. lines.append("# \(title)")
  6369. lines.append("")
  6370. lines.append("_\(aiCompanionDateText(for: summary))_")
  6371. lines.append("")
  6372. if let overview = summary.summaryOverview, overview.isEmpty == false {
  6373. lines.append("## Overview")
  6374. lines.append(overview)
  6375. lines.append("")
  6376. }
  6377. for section in summary.summaryDetails {
  6378. let header = section.label.isEmpty ? "Details" : section.label
  6379. lines.append("## \(header)")
  6380. if section.summary.isEmpty == false {
  6381. lines.append(section.summary)
  6382. }
  6383. lines.append("")
  6384. }
  6385. if summary.nextSteps.isEmpty == false {
  6386. lines.append("## Next steps")
  6387. for step in summary.nextSteps {
  6388. lines.append("- \(step)")
  6389. }
  6390. lines.append("")
  6391. }
  6392. if let url = summary.summaryDocURL {
  6393. lines.append("Full document: \(url.absoluteString)")
  6394. }
  6395. return lines.joined(separator: "\n")
  6396. }
  6397. @MainActor
  6398. private func refreshWidgetsPagePremiumState() {
  6399. guard let host = widgetsPageRootView as? NSHostingView<WidgetsRootView> else { return }
  6400. host.rootView = WidgetsRootView(
  6401. canAddWidgets: storeKitCoordinator.hasPremiumAccess,
  6402. onAddBlocked: { [weak self] in
  6403. self?.showPaywall()
  6404. }
  6405. )
  6406. }
  6407. @MainActor
  6408. private func reconcileDesktopWidgetsForPremiumAccess() {
  6409. refreshWidgetsPagePremiumState()
  6410. if storeKitCoordinator.hasPremiumAccess {
  6411. DesktopWidgetWindowManager.shared.restore()
  6412. } else {
  6413. DesktopWidgetWindowManager.shared.removeAllWidgets()
  6414. }
  6415. }
  6416. @MainActor
  6417. private func updateWidgetMeetingStoreFromCurrentMeetings() {
  6418. let upcoming = allScheduledMeetings
  6419. .filter { $0.start >= Date() }
  6420. .sorted(by: { $0.start < $1.start })
  6421. .prefix(3)
  6422. .map { meeting in
  6423. WidgetMeetingSnapshot(
  6424. id: meeting.meetingID ?? meeting.title,
  6425. title: meeting.title,
  6426. timeText: widgetTimeText(for: meeting),
  6427. joinLink: meeting.webURL?.absoluteString
  6428. )
  6429. }
  6430. WidgetMeetingStore.save(Array(upcoming))
  6431. DesktopWidgetWindowManager.shared.refreshForMeetingsChange()
  6432. }
  6433. private func widgetTimeText(for meeting: ScheduledMeeting) -> String {
  6434. let formatter = DateFormatter()
  6435. formatter.dateFormat = "h:mm a"
  6436. formatter.timeZone = meeting.meetingTimeZone ?? TimeZone.current
  6437. return formatter.string(from: meeting.start)
  6438. }
  6439. private func setupWidgetBridge() {
  6440. let center = NotificationCenter.default
  6441. center.addObserver(forName: .widgetOpenHomePage, object: nil, queue: .main) { [weak self] _ in
  6442. self?.menuBarShowSection(.home)
  6443. }
  6444. center.addObserver(forName: .widgetOpenMeetingsPage, object: nil, queue: .main) { [weak self] _ in
  6445. self?.menuBarShowSection(.meetings)
  6446. }
  6447. center.addObserver(forName: .widgetOpenSchedulerPage, object: nil, queue: .main) { [weak self] _ in
  6448. self?.menuBarShowSection(.scheduler)
  6449. }
  6450. center.addObserver(forName: .widgetOpenWidgetsPage, object: nil, queue: .main) { [weak self] _ in
  6451. self?.menuBarShowSection(.widgets)
  6452. }
  6453. center.addObserver(forName: .widgetOpenSettingsPage, object: nil, queue: .main) { [weak self] _ in
  6454. self?.menuBarShowSection(.settings)
  6455. }
  6456. center.addObserver(forName: .widgetStartNewMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  6457. self?.menuBarTriggerNewMeeting()
  6458. }
  6459. center.addObserver(forName: .widgetJoinMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  6460. self?.menuBarTriggerJoinMeeting()
  6461. }
  6462. center.addObserver(forName: .widgetScheduleMeetingRequested, object: nil, queue: .main) { [weak self] _ in
  6463. self?.menuBarTriggerScheduleMeeting()
  6464. }
  6465. center.addObserver(forName: .widgetRefreshRequested, object: nil, queue: .main) { [weak self] _ in
  6466. self?.triggerMeetingsRefresh(force: true)
  6467. }
  6468. center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
  6469. guard let self else { return }
  6470. guard let link = notification.userInfo?["link"] as? String,
  6471. let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
  6472. self.openWebURLPreferringInApp(url)
  6473. }
  6474. }
  6475. private func makeMeetingsPageView() -> NSView {
  6476. let root = NSView()
  6477. root.wantsLayer = true
  6478. root.layer?.backgroundColor = NSColor.clear.cgColor
  6479. let container = NSView()
  6480. container.translatesAutoresizingMaskIntoConstraints = false
  6481. container.wantsLayer = true
  6482. container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
  6483. container.layer?.cornerRadius = 16
  6484. container.layer?.borderWidth = 1
  6485. container.layer?.borderColor = palette.inputBorder.cgColor
  6486. root.addSubview(container)
  6487. let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
  6488. let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
  6489. let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
  6490. let titleStack = NSStackView(views: [title, subtitle, count])
  6491. titleStack.orientation = .vertical
  6492. titleStack.spacing = 4
  6493. titleStack.alignment = .leading
  6494. titleStack.translatesAutoresizingMaskIntoConstraints = false
  6495. let categoryFilter = NSPopUpButton()
  6496. categoryFilter.translatesAutoresizingMaskIntoConstraints = false
  6497. categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
  6498. categoryFilter.selectItem(withTitle: "All")
  6499. categoryFilter.target = self
  6500. categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
  6501. let fromDatePicker = NSDatePicker()
  6502. fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
  6503. fromDatePicker.datePickerStyle = .textFieldAndStepper
  6504. fromDatePicker.datePickerMode = .single
  6505. fromDatePicker.datePickerElements = [.yearMonthDay]
  6506. fromDatePicker.dateValue = Date()
  6507. fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  6508. let toDatePicker = NSDatePicker()
  6509. toDatePicker.translatesAutoresizingMaskIntoConstraints = false
  6510. toDatePicker.datePickerStyle = .textFieldAndStepper
  6511. toDatePicker.datePickerMode = .single
  6512. toDatePicker.datePickerElements = [.yearMonthDay]
  6513. toDatePicker.dateValue = Date()
  6514. toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
  6515. let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
  6516. applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  6517. applyFiltersButton.isBordered = false
  6518. applyFiltersButton.wantsLayer = true
  6519. applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
  6520. applyFiltersButton.layer?.cornerRadius = 10
  6521. applyFiltersButton.contentTintColor = .white
  6522. applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  6523. let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
  6524. resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
  6525. resetFiltersButton.isBordered = false
  6526. resetFiltersButton.wantsLayer = true
  6527. resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
  6528. resetFiltersButton.layer?.cornerRadius = 10
  6529. resetFiltersButton.layer?.borderWidth = 1
  6530. resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
  6531. resetFiltersButton.contentTintColor = primaryText
  6532. resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
  6533. let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
  6534. refreshButton.translatesAutoresizingMaskIntoConstraints = false
  6535. refreshButton.isBordered = false
  6536. refreshButton.wantsLayer = true
  6537. refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
  6538. refreshButton.layer?.cornerRadius = 10
  6539. refreshButton.layer?.borderWidth = 1
  6540. refreshButton.layer?.borderColor = palette.inputBorder.cgColor
  6541. refreshButton.contentTintColor = primaryText
  6542. refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
  6543. if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
  6544. refreshButton.image = image
  6545. refreshButton.imagePosition = .imageOnly
  6546. }
  6547. let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
  6548. filterRow.orientation = .horizontal
  6549. filterRow.spacing = 10
  6550. filterRow.alignment = .centerY
  6551. filterRow.translatesAutoresizingMaskIntoConstraints = false
  6552. let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
  6553. statusLabel.translatesAutoresizingMaskIntoConstraints = false
  6554. let listScroll = NSScrollView()
  6555. listScroll.translatesAutoresizingMaskIntoConstraints = false
  6556. listScroll.drawsBackground = false
  6557. listScroll.hasVerticalScroller = true
  6558. listScroll.hasHorizontalScroller = false
  6559. listScroll.autohidesScrollers = true
  6560. let listDocument = FlippedView()
  6561. listDocument.translatesAutoresizingMaskIntoConstraints = false
  6562. let listStack = NSStackView()
  6563. listStack.translatesAutoresizingMaskIntoConstraints = false
  6564. listStack.orientation = .vertical
  6565. listStack.spacing = 12
  6566. listStack.alignment = .leading
  6567. listStack.setContentHuggingPriority(.required, for: .vertical)
  6568. listStack.setContentCompressionResistancePriority(.required, for: .vertical)
  6569. listScroll.documentView = listDocument
  6570. listDocument.addSubview(listStack)
  6571. let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
  6572. empty.translatesAutoresizingMaskIntoConstraints = false
  6573. empty.isHidden = true
  6574. let loadMoreButton = NSButton(title: "", target: nil, action: nil)
  6575. loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
  6576. loadMoreButton.isBordered = false
  6577. loadMoreButton.wantsLayer = true
  6578. loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
  6579. loadMoreButton.layer?.cornerRadius = 10
  6580. loadMoreButton.layer?.borderWidth = 1
  6581. loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
  6582. loadMoreButton.contentTintColor = primaryText
  6583. loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
  6584. loadMoreButton.isHidden = true
  6585. [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
  6586. NSLayoutConstraint.activate([
  6587. container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  6588. container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  6589. container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  6590. container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  6591. titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
  6592. titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  6593. titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
  6594. filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
  6595. filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
  6596. filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
  6597. categoryFilter.widthAnchor.constraint(equalToConstant: 170),
  6598. fromDatePicker.widthAnchor.constraint(equalToConstant: 170),
  6599. toDatePicker.widthAnchor.constraint(equalToConstant: 170),
  6600. applyFiltersButton.widthAnchor.constraint(equalToConstant: 96),
  6601. resetFiltersButton.widthAnchor.constraint(equalToConstant: 96),
  6602. refreshButton.widthAnchor.constraint(equalToConstant: 44),
  6603. applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  6604. resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
  6605. refreshButton.heightAnchor.constraint(equalToConstant: 34),
  6606. statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
  6607. statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
  6608. statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
  6609. listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
  6610. listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  6611. listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  6612. listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  6613. listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
  6614. listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
  6615. listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
  6616. listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
  6617. // Pin bottom edge so document height grows with arranged subviews.
  6618. listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
  6619. empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
  6620. empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
  6621. loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
  6622. loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
  6623. loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
  6624. loadMoreButton.heightAnchor.constraint(equalToConstant: 1)
  6625. ])
  6626. meetingsPageCategoryFilter = categoryFilter
  6627. meetingsPageFromDatePicker = fromDatePicker
  6628. meetingsPageToDatePicker = toDatePicker
  6629. meetingsPageRefreshButton = refreshButton
  6630. meetingsPageCountLabel = count
  6631. meetingsPageStatusLabel = statusLabel
  6632. meetingsPageListStack = listStack
  6633. meetingsPageEmptyLabel = empty
  6634. meetingsPageScrollView = listScroll
  6635. meetingsPageLoadMoreButton = loadMoreButton
  6636. observeMeetingsPageInfiniteScroll(in: listScroll)
  6637. updateMeetingsDatePickersEnabledState()
  6638. return root
  6639. }
  6640. deinit {
  6641. premiumRatingPromptWorkItem?.cancel()
  6642. usageRatingPromptWorkItem?.cancel()
  6643. removeSearchFieldObserver()
  6644. removeMeetingSearchObserver()
  6645. clearMeetingsPageScrollObserver()
  6646. removeSearchShortcutMonitor()
  6647. for observer in windowFullScreenObservers {
  6648. NotificationCenter.default.removeObserver(observer)
  6649. }
  6650. windowFullScreenObservers.removeAll()
  6651. }
  6652. private func ensureUsageSessionStartDate() {
  6653. if UserDefaults.standard.object(forKey: usageSessionStartedAtKey) == nil {
  6654. UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  6655. }
  6656. }
  6657. private func usageSessionStartedAt() -> Date {
  6658. let storedValue = UserDefaults.standard.double(forKey: usageSessionStartedAtKey)
  6659. if storedValue > 0 {
  6660. return Date(timeIntervalSince1970: storedValue)
  6661. }
  6662. let now = Date()
  6663. UserDefaults.standard.set(now.timeIntervalSince1970, forKey: usageSessionStartedAtKey)
  6664. return now
  6665. }
  6666. private func hasCompletedOrSeenRatingFlow() -> Bool {
  6667. UserDefaults.standard.bool(forKey: ratingCompletedKey)
  6668. }
  6669. private func scheduleUsageBasedRatingPromptIfNeeded() {
  6670. usageRatingPromptWorkItem?.cancel()
  6671. guard hasCompletedOrSeenRatingFlow() == false else { return }
  6672. let elapsed = Date().timeIntervalSince(usageSessionStartedAt())
  6673. let delay = max(0, usageRatingPromptDelay - elapsed)
  6674. let workItem = DispatchWorkItem { [weak self] in
  6675. self?.presentRatingPrompt(source: "usage")
  6676. }
  6677. usageRatingPromptWorkItem = workItem
  6678. DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
  6679. }
  6680. private func schedulePremiumRatingPromptIfNeeded() {
  6681. guard hasCompletedOrSeenRatingFlow() == false else { return }
  6682. guard premiumRatingPromptWorkItem == nil else { return }
  6683. let workItem = DispatchWorkItem { [weak self] in
  6684. self?.premiumRatingPromptWorkItem = nil
  6685. self?.presentRatingPrompt(source: "premium")
  6686. }
  6687. premiumRatingPromptWorkItem = workItem
  6688. DispatchQueue.main.asyncAfter(deadline: .now() + premiumRatingPromptDelay, execute: workItem)
  6689. }
  6690. private func presentRatingPrompt(source: String) {
  6691. guard hasCompletedOrSeenRatingFlow() == false else { return }
  6692. UserDefaults.standard.set(true, forKey: ratingPromptShownKey)
  6693. NSApp.activate(ignoringOtherApps: true)
  6694. let alert = NSAlert()
  6695. alert.alertStyle = .informational
  6696. alert.messageText = "Enjoying Zoom App?"
  6697. if source == "premium" {
  6698. alert.informativeText = "You unlocked Premium. If you're enjoying the app, please rate us."
  6699. } else {
  6700. alert.informativeText = "You've been using the app for a while. If it's helping you, please rate us."
  6701. }
  6702. alert.addButton(withTitle: "Rate Now")
  6703. alert.addButton(withTitle: "Later")
  6704. let response = alert.runModal()
  6705. if response == .alertFirstButtonReturn {
  6706. UserDefaults.standard.set(true, forKey: ratingCompletedKey)
  6707. SKStoreReviewController.requestReview()
  6708. }
  6709. }
  6710. private func startClock() {
  6711. clockTimer?.invalidate()
  6712. clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
  6713. self?.updateClock()
  6714. }
  6715. updateClock()
  6716. }
  6717. private func updateClock() {
  6718. let now = Date()
  6719. let timeFormatter = DateFormatter()
  6720. timeFormatter.dateFormat = "h:mm a"
  6721. let dateFormatter = DateFormatter()
  6722. dateFormatter.dateFormat = "EEEE, d MMM"
  6723. timeLabel?.stringValue = timeFormatter.string(from: now)
  6724. dateLabel?.stringValue = dateFormatter.string(from: now)
  6725. }
  6726. // MARK: - Shared UI
  6727. private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .home) -> NSView {
  6728. let sidebar = NSView()
  6729. sidebar.wantsLayer = true
  6730. sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
  6731. let stack = NSStackView()
  6732. stack.orientation = .vertical
  6733. stack.spacing = style == .home ? 12 : 16
  6734. stack.alignment = .centerX
  6735. stack.distribution = .fill
  6736. // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
  6737. stack.setContentHuggingPriority(.required, for: .vertical)
  6738. stack.setContentCompressionResistancePriority(.required, for: .vertical)
  6739. stack.translatesAutoresizingMaskIntoConstraints = false
  6740. sidebar.addSubview(stack)
  6741. for item in items {
  6742. let row = NSView()
  6743. row.translatesAutoresizingMaskIntoConstraints = false
  6744. row.wantsLayer = true
  6745. let selectedRow = item == selected
  6746. row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  6747. row.layer?.cornerRadius = style == .home ? 12 : 10
  6748. row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
  6749. // Prevent rows from stretching/collapsing when the window resizes.
  6750. row.setContentHuggingPriority(.required, for: .vertical)
  6751. row.setContentCompressionResistancePriority(.required, for: .vertical)
  6752. if style == .home {
  6753. // Must be tall enough for icon (26) + paddings + label without clipping.
  6754. row.heightAnchor.constraint(equalToConstant: 66).isActive = true
  6755. }
  6756. if style == .home {
  6757. let iconContainer = NSView()
  6758. iconContainer.translatesAutoresizingMaskIntoConstraints = false
  6759. row.addSubview(iconContainer)
  6760. let iconView = NSImageView()
  6761. iconView.translatesAutoresizingMaskIntoConstraints = false
  6762. iconView.contentTintColor = selectedRow ? primaryText : sidebarInactiveText
  6763. iconView.imageScaling = .scaleProportionallyUpOrDown
  6764. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
  6765. let preferredSymbol = sidebarSymbolName(for: item, filled: selectedRow)
  6766. let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
  6767. iconView.image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
  6768. ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item)
  6769. iconContainer.addSubview(iconView)
  6770. let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
  6771. label.translatesAutoresizingMaskIntoConstraints = false
  6772. row.addSubview(label)
  6773. let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
  6774. hit.identifier = NSUserInterfaceItemIdentifier(item)
  6775. hit.isBordered = false
  6776. hit.bezelStyle = .shadowlessSquare
  6777. hit.focusRingType = .none
  6778. hit.translatesAutoresizingMaskIntoConstraints = false
  6779. row.addSubview(hit, positioned: .above, relativeTo: nil)
  6780. NSLayoutConstraint.activate([
  6781. iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
  6782. iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  6783. iconContainer.widthAnchor.constraint(equalToConstant: 26),
  6784. iconContainer.heightAnchor.constraint(equalToConstant: 26),
  6785. iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  6786. iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
  6787. iconView.widthAnchor.constraint(equalToConstant: 22),
  6788. iconView.heightAnchor.constraint(equalToConstant: 22),
  6789. label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
  6790. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  6791. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
  6792. hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
  6793. hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
  6794. hit.topAnchor.constraint(equalTo: row.topAnchor),
  6795. hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
  6796. ])
  6797. homeSidebarRowViews[item] = row
  6798. homeSidebarIconViews[item] = iconView
  6799. homeSidebarLabels[item] = label
  6800. if item == "Hub" {
  6801. let badge = makeSidebarBadge(text: "1")
  6802. badge.translatesAutoresizingMaskIntoConstraints = false
  6803. iconContainer.addSubview(badge)
  6804. NSLayoutConstraint.activate([
  6805. badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
  6806. badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
  6807. ])
  6808. } else if item == "More" {
  6809. let dot = NSView()
  6810. dot.translatesAutoresizingMaskIntoConstraints = false
  6811. dot.wantsLayer = true
  6812. dot.layer?.backgroundColor = NSColor.systemRed.cgColor
  6813. dot.layer?.cornerRadius = 4
  6814. row.addSubview(dot)
  6815. NSLayoutConstraint.activate([
  6816. dot.widthAnchor.constraint(equalToConstant: 8),
  6817. dot.heightAnchor.constraint(equalToConstant: 8),
  6818. dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
  6819. dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
  6820. ])
  6821. }
  6822. } else {
  6823. let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
  6824. let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
  6825. [icon, label].forEach {
  6826. $0.translatesAutoresizingMaskIntoConstraints = false
  6827. row.addSubview($0)
  6828. }
  6829. NSLayoutConstraint.activate([
  6830. icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
  6831. icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  6832. label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
  6833. label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
  6834. label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
  6835. ])
  6836. }
  6837. stack.addArrangedSubview(row)
  6838. }
  6839. NSLayoutConstraint.activate([
  6840. stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
  6841. stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
  6842. ])
  6843. if style == .home {
  6844. NSLayoutConstraint.activate([
  6845. stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
  6846. ])
  6847. }
  6848. stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
  6849. return sidebar
  6850. }
  6851. @objc private func homeSidebarItemTapped(_ sender: NSButton) {
  6852. guard let item = sender.identifier?.rawValue else { return }
  6853. closeEmbeddedBrowserPanel()
  6854. selectedHomeSidebarItem = item
  6855. updateHomeSidebarHighlight()
  6856. updateSelectedHomeSectionUI()
  6857. }
  6858. @objc private func newMeetingTapped() {
  6859. menuBarTriggerNewMeeting()
  6860. }
  6861. func menuBarIsUserLoggedIn() -> Bool {
  6862. isUserLoggedIn()
  6863. }
  6864. func menuBarShowSection(_ section: HomeSection) {
  6865. closeEmbeddedBrowserPanel()
  6866. selectedHomeSidebarItem = section.rawValue
  6867. updateHomeSidebarHighlight()
  6868. updateSelectedHomeSectionUI()
  6869. }
  6870. func menuBarTriggerNewMeeting() {
  6871. guard isUserLoggedIn() else {
  6872. showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
  6873. return
  6874. }
  6875. guard let url = URL(string: "https://zoom.us/start/videomeeting") else {
  6876. showSimpleAlert(title: "Unable to open", message: "Could not start a new Zoom meeting right now.")
  6877. return
  6878. }
  6879. openWebURLPreferringInApp(url)
  6880. }
  6881. func menuBarTriggerJoinMeeting() {
  6882. joinMeetingTapped()
  6883. }
  6884. func menuBarTriggerScheduleMeeting() {
  6885. scheduleMeetingTapped()
  6886. }
  6887. func menuBarTriggerAuthAction() {
  6888. if isUserLoggedIn() {
  6889. logoutTapped()
  6890. } else {
  6891. zoomPrimarySignInTapped()
  6892. }
  6893. }
  6894. @MainActor
  6895. private func updateHomeSidebarHighlight() {
  6896. for (item, row) in homeSidebarRowViews {
  6897. let selected = item == selectedHomeSidebarItem
  6898. row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
  6899. homeSidebarLabels[item]?.textColor = selected ? primaryText : sidebarInactiveText
  6900. homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : sidebarInactiveText
  6901. let preferredSymbol = sidebarSymbolName(for: item, filled: selected)
  6902. let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
  6903. if let image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
  6904. ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item) {
  6905. homeSidebarIconViews[item]?.image = image
  6906. }
  6907. }
  6908. }
  6909. @MainActor
  6910. private func updateSelectedHomeSectionUI() {
  6911. let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
  6912. let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
  6913. let isSettings = selectedHomeSidebarItem == HomeSection.settings.rawValue
  6914. let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
  6915. let isWidgets = selectedHomeSidebarItem == HomeSection.widgets.rawValue
  6916. let isAiCompanion = selectedHomeSidebarItem == HomeSection.aiCompanion.rawValue
  6917. let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
  6918. homeWelcomeLabel?.stringValue = "Home"
  6919. homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
  6920. let dashboardViews: [NSView?] = [
  6921. homeTimeLabelView,
  6922. homeDateLabelView,
  6923. homeActionsRow,
  6924. homeMeetingsPanel,
  6925. meetingsDayHeaderLabel,
  6926. meetingsStatusLabel,
  6927. meetingsPrevDayButton,
  6928. meetingsTodayButton,
  6929. meetingsNextDayButton,
  6930. meetingsScrollView,
  6931. refreshMeetingsButton
  6932. ]
  6933. let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler || isWidgets || isAiCompanion
  6934. dashboardViews.forEach { $0?.isHidden = hideDashboard }
  6935. // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
  6936. // and showed "No meetings…" on top of meeting cards when returning to Home.
  6937. if hideDashboard {
  6938. emptyMeetingLabel?.isHidden = true
  6939. } else {
  6940. let hasMeetingCards = (meetingsListStack?.arrangedSubviews.isEmpty == false)
  6941. emptyMeetingLabel?.isHidden = premiumLocked || hasMeetingCards
  6942. }
  6943. homeSettingsView?.isHidden = isSettings == false
  6944. meetingsPageRootView?.isHidden = isMeetings == false
  6945. schedulerRootView?.isHidden = isScheduler == false
  6946. widgetsPageRootView?.isHidden = isWidgets == false
  6947. aiCompanionPageRootView?.isHidden = isAiCompanion == false
  6948. if isHome {
  6949. homePlaceholderLabel?.isHidden = true
  6950. } else {
  6951. // Keep non-Home pages empty for now.
  6952. homePlaceholderLabel?.isHidden = true
  6953. }
  6954. if isScheduler {
  6955. updateSchedulerDateHeader()
  6956. applySchedulerMeetingsForSelectedDate()
  6957. renderSchedulerCalendarGrid()
  6958. }
  6959. if isMeetings {
  6960. applyMeetingsPageFilters(resetVisible: false)
  6961. }
  6962. if isAiCompanion {
  6963. aiCompanionRefreshIfStale()
  6964. }
  6965. }
  6966. private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
  6967. switch item {
  6968. case "Home":
  6969. return filled ? "house.fill" : "house"
  6970. case "Meetings":
  6971. return filled ? "video.fill" : "video"
  6972. case "Chat":
  6973. return filled ? "message.fill" : "message"
  6974. case "Scheduler":
  6975. // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
  6976. return "calendar.badge.clock"
  6977. case "Widgets":
  6978. return filled ? "rectangle.grid.2x2.fill" : "rectangle.grid.2x2"
  6979. case "Ai Companion":
  6980. return filled ? "sparkles" : "sparkles"
  6981. case "Settings":
  6982. // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
  6983. return filled ? "gearshape.fill" : "gearshape"
  6984. case "Hub":
  6985. return "square.grid.3x3"
  6986. case "More":
  6987. return "ellipsis"
  6988. default:
  6989. return "circle"
  6990. }
  6991. }
  6992. private func makeSidebarBadge(text: String) -> NSView {
  6993. let badge = NSView()
  6994. badge.wantsLayer = true
  6995. badge.layer?.backgroundColor = NSColor.systemRed.cgColor
  6996. badge.layer?.cornerRadius = 12 / 2
  6997. badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
  6998. badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
  6999. let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
  7000. label.translatesAutoresizingMaskIntoConstraints = false
  7001. badge.addSubview(label)
  7002. NSLayoutConstraint.activate([
  7003. label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
  7004. label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
  7005. ])
  7006. return badge
  7007. }
  7008. @MainActor
  7009. private func alignNativeTrafficLights() {
  7010. guard let window = view.window else { return }
  7011. guard let closeButton = window.standardWindowButton(.closeButton),
  7012. let miniButton = window.standardWindowButton(.miniaturizeButton),
  7013. let zoomButton = window.standardWindowButton(.zoomButton) else { return }
  7014. guard let buttonContainer = closeButton.superview else { return }
  7015. let buttons = [closeButton, miniButton, zoomButton]
  7016. // Compute from top inset so moving "down" is stable in titlebar coordinates.
  7017. let containerHeight = buttonContainer.bounds.height
  7018. let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
  7019. var nextX = nativeTrafficLightsLeading
  7020. for button in buttons {
  7021. button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
  7022. nextX += button.frame.width + 8
  7023. }
  7024. }
  7025. private func makeUpgradeToProButton(action: Selector?) -> NSButton {
  7026. let title = "Upgrade to Pro"
  7027. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  7028. button.isBordered = false
  7029. button.focusRingType = .none
  7030. button.wantsLayer = true
  7031. button.layer?.backgroundColor = accentBlue.cgColor
  7032. button.layer?.cornerRadius = 14
  7033. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  7034. button.attributedTitle = NSAttributedString(string: title, attributes: [
  7035. .foregroundColor: NSColor.white,
  7036. .font: font
  7037. ])
  7038. button.toolTip = title
  7039. button.translatesAutoresizingMaskIntoConstraints = false
  7040. button.heightAnchor.constraint(equalToConstant: 28).isActive = true
  7041. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 148).isActive = true
  7042. return button
  7043. }
  7044. /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
  7045. private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
  7046. let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
  7047. button.isBordered = false
  7048. button.bezelStyle = .shadowlessSquare
  7049. button.focusRingType = .none
  7050. button.contentTintColor = palette.isDarkMode
  7051. ? NSColor(calibratedWhite: 0.84, alpha: 1)
  7052. : NSColor(calibratedWhite: 0.22, alpha: 1)
  7053. if let toolTip {
  7054. button.toolTip = toolTip
  7055. }
  7056. let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
  7057. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
  7058. let image = base.withSymbolConfiguration(symbolConfig) {
  7059. image.isTemplate = true
  7060. button.image = image
  7061. }
  7062. button.imageScaling = .scaleProportionallyUpOrDown
  7063. button.imagePosition = .imageOnly
  7064. button.translatesAutoresizingMaskIntoConstraints = false
  7065. button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
  7066. button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
  7067. return button
  7068. }
  7069. private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
  7070. let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
  7071. button.isBordered = false
  7072. button.bezelStyle = .shadowlessSquare
  7073. button.focusRingType = .none
  7074. button.wantsLayer = true
  7075. button.layer?.backgroundColor = (palette.isDarkMode
  7076. ? NSColor.white.withAlphaComponent(0.06)
  7077. : NSColor.black.withAlphaComponent(0.06)
  7078. ).cgColor
  7079. button.layer?.cornerRadius = 10
  7080. button.layer?.borderWidth = 1
  7081. button.layer?.borderColor = (palette.isDarkMode
  7082. ? NSColor.white.withAlphaComponent(0.08)
  7083. : NSColor.black.withAlphaComponent(0.12)
  7084. ).cgColor
  7085. let tint = palette.isDarkMode
  7086. ? NSColor(calibratedWhite: 0.9, alpha: 1)
  7087. : NSColor(calibratedWhite: 0.18, alpha: 1)
  7088. let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
  7089. button.contentTintColor = tint
  7090. button.font = font
  7091. button.attributedTitle = NSAttributedString(string: title, attributes: [
  7092. .foregroundColor: tint,
  7093. .font: font
  7094. ])
  7095. if let symbol {
  7096. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
  7097. if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  7098. let image = base.withSymbolConfiguration(symbolConfig) {
  7099. image.isTemplate = true
  7100. button.image = image
  7101. button.imagePosition = .imageLeading
  7102. }
  7103. button.imageHugsTitle = true
  7104. button.imageScaling = .scaleNone
  7105. }
  7106. button.translatesAutoresizingMaskIntoConstraints = false
  7107. button.heightAnchor.constraint(equalToConstant: 24).isActive = true
  7108. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
  7109. return button
  7110. }
  7111. private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
  7112. let root = HoverTileView()
  7113. root.translatesAutoresizingMaskIntoConstraints = false
  7114. root.widthAnchor.constraint(equalToConstant: 88).isActive = true
  7115. // Give caption text a little more vertical room so descenders (e.g. "g") are never clipped.
  7116. root.heightAnchor.constraint(equalToConstant: 76).isActive = true
  7117. let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
  7118. iconButton.isBordered = false
  7119. iconButton.wantsLayer = true
  7120. iconButton.layer?.backgroundColor = color.cgColor
  7121. iconButton.layer?.cornerRadius = 15
  7122. iconButton.layer?.shadowOpacity = 0.2
  7123. iconButton.layer?.shadowRadius = 7
  7124. iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
  7125. let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
  7126. if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
  7127. let configured = baseImage.withSymbolConfiguration(symbolConfig) {
  7128. iconButton.image = configured
  7129. }
  7130. iconButton.contentTintColor = .white
  7131. iconButton.imageScaling = .scaleNone
  7132. let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
  7133. [iconButton, label].forEach {
  7134. $0.translatesAutoresizingMaskIntoConstraints = false
  7135. root.addSubview($0)
  7136. }
  7137. let iconTopConstraint = iconButton.topAnchor.constraint(equalTo: root.topAnchor)
  7138. NSLayoutConstraint.activate([
  7139. iconTopConstraint,
  7140. iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  7141. iconButton.widthAnchor.constraint(equalToConstant: 50),
  7142. iconButton.heightAnchor.constraint(equalToConstant: 50),
  7143. label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
  7144. label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
  7145. label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  7146. ])
  7147. root.onHoverChanged = { [weak iconButton, weak label] hovering in
  7148. NSAnimationContext.runAnimationGroup { context in
  7149. context.duration = hovering ? 0.16 : 0.14
  7150. context.timingFunction = CAMediaTimingFunction(name: hovering ? .easeOut : .easeInEaseOut)
  7151. iconTopConstraint.animator().constant = hovering ? -3 : 0
  7152. label?.animator().alphaValue = hovering ? 1.0 : 0.96
  7153. }
  7154. guard let iconButton else { return }
  7155. iconButton.layer?.shadowOpacity = hovering ? 0.28 : 0.2
  7156. iconButton.layer?.shadowRadius = hovering ? 10 : 7
  7157. iconButton.layer?.shadowOffset = hovering ? NSSize(width: 0, height: -3) : NSSize(width: 0, height: -1)
  7158. }
  7159. return root
  7160. }
  7161. private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
  7162. let hoverBackground = palette.isDarkMode
  7163. ? NSColor.white.withAlphaComponent(0.075)
  7164. : NSColor.black.withAlphaComponent(0.10)
  7165. let hoverBorder = palette.isDarkMode
  7166. ? NSColor.white.withAlphaComponent(0.85)
  7167. : NSColor.black.withAlphaComponent(0.22)
  7168. let card = MeetingCardView(
  7169. url: meeting.webURL,
  7170. hoverBackground: hoverBackground,
  7171. hoverBorder: hoverBorder,
  7172. onTapURL: { [weak self] url in
  7173. self?.openWebURLPreferringInApp(url)
  7174. }
  7175. )
  7176. card.wantsLayer = true
  7177. card.layer?.backgroundColor = meetingCardBackground.cgColor
  7178. card.layer?.cornerRadius = 13
  7179. card.layer?.borderWidth = 1
  7180. card.layer?.borderColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.10)).cgColor
  7181. card.translatesAutoresizingMaskIntoConstraints = false
  7182. card.heightAnchor.constraint(equalToConstant: 116).isActive = true
  7183. let dateFormatter = DateFormatter()
  7184. dateFormatter.dateFormat = "EEE, MMM d"
  7185. let timeFormatter = DateFormatter()
  7186. timeFormatter.dateFormat = "h:mm a"
  7187. let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
  7188. dateFormatter.timeZone = displayTimeZone
  7189. timeFormatter.timeZone = displayTimeZone
  7190. let startText = timeFormatter.string(from: meeting.start)
  7191. let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
  7192. let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
  7193. let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
  7194. let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
  7195. detail.maximumNumberOfLines = 2
  7196. let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
  7197. [title, detail, host].forEach {
  7198. $0.translatesAutoresizingMaskIntoConstraints = false
  7199. card.addSubview($0)
  7200. }
  7201. NSLayoutConstraint.activate([
  7202. title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
  7203. title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  7204. title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  7205. detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
  7206. detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7207. detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  7208. host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
  7209. host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  7210. host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
  7211. host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
  7212. ])
  7213. return card
  7214. }
  7215. private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
  7216. let label = NSTextField(labelWithString: text)
  7217. label.font = .systemFont(ofSize: size, weight: weight)
  7218. label.textColor = color
  7219. label.alignment = centered ? .center : .left
  7220. return label
  7221. }
  7222. private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
  7223. let wrapper = NSView()
  7224. let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
  7225. button.font = .systemFont(ofSize: 20, weight: .medium)
  7226. button.isBordered = false
  7227. button.wantsLayer = true
  7228. button.layer?.cornerRadius = 12
  7229. button.layer?.backgroundColor = cardBackground.cgColor
  7230. button.contentTintColor = primaryText
  7231. button.translatesAutoresizingMaskIntoConstraints = false
  7232. let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
  7233. label.translatesAutoresizingMaskIntoConstraints = false
  7234. wrapper.addSubview(button)
  7235. wrapper.addSubview(label)
  7236. NSLayoutConstraint.activate([
  7237. button.topAnchor.constraint(equalTo: wrapper.topAnchor),
  7238. button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  7239. button.widthAnchor.constraint(equalToConstant: 52),
  7240. button.heightAnchor.constraint(equalToConstant: 52),
  7241. label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
  7242. label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
  7243. label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  7244. ])
  7245. return (wrapper, action == nil ? nil : button)
  7246. }
  7247. }
  7248. extension ViewController: EmbeddedOAuthPresenting {}
  7249. extension ViewController: WKNavigationDelegate, WKUIDelegate {
  7250. func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  7251. guard webView === embeddedBrowserWebView else {
  7252. decisionHandler(.allow)
  7253. return
  7254. }
  7255. if let prefix = oauthSessionCallbackPrefix,
  7256. let targetURL = navigationAction.request.url,
  7257. targetURL.absoluteString.hasPrefix(prefix) {
  7258. oauthSessionContinuation?.resume(returning: targetURL)
  7259. oauthSessionContinuation = nil
  7260. oauthSessionCallbackPrefix = nil
  7261. decisionHandler(.cancel)
  7262. return
  7263. }
  7264. decisionHandler(.allow)
  7265. }
  7266. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
  7267. guard webView === embeddedBrowserWebView else { return }
  7268. refreshEmbeddedBrowserNavButtons()
  7269. }
  7270. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  7271. guard webView === embeddedBrowserWebView else { return }
  7272. refreshEmbeddedBrowserNavButtons()
  7273. }
  7274. func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
  7275. guard webView === embeddedBrowserWebView else { return nil }
  7276. if navigationAction.targetFrame == nil {
  7277. webView.load(navigationAction.request)
  7278. }
  7279. return nil
  7280. }
  7281. }
  7282. extension ViewController: NSWindowDelegate {
  7283. func windowWillClose(_ notification: Notification) {
  7284. guard let window = notification.object as? NSWindow else { return }
  7285. if window === joinMeetingWindow {
  7286. resetJoinMeetingPanelReferences()
  7287. }
  7288. if window === scheduleMeetingWindow {
  7289. resetScheduleMeetingPanelReferences()
  7290. }
  7291. }
  7292. }
  7293. private extension Array {
  7294. subscript(safe index: Int) -> Element? {
  7295. guard index >= 0, index < count else { return nil }
  7296. return self[index]
  7297. }
  7298. }
  7299. /// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
  7300. private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
  7301. private func lineHeight() -> CGFloat {
  7302. guard let font = font else { return 0 }
  7303. return ceil(font.ascender - font.descender + font.leading)
  7304. }
  7305. private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
  7306. let lh = lineHeight()
  7307. guard lh > 0, h > lh else { return 0 }
  7308. return max(0, floor((h - lh) / 2))
  7309. }
  7310. private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
  7311. let lh = lineHeight()
  7312. guard lh > 0 else { return cellFrame }
  7313. let m = verticalMargin(forBoundsHeight: cellFrame.height)
  7314. var r = cellFrame
  7315. r.origin.y += m
  7316. r.size.height = lh
  7317. return r
  7318. }
  7319. override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
  7320. super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
  7321. }
  7322. override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
  7323. let text = super.setUpFieldEditorAttributes(textObj)
  7324. guard let tv = text as? NSTextView else { return text }
  7325. // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
  7326. let h = max(controlView?.bounds.height ?? 0, 42)
  7327. let margin = verticalMargin(forBoundsHeight: h)
  7328. if margin > 0 {
  7329. // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
  7330. tv.textContainerInset = NSSize(width: 0, height: margin)
  7331. }
  7332. return text
  7333. }
  7334. }
  7335. /// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
  7336. private final class JoinPanelFieldContainer: NSView {
  7337. let textField: NSTextField
  7338. private let normalBorder: NSColor
  7339. private let focusBorder: NSColor
  7340. private let fill: NSColor
  7341. private var beginObserver: NSObjectProtocol?
  7342. private var endObserver: NSObjectProtocol?
  7343. init(
  7344. placeholder: String,
  7345. normalBorder: NSColor,
  7346. focusBorder: NSColor,
  7347. fill: NSColor,
  7348. primaryText: NSColor,
  7349. mutedText: NSColor
  7350. ) {
  7351. self.normalBorder = normalBorder
  7352. self.focusBorder = focusBorder
  7353. self.fill = fill
  7354. textField = NSTextField()
  7355. textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
  7356. super.init(frame: .zero)
  7357. translatesAutoresizingMaskIntoConstraints = false
  7358. wantsLayer = true
  7359. // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
  7360. layer?.masksToBounds = false
  7361. layer?.cornerRadius = 23
  7362. layer?.backgroundColor = fill.cgColor
  7363. applyBorder(focused: false)
  7364. textField.font = .systemFont(ofSize: 14, weight: .regular)
  7365. textField.textColor = primaryText
  7366. textField.alignment = .center
  7367. textField.isEditable = true
  7368. textField.isSelectable = true
  7369. textField.refusesFirstResponder = false
  7370. textField.focusRingType = .none
  7371. textField.isBordered = false
  7372. textField.drawsBackground = false
  7373. textField.translatesAutoresizingMaskIntoConstraints = false
  7374. if let cell = textField.cell as? NSTextFieldCell {
  7375. cell.isBezeled = false
  7376. cell.drawsBackground = false
  7377. cell.alignment = .center
  7378. cell.usesSingleLineMode = true
  7379. cell.lineBreakMode = .byTruncatingTail
  7380. let placeholderParagraph = NSMutableParagraphStyle()
  7381. placeholderParagraph.alignment = .center
  7382. cell.placeholderAttributedString = NSAttributedString(
  7383. string: placeholder,
  7384. attributes: [
  7385. .foregroundColor: mutedText.withAlphaComponent(0.88),
  7386. .font: NSFont.systemFont(ofSize: 14, weight: .regular),
  7387. .paragraphStyle: placeholderParagraph
  7388. ]
  7389. )
  7390. }
  7391. addSubview(textField)
  7392. NSLayoutConstraint.activate([
  7393. textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
  7394. textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
  7395. textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
  7396. textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
  7397. heightAnchor.constraint(equalToConstant: 46)
  7398. ])
  7399. beginObserver = NotificationCenter.default.addObserver(
  7400. forName: NSControl.textDidBeginEditingNotification,
  7401. object: textField,
  7402. queue: .main
  7403. ) { [weak self] _ in
  7404. self?.applyBorder(focused: true)
  7405. }
  7406. endObserver = NotificationCenter.default.addObserver(
  7407. forName: NSControl.textDidEndEditingNotification,
  7408. object: textField,
  7409. queue: .main
  7410. ) { [weak self] _ in
  7411. self?.applyBorder(focused: false)
  7412. }
  7413. }
  7414. required init?(coder: NSCoder) {
  7415. fatalError("init(coder:) has not been implemented")
  7416. }
  7417. deinit {
  7418. if let beginObserver {
  7419. NotificationCenter.default.removeObserver(beginObserver)
  7420. }
  7421. if let endObserver {
  7422. NotificationCenter.default.removeObserver(endObserver)
  7423. }
  7424. }
  7425. private func applyBorder(focused: Bool) {
  7426. layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
  7427. layer?.borderWidth = focused ? 1.5 : 1
  7428. }
  7429. override func viewDidMoveToWindow() {
  7430. super.viewDidMoveToWindow()
  7431. if window == nil {
  7432. applyBorder(focused: false)
  7433. }
  7434. }
  7435. }
  7436. private final class SearchPillTextField: NSTextField {
  7437. var onFocusChange: ((Bool) -> Void)?
  7438. private(set) var isSearchFocused = false
  7439. func forceClearFocusState() {
  7440. isSearchFocused = false
  7441. onFocusChange?(false)
  7442. }
  7443. override func becomeFirstResponder() -> Bool {
  7444. let ok = super.becomeFirstResponder()
  7445. if ok {
  7446. isSearchFocused = true
  7447. onFocusChange?(true)
  7448. }
  7449. return ok
  7450. }
  7451. override func resignFirstResponder() -> Bool {
  7452. let ok = super.resignFirstResponder()
  7453. if ok {
  7454. isSearchFocused = false
  7455. onFocusChange?(false)
  7456. }
  7457. return ok
  7458. }
  7459. }
  7460. private final class FlippedView: NSView {
  7461. override var isFlipped: Bool { true }
  7462. }
  7463. private final class MeetingCardView: NSView {
  7464. private let url: URL?
  7465. private let hoverBackground: NSColor
  7466. private let hoverBorder: NSColor
  7467. private let onTapURL: ((URL) -> Void)?
  7468. private var tracking: NSTrackingArea?
  7469. private var isHovering = false
  7470. private var normalBackgroundColor: CGColor?
  7471. private var normalBorderColor: CGColor?
  7472. private var normalBorderWidth: CGFloat?
  7473. init(url: URL?, hoverBackground: NSColor, hoverBorder: NSColor, onTapURL: ((URL) -> Void)? = nil) {
  7474. self.url = url
  7475. self.hoverBackground = hoverBackground
  7476. self.hoverBorder = hoverBorder
  7477. self.onTapURL = onTapURL
  7478. super.init(frame: .zero)
  7479. }
  7480. required init?(coder: NSCoder) {
  7481. self.url = nil
  7482. self.hoverBackground = NSColor.white.withAlphaComponent(0.075)
  7483. self.hoverBorder = NSColor.white.withAlphaComponent(0.85)
  7484. self.onTapURL = nil
  7485. super.init(coder: coder)
  7486. }
  7487. override func updateTrackingAreas() {
  7488. super.updateTrackingAreas()
  7489. if let tracking { removeTrackingArea(tracking) }
  7490. let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
  7491. let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
  7492. addTrackingArea(area)
  7493. tracking = area
  7494. }
  7495. override func resetCursorRects() {
  7496. super.resetCursorRects()
  7497. if url != nil {
  7498. addCursorRect(bounds, cursor: .pointingHand)
  7499. }
  7500. }
  7501. override func mouseEntered(with event: NSEvent) {
  7502. super.mouseEntered(with: event)
  7503. guard url != nil else { return }
  7504. isHovering = true
  7505. applyHoverState()
  7506. }
  7507. override func mouseExited(with event: NSEvent) {
  7508. super.mouseExited(with: event)
  7509. guard url != nil else { return }
  7510. isHovering = false
  7511. applyHoverState()
  7512. }
  7513. override func mouseUp(with event: NSEvent) {
  7514. super.mouseUp(with: event)
  7515. guard let url else { return }
  7516. if let onTapURL {
  7517. onTapURL(url)
  7518. } else {
  7519. NSWorkspace.shared.open(url)
  7520. }
  7521. }
  7522. private func applyHoverState() {
  7523. guard let layer else { return }
  7524. if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
  7525. if normalBorderColor == nil { normalBorderColor = layer.borderColor }
  7526. if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
  7527. if isHovering {
  7528. layer.backgroundColor = hoverBackground.cgColor
  7529. layer.borderColor = hoverBorder.cgColor
  7530. layer.borderWidth = max(1.5, layer.borderWidth)
  7531. } else {
  7532. layer.backgroundColor = normalBackgroundColor
  7533. layer.borderColor = normalBorderColor
  7534. if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
  7535. }
  7536. }
  7537. }
  7538. struct GoogleOAuthTokens: Codable, Equatable {
  7539. var accessToken: String
  7540. var refreshToken: String?
  7541. var expiresAt: Date
  7542. var scope: String?
  7543. var tokenType: String?
  7544. }
  7545. struct GoogleUserProfile: Codable, Equatable {
  7546. var name: String?
  7547. var email: String?
  7548. var picture: String?
  7549. }
  7550. struct ZoomOAuthTokens: Codable, Equatable {
  7551. var accessToken: String
  7552. var refreshToken: String?
  7553. var expiresAt: Date
  7554. var scope: String?
  7555. var tokenType: String?
  7556. }
  7557. enum ZoomOAuthError: Error {
  7558. case missingClientId
  7559. case missingClientSecret
  7560. case invalidCallbackURL
  7561. case missingAuthorizationCode
  7562. case tokenExchangeFailed(String)
  7563. case missingRequiredScope(String)
  7564. case rateLimited(retryAfterSeconds: Int?)
  7565. case unableToOpenBrowser
  7566. case authenticationTimedOut
  7567. case authenticationCancelled
  7568. }
  7569. final class ZoomOAuthTokenStore {
  7570. private let defaultsKey: String
  7571. private let defaults: UserDefaults
  7572. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  7573. account: String = "zoomOAuthTokens",
  7574. defaults: UserDefaults = .standard) {
  7575. self.defaultsKey = "\(service).\(account)"
  7576. self.defaults = defaults
  7577. }
  7578. func readTokens() throws -> ZoomOAuthTokens? {
  7579. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  7580. return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
  7581. }
  7582. func writeTokens(_ tokens: ZoomOAuthTokens) throws {
  7583. let data = try JSONEncoder().encode(tokens)
  7584. defaults.set(data, forKey: defaultsKey)
  7585. }
  7586. func clearTokens() {
  7587. defaults.removeObject(forKey: defaultsKey)
  7588. }
  7589. }
  7590. final class ZoomOAuthService: NSObject {
  7591. static let shared = ZoomOAuthService()
  7592. private var inAppOAuthWindowController: InAppOAuthWindowController?
  7593. private let tokenStore = ZoomOAuthTokenStore()
  7594. private let clientIdDefaultsKey = "zoom.oauth.clientId"
  7595. private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
  7596. private let infoPlistClientIdKey = "ZoomOAuthClientId"
  7597. private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
  7598. // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
  7599. /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
  7600. private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
  7601. /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
  7602. private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
  7603. func setClientCredentials(clientId: String, clientSecret: String) {
  7604. UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
  7605. UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
  7606. }
  7607. func configuredClientId() -> String? {
  7608. if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
  7609. let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
  7610. if trimmed.isEmpty == false { return trimmed }
  7611. }
  7612. let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
  7613. .trimmingCharacters(in: .whitespacesAndNewlines)
  7614. if let value, value.isEmpty == false { return value }
  7615. return bundledClientId.isEmpty ? nil : bundledClientId
  7616. }
  7617. func configuredClientSecret() -> String? {
  7618. if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
  7619. let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
  7620. if trimmed.isEmpty == false { return trimmed }
  7621. }
  7622. let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
  7623. .trimmingCharacters(in: .whitespacesAndNewlines)
  7624. if let value, value.isEmpty == false { return value }
  7625. return bundledClientSecret.isEmpty ? nil : bundledClientSecret
  7626. }
  7627. func clearSavedTokens() {
  7628. tokenStore.clearTokens()
  7629. }
  7630. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  7631. // Default: baseline meeting access used throughout the app.
  7632. try await validAccessToken(presentingWindow: presentingWindow, requiringAnyOf: [
  7633. // Zoom's newer granular scopes for listing and scheduling meetings.
  7634. "meeting:read:list_meetings",
  7635. "meeting:write:meeting"
  7636. ])
  7637. }
  7638. func validAccessToken(presentingWindow: NSWindow?, requiringAnyOf requiredScopes: [String]) async throws -> String {
  7639. if let tokens = try tokenStore.readTokens(),
  7640. tokens.expiresAt.timeIntervalSinceNow > 60,
  7641. tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
  7642. return tokens.accessToken
  7643. } else if var tokens = try tokenStore.readTokens(),
  7644. let refreshed = try await refreshTokens(tokens) {
  7645. tokens = refreshed
  7646. try tokenStore.writeTokens(tokens)
  7647. if tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) {
  7648. return tokens.accessToken
  7649. }
  7650. }
  7651. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  7652. try tokenStore.writeTokens(tokens)
  7653. guard tokenHasScopes(tokens.scope, requiringAnyOf: requiredScopes) else {
  7654. throw ZoomOAuthError.missingRequiredScope(tokens.scope ?? "Missing required scope(s).")
  7655. }
  7656. return tokens.accessToken
  7657. }
  7658. /// User-managed OAuth apps often **cannot** add `meeting:read:list_summaries` (admin/master only).
  7659. /// In that case we load AI summaries by listing past meetings (`meeting:read:list_meetings`) and
  7660. /// calling `meeting:read:summary` per meeting.
  7661. func validAccessTokenForMeetingSummaries(presentingWindow: NSWindow?) async throws -> String {
  7662. func canLoadSummaries(_ scope: String?) -> Bool {
  7663. tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_summaries"])
  7664. || (tokenHasScopes(scope, requiringAnyOf: ["meeting:read:summary"])
  7665. && tokenHasScopes(scope, requiringAnyOf: ["meeting:read:list_meetings"]))
  7666. }
  7667. if let tokens = try tokenStore.readTokens(),
  7668. tokens.expiresAt.timeIntervalSinceNow > 60,
  7669. canLoadSummaries(tokens.scope) {
  7670. return tokens.accessToken
  7671. } else if var tokens = try tokenStore.readTokens(),
  7672. let refreshed = try await refreshTokens(tokens) {
  7673. tokens = refreshed
  7674. try tokenStore.writeTokens(tokens)
  7675. if canLoadSummaries(tokens.scope) {
  7676. return tokens.accessToken
  7677. }
  7678. }
  7679. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  7680. try tokenStore.writeTokens(tokens)
  7681. guard canLoadSummaries(tokens.scope) else {
  7682. let hint = " Grant meeting:read:list_summaries (if available), or both meeting:read:list_meetings and meeting:read:summary."
  7683. throw ZoomOAuthError.missingRequiredScope((tokens.scope ?? "Missing required scope(s).") + hint)
  7684. }
  7685. return tokens.accessToken
  7686. }
  7687. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
  7688. guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
  7689. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  7690. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  7691. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  7692. let state = UUID().uuidString
  7693. // Explicitly request the scopes we rely on.
  7694. // Without this, Zoom can reuse a previously granted token that doesn't include newly added scopes.
  7695. // Note: `meeting:read:list_summaries` is often unavailable on user-managed General apps; we
  7696. // fall back to per-meeting summary fetches using `meeting:read:summary` + past meeting list.
  7697. let requestedScopes = [
  7698. "meeting:read:list_meetings",
  7699. "meeting:write:meeting",
  7700. "meeting:read:summary",
  7701. "zoomapp:inmeeting"
  7702. ]
  7703. var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
  7704. components.queryItems = [
  7705. URLQueryItem(name: "response_type", value: "code"),
  7706. URLQueryItem(name: "client_id", value: clientId),
  7707. URLQueryItem(name: "redirect_uri", value: redirectURI),
  7708. URLQueryItem(name: "scope", value: requestedScopes.joined(separator: " ")),
  7709. // Force consent so updated scopes are actually granted.
  7710. URLQueryItem(name: "prompt", value: "consent"),
  7711. // Zoom-specific: forces interactive verification even if already signed in.
  7712. URLQueryItem(name: "force_verify", value: "true"),
  7713. URLQueryItem(name: "state", value: state)
  7714. ]
  7715. guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
  7716. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  7717. do {
  7718. let callbackURL = try await oauthWindow.authenticate(
  7719. startURL: authURL,
  7720. callbackURLPrefix: redirectURI,
  7721. presentingWindow: presentingWindow,
  7722. timeoutSeconds: 180
  7723. )
  7724. let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  7725. guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
  7726. guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  7727. throw ZoomOAuthError.missingAuthorizationCode
  7728. }
  7729. return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
  7730. } catch GoogleOAuthError.authenticationTimedOut {
  7731. throw ZoomOAuthError.authenticationTimedOut
  7732. } catch GoogleOAuthError.authenticationCancelled {
  7733. throw ZoomOAuthError.authenticationCancelled
  7734. } catch {
  7735. throw error
  7736. }
  7737. }
  7738. @MainActor
  7739. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  7740. if let existing = inAppOAuthWindowController { return existing }
  7741. let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
  7742. inAppOAuthWindowController = controller
  7743. return controller
  7744. }
  7745. private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
  7746. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  7747. request.httpMethod = "POST"
  7748. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  7749. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  7750. request.httpBody = Self.formURLEncoded([
  7751. "grant_type": "authorization_code",
  7752. "code": code,
  7753. "redirect_uri": redirectURI
  7754. ])
  7755. let (data, response) = try await URLSession.shared.data(for: request)
  7756. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  7757. throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  7758. }
  7759. struct TokenResponse: Decodable {
  7760. let access_token: String
  7761. let refresh_token: String?
  7762. let expires_in: Double
  7763. let scope: String?
  7764. let token_type: String?
  7765. }
  7766. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  7767. return ZoomOAuthTokens(
  7768. accessToken: decoded.access_token,
  7769. refreshToken: decoded.refresh_token,
  7770. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  7771. scope: decoded.scope,
  7772. tokenType: decoded.token_type
  7773. )
  7774. }
  7775. private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
  7776. guard let refreshToken = tokens.refreshToken else { return nil }
  7777. guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
  7778. guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
  7779. var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
  7780. request.httpMethod = "POST"
  7781. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  7782. request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
  7783. request.httpBody = Self.formURLEncoded([
  7784. "grant_type": "refresh_token",
  7785. "refresh_token": refreshToken
  7786. ])
  7787. let (data, response) = try await URLSession.shared.data(for: request)
  7788. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  7789. return nil
  7790. }
  7791. struct RefreshResponse: Decodable {
  7792. let access_token: String
  7793. let refresh_token: String?
  7794. let expires_in: Double
  7795. let scope: String?
  7796. let token_type: String?
  7797. }
  7798. let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
  7799. return ZoomOAuthTokens(
  7800. accessToken: decoded.access_token,
  7801. refreshToken: decoded.refresh_token ?? refreshToken,
  7802. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  7803. scope: decoded.scope ?? tokens.scope,
  7804. tokenType: decoded.token_type ?? tokens.tokenType
  7805. )
  7806. }
  7807. private func normalizedGrantedScopes(_ scopeValue: String?) -> Set<String> {
  7808. guard let scopeValue, scopeValue.isEmpty == false else { return [] }
  7809. let parts = scopeValue.split { $0 == " " || $0 == "," }
  7810. .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
  7811. .filter { $0.isEmpty == false }
  7812. return Set(parts)
  7813. }
  7814. /// Whether `granted` includes the Zoom scope `req` (granular exact match, admin/master variants,
  7815. /// or legacy broad scopes Zoom still returns on some tokens).
  7816. private func grantedScopeSatisfies(_ granted: Set<String>, requirement req: String) -> Bool {
  7817. if granted.contains(req)
  7818. || granted.contains("\(req):admin")
  7819. || granted.contains("\(req):master") {
  7820. return true
  7821. }
  7822. // Classic scopes: listing / reading meetings and summaries often appear as `meeting:read`
  7823. // instead of `meeting:read:list_meetings` / `meeting:read:summary` on the token string.
  7824. switch req {
  7825. case "meeting:read:list_meetings", "meeting:read:summary":
  7826. return granted.contains("meeting:read") || granted.contains("meeting:read:admin")
  7827. case "meeting:write:meeting":
  7828. return granted.contains("meeting:write") || granted.contains("meeting:write:admin")
  7829. default:
  7830. return false
  7831. }
  7832. }
  7833. private func tokenHasScopes(_ scopeValue: String?, requiringAnyOf requiredScopes: [String]) -> Bool {
  7834. let granted = normalizedGrantedScopes(scopeValue)
  7835. if granted.isEmpty { return false }
  7836. return requiredScopes.contains { grantedScopeSatisfies(granted, requirement: $0) }
  7837. }
  7838. private static func basicAuth(clientId: String, clientSecret: String) -> String {
  7839. let joined = "\(clientId):\(clientSecret)"
  7840. return Data(joined.utf8).base64EncodedString()
  7841. }
  7842. private static func formURLEncoded(_ params: [String: String]) -> Data {
  7843. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  7844. let pairs = params.map { key, value in
  7845. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  7846. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  7847. return "\(k)=\(v)"
  7848. }.joined(separator: "&")
  7849. return Data(pairs.utf8)
  7850. }
  7851. }
  7852. enum GoogleOAuthError: Error {
  7853. case missingClientId
  7854. case missingClientSecret
  7855. case invalidCallbackURL
  7856. case missingAuthorizationCode
  7857. case tokenExchangeFailed(String)
  7858. case unableToOpenBrowser
  7859. case authenticationTimedOut
  7860. case authenticationCancelled
  7861. }
  7862. final class GoogleOAuthService: NSObject {
  7863. static let shared = GoogleOAuthService()
  7864. private var inAppOAuthWindowController: InAppOAuthWindowController?
  7865. private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
  7866. private let clientSecret = "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"
  7867. private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
  7868. private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
  7869. private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
  7870. func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
  7871. func clearSavedTokens() {
  7872. tokenStore.clearTokens()
  7873. }
  7874. func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
  7875. var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
  7876. request.httpMethod = "GET"
  7877. request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
  7878. let (data, response) = try await URLSession.shared.data(for: request)
  7879. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  7880. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  7881. }
  7882. return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
  7883. }
  7884. func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
  7885. if let tokens = try tokenStore.readTokens(),
  7886. tokens.expiresAt.timeIntervalSinceNow > 60,
  7887. tokenHasCalendarScope(tokens.scope) {
  7888. return tokens.accessToken
  7889. }
  7890. let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
  7891. try tokenStore.writeTokens(tokens)
  7892. return tokens.accessToken
  7893. }
  7894. private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
  7895. guard let presentingWindow else { throw GoogleOAuthError.unableToOpenBrowser }
  7896. let oauthWindow = await MainActor.run { makeOAuthWindowController() }
  7897. let codeVerifier = Self.randomURLSafeString(length: 64)
  7898. let codeChallenge = Self.pkceChallenge(for: codeVerifier)
  7899. let state = Self.randomURLSafeString(length: 32)
  7900. // Google OAuth app settings often already include this callback from prior loopback setup.
  7901. let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
  7902. var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
  7903. components.queryItems = [
  7904. URLQueryItem(name: "client_id", value: clientId),
  7905. URLQueryItem(name: "redirect_uri", value: redirectURI),
  7906. URLQueryItem(name: "response_type", value: "code"),
  7907. URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
  7908. URLQueryItem(name: "state", value: state),
  7909. URLQueryItem(name: "code_challenge", value: codeChallenge),
  7910. URLQueryItem(name: "code_challenge_method", value: "S256"),
  7911. URLQueryItem(name: "access_type", value: "offline"),
  7912. URLQueryItem(name: "prompt", value: "consent")
  7913. ]
  7914. guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
  7915. let callbackURL = try await oauthWindow.authenticate(
  7916. startURL: authURL,
  7917. callbackURLPrefix: redirectURI,
  7918. presentingWindow: presentingWindow
  7919. )
  7920. let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
  7921. guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
  7922. guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
  7923. throw GoogleOAuthError.missingAuthorizationCode
  7924. }
  7925. return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
  7926. }
  7927. @MainActor
  7928. private func makeOAuthWindowController() -> InAppOAuthWindowController {
  7929. if let existing = inAppOAuthWindowController { return existing }
  7930. let controller = InAppOAuthWindowController(title: "Google Sign-In")
  7931. inAppOAuthWindowController = controller
  7932. return controller
  7933. }
  7934. private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
  7935. var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
  7936. request.httpMethod = "POST"
  7937. request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
  7938. request.httpBody = Self.formURLEncoded([
  7939. "client_id": clientId,
  7940. "client_secret": clientSecret,
  7941. "code": code,
  7942. "code_verifier": codeVerifier,
  7943. "redirect_uri": redirectURI,
  7944. "grant_type": "authorization_code"
  7945. ])
  7946. let (data, response) = try await URLSession.shared.data(for: request)
  7947. guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
  7948. throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
  7949. }
  7950. struct TokenResponse: Decodable {
  7951. let access_token: String
  7952. let expires_in: Double
  7953. let refresh_token: String?
  7954. let scope: String?
  7955. let token_type: String?
  7956. }
  7957. let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
  7958. return GoogleOAuthTokens(
  7959. accessToken: decoded.access_token,
  7960. refreshToken: decoded.refresh_token,
  7961. expiresAt: Date().addingTimeInterval(decoded.expires_in),
  7962. scope: decoded.scope,
  7963. tokenType: decoded.token_type
  7964. )
  7965. }
  7966. private static func pkceChallenge(for verifier: String) -> String {
  7967. let digest = SHA256.hash(data: Data(verifier.utf8))
  7968. return Data(digest).base64URLEncodedString()
  7969. }
  7970. private static func randomURLSafeString(length: Int) -> String {
  7971. var bytes = [UInt8](repeating: 0, count: length)
  7972. _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
  7973. return Data(bytes).base64URLEncodedString()
  7974. }
  7975. private static func formURLEncoded(_ params: [String: String]) -> Data {
  7976. let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
  7977. let pairs = params.map { key, value in
  7978. let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
  7979. let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
  7980. return "\(k)=\(v)"
  7981. }.joined(separator: "&")
  7982. return Data(pairs.utf8)
  7983. }
  7984. private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
  7985. guard let scopeValue else { return false }
  7986. return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
  7987. }
  7988. }
  7989. private extension Data {
  7990. func base64URLEncodedString() -> String {
  7991. base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
  7992. }
  7993. }
  7994. final class KeychainTokenStore {
  7995. private let defaultsKey: String
  7996. private let defaults: UserDefaults
  7997. init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
  7998. account: String = "googleOAuthTokens",
  7999. defaults: UserDefaults = .standard) {
  8000. self.defaultsKey = "\(service).\(account)"
  8001. self.defaults = defaults
  8002. }
  8003. func readTokens() throws -> GoogleOAuthTokens? {
  8004. guard let data = defaults.data(forKey: defaultsKey) else { return nil }
  8005. return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
  8006. }
  8007. func writeTokens(_ tokens: GoogleOAuthTokens) throws {
  8008. let data = try JSONEncoder().encode(tokens)
  8009. defaults.set(data, forKey: defaultsKey)
  8010. }
  8011. func clearTokens() {
  8012. defaults.removeObject(forKey: defaultsKey)
  8013. }
  8014. }
  8015. // MARK: - OAuth window (previous sign-in behavior)
  8016. @MainActor
  8017. private final class OAuthWebViewContainerView: NSView {
  8018. private let webView: WKWebView
  8019. init(webView: WKWebView) {
  8020. self.webView = webView
  8021. super.init(frame: .zero)
  8022. addSubview(webView)
  8023. }
  8024. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  8025. override func layout() {
  8026. super.layout()
  8027. webView.frame = bounds
  8028. }
  8029. }
  8030. /// 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.
  8031. @MainActor
  8032. private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
  8033. private let webView: WKWebView
  8034. private var callbackPrefix: String?
  8035. private var callbackContinuation: CheckedContinuation<URL, Error>?
  8036. private weak var parentWindowForChild: NSWindow?
  8037. /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
  8038. private var suppressUserCloseCancellation = false
  8039. init(title: String) {
  8040. let configuration = WKWebViewConfiguration()
  8041. configuration.websiteDataStore = .nonPersistent()
  8042. self.webView = WKWebView(frame: .zero, configuration: configuration)
  8043. let container = OAuthWebViewContainerView(webView: webView)
  8044. let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  8045. let window = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
  8046. window.title = title
  8047. window.contentView = container
  8048. window.contentMinSize = contentSize
  8049. window.contentMaxSize = contentSize
  8050. window.isReleasedWhenClosed = false
  8051. super.init(window: window)
  8052. window.delegate = self
  8053. webView.navigationDelegate = self
  8054. }
  8055. @available(*, unavailable) required init?(coder: NSCoder) { nil }
  8056. func windowWillClose(_ notification: Notification) {
  8057. guard suppressUserCloseCancellation == false else { return }
  8058. removeFromParentIfNeeded()
  8059. webView.stopLoading()
  8060. if let continuation = callbackContinuation {
  8061. callbackContinuation = nil
  8062. continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
  8063. }
  8064. }
  8065. func authenticate(startURL: URL, callbackURLPrefix: String, presentingWindow: NSWindow?, timeoutSeconds: Double = 120) async throws -> URL {
  8066. self.callbackPrefix = callbackURLPrefix
  8067. parentWindowForChild = presentingWindow
  8068. applyFixedOAuthFrame(presentingWindow: presentingWindow)
  8069. if let parent = presentingWindow, let w = window {
  8070. parent.addChildWindow(w, ordered: .above)
  8071. }
  8072. showWindow(nil)
  8073. window?.makeKeyAndOrderFront(nil)
  8074. webView.load(URLRequest(url: startURL))
  8075. do {
  8076. return try await withThrowingTaskGroup(of: URL.self) { group in
  8077. group.addTask { [weak self] in
  8078. guard let self else { throw GoogleOAuthError.invalidCallbackURL }
  8079. return try await self.awaitCallback()
  8080. }
  8081. group.addTask {
  8082. try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
  8083. throw GoogleOAuthError.authenticationTimedOut
  8084. }
  8085. let url = try await group.next()!
  8086. group.cancelAll()
  8087. self.closeOAuthWindowAfterFlow()
  8088. return url
  8089. }
  8090. } catch {
  8091. self.closeOAuthWindowAfterFlow()
  8092. throw error
  8093. }
  8094. }
  8095. private func closeOAuthWindowAfterFlow() {
  8096. suppressUserCloseCancellation = true
  8097. webView.stopLoading()
  8098. removeFromParentIfNeeded()
  8099. close()
  8100. suppressUserCloseCancellation = false
  8101. }
  8102. private func awaitCallback() async throws -> URL {
  8103. try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
  8104. callbackContinuation = continuation
  8105. }
  8106. }
  8107. private func removeFromParentIfNeeded() {
  8108. if let parent = parentWindowForChild, let w = window {
  8109. parent.removeChildWindow(w)
  8110. }
  8111. parentWindowForChild = nil
  8112. }
  8113. private func applyFixedOAuthFrame(presentingWindow: NSWindow?) {
  8114. guard let window else { return }
  8115. if let parent = presentingWindow {
  8116. let parentContent = parent.contentRect(forFrameRect: parent.frame)
  8117. let oauthFrame = window.frameRect(forContentRect: parentContent)
  8118. window.setFrame(oauthFrame, display: true)
  8119. } else {
  8120. let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
  8121. window.setContentSize(contentSize)
  8122. window.center()
  8123. }
  8124. let lockedContentSize = window.contentRect(forFrameRect: window.frame).size
  8125. window.contentMinSize = lockedContentSize
  8126. window.contentMaxSize = lockedContentSize
  8127. }
  8128. func webView(_ webView: WKWebView,
  8129. decidePolicyFor navigationAction: WKNavigationAction,
  8130. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
  8131. guard let targetURL = navigationAction.request.url,
  8132. let callbackPrefix,
  8133. targetURL.absoluteString.hasPrefix(callbackPrefix) else {
  8134. decisionHandler(.allow)
  8135. return
  8136. }
  8137. callbackContinuation?.resume(returning: targetURL)
  8138. callbackContinuation = nil
  8139. decisionHandler(.cancel)
  8140. }
  8141. }
  8142. extension GoogleOAuthError: LocalizedError {
  8143. var errorDescription: String? {
  8144. switch self {
  8145. case .missingClientId: return "Missing Google OAuth Client ID."
  8146. case .missingClientSecret: return "Missing Google OAuth Client Secret."
  8147. case .invalidCallbackURL: return "Invalid OAuth callback URL."
  8148. case .missingAuthorizationCode: return "Google did not return an authorization code."
  8149. case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
  8150. case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
  8151. case .authenticationTimedOut: return "Google sign-in timed out."
  8152. case .authenticationCancelled: return "Sign-in was cancelled."
  8153. }
  8154. }
  8155. }
  8156. extension ZoomOAuthError: LocalizedError {
  8157. var errorDescription: String? {
  8158. switch self {
  8159. case .missingClientId:
  8160. return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
  8161. case .missingClientSecret:
  8162. return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
  8163. case .invalidCallbackURL:
  8164. return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
  8165. case .missingAuthorizationCode:
  8166. return "Zoom did not return an authorization code."
  8167. case .tokenExchangeFailed(let details):
  8168. return details
  8169. case .missingRequiredScope(let details):
  8170. return "The Zoom access token is missing required scopes. \(details)"
  8171. case .rateLimited(let retryAfterSeconds):
  8172. if let retryAfterSeconds {
  8173. return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
  8174. }
  8175. return "Zoom rate limit reached. Try again later."
  8176. case .unableToOpenBrowser:
  8177. return "Could not open Zoom sign-in (no window to present from)."
  8178. case .authenticationTimedOut:
  8179. return "Zoom sign-in timed out waiting for the browser redirect."
  8180. case .authenticationCancelled:
  8181. return "Zoom sign-in was cancelled."
  8182. }
  8183. }
  8184. }
  8185. // MARK: - Desktop Widgets (Zoom)
  8186. private struct WidgetQuickAction: Identifiable, Hashable {
  8187. enum Destination: String, Hashable {
  8188. case home
  8189. case meetings
  8190. case scheduler
  8191. case widgets
  8192. case settings
  8193. case newMeeting
  8194. case joinMeeting
  8195. case scheduleMeeting
  8196. case refreshMeetings
  8197. }
  8198. let id: String
  8199. let title: String
  8200. let systemImage: String
  8201. let destination: Destination
  8202. }
  8203. private struct WidgetVariant: Identifiable, Hashable {
  8204. let id: String
  8205. let title: String
  8206. let subtitle: String
  8207. let size: WidgetSize
  8208. let quickActions: [WidgetQuickAction]
  8209. }
  8210. private enum WidgetTemplates {
  8211. static let zoomVariants: [WidgetVariant] = [
  8212. WidgetVariant(
  8213. id: "zoom_small",
  8214. title: "Zoom",
  8215. subtitle: "Quick actions",
  8216. size: .small,
  8217. quickActions: [
  8218. WidgetQuickAction(id: "new", title: "New meeting", systemImage: "video.fill", destination: .newMeeting),
  8219. WidgetQuickAction(id: "join", title: "Join", systemImage: "plus", destination: .joinMeeting),
  8220. WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "calendar", destination: .scheduleMeeting),
  8221. WidgetQuickAction(id: "open", title: "Meetings", systemImage: "list.bullet.rectangle", destination: .meetings)
  8222. ]
  8223. ),
  8224. WidgetVariant(
  8225. id: "zoom_medium",
  8226. title: "Zoom",
  8227. subtitle: "Upcoming meetings",
  8228. size: .medium,
  8229. quickActions: [
  8230. WidgetQuickAction(id: "meetings", title: "Meetings", systemImage: "video", destination: .meetings),
  8231. WidgetQuickAction(id: "scheduler", title: "Scheduler", systemImage: "calendar.badge.clock", destination: .scheduler),
  8232. WidgetQuickAction(id: "widgets", title: "Widgets", systemImage: "rectangle.grid.2x2", destination: .widgets),
  8233. WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
  8234. ]
  8235. )
  8236. ]
  8237. static func variant(for variantID: String) -> WidgetVariant {
  8238. zoomVariants.first(where: { $0.id == variantID }) ?? zoomVariants[0]
  8239. }
  8240. }
  8241. private extension WidgetVariant {
  8242. var previewCardSize: CGSize {
  8243. switch size {
  8244. case .small:
  8245. return CGSize(width: 196, height: 196)
  8246. case .medium:
  8247. return CGSize(width: 340, height: 288)
  8248. }
  8249. }
  8250. // Simple Zoom-blue palette shared by all widget sizes.
  8251. var gradientStops: [Color] {
  8252. [
  8253. Color(red: 0.04, green: 0.32, blue: 0.86),
  8254. Color(red: 0.10, green: 0.50, blue: 0.96)
  8255. ]
  8256. }
  8257. // Neutral highlight keeps the blue clean without washing out the logo.
  8258. var highlightTint: Color {
  8259. Color.white.opacity(0.16)
  8260. }
  8261. }
  8262. private enum WidgetSize: String, CaseIterable, Codable, Hashable {
  8263. case small
  8264. case medium
  8265. var title: String {
  8266. switch self {
  8267. case .small: return "Small"
  8268. case .medium: return "Medium"
  8269. }
  8270. }
  8271. }
  8272. private struct WidgetInstance: Identifiable, Codable, Hashable {
  8273. struct Origin: Codable, Hashable {
  8274. var x: Double
  8275. var y: Double
  8276. }
  8277. let id: UUID
  8278. let variantID: String
  8279. var size: WidgetSize
  8280. var origin: Origin?
  8281. init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
  8282. self.id = id
  8283. self.variantID = variantID
  8284. self.size = size
  8285. self.origin = origin
  8286. }
  8287. }
  8288. private struct WidgetMeetingSnapshot: Codable, Identifiable {
  8289. let id: String
  8290. let title: String
  8291. let timeText: String
  8292. let joinLink: String?
  8293. }
  8294. private enum WidgetMeetingStore {
  8295. static let key = "zoom_app.widget.topMeetings"
  8296. static func load() -> [WidgetMeetingSnapshot] {
  8297. guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
  8298. return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
  8299. }
  8300. static func save(_ meetings: [WidgetMeetingSnapshot]) {
  8301. guard let data = try? JSONEncoder().encode(meetings) else { return }
  8302. UserDefaults.standard.set(data, forKey: key)
  8303. }
  8304. }
  8305. private enum WidgetAppNavigator {
  8306. static func open(target: WidgetQuickAction.Destination) {
  8307. DispatchQueue.main.async {
  8308. bringAppToFront()
  8309. DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
  8310. NotificationCenter.default.post(name: notificationName(for: target), object: nil)
  8311. }
  8312. }
  8313. }
  8314. static func openMeetingLink(_ link: String) {
  8315. DispatchQueue.main.async {
  8316. bringAppToFront()
  8317. DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
  8318. NotificationCenter.default.post(
  8319. name: .widgetOpenMeetingLinkRequested,
  8320. object: nil,
  8321. userInfo: ["link": link]
  8322. )
  8323. }
  8324. }
  8325. }
  8326. private static func notificationName(for target: WidgetQuickAction.Destination) -> Notification.Name {
  8327. switch target {
  8328. case .home: return .widgetOpenHomePage
  8329. case .meetings: return .widgetOpenMeetingsPage
  8330. case .scheduler: return .widgetOpenSchedulerPage
  8331. case .widgets: return .widgetOpenWidgetsPage
  8332. case .settings: return .widgetOpenSettingsPage
  8333. case .newMeeting: return .widgetStartNewMeetingRequested
  8334. case .joinMeeting: return .widgetJoinMeetingRequested
  8335. case .scheduleMeeting: return .widgetScheduleMeetingRequested
  8336. case .refreshMeetings: return .widgetRefreshRequested
  8337. }
  8338. }
  8339. private static func bringAppToFront() {
  8340. NSApp.unhide(nil)
  8341. NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
  8342. NSApp.activate(ignoringOtherApps: true)
  8343. NSApp.arrangeInFront(nil)
  8344. for window in NSApp.windows where window.contentViewController is ViewController {
  8345. if window.isMiniaturized {
  8346. window.deminiaturize(nil)
  8347. }
  8348. window.orderFrontRegardless()
  8349. window.makeKeyAndOrderFront(nil)
  8350. }
  8351. }
  8352. }
  8353. private extension Notification.Name {
  8354. static let widgetOpenHomePage = Notification.Name("widgetOpenHomePage")
  8355. static let widgetOpenMeetingsPage = Notification.Name("widgetOpenMeetingsPage")
  8356. static let widgetOpenSchedulerPage = Notification.Name("widgetOpenSchedulerPage")
  8357. static let widgetOpenWidgetsPage = Notification.Name("widgetOpenWidgetsPage")
  8358. static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
  8359. static let widgetStartNewMeetingRequested = Notification.Name("widgetStartNewMeetingRequested")
  8360. static let widgetJoinMeetingRequested = Notification.Name("widgetJoinMeetingRequested")
  8361. static let widgetScheduleMeetingRequested = Notification.Name("widgetScheduleMeetingRequested")
  8362. static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
  8363. static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
  8364. }
  8365. private final class DesktopWidgetWindowManager: ObservableObject {
  8366. static let shared = DesktopWidgetWindowManager()
  8367. private let storageKey = "zoom_app.widget.instancesData"
  8368. private var windowsByInstanceID: [UUID: NSPanel] = [:]
  8369. @Published private(set) var instancesChangeToken: Int = 0
  8370. @Published private(set) var meetingsRefreshToken: Int = 0
  8371. private init() {}
  8372. func hasInstance(variantID: String) -> Bool {
  8373. loadInstances().contains { $0.variantID == variantID }
  8374. }
  8375. func show(instance: WidgetInstance) {
  8376. let variant = WidgetTemplates.variant(for: instance.variantID)
  8377. var resolved = instance
  8378. if resolved.origin == nil {
  8379. resolved.origin = nextAutoPlacement(for: variant)
  8380. }
  8381. let panel = makePanel(for: resolved, variant: variant)
  8382. windowsByInstanceID[resolved.id] = panel
  8383. persistUpsert(instance: resolved)
  8384. if let origin = resolved.origin {
  8385. panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
  8386. persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
  8387. } else {
  8388. panel.center()
  8389. persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
  8390. }
  8391. panel.orderFront(nil)
  8392. panel.orderFrontRegardless()
  8393. instancesChangeToken += 1
  8394. }
  8395. @discardableResult
  8396. func removeInstances(variantID: String) -> Int {
  8397. let instances = loadInstances()
  8398. let removed = instances.filter { $0.variantID == variantID }
  8399. let remaining = instances.filter { $0.variantID != variantID }
  8400. for instance in removed {
  8401. if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
  8402. panel.close()
  8403. }
  8404. }
  8405. saveInstances(remaining)
  8406. instancesChangeToken += 1
  8407. return removed.count
  8408. }
  8409. @discardableResult
  8410. func removeAllWidgets() -> Int {
  8411. let removedCount = loadInstances().count
  8412. for panel in windowsByInstanceID.values {
  8413. panel.close()
  8414. }
  8415. windowsByInstanceID.removeAll()
  8416. saveInstances([])
  8417. instancesChangeToken += 1
  8418. return removedCount
  8419. }
  8420. func restore() {
  8421. for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
  8422. show(instance: instance)
  8423. }
  8424. }
  8425. func refreshForMeetingsChange() {
  8426. resizeVisiblePanelsForCurrentContent()
  8427. meetingsRefreshToken += 1
  8428. }
  8429. private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
  8430. let size = panelSize(for: variant.size)
  8431. let panel = DesktopWidgetPanel(
  8432. contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
  8433. styleMask: [.nonactivatingPanel, .borderless],
  8434. backing: .buffered,
  8435. defer: false
  8436. )
  8437. panel.becomesKeyOnlyIfNeeded = false
  8438. panel.isReleasedWhenClosed = false
  8439. panel.isFloatingPanel = false
  8440. panel.isMovable = true
  8441. panel.hasShadow = false
  8442. panel.isOpaque = false
  8443. panel.backgroundColor = .clear
  8444. panel.isMovableByWindowBackground = true
  8445. panel.hidesOnDeactivate = false
  8446. panel.ignoresMouseEvents = false
  8447. let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
  8448. panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
  8449. panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
  8450. let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
  8451. host.translatesAutoresizingMaskIntoConstraints = false
  8452. let container = NSView()
  8453. container.wantsLayer = true
  8454. container.layer?.backgroundColor = NSColor.clear.cgColor
  8455. container.layer?.cornerRadius = 22
  8456. container.layer?.masksToBounds = true
  8457. container.addSubview(host)
  8458. NSLayoutConstraint.activate([
  8459. host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  8460. host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
  8461. host.topAnchor.constraint(equalTo: container.topAnchor),
  8462. host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
  8463. ])
  8464. panel.contentView = container
  8465. NotificationCenter.default.addObserver(
  8466. forName: NSWindow.didMoveNotification,
  8467. object: panel,
  8468. queue: .main
  8469. ) { [weak self, weak panel] _ in
  8470. guard let self, let panel else { return }
  8471. self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
  8472. }
  8473. return panel
  8474. }
  8475. private func panelSize(for size: WidgetSize) -> CGSize {
  8476. switch size {
  8477. case .small:
  8478. return CGSize(width: 220, height: 204)
  8479. case .medium:
  8480. return CGSize(width: 390, height: mediumPanelHeight())
  8481. }
  8482. }
  8483. private func mediumPanelHeight() -> CGFloat {
  8484. let visibleMeetingCount = min(WidgetMeetingStore.load().count, 2)
  8485. switch visibleMeetingCount {
  8486. case 0:
  8487. return 252
  8488. case 1:
  8489. return 270
  8490. default:
  8491. return 288
  8492. }
  8493. }
  8494. private func resizeVisiblePanelsForCurrentContent() {
  8495. let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
  8496. for (instanceID, panel) in windowsByInstanceID {
  8497. guard let instance = instancesByID[instanceID] else { continue }
  8498. let variant = WidgetTemplates.variant(for: instance.variantID)
  8499. let targetSize = panelSize(for: variant.size)
  8500. let currentSize = panel.frame.size
  8501. guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
  8502. continue
  8503. }
  8504. var frame = panel.frame
  8505. let oldMaxY = frame.maxY
  8506. frame.size = targetSize
  8507. frame.origin.y = oldMaxY - targetSize.height
  8508. panel.setFrame(frame, display: true)
  8509. persistOrigin(instanceID: instanceID, origin: frame.origin)
  8510. }
  8511. }
  8512. private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
  8513. let size = panelSize(for: variant.size)
  8514. let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
  8515. let margin: CGFloat = 18
  8516. let gapX: CGFloat = 22
  8517. let gapY: CGFloat = 18
  8518. let cellW = size.width + gapX
  8519. let cellH = size.height + gapY
  8520. let existingRects = loadInstances().compactMap { instance -> NSRect? in
  8521. guard let origin = instance.origin else { return nil }
  8522. let instanceSize = panelSize(for: instance.size)
  8523. return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
  8524. }
  8525. let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
  8526. let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
  8527. let maxSlots = max(120, cols * rows)
  8528. for slot in 0..<maxSlots {
  8529. let row = slot / cols
  8530. let col = cols - 1 - (slot % cols)
  8531. let x = frame.minX + margin + CGFloat(col) * cellW
  8532. let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
  8533. let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
  8534. guard candidate.minX >= frame.minX + margin,
  8535. candidate.maxX <= frame.maxX - margin,
  8536. candidate.minY >= frame.minY + margin else {
  8537. continue
  8538. }
  8539. if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
  8540. return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
  8541. }
  8542. }
  8543. return WidgetInstance.Origin(
  8544. x: frame.midX - size.width / 2,
  8545. y: frame.midY - size.height / 2
  8546. )
  8547. }
  8548. private func close(instanceID: UUID) {
  8549. if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
  8550. panel.close()
  8551. }
  8552. let remaining = loadInstances().filter { $0.id != instanceID }
  8553. saveInstances(remaining)
  8554. instancesChangeToken += 1
  8555. }
  8556. private func loadInstances() -> [WidgetInstance] {
  8557. guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
  8558. let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
  8559. let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.zoomVariants.map { ($0.id, $0.size) })
  8560. let sanitized = decoded.compactMap { instance -> WidgetInstance? in
  8561. guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
  8562. var normalized = instance
  8563. normalized.size = expectedSize
  8564. return normalized
  8565. }
  8566. if sanitized != decoded {
  8567. saveInstances(sanitized)
  8568. }
  8569. return sanitized
  8570. }
  8571. private func saveInstances(_ instances: [WidgetInstance]) {
  8572. guard let data = try? JSONEncoder().encode(instances) else { return }
  8573. UserDefaults.standard.set(data, forKey: storageKey)
  8574. }
  8575. private func persistUpsert(instance: WidgetInstance) {
  8576. var instances = loadInstances()
  8577. if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
  8578. instances[idx] = instance
  8579. } else {
  8580. instances.append(instance)
  8581. }
  8582. saveInstances(instances)
  8583. }
  8584. private func persistOrigin(instanceID: UUID, origin: NSPoint) {
  8585. var instances = loadInstances()
  8586. guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
  8587. instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
  8588. saveInstances(instances)
  8589. }
  8590. private struct DesktopWidgetHostView: View {
  8591. let instanceID: UUID
  8592. let variant: WidgetVariant
  8593. @ObservedObject private var manager = DesktopWidgetWindowManager.shared
  8594. var body: some View {
  8595. DesktopWidgetView(variant: variant, isPreview: false)
  8596. .id(manager.meetingsRefreshToken)
  8597. .contextMenu {
  8598. Button("Remove Widget", role: .destructive) {
  8599. DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
  8600. }
  8601. }
  8602. }
  8603. }
  8604. }
  8605. private final class DesktopWidgetPanel: NSPanel {
  8606. override var canBecomeKey: Bool { true }
  8607. override var canBecomeMain: Bool { false }
  8608. override func mouseDown(with event: NSEvent) {
  8609. NSApp.activate(ignoringOtherApps: true)
  8610. makeKeyAndOrderFront(nil)
  8611. super.mouseDown(with: event)
  8612. }
  8613. }
  8614. private struct WidgetsRootView: View {
  8615. @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
  8616. @Environment(\.colorScheme) private var colorScheme
  8617. @State private var toastMessage: String?
  8618. @State private var toastTask: Task<Void, Never>?
  8619. let canAddWidgets: Bool
  8620. let onAddBlocked: (() -> Void)?
  8621. private let variants = WidgetTemplates.zoomVariants
  8622. init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
  8623. self.canAddWidgets = canAddWidgets
  8624. self.onAddBlocked = onAddBlocked
  8625. }
  8626. var body: some View {
  8627. VStack(alignment: .leading, spacing: 18) {
  8628. HStack {
  8629. VStack(alignment: .leading, spacing: 3) {
  8630. Text("Add Widget to Desktop")
  8631. .font(.system(size: 20, weight: .bold))
  8632. Text("Choose a size and add your Zoom desktop widget.")
  8633. .font(.system(size: 13, weight: .medium))
  8634. .foregroundStyle(secondaryTextColor)
  8635. }
  8636. Spacer()
  8637. }
  8638. GeometryReader { geo in
  8639. let count = max(variants.count, 1)
  8640. let spacing: CGFloat = 24
  8641. let baseRowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width }
  8642. let usableWidth = max(1, geo.size.width)
  8643. let usableHeight = max(1, geo.size.height)
  8644. let maxPreviewHeight = variants.map(\.previewCardSize.height).max() ?? 320
  8645. let widthScale = (usableWidth - CGFloat(count - 1) * spacing) / max(1, baseRowWidth)
  8646. let heightScale = usableHeight / max(1, maxPreviewHeight)
  8647. let scale = max(0.45, min(1.0, min(widthScale, heightScale)))
  8648. HStack(alignment: .top, spacing: spacing) {
  8649. ForEach(variants) { variant in
  8650. WidgetPreviewAddCard(
  8651. variant: variant,
  8652. layoutScale: scale,
  8653. isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
  8654. isLocked: canAddWidgets == false,
  8655. onToggle: { toggleWidget(variant: variant) }
  8656. )
  8657. }
  8658. Spacer(minLength: 0)
  8659. }
  8660. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  8661. }
  8662. .frame(maxWidth: .infinity, maxHeight: .infinity)
  8663. Text("Tip: Right-click a desktop widget to remove it.")
  8664. .font(.system(size: 12, weight: .medium))
  8665. .foregroundStyle(secondaryTextColor)
  8666. }
  8667. .padding(22)
  8668. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  8669. .background(
  8670. RoundedRectangle(cornerRadius: 18, style: .continuous)
  8671. .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
  8672. )
  8673. .overlay(
  8674. RoundedRectangle(cornerRadius: 18, style: .continuous)
  8675. .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
  8676. )
  8677. .padding(.horizontal, 12)
  8678. .padding(.vertical, 12)
  8679. .overlay(alignment: .top) {
  8680. if let toastMessage {
  8681. Text(toastMessage)
  8682. .font(.system(size: 13, weight: .semibold))
  8683. .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
  8684. .padding(.horizontal, 14)
  8685. .padding(.vertical, 9)
  8686. .background(
  8687. RoundedRectangle(cornerRadius: 12, style: .continuous)
  8688. .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
  8689. )
  8690. .overlay(
  8691. RoundedRectangle(cornerRadius: 12, style: .continuous)
  8692. .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
  8693. )
  8694. .padding(.top, 8)
  8695. }
  8696. }
  8697. .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
  8698. }
  8699. private var secondaryTextColor: Color {
  8700. colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
  8701. }
  8702. private func toggleWidget(variant: WidgetVariant) {
  8703. guard canAddWidgets else {
  8704. showToast("Widgets are Premium only. Upgrade to add widgets.")
  8705. onAddBlocked?()
  8706. return
  8707. }
  8708. if desktopWidgetManager.hasInstance(variantID: variant.id) {
  8709. let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
  8710. guard removed > 0 else { return }
  8711. showToast("Removed \(variant.size.title) widget from desktop.")
  8712. } else {
  8713. desktopWidgetManager.show(
  8714. instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
  8715. )
  8716. showToast("Added \(variant.size.title) widget to desktop.")
  8717. }
  8718. }
  8719. private func showToast(_ message: String) {
  8720. toastTask?.cancel()
  8721. toastMessage = message
  8722. toastTask = Task { @MainActor in
  8723. try? await Task.sleep(nanoseconds: 1_800_000_000)
  8724. guard !Task.isCancelled else { return }
  8725. toastMessage = nil
  8726. }
  8727. }
  8728. }
  8729. private struct WidgetPreviewAddCard: View {
  8730. let variant: WidgetVariant
  8731. var layoutScale: CGFloat = 1
  8732. let isAdded: Bool
  8733. let isLocked: Bool
  8734. let onToggle: () -> Void
  8735. @State private var hovering = false
  8736. var body: some View {
  8737. ZStack(alignment: .topTrailing) {
  8738. Button(action: onToggle) {
  8739. WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
  8740. .opacity(isLocked ? 0.52 : 1)
  8741. .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
  8742. .overlay {
  8743. if isLocked {
  8744. RoundedRectangle(cornerRadius: 18, style: .continuous)
  8745. .fill(Color.black.opacity(0.28))
  8746. .frame(
  8747. width: variant.previewCardSize.width * max(0.01, layoutScale),
  8748. height: variant.previewCardSize.height * max(0.01, layoutScale)
  8749. )
  8750. }
  8751. }
  8752. .overlay {
  8753. if isLocked {
  8754. VStack(spacing: 6) {
  8755. Image(systemName: "lock.fill")
  8756. .font(.system(size: 18, weight: .bold))
  8757. Text("Premium")
  8758. .font(.system(size: 13, weight: .bold))
  8759. }
  8760. .foregroundStyle(.white.opacity(0.96))
  8761. .padding(.horizontal, 14)
  8762. .padding(.vertical, 10)
  8763. .background(
  8764. RoundedRectangle(cornerRadius: 12, style: .continuous)
  8765. .fill(Color.black.opacity(0.46))
  8766. )
  8767. .frame(
  8768. width: variant.previewCardSize.width * max(0.01, layoutScale),
  8769. height: variant.previewCardSize.height * max(0.01, layoutScale)
  8770. )
  8771. }
  8772. }
  8773. }
  8774. .buttonStyle(.plain)
  8775. Button(action: onToggle) {
  8776. Image(systemName: isLocked ? "lock.fill" : (isAdded ? "minus" : "plus"))
  8777. .font(.system(size: 12, weight: .bold))
  8778. .foregroundStyle(.white.opacity(0.95))
  8779. .frame(width: 26, height: 26)
  8780. .background(
  8781. Circle().fill(
  8782. isLocked
  8783. ? Color.orange.opacity(0.95)
  8784. : (isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95))
  8785. )
  8786. )
  8787. .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
  8788. }
  8789. .buttonStyle(.plain)
  8790. .offset(x: 10 * layoutScale, y: -10 * layoutScale)
  8791. .opacity((isAdded || isLocked) ? 1 : (hovering ? 1 : 0))
  8792. .animation(.easeOut(duration: 0.12), value: hovering)
  8793. }
  8794. .onHover { hovering = $0 }
  8795. }
  8796. }
  8797. private struct WidgetPreviewCard: View {
  8798. let variant: WidgetVariant
  8799. var layoutScale: CGFloat = 1
  8800. var body: some View {
  8801. let size = variant.previewCardSize
  8802. let scale = max(0.01, layoutScale)
  8803. DesktopWidgetView(variant: variant, isPreview: true)
  8804. .frame(width: size.width, height: size.height)
  8805. .scaleEffect(scale)
  8806. .frame(width: size.width * scale, height: size.height * scale)
  8807. .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
  8808. .overlay(
  8809. RoundedRectangle(cornerRadius: 18, style: .continuous)
  8810. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  8811. )
  8812. }
  8813. }
  8814. private struct DesktopWidgetView: View {
  8815. let variant: WidgetVariant
  8816. var isPreview: Bool
  8817. private var topMeetings: [WidgetMeetingSnapshot] {
  8818. WidgetMeetingStore.load().prefix(3).map { $0 }
  8819. }
  8820. var body: some View {
  8821. ZStack {
  8822. RoundedRectangle(cornerRadius: 22, style: .continuous)
  8823. .fill(
  8824. LinearGradient(
  8825. colors: variant.gradientStops,
  8826. startPoint: .topLeading,
  8827. endPoint: .bottomTrailing
  8828. )
  8829. )
  8830. .overlay(
  8831. RoundedRectangle(cornerRadius: 22, style: .continuous)
  8832. .fill(
  8833. RadialGradient(
  8834. colors: [variant.highlightTint, Color.clear],
  8835. center: .topLeading,
  8836. startRadius: 4,
  8837. endRadius: 200
  8838. )
  8839. )
  8840. .blendMode(.plusLighter)
  8841. .allowsHitTesting(false)
  8842. )
  8843. .overlay(
  8844. RoundedRectangle(cornerRadius: 22, style: .continuous)
  8845. .fill(
  8846. LinearGradient(
  8847. colors: [Color.clear, Color.black.opacity(0.18)],
  8848. startPoint: .top,
  8849. endPoint: .bottom
  8850. )
  8851. )
  8852. .allowsHitTesting(false)
  8853. )
  8854. .overlay(
  8855. RoundedRectangle(cornerRadius: 22, style: .continuous)
  8856. .stroke(Color.white.opacity(0.16), lineWidth: 1)
  8857. )
  8858. VStack(alignment: .leading, spacing: 12) {
  8859. widgetHeader
  8860. contentBody
  8861. if variant.size != .small {
  8862. Spacer(minLength: 0)
  8863. }
  8864. }
  8865. .padding(.horizontal, 14)
  8866. .padding(.bottom, variant.size == .medium ? 16 : 14)
  8867. .padding(.top, variant.size == .medium ? 18 : 10)
  8868. }
  8869. }
  8870. @ViewBuilder
  8871. private var contentBody: some View {
  8872. switch variant.size {
  8873. case .small:
  8874. VStack(alignment: .leading, spacing: 10) {
  8875. Text("Quick actions")
  8876. .font(.system(size: 11.5, weight: .semibold))
  8877. .foregroundStyle(.white.opacity(0.78))
  8878. compactActionButton(title: "New meeting", icon: "video.fill", destination: .newMeeting)
  8879. HStack(spacing: 8) {
  8880. compactActionButton(title: "Join", icon: "plus", destination: .joinMeeting)
  8881. compactActionButton(title: "Schedule", icon: "calendar", destination: .scheduleMeeting)
  8882. }
  8883. }
  8884. case .medium:
  8885. meetingBlock(maxRows: 2)
  8886. LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
  8887. ForEach(variant.quickActions.prefix(4)) { action in
  8888. actionTile(action)
  8889. }
  8890. }
  8891. }
  8892. }
  8893. private var widgetHeader: some View {
  8894. HStack(spacing: 10) {
  8895. Image(systemName: "video.fill")
  8896. .font(.system(size: 16, weight: .bold))
  8897. .foregroundStyle(.white.opacity(0.95))
  8898. .frame(width: 30, height: 30)
  8899. .background(
  8900. RoundedRectangle(cornerRadius: 8, style: .continuous)
  8901. .fill(
  8902. LinearGradient(
  8903. colors: [Color.white.opacity(0.22), Color.black.opacity(0.18)],
  8904. startPoint: .top,
  8905. endPoint: .bottom
  8906. )
  8907. )
  8908. )
  8909. .overlay(
  8910. RoundedRectangle(cornerRadius: 8, style: .continuous)
  8911. .stroke(Color.white.opacity(0.18), lineWidth: 1)
  8912. )
  8913. VStack(alignment: .leading, spacing: 2) {
  8914. Text(variant.title)
  8915. .font(.system(size: 16, weight: .bold))
  8916. .foregroundStyle(.white.opacity(0.95))
  8917. Text(variant.subtitle)
  8918. .font(.system(size: 12, weight: .medium))
  8919. .foregroundStyle(.white.opacity(0.8))
  8920. }
  8921. Spacer(minLength: 0)
  8922. }
  8923. }
  8924. private func meetingBlock(maxRows: Int) -> some View {
  8925. VStack(alignment: .leading, spacing: 9) {
  8926. Text("Upcoming meetings")
  8927. .font(.system(size: 11.5, weight: .semibold))
  8928. .foregroundStyle(.white.opacity(0.80))
  8929. if topMeetings.isEmpty {
  8930. Text("No upcoming meetings")
  8931. .font(.system(size: 12, weight: .medium))
  8932. .foregroundStyle(.white.opacity(0.82))
  8933. } else {
  8934. VStack(alignment: .leading, spacing: 8) {
  8935. ForEach(topMeetings.prefix(maxRows)) { meeting in
  8936. meetingRow(meeting)
  8937. }
  8938. }
  8939. }
  8940. }
  8941. }
  8942. private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
  8943. Button(action: {
  8944. if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
  8945. WidgetAppNavigator.openMeetingLink(link)
  8946. } else {
  8947. WidgetAppNavigator.open(target: .meetings)
  8948. }
  8949. }) {
  8950. HStack(spacing: 8) {
  8951. Image(systemName: "video.fill")
  8952. .font(.system(size: 11, weight: .semibold))
  8953. .foregroundStyle(.white.opacity(0.88))
  8954. VStack(alignment: .leading, spacing: 1) {
  8955. Text(meeting.title)
  8956. .font(.system(size: 11.5, weight: .semibold))
  8957. .foregroundStyle(.white.opacity(0.93))
  8958. .lineLimit(1)
  8959. Text(meeting.timeText)
  8960. .font(.system(size: 10.5, weight: .medium))
  8961. .foregroundStyle(.white.opacity(0.74))
  8962. .lineLimit(1)
  8963. }
  8964. Spacer(minLength: 0)
  8965. }
  8966. .padding(.horizontal, 10)
  8967. .padding(.vertical, 7)
  8968. .background(
  8969. RoundedRectangle(cornerRadius: 10, style: .continuous)
  8970. .fill(
  8971. LinearGradient(
  8972. colors: [Color.white.opacity(0.14), Color.black.opacity(0.22)],
  8973. startPoint: .top,
  8974. endPoint: .bottom
  8975. )
  8976. )
  8977. )
  8978. .overlay(
  8979. RoundedRectangle(cornerRadius: 10, style: .continuous)
  8980. .stroke(Color.white.opacity(0.12), lineWidth: 1)
  8981. )
  8982. }
  8983. .buttonStyle(.plain)
  8984. .disabled(isPreview)
  8985. .allowsHitTesting(!isPreview)
  8986. }
  8987. private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
  8988. Button(action: { WidgetAppNavigator.open(target: destination) }) {
  8989. HStack(spacing: 5) {
  8990. Image(systemName: icon)
  8991. .font(.system(size: 10.5, weight: .semibold))
  8992. .frame(width: 14)
  8993. Text(title)
  8994. .font(.system(size: 11.5, weight: .bold))
  8995. .lineLimit(1)
  8996. .minimumScaleFactor(0.82)
  8997. .layoutPriority(1)
  8998. }
  8999. .foregroundStyle(.white.opacity(0.97))
  9000. .padding(.horizontal, 10)
  9001. .padding(.vertical, 8)
  9002. .frame(maxWidth: .infinity)
  9003. .background(
  9004. RoundedRectangle(cornerRadius: 12, style: .continuous)
  9005. .fill(
  9006. LinearGradient(
  9007. colors: [Color.white.opacity(0.20), Color.black.opacity(0.28)],
  9008. startPoint: .top,
  9009. endPoint: .bottom
  9010. )
  9011. )
  9012. )
  9013. .overlay(
  9014. RoundedRectangle(cornerRadius: 12, style: .continuous)
  9015. .stroke(Color.white.opacity(0.26), lineWidth: 1)
  9016. )
  9017. }
  9018. .buttonStyle(.plain)
  9019. .disabled(isPreview)
  9020. .allowsHitTesting(!isPreview)
  9021. }
  9022. private func actionTile(_ action: WidgetQuickAction) -> some View {
  9023. Button(action: { open(action: action) }) {
  9024. HStack(spacing: 8) {
  9025. Image(systemName: action.systemImage)
  9026. .font(.system(size: 11, weight: .semibold))
  9027. Text(action.title)
  9028. .font(.system(size: 12, weight: .bold))
  9029. .lineLimit(1)
  9030. Spacer(minLength: 0)
  9031. }
  9032. .foregroundStyle(.white.opacity(0.95))
  9033. .padding(.horizontal, 10)
  9034. .padding(.vertical, 9)
  9035. .background(
  9036. RoundedRectangle(cornerRadius: 12, style: .continuous)
  9037. .fill(
  9038. LinearGradient(
  9039. colors: [Color.white.opacity(0.16), Color.black.opacity(0.22)],
  9040. startPoint: .top,
  9041. endPoint: .bottom
  9042. )
  9043. )
  9044. )
  9045. .overlay(
  9046. RoundedRectangle(cornerRadius: 12, style: .continuous)
  9047. .stroke(Color.white.opacity(0.16), lineWidth: 1)
  9048. )
  9049. }
  9050. .buttonStyle(.plain)
  9051. .disabled(isPreview)
  9052. .allowsHitTesting(!isPreview)
  9053. }
  9054. private func open(action: WidgetQuickAction) {
  9055. guard !isPreview else { return }
  9056. WidgetAppNavigator.open(target: action.destination)
  9057. }
  9058. }