ViewController.swift 383 KB

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